mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
feat: implement R38/R96 streaming assist UX and docs full update
This commit is contained in:
@@ -39,6 +39,10 @@ This project is designed to make **ComfyUI a reliable automation target** with a
|
||||
- Adversarial verification is execution-gated (bounded fuzz + mutation smoke) in CI and local full-test/pre-push workflows with replayable artifacts
|
||||
- Wave E closeout hardening: deployment profile gates and critical flow parity are now enforced together with signed policy posture control, bounded anomaly telemetry, adversarial fuzz validation, and mutation-baseline regression sensitivity checks
|
||||
- Wave A/B/C closeout hardening: runtime/config/session stability contracts, strict outbound and supply-chain controls, and capability-aware operator guidance with bounded Parameter Lab/compare workflows
|
||||
- Runtime guardrails are enforced as a runtime-only contract with diagnostics, clamping, and reject-on-persist behavior for safety-critical limits (timeouts/retries/queue bounds/provider safety defaults)
|
||||
- Cryptographic lifecycle drills are automated with machine-readable evidence for rotation, revoke, key-loss recovery, and token-compromise fail-closed exercises
|
||||
- Management query paths now use deterministic pagination normalization and bounded scans to reduce malformed-input abuse and unbounded admin/list query cost
|
||||
- Compatibility matrix freshness/drift governance is operator-visible via Doctor checks and a repeatable refresh workflow with evidence output, reducing stale deployment assumptions before release
|
||||
|
||||
Deployment profiles and hardening checklists:
|
||||
- [Security Deployment Guide](docs/security_deployment_guide.md) (local / LAN / public templates + self-check command)
|
||||
@@ -50,6 +54,19 @@ Deployment profiles and hardening checklists:
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Assist streaming UX and frontend fetch-wrapper safety hardening</strong></summary>
|
||||
|
||||
- Completed a focused assist UX + frontend transport reliability batch with full SOP verification:
|
||||
- added optional streaming assist paths for Planner/Refiner with incremental preview updates and staged progress events
|
||||
- added backend streaming endpoints for planner/refiner assist flows with capability-gated frontend enablement and safe fallback to the existing non-stream path
|
||||
- added frontend live preview rendering for Planner/Refiner while preserving cancel/stale-response safety behavior
|
||||
- added idempotent fetch-wrapper composition guards to prevent duplicate wrapper stacking during repeated frontend bootstrap/setup
|
||||
- added backend/parser/frontend regression coverage for streaming assist behavior and fetch-wrapper idempotence, plus full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Recent hardening and reliability improvements: runtime guardrails, crypto drills, compatibility governance, and safer management queries</strong></summary>
|
||||
|
||||
- Completed a focused reliability + operations hardening batch with full SOP verification:
|
||||
@@ -518,6 +535,7 @@ Use `/api/...` from browsers and extension JS.
|
||||
- `GET /openclaw/logs/tail?n=50` - log tail (supports `trace_id` / `prompt_id` filters)
|
||||
- `GET /openclaw/trace/{prompt_id}` -trace timeline (redacted)
|
||||
- `GET /openclaw/capabilities` -feature/capability probe for frontend compatibility
|
||||
- includes feature flags such as `assist_planner`, `assist_refiner`, and optional `assist_streaming` (when incremental assist preview is available)
|
||||
- `GET /openclaw/jobs` -currently a stub (returns an empty list)
|
||||
|
||||
Access control:
|
||||
@@ -532,10 +550,15 @@ Access control:
|
||||
- `POST /openclaw/llm/test` -test connectivity (admin boundary)
|
||||
- `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)
|
||||
- `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)
|
||||
|
||||
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.
|
||||
- `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`
|
||||
|
||||
+332
-66
@@ -1,4 +1,8 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@@ -40,6 +44,9 @@ logger = logging.getLogger("ComfyUI-OpenClaw.api.assist")
|
||||
MAX_REQUIREMENTS_LEN = 8000
|
||||
MAX_STYLE_LEN = 2000
|
||||
MAX_IMAGE_B64_LEN = 5 * 1024 * 1024 # ~5MB base64 string length
|
||||
MAX_STREAM_DELTA_CHARS = 256
|
||||
MAX_STREAM_PREVIEW_CHARS = 16_000
|
||||
STREAM_KEEPALIVE_SEC = 1.0
|
||||
|
||||
|
||||
class AssistHandlers:
|
||||
@@ -48,6 +55,247 @@ class AssistHandlers:
|
||||
self.refiner = RefinerService()
|
||||
self.composer = AutomationComposerService()
|
||||
|
||||
async def _require_admin_and_rate_limit(
|
||||
self, request: web.Request
|
||||
) -> Optional[web.Response]:
|
||||
authorized, _err_msg = require_admin_token(request)
|
||||
if not authorized:
|
||||
return web.json_response({"error": "Unauthorized"}, status=401)
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return web.json_response({"error": "Rate limit exceeded"}, status=429)
|
||||
return None
|
||||
|
||||
async def _parse_json_body(
|
||||
self, request: web.Request
|
||||
) -> tuple[Optional[dict], Optional[web.Response]]:
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return None, web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
if not isinstance(data, dict):
|
||||
return None, web.json_response(
|
||||
{"error": "JSON object required"}, status=400
|
||||
)
|
||||
return data, None
|
||||
|
||||
def _validate_planner_payload(
|
||||
self, data: dict
|
||||
) -> tuple[Optional[dict], Optional[web.Response]]:
|
||||
profile = data.get("profile", "SDXL-v1")
|
||||
requirements = data.get("requirements", "")
|
||||
style = data.get("style_directives", "")
|
||||
seed = data.get("seed", 0)
|
||||
|
||||
if not isinstance(profile, str):
|
||||
return None, web.json_response(
|
||||
{"error": "profile must be string"}, status=400
|
||||
)
|
||||
if not isinstance(requirements, str):
|
||||
return None, web.json_response(
|
||||
{"error": "requirements must be string"}, status=400
|
||||
)
|
||||
if not isinstance(style, str):
|
||||
return None, web.json_response(
|
||||
{"error": "style_directives must be string"}, status=400
|
||||
)
|
||||
if len(requirements) > MAX_REQUIREMENTS_LEN:
|
||||
return None, web.json_response(
|
||||
{"error": f"requirements exceeds {MAX_REQUIREMENTS_LEN} chars"},
|
||||
status=400,
|
||||
)
|
||||
if len(style) > MAX_STYLE_LEN:
|
||||
return None, web.json_response(
|
||||
{"error": f"style_directives exceeds {MAX_STYLE_LEN} chars"}, status=400
|
||||
)
|
||||
try:
|
||||
seed = int(seed)
|
||||
except Exception:
|
||||
seed = 0
|
||||
return {
|
||||
"profile": profile,
|
||||
"requirements": requirements,
|
||||
"style_directives": style,
|
||||
"seed": seed,
|
||||
}, None
|
||||
|
||||
def _validate_refiner_payload(
|
||||
self, data: dict
|
||||
) -> tuple[Optional[dict], Optional[web.Response]]:
|
||||
image_b64 = data.get("image_b64", "")
|
||||
orig_pos = data.get("orig_positive", "")
|
||||
orig_neg = data.get("orig_negative", "")
|
||||
issue = data.get("issue", "Fix issues")
|
||||
params_json = data.get("params_json", "{}")
|
||||
goal = data.get("goal", "Fix issues")
|
||||
|
||||
if not isinstance(image_b64, str) or not image_b64:
|
||||
return None, web.json_response({"error": "image_b64 required"}, status=400)
|
||||
if len(image_b64) > MAX_IMAGE_B64_LEN:
|
||||
return None, web.json_response(
|
||||
{"error": f"image_b64 exceeds {MAX_IMAGE_B64_LEN // 1024 // 1024}MB"},
|
||||
status=400,
|
||||
)
|
||||
for key, value in (
|
||||
("orig_positive", orig_pos),
|
||||
("orig_negative", orig_neg),
|
||||
("issue", issue),
|
||||
("params_json", params_json),
|
||||
("goal", goal),
|
||||
):
|
||||
if not isinstance(value, str):
|
||||
return None, web.json_response(
|
||||
{"error": f"{key} must be string"}, status=400
|
||||
)
|
||||
if len(orig_pos) > MAX_REQUIREMENTS_LEN or len(orig_neg) > MAX_REQUIREMENTS_LEN:
|
||||
return None, web.json_response({"error": "Prompt too long"}, status=400)
|
||||
|
||||
return {
|
||||
"image_b64": image_b64,
|
||||
"orig_positive": orig_pos,
|
||||
"orig_negative": orig_neg,
|
||||
"issue": issue,
|
||||
"params_json": params_json,
|
||||
"goal": goal,
|
||||
}, None
|
||||
|
||||
@staticmethod
|
||||
def _sse_frame(event: str, payload: Dict[str, Any]) -> bytes:
|
||||
return (
|
||||
f"event: {event}\n"
|
||||
f"data: {json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
||||
).encode("utf-8")
|
||||
|
||||
async def _write_sse_event(
|
||||
self, response: web.StreamResponse, event: str, payload: Dict[str, Any]
|
||||
) -> bool:
|
||||
try:
|
||||
await response.write(self._sse_frame(event, payload))
|
||||
return True
|
||||
except (ConnectionError, RuntimeError):
|
||||
return False
|
||||
|
||||
async def _assist_stream_session(
|
||||
self,
|
||||
request: web.Request,
|
||||
*,
|
||||
kind: str,
|
||||
worker_fn,
|
||||
worker_kwargs: Dict[str, Any],
|
||||
) -> web.StreamResponse:
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
headers={
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
await response.prepare(request)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
preview_chars = 0
|
||||
|
||||
def emit(event: str, payload: Dict[str, Any]) -> None:
|
||||
try:
|
||||
loop.call_soon_threadsafe(queue.put_nowait, (event, payload))
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def on_text_delta(delta: str) -> None:
|
||||
nonlocal preview_chars
|
||||
if not isinstance(delta, str) or not delta:
|
||||
return
|
||||
remaining = MAX_STREAM_PREVIEW_CHARS - preview_chars
|
||||
if remaining <= 0:
|
||||
return
|
||||
clipped = delta[: min(remaining, MAX_STREAM_DELTA_CHARS)]
|
||||
if not clipped:
|
||||
return
|
||||
preview_chars += len(clipped)
|
||||
emit("delta", {"text": clipped, "preview_chars": preview_chars})
|
||||
|
||||
async def runner() -> None:
|
||||
emit("ready", {"ok": True, "kind": kind, "mode": "sse"})
|
||||
emit(
|
||||
"stage", {"phase": "dispatch", "message": "Dispatching assist request"}
|
||||
)
|
||||
try:
|
||||
call_kwargs = dict(worker_kwargs)
|
||||
call_kwargs["on_text_delta"] = on_text_delta
|
||||
result = await run_in_thread(worker_fn, **call_kwargs)
|
||||
emit(
|
||||
"stage",
|
||||
{"phase": "finalize", "message": "Parsing and validating output"},
|
||||
)
|
||||
if kind == "planner":
|
||||
pos, neg, params = result
|
||||
final_payload = {
|
||||
"positive": pos,
|
||||
"negative": neg,
|
||||
"params": params,
|
||||
}
|
||||
elif kind == "refiner":
|
||||
new_pos, new_neg, patch, rationale = result
|
||||
final_payload = {
|
||||
"refined_positive": new_pos,
|
||||
"refined_negative": new_neg,
|
||||
"param_patch": patch,
|
||||
"rationale": rationale,
|
||||
}
|
||||
else:
|
||||
final_payload = {"result": result}
|
||||
emit(
|
||||
"final",
|
||||
{
|
||||
"ok": True,
|
||||
"kind": kind,
|
||||
"result": final_payload,
|
||||
"streaming": {
|
||||
"preview_chars": preview_chars,
|
||||
"preview_truncated": preview_chars
|
||||
>= MAX_STREAM_PREVIEW_CHARS,
|
||||
},
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Assist streaming API failed (%s)", kind)
|
||||
emit(
|
||||
"error",
|
||||
{"ok": False, "kind": kind, "error": "Internal server error"},
|
||||
)
|
||||
finally:
|
||||
emit("__done__", {})
|
||||
|
||||
runner_task = asyncio.create_task(runner())
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
event, payload = await asyncio.wait_for(
|
||||
queue.get(), timeout=STREAM_KEEPALIVE_SEC
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
if runner_task.done():
|
||||
break
|
||||
if not await self._write_sse_event(
|
||||
response, "keepalive", {"ok": True}
|
||||
):
|
||||
break
|
||||
continue
|
||||
|
||||
if event == "__done__":
|
||||
break
|
||||
if not await self._write_sse_event(response, event, payload):
|
||||
break
|
||||
finally:
|
||||
if not runner_task.done():
|
||||
runner_task.cancel()
|
||||
with contextlib.suppress(BaseException):
|
||||
await runner_task
|
||||
return response
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
@@ -62,39 +310,26 @@ class AssistHandlers:
|
||||
JSON: { profile, requirements, style_directives, seed }
|
||||
"""
|
||||
# Security: Admin Token required
|
||||
authorized, err_msg = require_admin_token(request)
|
||||
if not authorized:
|
||||
return web.json_response({"error": "Unauthorized"}, status=401)
|
||||
|
||||
# Security: Rate Limit
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return web.json_response({"error": "Rate limit exceeded"}, status=429)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
|
||||
profile = data.get("profile", "SDXL-v1")
|
||||
requirements = data.get("requirements", "")
|
||||
style = data.get("style_directives", "")
|
||||
seed = data.get("seed", 0)
|
||||
|
||||
# Security: Payload size clamps
|
||||
if len(requirements) > MAX_REQUIREMENTS_LEN:
|
||||
return web.json_response(
|
||||
{"error": f"requirements exceeds {MAX_REQUIREMENTS_LEN} chars"},
|
||||
status=400,
|
||||
)
|
||||
if len(style) > MAX_STYLE_LEN:
|
||||
return web.json_response(
|
||||
{"error": f"style_directives exceeds {MAX_STYLE_LEN} chars"}, status=400
|
||||
)
|
||||
auth_resp = await self._require_admin_and_rate_limit(request)
|
||||
if auth_resp:
|
||||
return auth_resp
|
||||
data, error_resp = await self._parse_json_body(request)
|
||||
if error_resp:
|
||||
return error_resp
|
||||
assert data is not None
|
||||
payload, payload_err = self._validate_planner_payload(data)
|
||||
if payload_err:
|
||||
return payload_err
|
||||
assert payload is not None
|
||||
|
||||
try:
|
||||
# Run sync LLM call in thread pool to avoid blocking event loop
|
||||
pos, neg, params = await run_in_thread(
|
||||
self.planner.plan_generation, profile, requirements, style, seed
|
||||
self.planner.plan_generation,
|
||||
payload["profile"],
|
||||
payload["requirements"],
|
||||
payload["style_directives"],
|
||||
payload["seed"],
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
@@ -119,48 +354,23 @@ class AssistHandlers:
|
||||
JSON: { image_b64, orig_positive, orig_negative, issue, params_json, goal }
|
||||
"""
|
||||
# Security checks
|
||||
authorized, err_msg = require_admin_token(request)
|
||||
if not authorized:
|
||||
return web.json_response({"error": "Unauthorized"}, status=401)
|
||||
|
||||
# Security: Rate Limit
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return web.json_response({"error": "Rate limit exceeded"}, status=429)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
|
||||
# Extract payload (aligned with service signature)
|
||||
image_b64 = data.get("image_b64", "")
|
||||
orig_pos = data.get("orig_positive", "")
|
||||
orig_neg = data.get("orig_negative", "")
|
||||
issue = data.get("issue", "Fix issues")
|
||||
params_json = data.get("params_json", "{}")
|
||||
goal = data.get("goal", "Fix issues")
|
||||
|
||||
# Validation & Size clamps
|
||||
if not image_b64:
|
||||
return web.json_response({"error": "image_b64 required"}, status=400)
|
||||
if len(image_b64) > MAX_IMAGE_B64_LEN:
|
||||
return web.json_response(
|
||||
{"error": f"image_b64 exceeds {MAX_IMAGE_B64_LEN // 1024 // 1024}MB"},
|
||||
status=400,
|
||||
)
|
||||
if len(orig_pos) > MAX_REQUIREMENTS_LEN or len(orig_neg) > MAX_REQUIREMENTS_LEN:
|
||||
return web.json_response({"error": "Prompt too long"}, status=400)
|
||||
auth_resp = await self._require_admin_and_rate_limit(request)
|
||||
if auth_resp:
|
||||
return auth_resp
|
||||
data, error_resp = await self._parse_json_body(request)
|
||||
if error_resp:
|
||||
return error_resp
|
||||
assert data is not None
|
||||
payload, payload_err = self._validate_refiner_payload(data)
|
||||
if payload_err:
|
||||
return payload_err
|
||||
assert payload is not None
|
||||
|
||||
try:
|
||||
# Run sync LLM call in thread pool
|
||||
new_pos, new_neg, patch, rationale = await run_in_thread(
|
||||
self.refiner.refine_prompt,
|
||||
image_b64=image_b64,
|
||||
orig_positive=orig_pos,
|
||||
orig_negative=orig_neg,
|
||||
issue=issue,
|
||||
params_json=params_json,
|
||||
goal=goal,
|
||||
**payload,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
@@ -175,6 +385,62 @@ class AssistHandlers:
|
||||
logger.exception("Refiner API failed")
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Run planner (streaming)",
|
||||
description="Generate prompts from requirements via LLM with SSE-style incremental updates.",
|
||||
audit="assist.planner.stream",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def planner_stream_handler(self, request):
|
||||
auth_resp = await self._require_admin_and_rate_limit(request)
|
||||
if auth_resp:
|
||||
return auth_resp
|
||||
data, error_resp = await self._parse_json_body(request)
|
||||
if error_resp:
|
||||
return error_resp
|
||||
assert data is not None
|
||||
payload, payload_err = self._validate_planner_payload(data)
|
||||
if payload_err:
|
||||
return payload_err
|
||||
assert payload is not None
|
||||
|
||||
return await self._assist_stream_session(
|
||||
request,
|
||||
kind="planner",
|
||||
worker_fn=self.planner.plan_generation,
|
||||
worker_kwargs=payload,
|
||||
)
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
summary="Run refiner (streaming)",
|
||||
description="Refine prompt/parameters with SSE-style incremental updates.",
|
||||
audit="assist.refiner.stream",
|
||||
plane=RoutePlane.ADMIN,
|
||||
)
|
||||
async def refiner_stream_handler(self, request):
|
||||
auth_resp = await self._require_admin_and_rate_limit(request)
|
||||
if auth_resp:
|
||||
return auth_resp
|
||||
data, error_resp = await self._parse_json_body(request)
|
||||
if error_resp:
|
||||
return error_resp
|
||||
assert data is not None
|
||||
payload, payload_err = self._validate_refiner_payload(data)
|
||||
if payload_err:
|
||||
return payload_err
|
||||
assert payload is not None
|
||||
|
||||
return await self._assist_stream_session(
|
||||
request,
|
||||
kind="refiner",
|
||||
worker_fn=self.refiner.refine_prompt,
|
||||
worker_kwargs=payload,
|
||||
)
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
|
||||
@@ -833,9 +833,21 @@ def register_routes(server) -> None:
|
||||
register_dual_route(
|
||||
server, "POST", f"{prefix}/assist/planner", assist.planner_handler
|
||||
)
|
||||
register_dual_route(
|
||||
server,
|
||||
"POST",
|
||||
f"{prefix}/assist/planner/stream",
|
||||
assist.planner_stream_handler,
|
||||
)
|
||||
register_dual_route(
|
||||
server, "POST", f"{prefix}/assist/refiner", assist.refiner_handler
|
||||
)
|
||||
register_dual_route(
|
||||
server,
|
||||
"POST",
|
||||
f"{prefix}/assist/refiner/stream",
|
||||
assist.refiner_stream_handler,
|
||||
)
|
||||
register_dual_route(
|
||||
server,
|
||||
"POST",
|
||||
|
||||
@@ -17,22 +17,26 @@ This document summarizes the current OpenClaw sidebar UI structure and how to ve
|
||||
- Frontend fetches capabilities during setup and conditionally registers tabs:
|
||||
- `assist_planner` → Planner
|
||||
- `assist_refiner` → Refiner
|
||||
- `assist_streaming` → enable Planner/Refiner incremental live preview (fallback remains non-streaming)
|
||||
- `scheduler` → Variants (current gating)
|
||||
- `presets` → Library
|
||||
- `approvals` → Approvals
|
||||
|
||||
If capabilities are unavailable, the full tab set is registered to surface actionable errors (instead of “missing tabs”).
|
||||
If `assist_streaming` is unavailable or the stream transport degrades, Planner/Refiner automatically fall back to the existing non-stream request path.
|
||||
|
||||
## Quick Manual Checks
|
||||
|
||||
1. Open ComfyUI and confirm OpenClaw appears in the sidebar.
|
||||
2. Switch between all visible tabs multiple times (and reopen the sidebar if possible) and ensure panes do not go blank.
|
||||
3. Planner: click **Plan Generation** with minimal input and confirm either results render or a readable error appears.
|
||||
4. Refiner: click **Refine Prompts** (with or without image) and confirm either results render or a readable error appears.
|
||||
3. Planner: click **Plan Generation** with minimal input and confirm either live preview/stage updates appear (when streaming is supported) or a readable fallback result/error appears.
|
||||
4. Refiner: click **Refine Prompts** (with or without image) and confirm either live preview/stage updates appear (when streaming is supported) or a readable fallback result/error appears.
|
||||
5. Library/Approvals: if backend endpoints are not enabled, confirm the UI shows a clear error state (no crashes).
|
||||
6. If you simulate/fake a stream failure in dev tools, confirm Planner/Refiner retry through the classic non-stream path without duplicate submits or broken loading state.
|
||||
|
||||
## E2E (Playwright) Checks
|
||||
|
||||
- Run: `npm test`
|
||||
- Tests live in: `tests/e2e/specs/`
|
||||
- Harness: `tests/e2e/test-harness.html` (mocks ComfyUI core + basic OpenClaw API calls)
|
||||
- Web helper/self-test harness: `web/tests/e2e-harness.html` (includes frontend helper and wrapper idempotence checks)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# OpenClaw API Contract (v1)
|
||||
|
||||
> **Status**: normative
|
||||
> **Version**: 1.0.0
|
||||
> **Date**: 2026-02-09
|
||||
> **Version**: 1.0.1
|
||||
> **Date**: 2026-02-26
|
||||
|
||||
This document defines the public API contract for OpenClaw. It serves as the authoritative baseline for client compatibility and breaking change policies.
|
||||
|
||||
@@ -17,9 +17,11 @@ All new integrations should use the `/openclaw/` prefix. Use of `/moltbot/` is d
|
||||
| Method | Path | Legacy Path | Auth | Description |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/health` | `/moltbot/health` | None | System status, uptime, and dependencies. |
|
||||
| `GET` | `/capabilities` | `/moltbot/capabilities` | None | Feature flags and supported extensions. |
|
||||
| `GET` | `/capabilities` | `/moltbot/capabilities` | None | Feature flags and supported extensions (includes optional UX/runtime features such as assist streaming support). |
|
||||
| `GET` | `/logs/tail` | `/moltbot/logs/tail` | Observability | Tail recent log lines (rate-limited). |
|
||||
| `GET` | `/trace/{prompt_id}` | `/moltbot/trace/{id}` | Observability | Get execution trace by prompt ID. |
|
||||
| `GET` | `/events` | `/moltbot/events` | Observability | List recent job lifecycle events (JSON polling fallback; includes pagination/scan diagnostics). |
|
||||
| `GET` | `/events/stream` | `/moltbot/events/stream` | Observability | SSE stream of job lifecycle events with resume support. |
|
||||
| `GET` | `/config` | `/moltbot/config` | Observability | Read-only view of sanitized provider config. |
|
||||
| `PUT` | `/config` | `/moltbot/config` | Admin | Update system configuration. |
|
||||
| `GET` | `/jobs` | `/moltbot/jobs` | Observability | List recent jobs (Stub/Not Implemented). |
|
||||
@@ -35,9 +37,18 @@ All new integrations should use the `/openclaw/` prefix. Use of `/moltbot/` is d
|
||||
| `POST` | `/webhook/validate` | `/moltbot/webhook/validate` | Webhook Secret | Dry-run validation of webhook payload. |
|
||||
| `POST` | `/triggers/fire` | `/moltbot/triggers/fire` | Admin | Fire an ad-hoc workflow trigger from external system. |
|
||||
|
||||
### 1.3 LLM & Chat
|
||||
### 1.3 Assist, LLM & Chat
|
||||
|
||||
**Base Path**: `/openclaw/llm/`
|
||||
**Assist Base Path**: `/openclaw/assist/`
|
||||
|
||||
| Method | Path | Legacy Path | Auth | Description |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `POST` | `/assist/planner` | `/moltbot/assist/planner` | Admin/Local | Planner structured prompt generation. |
|
||||
| `POST` | `/assist/refiner` | `/moltbot/assist/refiner` | Admin/Local | Prompt refinement with optional image context. |
|
||||
| `POST` | `/assist/planner/stream` | `/moltbot/assist/planner/stream` | Admin/Local | Optional SSE-style planner streaming response (`text/event-stream`) with staged progress + final payload. |
|
||||
| `POST` | `/assist/refiner/stream` | `/moltbot/assist/refiner/stream` | Admin/Local | Optional SSE-style refiner streaming response (`text/event-stream`) with staged progress + final payload. |
|
||||
|
||||
**LLM Base Path**: `/openclaw/llm/`
|
||||
|
||||
| Method | Path | Legacy Path | Auth | Description |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
@@ -76,7 +87,7 @@ All new integrations should use the `/openclaw/` prefix. Use of `/moltbot/` is d
|
||||
| `DELETE` | `/schedules/{id}` | Delete a schedule. |
|
||||
| `POST` | `/schedules/{id}/run` | Manually trigger a schedule. |
|
||||
| `GET` | `/schedules/{id}/runs` | Get run history for a schedule. |
|
||||
| `GET` | `/approvals` | List pending approvals. |
|
||||
| `GET` | `/approvals` | List pending approvals (includes pagination/scan diagnostics; bounded serialization scan on malformed records). |
|
||||
| `POST` | `/approvals/{id}/approve` | Approve a pending request. |
|
||||
| `POST` | `/approvals/{id}/reject` | Reject a pending request. |
|
||||
|
||||
@@ -128,6 +139,31 @@ All JSON responses (success or error) share a common structure:
|
||||
| `500` | Internal Error | Unhandled server exception. |
|
||||
| `503` | Unavailable | Feature disabled or service not wired. |
|
||||
|
||||
### 2.3 SSE Endpoint Notes (Contractual Behavior)
|
||||
|
||||
- SSE endpoints return `Content-Type: text/event-stream`.
|
||||
- Current SSE surfaces include:
|
||||
- `/openclaw/events/stream` (job lifecycle events)
|
||||
- optional `/openclaw/assist/planner/stream` and `/openclaw/assist/refiner/stream` (assist incremental preview path)
|
||||
- Assist streaming emits event types from the following set:
|
||||
- `ready`
|
||||
- `stage`
|
||||
- `delta`
|
||||
- `final`
|
||||
- `error`
|
||||
- `keepalive`
|
||||
- Clients MUST treat `final` as the source of truth for structured assist results. `delta` preview text is best-effort and may be truncated or differ from the final parsed payload.
|
||||
- Clients SHOULD gracefully fall back to non-streaming assist endpoints when streaming capability is absent or streaming transport fails.
|
||||
|
||||
### 2.4 Pagination & Scan Diagnostics (Management Query Contract)
|
||||
|
||||
- `GET /openclaw/events` and `GET /openclaw/approvals` include deterministic pagination normalization.
|
||||
- Responses may include `pagination` and `scan` diagnostic objects so clients/operators can detect:
|
||||
- normalized limit/offset/cursor values
|
||||
- stale/future cursor resets
|
||||
- bounded scan truncation or malformed-record skips
|
||||
- Backend/runtime errors outside pagination normalization are still surfaced explicitly (not silently swallowed).
|
||||
|
||||
---
|
||||
|
||||
## 3. Limits & Budgets
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# OpenClaw Config & Secrets Contract (v1)
|
||||
|
||||
> **Status**: normative
|
||||
> **Version**: 1.0.1
|
||||
> **Date**: 2026-02-19
|
||||
> **Version**: 1.0.2
|
||||
> **Date**: 2026-02-26
|
||||
|
||||
This document defines the authoritative configuration contract for OpenClaw. It enumerates all supported environment variables, their precedence rules, and security classifications.
|
||||
|
||||
@@ -101,6 +101,11 @@ Contractual limits to prevent resource exhaustion.
|
||||
| `OPENCLAW_DIAGNOSTICS` | Comma-separated list of subsystems to enable debug logging for (e.g. `webhook.*,templates`). Safe-redacted. |
|
||||
| `OPENCLAW_CONNECTOR_DEBUG` | Set `1` to enable verbose debug logging in Connector. |
|
||||
|
||||
Runtime guardrails contract (ENV-driven, runtime-only):
|
||||
- `GET /openclaw/config` may include a `runtime_guardrails` diagnostics object describing effective runtime caps, sources, and degraded status.
|
||||
- Runtime guardrails are evaluated at runtime (deployment/runtime profile aware) and are not part of the persisted user config contract.
|
||||
- `PUT /openclaw/config` rejects attempts to persist `runtime_guardrails` / legacy guardrail payloads; callers must change the underlying environment variables instead.
|
||||
|
||||
---
|
||||
|
||||
## 3. Secret Rotation & Migration
|
||||
@@ -126,3 +131,7 @@ If multiple keys are configured for the same purpose, the following order applie
|
||||
### 3.3 Persistence
|
||||
|
||||
Non-secret configuration (such as enabled/disabled flags, feature toggles) may be persisted in the `OPENCLAW_STATE_DIR/config.json` via the Settings API. However, **environment variables always override persisted settings**.
|
||||
|
||||
Persistence guardrails:
|
||||
- Runtime-only guardrail fields (for example `runtime_guardrails` and legacy guardrail aliases) are stripped/ignored when loading persisted config and rejected on `/config` write requests.
|
||||
- This prevents runtime safety caps (timeouts/retries/provider safety clamps) from being silently converted into mutable persisted settings.
|
||||
|
||||
@@ -156,7 +156,44 @@ PY
|
||||
2. If integrity is uncertain, revoke all active tokens and re-issue per device.
|
||||
3. Validate with `tests.test_s58_bridge_token_lifecycle` and `tests.test_s58_bridge_auth_integration`.
|
||||
|
||||
## 6. Validation Gate After Any Lifecycle Change
|
||||
## 6. Drill Automation (Evidence-Generating, Optional but Recommended)
|
||||
|
||||
In addition to the manual procedures above, OpenClaw provides a local/CI-safe drill runner that simulates lifecycle incidents and emits machine-readable evidence.
|
||||
|
||||
Script:
|
||||
- `scripts/run_crypto_lifecycle_drills.py`
|
||||
|
||||
Supported scenarios:
|
||||
- `planned_rotation`
|
||||
- `emergency_revoke`
|
||||
- `key_loss_recovery`
|
||||
- `token_compromise`
|
||||
|
||||
Example commands:
|
||||
|
||||
```bash
|
||||
python scripts/run_crypto_lifecycle_drills.py --pretty
|
||||
python scripts/run_crypto_lifecycle_drills.py --scenarios planned_rotation,emergency_revoke --output .planning/logs/crypto_drills.json --pretty
|
||||
```
|
||||
|
||||
Evidence bundle contract (JSON):
|
||||
- top-level fields include `schema_version`, `bundle`, `state_dir`, and `drills`
|
||||
- each drill record includes:
|
||||
- `operation`
|
||||
- `scenario`
|
||||
- `precheck`
|
||||
- `result`
|
||||
- `rollback_status`
|
||||
- `artifacts`
|
||||
- `decision_codes`
|
||||
- `fail_closed_assertions`
|
||||
|
||||
Operational notes:
|
||||
- This drill runner is for verification/training/evidence collection and does not replace maintenance-window production rotation procedures.
|
||||
- Use an isolated or temporary state directory unless you intentionally want artifacts written to a specific test state path.
|
||||
- Store drill evidence alongside change tickets or implementation records when lifecycle readiness is part of acceptance criteria.
|
||||
|
||||
## 7. Validation Gate After Any Lifecycle Change
|
||||
|
||||
Run at minimum:
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ def get_capabilities() -> dict:
|
||||
"approvals": True,
|
||||
"assist_planner": True,
|
||||
"assist_refiner": True,
|
||||
"assist_streaming": True, # R38 optional SSE-style assist streaming path
|
||||
"assist_automation_compose": True,
|
||||
"scheduler": True,
|
||||
"triggers": True,
|
||||
|
||||
+36
-1
@@ -7,7 +7,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
from ..config import setup_logger
|
||||
@@ -353,6 +353,8 @@ class LLMClient:
|
||||
max_tokens: int,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = None,
|
||||
streaming: bool = False,
|
||||
on_text_delta: Optional[Callable[[str], None]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a single request attempt (factored out for failover)."""
|
||||
api_type = self._get_api_type()
|
||||
@@ -380,6 +382,8 @@ class LLMClient:
|
||||
max_tokens,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
streaming=streaming,
|
||||
on_text_delta=on_text_delta,
|
||||
)
|
||||
|
||||
def complete(
|
||||
@@ -395,6 +399,8 @@ class LLMClient:
|
||||
] = None, # F25: Optional tool calling schemas
|
||||
tool_choice: Optional[str] = None, # F25: Optional tool_choice (OpenAI-compat)
|
||||
trace_id: Optional[str] = None, # R25: Trace context
|
||||
streaming: bool = False, # R38: optional provider streaming path
|
||||
on_text_delta: Optional[Callable[[str], None]] = None, # R38 callback
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Send a completion request to the configured provider.
|
||||
@@ -657,6 +663,8 @@ class LLMClient:
|
||||
max_tokens,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
streaming=streaming,
|
||||
on_text_delta=on_text_delta,
|
||||
)
|
||||
|
||||
# R37: Update health score on success
|
||||
@@ -787,6 +795,8 @@ class LLMClient:
|
||||
*,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = None,
|
||||
streaming: bool = False,
|
||||
on_text_delta: Optional[Callable[[str], None]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Complete using OpenAI-compatible API."""
|
||||
egress_controls = self._get_egress_controls(self.provider, self.base_url)
|
||||
@@ -801,6 +811,31 @@ class LLMClient:
|
||||
else:
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
|
||||
# R38: Streaming is optional and currently only enabled for non-tool
|
||||
# OpenAI-compatible text paths. Tool-call streaming deltas are not parsed yet.
|
||||
if streaming and not tools and not tool_choice:
|
||||
try:
|
||||
return openai_compat.make_request_stream(
|
||||
base_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
messages=messages,
|
||||
model=self.model,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=self.timeout,
|
||||
allow_hosts=egress_controls.get("allow_hosts"),
|
||||
allow_any_public_host=bool(
|
||||
egress_controls.get("allow_any_public_host")
|
||||
),
|
||||
allow_loopback_hosts=egress_controls.get("allow_loopback_hosts"),
|
||||
on_text_delta=on_text_delta,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"R38: Streaming request unavailable/failed; falling back to non-streaming request: %s",
|
||||
e,
|
||||
)
|
||||
|
||||
return openai_compat.make_request(
|
||||
base_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
|
||||
+13
-4
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from typing import Any, Callable, Dict, Optional, Tuple
|
||||
|
||||
from .llm_client import LLMClient
|
||||
from .llm_output import extract_json_object, sanitize_string
|
||||
@@ -55,7 +54,12 @@ class PlannerService:
|
||||
self.llm_client = LLMClient()
|
||||
|
||||
def plan_generation(
|
||||
self, profile_id: str, requirements: str, style_directives: str, seed: int = 0
|
||||
self,
|
||||
profile_id: str,
|
||||
requirements: str,
|
||||
style_directives: str,
|
||||
seed: int = 0,
|
||||
on_text_delta: Optional[Callable[[str], None]] = None,
|
||||
) -> Tuple[str, str, Dict[str, Any]]:
|
||||
"""
|
||||
Plan prompt and params via LLM.
|
||||
@@ -165,7 +169,12 @@ Style: {style_directives}
|
||||
else:
|
||||
# Traditional mode: Call LLM normally
|
||||
logger.info(f"Sending request to LLM for profile {profile_id}...")
|
||||
response = self.llm_client.complete(system_prompt, user_message)
|
||||
response = self.llm_client.complete(
|
||||
system_prompt,
|
||||
user_message,
|
||||
streaming=on_text_delta is not None,
|
||||
on_text_delta=on_text_delta,
|
||||
)
|
||||
|
||||
# Traditional JSON extraction (fallback or default path)
|
||||
content = response.get("text", "")
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from ..provider_errors import ProviderHTTPError
|
||||
from ..retry_after import get_retry_after_seconds
|
||||
from ..safe_io import STANDARD_OUTBOUND_POLICY, SSRFError, safe_request_json
|
||||
from ..safe_io import (
|
||||
STANDARD_OUTBOUND_POLICY,
|
||||
SSRFError,
|
||||
safe_request_json,
|
||||
safe_request_text_stream,
|
||||
)
|
||||
except ImportError:
|
||||
from services.provider_errors import ProviderHTTPError
|
||||
from services.retry_after import get_retry_after_seconds
|
||||
from services.safe_io import STANDARD_OUTBOUND_POLICY, SSRFError, safe_request_json
|
||||
from services.safe_io import (
|
||||
STANDARD_OUTBOUND_POLICY,
|
||||
SSRFError,
|
||||
safe_request_json,
|
||||
safe_request_text_stream,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.services.providers.openai_compat")
|
||||
|
||||
@@ -149,6 +159,141 @@ def make_request(
|
||||
raise RuntimeError(f"API request failed: {e}")
|
||||
|
||||
|
||||
def make_request_stream(
|
||||
base_url: str,
|
||||
api_key: Optional[str],
|
||||
messages: List[Dict[str, Any]],
|
||||
model: str,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 4096,
|
||||
timeout: float = 120.0,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = None,
|
||||
allow_hosts: Optional[set[str]] = None,
|
||||
allow_any_public_host: bool = False,
|
||||
allow_loopback_hosts: Optional[set[str]] = None,
|
||||
on_text_delta: Optional[Callable[[str], None]] = None,
|
||||
max_preview_chars: int = 16000,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Best-effort streaming request to OpenAI-compatible /chat/completions endpoint.
|
||||
|
||||
Parses SSE `data:` lines and emits incremental text deltas when present.
|
||||
Falls back to final accumulated text result shape `{"text": str, "raw": dict}`.
|
||||
"""
|
||||
endpoint = f"{base_url.rstrip('/')}/chat/completions"
|
||||
payload = build_chat_request(
|
||||
messages, model, temperature, max_tokens, tools, tool_choice
|
||||
)
|
||||
payload["stream"] = True
|
||||
|
||||
headers = {"Content-Type": "application/json", "Accept": "text/event-stream"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
full_text_parts: List[str] = []
|
||||
chunk_count = 0
|
||||
saw_done = False
|
||||
|
||||
def _emit_delta(delta: str) -> None:
|
||||
if not delta:
|
||||
return
|
||||
nonlocal full_text_parts
|
||||
current_len = sum(len(p) for p in full_text_parts)
|
||||
if current_len >= max_preview_chars:
|
||||
return
|
||||
clipped = delta[: max_preview_chars - current_len]
|
||||
if not clipped:
|
||||
return
|
||||
full_text_parts.append(clipped)
|
||||
if on_text_delta:
|
||||
try:
|
||||
on_text_delta(clipped)
|
||||
except Exception:
|
||||
# Callback errors must not break provider parsing.
|
||||
logger.debug("Ignoring on_text_delta callback error", exc_info=True)
|
||||
|
||||
try:
|
||||
for line in safe_request_text_stream(
|
||||
method="POST",
|
||||
url=endpoint,
|
||||
json_body=payload,
|
||||
headers=headers,
|
||||
timeout_sec=int(timeout),
|
||||
policy=STANDARD_OUTBOUND_POLICY,
|
||||
allow_hosts=allow_hosts,
|
||||
allow_any_public_host=allow_any_public_host,
|
||||
allow_loopback_hosts=allow_loopback_hosts,
|
||||
):
|
||||
line = line.rstrip("\r\n")
|
||||
if not line or line.startswith(":"):
|
||||
continue
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
|
||||
data_str = line[5:].strip()
|
||||
if not data_str:
|
||||
continue
|
||||
if data_str == "[DONE]":
|
||||
saw_done = True
|
||||
break
|
||||
|
||||
try:
|
||||
payload_obj = json.loads(data_str)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
chunk_count += 1
|
||||
choices = payload_obj.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta") or {}
|
||||
content = delta.get("content")
|
||||
|
||||
if isinstance(content, str):
|
||||
_emit_delta(content)
|
||||
elif isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
text = part.get("text")
|
||||
if isinstance(text, str):
|
||||
_emit_delta(text)
|
||||
|
||||
return {
|
||||
"text": "".join(full_text_parts),
|
||||
"raw": {
|
||||
"stream": True,
|
||||
"provider": "openai_compat",
|
||||
"chunks": chunk_count,
|
||||
"saw_done": saw_done,
|
||||
},
|
||||
}
|
||||
|
||||
except RuntimeError as e:
|
||||
params = str(e)
|
||||
status_code = 500
|
||||
import re
|
||||
|
||||
m = re.search(r"HTTP error (\d+)", params)
|
||||
if m:
|
||||
status_code = int(m.group(1))
|
||||
|
||||
logger.error(f"OpenAI-compat streaming API error: {e}")
|
||||
raise ProviderHTTPError(
|
||||
status_code=status_code,
|
||||
message=str(e),
|
||||
provider="openai_compat",
|
||||
model=model,
|
||||
retry_after=0,
|
||||
)
|
||||
except SSRFError as e:
|
||||
logger.error(f"OpenAI-compat streaming SSRF blocked: {e}")
|
||||
raise RuntimeError(f"Security policy blocked request: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"OpenAI-compat streaming unexpected error: {e}")
|
||||
raise RuntimeError(f"API request failed: {e}")
|
||||
|
||||
|
||||
def build_vision_message(
|
||||
text_prompt: str,
|
||||
image_base64: str,
|
||||
|
||||
+4
-1
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from typing import Any, Callable, Dict, Optional, Tuple
|
||||
|
||||
from .llm_client import LLMClient
|
||||
from .llm_output import extract_json_object, filter_allowed_keys, sanitize_string
|
||||
@@ -52,6 +52,7 @@ class RefinerService:
|
||||
issue: str,
|
||||
params_json: str = "{}",
|
||||
goal: str = "Fix the issues",
|
||||
on_text_delta: Optional[Callable[[str], None]] = None,
|
||||
) -> Tuple[str, str, Dict[str, Any], str]:
|
||||
"""
|
||||
Refine prompt based on image + issue.
|
||||
@@ -178,6 +179,8 @@ Issue: {issue}
|
||||
system=system_prompt,
|
||||
user_message=user_message,
|
||||
image_base64=image_b64,
|
||||
streaming=on_text_delta is not None,
|
||||
on_text_delta=on_text_delta,
|
||||
)
|
||||
|
||||
content = response.get("text", "")
|
||||
|
||||
@@ -641,3 +641,111 @@ def safe_request_json(
|
||||
if isinstance(e.reason, SSRFError):
|
||||
raise e.reason
|
||||
raise RuntimeError(f"Request failed: {e}")
|
||||
|
||||
|
||||
def safe_request_text_stream(
|
||||
method: str,
|
||||
url: str,
|
||||
json_body: Any = None,
|
||||
*,
|
||||
allow_hosts: Optional[Set[str]] = None,
|
||||
allow_any_public_host: bool = False,
|
||||
allow_loopback_hosts: Optional[Set[str]] = None,
|
||||
headers: Optional[dict] = None,
|
||||
timeout_sec: int = 10,
|
||||
max_line_bytes: int = 64 * 1024,
|
||||
max_redirects: int = 0,
|
||||
policy: Optional[OutboundPolicy] = None,
|
||||
):
|
||||
"""
|
||||
Perform a safe HTTP request and yield response lines as UTF-8 text.
|
||||
|
||||
Intended for SSE/event-stream style provider responses.
|
||||
"""
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
|
||||
current_url = url
|
||||
current_method = method
|
||||
current_body = json.dumps(json_body).encode("utf-8") if json_body else None
|
||||
redirects_followed = 0
|
||||
|
||||
while True:
|
||||
_scheme, _host, _port, pinned_ips = validate_outbound_url(
|
||||
current_url,
|
||||
allow_hosts=allow_hosts,
|
||||
allow_any_public_host=allow_any_public_host,
|
||||
allow_loopback_hosts=allow_loopback_hosts,
|
||||
policy=policy,
|
||||
)
|
||||
|
||||
request = urllib.request.Request(
|
||||
current_url, data=current_body, method=current_method
|
||||
)
|
||||
try:
|
||||
from ..config import PACK_VERSION
|
||||
except ImportError: # pragma: no cover
|
||||
try:
|
||||
from config import PACK_VERSION # type: ignore
|
||||
except ImportError:
|
||||
PACK_VERSION = "0.0.0"
|
||||
|
||||
request.add_header("User-Agent", f"ComfyUI-OpenClaw/{PACK_VERSION}")
|
||||
request.add_header("Content-Type", "application/json")
|
||||
|
||||
ALLOWED_HEADER_PREFIXES = ("x-", "content-type", "authorization", "accept")
|
||||
if headers:
|
||||
for key, value in headers.items():
|
||||
key_lower = key.lower()
|
||||
if any(key_lower.startswith(p) for p in ALLOWED_HEADER_PREFIXES):
|
||||
request.add_header(key, value)
|
||||
else:
|
||||
logger.debug(f"Skipping disallowed header: {key}")
|
||||
|
||||
opener = _build_pinned_opener(pinned_ips)
|
||||
|
||||
try:
|
||||
response = opener.open(request, timeout=timeout_sec)
|
||||
code = response.getcode()
|
||||
|
||||
if code in (301, 302, 303, 307, 308):
|
||||
try:
|
||||
response.close()
|
||||
except Exception:
|
||||
pass
|
||||
if max_redirects > 0 and redirects_followed < max_redirects:
|
||||
redirects_followed += 1
|
||||
new_loc = getattr(response, "headers", {}).get("Location")
|
||||
if not new_loc:
|
||||
raise RuntimeError(f"Redirect without Location: {code}")
|
||||
current_url = urllib.parse.urljoin(current_url, new_loc)
|
||||
if code in (301, 302, 303):
|
||||
current_method = "GET"
|
||||
current_body = None
|
||||
continue
|
||||
raise RuntimeError(f"Too many redirects: {max_redirects}")
|
||||
|
||||
try:
|
||||
while True:
|
||||
line = response.readline(max_line_bytes + 1)
|
||||
if not line:
|
||||
break
|
||||
if len(line) > max_line_bytes:
|
||||
raise RuntimeError(
|
||||
f"Stream line exceeds max_line_bytes ({max_line_bytes})"
|
||||
)
|
||||
yield line.decode("utf-8", errors="replace")
|
||||
finally:
|
||||
try:
|
||||
response.close()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
raise RuntimeError(f"HTTP error {e.code}: {e.reason}")
|
||||
except urllib.error.URLError as e:
|
||||
if isinstance(e.reason, SSRFError):
|
||||
raise e.reason
|
||||
raise RuntimeError(f"Request failed: {e}")
|
||||
|
||||
@@ -113,4 +113,50 @@ test.describe('R38 Lite UX lifecycle', () => {
|
||||
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('Planner streaming path renders live preview and final result', async ({ page }) => {
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (e) => pageErrors.push(e.message));
|
||||
|
||||
await page.route('**/openclaw/assist/planner/stream', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/event-stream',
|
||||
body:
|
||||
'event: ready\n' +
|
||||
'data: {"ok":true,"kind":"planner","mode":"sse"}\n\n' +
|
||||
'event: stage\n' +
|
||||
'data: {"phase":"dispatch","message":"Dispatching assist request"}\n\n' +
|
||||
'event: delta\n' +
|
||||
'data: {"text":"{\\"positive_prompt\\":\\"foggy ","preview_chars":28}\n\n' +
|
||||
'event: delta\n' +
|
||||
'data: {"text":"mountain\\"}","preview_chars":38}\n\n' +
|
||||
'event: final\n' +
|
||||
'data: {"ok":true,"kind":"planner","result":{"positive":"A foggy mountain valley","negative":"lowres, blurry","params":{"width":1024,"height":1024}},"streaming":{"preview_chars":38,"preview_truncated":false}}\n\n',
|
||||
});
|
||||
});
|
||||
|
||||
// Force streaming capability in the shared API instance cache for this page.
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/web/openclaw_api.js');
|
||||
mod.openclawApi._capabilitiesCache = {
|
||||
ok: true,
|
||||
data: { features: { assist_streaming: true } },
|
||||
};
|
||||
mod.openclawApi._capabilitiesCacheTs = Date.now();
|
||||
});
|
||||
|
||||
await clickTab(page, 'Planner');
|
||||
await page.locator('#planner-run-btn').click();
|
||||
|
||||
await expect(page.locator('#planner-loading')).toBeVisible();
|
||||
await expect(page.locator('#planner-stream-preview')).toHaveValue(/foggy/, { timeout: 2000 });
|
||||
await expect(page.locator('#planner-stage')).toHaveText(
|
||||
/Dispatching assist request|Parsing and validating output\.\.\./,
|
||||
);
|
||||
await expect(page.locator('#planner-out-pos')).toHaveValue('A foggy mountain valley');
|
||||
await expect(page.locator('#planner-out-neg')).toHaveValue('lowres, blurry');
|
||||
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,6 +113,98 @@ class TestAssistAPI(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(body["refined_positive"], "new_pos")
|
||||
self.assertEqual(body["rationale"], "Fixed hands")
|
||||
|
||||
async def test_planner_stream_success_emits_delta_and_final(self):
|
||||
request = AsyncMock()
|
||||
request.json = AsyncMock(
|
||||
return_value={
|
||||
"profile": "SDXL-v1",
|
||||
"requirements": "cat",
|
||||
"style_directives": "cinematic",
|
||||
"seed": 123,
|
||||
}
|
||||
)
|
||||
|
||||
class FakeStreamResponse:
|
||||
def __init__(self, status=200, headers=None):
|
||||
self.status = status
|
||||
self.headers = headers or {}
|
||||
self.writes = []
|
||||
|
||||
async def prepare(self, _request):
|
||||
return self
|
||||
|
||||
async def write(self, data):
|
||||
self.writes.append(data)
|
||||
return None
|
||||
|
||||
async def fake_run_in_thread(func, *args, **kwargs):
|
||||
cb = kwargs.get("on_text_delta")
|
||||
if callable(cb):
|
||||
cb("partial-json ")
|
||||
cb("preview")
|
||||
return ("pos", "neg", {"width": 1024, "seed": 123})
|
||||
|
||||
with (
|
||||
patch("api.assist.require_admin_token", return_value=(True, None)),
|
||||
patch("api.assist.check_rate_limit", return_value=True),
|
||||
patch("api.assist.web.StreamResponse", FakeStreamResponse),
|
||||
patch("api.assist.run_in_thread", side_effect=fake_run_in_thread),
|
||||
):
|
||||
resp = await self.handler.planner_stream_handler(request)
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
text = b"".join(resp.writes).decode("utf-8", errors="replace")
|
||||
self.assertIn("event: ready", text)
|
||||
self.assertIn("event: delta", text)
|
||||
self.assertIn("event: final", text)
|
||||
self.assertIn('"positive":"pos"', text)
|
||||
self.assertIn('"preview_chars"', text)
|
||||
|
||||
async def test_refiner_stream_unauthorized(self):
|
||||
request = AsyncMock()
|
||||
request.headers = {}
|
||||
with patch("api.assist.require_admin_token", return_value=(False, "Denied")):
|
||||
resp = await self.handler.refiner_stream_handler(request)
|
||||
self.assertEqual(resp.status, 401)
|
||||
|
||||
async def test_planner_stream_internal_error_emits_error_event(self):
|
||||
request = AsyncMock()
|
||||
request.json = AsyncMock(
|
||||
return_value={
|
||||
"profile": "SDXL-v1",
|
||||
"requirements": "cat",
|
||||
"style_directives": "cinematic",
|
||||
}
|
||||
)
|
||||
|
||||
class FakeStreamResponse:
|
||||
def __init__(self, status=200, headers=None):
|
||||
self.status = status
|
||||
self.headers = headers or {}
|
||||
self.writes = []
|
||||
|
||||
async def prepare(self, _request):
|
||||
return self
|
||||
|
||||
async def write(self, data):
|
||||
self.writes.append(data)
|
||||
return None
|
||||
|
||||
async def fake_run_in_thread(func, *args, **kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
with (
|
||||
patch("api.assist.require_admin_token", return_value=(True, None)),
|
||||
patch("api.assist.check_rate_limit", return_value=True),
|
||||
patch("api.assist.web.StreamResponse", FakeStreamResponse),
|
||||
patch("api.assist.run_in_thread", side_effect=fake_run_in_thread),
|
||||
):
|
||||
resp = await self.handler.planner_stream_handler(request)
|
||||
|
||||
text = b"".join(resp.writes).decode("utf-8", errors="replace")
|
||||
self.assertIn("event: error", text)
|
||||
self.assertIn("Internal server error", text)
|
||||
|
||||
async def test_compose_no_auth(self):
|
||||
"""Test compose rejects unauthenticated requests."""
|
||||
request = AsyncMock()
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
R38: OpenAI-compatible streaming parser contract tests.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from services.providers import openai_compat
|
||||
|
||||
|
||||
class TestR38OpenAICompatStreamParser(unittest.TestCase):
|
||||
def test_make_request_stream_aggregates_deltas_and_emits_callback(self):
|
||||
lines = [
|
||||
'data: {"choices":[{"delta":{"content":"Hello "}}]}\n',
|
||||
'data: {"choices":[{"delta":{"content":"world"}}]}\n',
|
||||
"data: [DONE]\n",
|
||||
]
|
||||
seen = []
|
||||
|
||||
with patch(
|
||||
"services.providers.openai_compat.safe_request_text_stream",
|
||||
return_value=iter(lines),
|
||||
):
|
||||
result = openai_compat.make_request_stream(
|
||||
base_url="https://api.example.com/v1",
|
||||
api_key="sk-test",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
model="test-model",
|
||||
on_text_delta=seen.append,
|
||||
allow_any_public_host=True,
|
||||
)
|
||||
|
||||
self.assertEqual(result["text"], "Hello world")
|
||||
self.assertEqual(seen, ["Hello ", "world"])
|
||||
self.assertTrue(result["raw"]["stream"])
|
||||
self.assertEqual(result["raw"]["chunks"], 2)
|
||||
self.assertTrue(result["raw"]["saw_done"])
|
||||
|
||||
def test_make_request_stream_ignores_non_json_data_lines(self):
|
||||
lines = [
|
||||
": keepalive\n",
|
||||
"data: not-json\n",
|
||||
'data: {"choices":[{"delta":{"content":"ok"}}]}\n',
|
||||
"data: [DONE]\n",
|
||||
]
|
||||
with patch(
|
||||
"services.providers.openai_compat.safe_request_text_stream",
|
||||
return_value=iter(lines),
|
||||
):
|
||||
result = openai_compat.make_request_stream(
|
||||
base_url="https://api.example.com/v1",
|
||||
api_key="sk-test",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
model="test-model",
|
||||
allow_any_public_host=True,
|
||||
)
|
||||
self.assertEqual(result["text"], "ok")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+239
-38
@@ -5,12 +5,33 @@
|
||||
import { OpenClawSession } from "./openclaw_session.js";
|
||||
import { fetchApi, apiURL, fileURL } from "./openclaw_comfy_api.js";
|
||||
import { isAbortError, linkAbortSignal, parseJsonSafe } from "./openclaw_utils.js";
|
||||
import {
|
||||
composeFetchWrappersOnce,
|
||||
withAbortPassthrough,
|
||||
withGetRetry,
|
||||
withPreconnectHint,
|
||||
} from "./openclaw_fetch_wrappers.js";
|
||||
|
||||
export class OpenClawAPI {
|
||||
constructor() {
|
||||
// baseUrl is handled by ComfyUI shim provided via fetchApi
|
||||
this.prefix = "/openclaw";
|
||||
this.legacyPrefix = "/moltbot";
|
||||
this._capabilitiesCache = null;
|
||||
this._capabilitiesCacheTs = 0;
|
||||
|
||||
// R96: Compose fetch wrappers exactly once per fetch instance to avoid
|
||||
// duplicate retry/preconnect/abort decoration on repeated bootstrap.
|
||||
this._decoratedFetchApi = composeFetchWrappersOnce(fetchApi, [
|
||||
withAbortPassthrough(),
|
||||
withPreconnectHint(),
|
||||
withGetRetry({ retries: 1 }),
|
||||
]);
|
||||
this._decoratedNativeFetch = composeFetchWrappersOnce(fetch.bind(window), [
|
||||
withAbortPassthrough(),
|
||||
withPreconnectHint(),
|
||||
withGetRetry({ retries: 1 }),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,6 +45,43 @@ export class OpenClawAPI {
|
||||
return `${this.prefix}${suffix}`;
|
||||
}
|
||||
|
||||
_candidatePaths(url) {
|
||||
const candidates = [];
|
||||
if (typeof url === "string") {
|
||||
candidates.push(url);
|
||||
if (url.startsWith(this.prefix + "/")) {
|
||||
candidates.push(url.replace(this.prefix, this.legacyPrefix));
|
||||
} else if (url.startsWith(this.legacyPrefix + "/")) {
|
||||
candidates.push(url.replace(this.legacyPrefix, this.prefix));
|
||||
}
|
||||
} else {
|
||||
candidates.push(url);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async _fetchWithCandidates(url, options = {}) {
|
||||
let response = null;
|
||||
const candidates = this._candidatePaths(url);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
response = await this._decoratedFetchApi(candidate, options);
|
||||
if (response.status !== 404) break;
|
||||
}
|
||||
|
||||
if (response && response.status === 404 && typeof url === "string") {
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
response = await this._decoratedNativeFetch(fileURL(candidate), options);
|
||||
if (response.status !== 404) break;
|
||||
} catch {
|
||||
// ignore and continue fallback probes
|
||||
}
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
_adminTokenHeaders(token) {
|
||||
const t = token || this._getAdminToken();
|
||||
return {
|
||||
@@ -62,43 +120,10 @@ export class OpenClawAPI {
|
||||
|
||||
try {
|
||||
// R26: Use ComfyUI shim (fetchApi) which handles base path automatically
|
||||
let response = null;
|
||||
|
||||
const candidates = [];
|
||||
if (typeof url === "string") {
|
||||
candidates.push(url);
|
||||
if (url.startsWith(this.prefix + "/")) {
|
||||
candidates.push(url.replace(this.prefix, this.legacyPrefix));
|
||||
} else if (url.startsWith(this.legacyPrefix + "/")) {
|
||||
candidates.push(url.replace(this.legacyPrefix, this.prefix));
|
||||
}
|
||||
} else {
|
||||
candidates.push(url);
|
||||
}
|
||||
|
||||
// 1) Try fetchApi (preferred)
|
||||
for (const candidate of candidates) {
|
||||
response = await fetchApi(candidate, {
|
||||
...fetchOptions,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.status !== 404) break;
|
||||
}
|
||||
|
||||
// 2) Try direct non-/api route as a hardened fallback (legacy loader / routing order issues)
|
||||
if (response && response.status === 404 && typeof url === "string") {
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
response = await fetch(fileURL(candidate), {
|
||||
...fetchOptions,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.status !== 404) break;
|
||||
} catch {
|
||||
// ignore and keep trying
|
||||
}
|
||||
}
|
||||
}
|
||||
const response = await this._fetchWithCandidates(url, {
|
||||
...fetchOptions,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
@@ -176,7 +201,21 @@ export class OpenClawAPI {
|
||||
|
||||
// R19: Capabilities
|
||||
async getCapabilities() {
|
||||
return this.fetch(this._path("/capabilities"));
|
||||
const now = Date.now();
|
||||
if (this._capabilitiesCache && (now - this._capabilitiesCacheTs) < 5000) {
|
||||
return this._capabilitiesCache;
|
||||
}
|
||||
const res = await this.fetch(this._path("/capabilities"));
|
||||
if (res?.ok) {
|
||||
this._capabilitiesCache = res;
|
||||
this._capabilitiesCacheTs = now;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async supportsAssistStreaming() {
|
||||
const caps = await this.getCapabilities();
|
||||
return !!caps?.ok && !!caps?.data?.features?.assist_streaming;
|
||||
}
|
||||
|
||||
// F17: ComfyUI History
|
||||
@@ -321,6 +360,152 @@ export class OpenClawAPI {
|
||||
|
||||
// --- Assist Endpoints (F8/F21) ---
|
||||
|
||||
_parseSSEChunk(rawChunk) {
|
||||
const lines = rawChunk.split(/\r?\n/);
|
||||
let event = "message";
|
||||
const dataLines = [];
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
if (line.startsWith("event:")) {
|
||||
event = line.slice(6).trim() || "message";
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
if (!dataLines.length) return null;
|
||||
const joined = dataLines.join("\n");
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(joined);
|
||||
} catch {
|
||||
data = { raw: joined };
|
||||
}
|
||||
return { event, data };
|
||||
}
|
||||
|
||||
async streamSSEPost(url, payload, { signal = null, timeout = 60000, onEvent = null } = {}) {
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
let cancelledByCaller = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeout);
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
cancelledByCaller = true;
|
||||
controller.abort();
|
||||
} else {
|
||||
signal.addEventListener("abort", () => {
|
||||
cancelledByCaller = true;
|
||||
controller.abort();
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this._fetchWithCandidates(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
...this._adminTokenHeaders(),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response || !response.ok) {
|
||||
clearTimeout(timeoutId);
|
||||
let data = null;
|
||||
try {
|
||||
data = await response?.json?.();
|
||||
} catch {
|
||||
try { data = await response?.text?.(); } catch { }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: response ? response.status : 0,
|
||||
error: (data && data.error) || response?.statusText || "request_failed",
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
const finalEnvelope = { value: null };
|
||||
const dispatchEvent = (evt) => {
|
||||
if (!evt) return;
|
||||
if (evt.event === "final") {
|
||||
finalEnvelope.value = evt.data;
|
||||
}
|
||||
if (typeof onEvent === "function") onEvent(evt);
|
||||
};
|
||||
|
||||
if (!response.body || typeof response.body.getReader !== "function") {
|
||||
const text = await response.text();
|
||||
const chunks = text.split(/\r?\n\r?\n/);
|
||||
for (const chunk of chunks) dispatchEvent(this._parseSSEChunk(chunk));
|
||||
} else {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const findBoundary = (text) => {
|
||||
const idxCRLF = text.indexOf("\r\n\r\n");
|
||||
const idxLF = text.indexOf("\n\n");
|
||||
if (idxCRLF === -1) return { index: idxLF, len: 2 };
|
||||
if (idxLF === -1) return { index: idxCRLF, len: 4 };
|
||||
return idxCRLF < idxLF ? { index: idxCRLF, len: 4 } : { index: idxLF, len: 2 };
|
||||
};
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let boundary = findBoundary(buffer);
|
||||
while (boundary.index >= 0) {
|
||||
const rawChunk = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.len);
|
||||
dispatchEvent(this._parseSSEChunk(rawChunk));
|
||||
boundary = findBoundary(buffer);
|
||||
}
|
||||
}
|
||||
buffer += decoder.decode();
|
||||
if (buffer.trim()) {
|
||||
dispatchEvent(this._parseSSEChunk(buffer));
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
if (finalEnvelope.value?.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: finalEnvelope.value.result,
|
||||
stream: finalEnvelope.value.streaming || {},
|
||||
envelope: finalEnvelope.value,
|
||||
};
|
||||
}
|
||||
if (finalEnvelope.value && finalEnvelope.value.ok === false) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
error: finalEnvelope.value.error || "stream_failed",
|
||||
data: finalEnvelope.value,
|
||||
};
|
||||
}
|
||||
return { ok: false, status: 0, error: "stream_incomplete" };
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
const isAbort = err?.name === "AbortError";
|
||||
const abortKind = cancelledByCaller ? "cancelled" : (timedOut ? "timeout" : "cancelled");
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
error: isAbort ? abortKind : "network_error",
|
||||
detail: err?.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Prompt Planner.
|
||||
* @param {object} params - { profile, requirements, style_directives, seed }
|
||||
@@ -339,6 +524,14 @@ export class OpenClawAPI {
|
||||
});
|
||||
}
|
||||
|
||||
async runPlannerStream(params, { signal = null, onEvent = null } = {}) {
|
||||
return this.streamSSEPost(this._path("/assist/planner/stream"), params, {
|
||||
signal,
|
||||
timeout: 60000,
|
||||
onEvent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Prompt Refiner.
|
||||
* @param {object} params - { image_b64, orig_positive, orig_negative, issue, params_json, goal }
|
||||
@@ -357,6 +550,14 @@ export class OpenClawAPI {
|
||||
});
|
||||
}
|
||||
|
||||
async runRefinerStream(params, { signal = null, onEvent = null } = {}) {
|
||||
return this.streamSSEPost(this._path("/assist/refiner/stream"), params, {
|
||||
signal,
|
||||
timeout: 60000,
|
||||
onEvent,
|
||||
});
|
||||
}
|
||||
|
||||
// --- F22: Presets ---
|
||||
|
||||
async listPresets(category) {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* R96: Fetch wrapper composition helpers with idempotence guard.
|
||||
*
|
||||
* Prevents duplicate wrapper stacking when bootstrap code runs multiple times.
|
||||
*/
|
||||
|
||||
const WRAP_META = Symbol.for("openclaw.fetch_wrapper_meta");
|
||||
const PRECONNECTED_ORIGINS = new Set();
|
||||
|
||||
function getDecoratorId(decorator, index) {
|
||||
if (typeof decorator?.id === "string" && decorator.id) return decorator.id;
|
||||
if (typeof decorator?.name === "string" && decorator.name) return decorator.name;
|
||||
return `decorator_${index}`;
|
||||
}
|
||||
|
||||
function arraysEqual(a = [], b = []) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getFetchWrapperMeta(fetchFn) {
|
||||
return fetchFn?.[WRAP_META] || null;
|
||||
}
|
||||
|
||||
export function composeFetchWrappersOnce(fetchFn, decorators = []) {
|
||||
if (typeof fetchFn !== "function") {
|
||||
throw new TypeError("fetchFn must be a function");
|
||||
}
|
||||
if (!Array.isArray(decorators)) {
|
||||
throw new TypeError("decorators must be an array");
|
||||
}
|
||||
|
||||
const chainIds = decorators.map(getDecoratorId);
|
||||
const existingMeta = getFetchWrapperMeta(fetchFn);
|
||||
if (existingMeta && arraysEqual(existingMeta.chainIds, chainIds)) {
|
||||
return fetchFn;
|
||||
}
|
||||
|
||||
let wrapped = fetchFn;
|
||||
decorators.forEach((decorator, index) => {
|
||||
if (typeof decorator !== "function") {
|
||||
throw new TypeError(`decorator at index ${index} must be a function`);
|
||||
}
|
||||
wrapped = decorator(wrapped);
|
||||
});
|
||||
|
||||
const baseMeta = existingMeta || { baseFetch: fetchFn, appliedCount: 0, chainIds: [] };
|
||||
Object.defineProperty(wrapped, WRAP_META, {
|
||||
value: {
|
||||
baseFetch: baseMeta.baseFetch || fetchFn,
|
||||
appliedCount: (baseMeta.appliedCount || 0) + 1,
|
||||
chainIds,
|
||||
},
|
||||
enumerable: false,
|
||||
configurable: false,
|
||||
writable: false,
|
||||
});
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator: ensures signal is passed through when options contain one.
|
||||
* (Useful when other decorators clone/normalize init objects.)
|
||||
*/
|
||||
export function withAbortPassthrough() {
|
||||
const decorator = async (next, input, init = {}) => next(input, { ...init });
|
||||
const wrapper = (next) => (input, init) => decorator(next, input, init);
|
||||
wrapper.id = "abort_passthrough";
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator: best-effort browser preconnect hint for HTTP(S) origins.
|
||||
*/
|
||||
export function withPreconnectHint() {
|
||||
const wrapper = (next) => (input, init) => {
|
||||
try {
|
||||
const url = typeof input === "string" ? input : (input?.url || "");
|
||||
if (url && typeof document !== "undefined" && /^https?:\/\//i.test(url)) {
|
||||
const origin = new URL(url, window.location.href).origin;
|
||||
if (!PRECONNECTED_ORIGINS.has(origin)) {
|
||||
PRECONNECTED_ORIGINS.add(origin);
|
||||
const link = document.createElement("link");
|
||||
link.rel = "preconnect";
|
||||
link.href = origin;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort only
|
||||
}
|
||||
return next(input, init);
|
||||
};
|
||||
wrapper.id = "preconnect_hint";
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator: single retry for idempotent GET requests on network failures.
|
||||
*/
|
||||
export function withGetRetry({ retries = 1 } = {}) {
|
||||
const wrapper = (next) => async (input, init = {}) => {
|
||||
const method = String(init?.method || "GET").toUpperCase();
|
||||
const shouldRetry = method === "GET" && retries > 0;
|
||||
let lastErr;
|
||||
const attempts = shouldRetry ? retries + 1 : 1;
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
try {
|
||||
return await next(input, init);
|
||||
} catch (err) {
|
||||
const isAbort = err?.name === "AbortError";
|
||||
if (isAbort || i >= attempts - 1) throw err;
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
throw lastErr || new Error("fetch_retry_exhausted");
|
||||
};
|
||||
wrapper.id = `retry_get_${retries}`;
|
||||
return wrapper;
|
||||
}
|
||||
+35
-8
@@ -43,6 +43,10 @@ export const PlannerTab = {
|
||||
<div id="planner-elapsed" style="font-size: 0.9em; opacity: 0.7;">Elapsed: 0s</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px;">
|
||||
<div style="font-size:0.85em; opacity:0.8; margin-bottom:4px;">Live Preview (best effort)</div>
|
||||
<textarea id="planner-stream-preview" class="openclaw-textarea openclaw-textarea moltbot-textarea" style="min-height:70px;" readonly></textarea>
|
||||
</div>
|
||||
<button id="planner-cancel-btn" class="openclaw-btn openclaw-btn moltbot-btn" style="margin-top: 8px; width: 100%; background: var(--input-background); border: 1px solid var(--border-color);">Cancel</button>
|
||||
</div>
|
||||
|
||||
@@ -90,9 +94,11 @@ export const PlannerTab = {
|
||||
const style = container.querySelector("#planner-style").value;
|
||||
|
||||
const resDiv = container.querySelector("#planner-results");
|
||||
const previewEl = container.querySelector("#planner-stream-preview");
|
||||
|
||||
clearError(container);
|
||||
resDiv.style.display = "none";
|
||||
if (previewEl) previewEl.value = "";
|
||||
|
||||
const requestId = ++activeRequestId;
|
||||
const signal = lifecycle.begin("Preparing request...");
|
||||
@@ -102,14 +108,35 @@ export const PlannerTab = {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
lifecycle.setStage("Waiting for provider response...");
|
||||
|
||||
const res = await openclawApi.runPlanner(
|
||||
{
|
||||
profile,
|
||||
requirements: reqs,
|
||||
style_directives: style
|
||||
},
|
||||
signal
|
||||
);
|
||||
const payload = {
|
||||
profile,
|
||||
requirements: reqs,
|
||||
style_directives: style
|
||||
};
|
||||
|
||||
let res;
|
||||
const streamingSupported = await openclawApi.supportsAssistStreaming();
|
||||
if (streamingSupported) {
|
||||
res = await openclawApi.runPlannerStream(payload, {
|
||||
signal,
|
||||
onEvent: (evt) => {
|
||||
if (requestId !== activeRequestId || !evt) return;
|
||||
if (evt.event === "stage" && evt.data?.message) {
|
||||
lifecycle.setStage(evt.data.message);
|
||||
} else if (evt.event === "delta" && typeof evt.data?.text === "string" && previewEl) {
|
||||
previewEl.value += evt.data.text;
|
||||
previewEl.scrollTop = previewEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!res.ok && !["cancelled", "timeout"].includes(res.error || "")) {
|
||||
// Fallback to classic path if streaming transport/path degrades.
|
||||
lifecycle.setStage("Streaming unavailable, falling back...");
|
||||
res = await openclawApi.runPlanner(payload, signal);
|
||||
}
|
||||
} else {
|
||||
res = await openclawApi.runPlanner(payload, signal);
|
||||
}
|
||||
|
||||
if (requestId !== activeRequestId) {
|
||||
return;
|
||||
|
||||
+35
-9
@@ -49,6 +49,10 @@ export const RefinerTab = {
|
||||
<div id="refiner-elapsed" style="font-size: 0.9em; opacity: 0.7;">Elapsed: 0s</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px;">
|
||||
<div style="font-size:0.85em; opacity:0.8; margin-bottom:4px;">Live Preview (best effort)</div>
|
||||
<textarea id="refiner-stream-preview" class="openclaw-textarea openclaw-textarea moltbot-textarea" style="min-height:70px;" readonly></textarea>
|
||||
</div>
|
||||
<button id="refiner-cancel-btn" class="openclaw-btn openclaw-btn moltbot-btn" style="margin-top: 8px; width: 100%; background: var(--input-background); border: 1px solid var(--border-color);">Cancel</button>
|
||||
</div>
|
||||
|
||||
@@ -112,7 +116,9 @@ export const RefinerTab = {
|
||||
container.querySelector("#refiner-run-btn").onclick = async () => {
|
||||
clearError(container);
|
||||
const resDiv = container.querySelector("#refiner-results");
|
||||
const previewEl = container.querySelector("#refiner-stream-preview");
|
||||
resDiv.style.display = "none";
|
||||
if (previewEl) previewEl.value = "";
|
||||
|
||||
const requestId = ++activeRequestId;
|
||||
const signal = lifecycle.begin("Preparing request...");
|
||||
@@ -122,15 +128,35 @@ export const RefinerTab = {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
lifecycle.setStage("Waiting for provider response...");
|
||||
|
||||
const res = await openclawApi.runRefiner(
|
||||
{
|
||||
image_b64: currentImgB64,
|
||||
orig_positive: container.querySelector("#refiner-orig-pos").value,
|
||||
orig_negative: container.querySelector("#refiner-orig-neg").value,
|
||||
issue: container.querySelector("#refiner-issue").value
|
||||
},
|
||||
signal
|
||||
);
|
||||
const payload = {
|
||||
image_b64: currentImgB64,
|
||||
orig_positive: container.querySelector("#refiner-orig-pos").value,
|
||||
orig_negative: container.querySelector("#refiner-orig-neg").value,
|
||||
issue: container.querySelector("#refiner-issue").value
|
||||
};
|
||||
|
||||
let res;
|
||||
const streamingSupported = await openclawApi.supportsAssistStreaming();
|
||||
if (streamingSupported) {
|
||||
res = await openclawApi.runRefinerStream(payload, {
|
||||
signal,
|
||||
onEvent: (evt) => {
|
||||
if (requestId !== activeRequestId || !evt) return;
|
||||
if (evt.event === "stage" && evt.data?.message) {
|
||||
lifecycle.setStage(evt.data.message);
|
||||
} else if (evt.event === "delta" && typeof evt.data?.text === "string" && previewEl) {
|
||||
previewEl.value += evt.data.text;
|
||||
previewEl.scrollTop = previewEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!res.ok && !["cancelled", "timeout"].includes(res.error || "")) {
|
||||
lifecycle.setStage("Streaming unavailable, falling back...");
|
||||
res = await openclawApi.runRefiner(payload, signal);
|
||||
}
|
||||
} else {
|
||||
res = await openclawApi.runRefiner(payload, signal);
|
||||
}
|
||||
|
||||
if (requestId !== activeRequestId) {
|
||||
return;
|
||||
|
||||
@@ -22,6 +22,13 @@
|
||||
parseJsonOrThrow,
|
||||
parseJsonSafe,
|
||||
} from '../openclaw_utils.js';
|
||||
import {
|
||||
composeFetchWrappersOnce,
|
||||
getFetchWrapperMeta,
|
||||
withAbortPassthrough,
|
||||
withGetRetry,
|
||||
withPreconnectHint,
|
||||
} from '../openclaw_fetch_wrappers.js';
|
||||
|
||||
const logEl = document.getElementById('log');
|
||||
const mount = document.getElementById('mount');
|
||||
@@ -162,6 +169,29 @@
|
||||
detach();
|
||||
});
|
||||
|
||||
await run('R96 fetch wrapper composition is idempotent', async () => {
|
||||
let calls = 0;
|
||||
const baseFetch = async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error('transient');
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
};
|
||||
|
||||
const decorators = [withAbortPassthrough(), withPreconnectHint(), withGetRetry({ retries: 1 })];
|
||||
const wrapped1 = composeFetchWrappersOnce(baseFetch, decorators);
|
||||
const wrapped2 = composeFetchWrappersOnce(wrapped1, decorators);
|
||||
|
||||
assert(wrapped1 === wrapped2, 'expected idempotent wrapper reuse on repeated composition');
|
||||
|
||||
const res = await wrapped2('https://example.com/health', { method: 'GET' });
|
||||
assert(res.status === 200, 'wrapped fetch did not return response');
|
||||
assert(calls === 2, `expected single retry path (2 calls), got ${calls}`);
|
||||
|
||||
const meta = getFetchWrapperMeta(wrapped2);
|
||||
assert(meta && Array.isArray(meta.chainIds), 'missing wrapper metadata');
|
||||
assert(meta.chainIds.length === 3, 'unexpected decorator chain metadata');
|
||||
});
|
||||
|
||||
setDone();
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user