From c237a1cb9cdb7586580f24598a49adb8c21cb4cf Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:37:10 +0000 Subject: [PATCH] feat(editor): harden photo editor security and interactions --- .gitignore | 2 + routes/editor_agent_routes.py | 105 +++- routes/gallery/gallery_routes.py | 44 +- scripts/sanitize_trace_artifacts.py | 336 ++++++++++++ src/editor_agent_model.py | 239 ++++++++- static/css/13-image-editor.css | 116 +++- static/js/colorPicker.js | 28 +- static/js/editor/ai-models.js | 123 +++-- static/js/editor/build/right-panel.js | 17 +- static/js/editor/build/toolbar.js | 8 +- static/js/editor/build/topbar.js | 1 + static/js/editor/canvas-coords.js | 23 +- static/js/editor/canvas-events.js | 12 +- static/js/editor/clipboard-and-drop.js | 3 +- static/js/editor/fx/adj-popup.js | 23 +- static/js/editor/layer-panel.js | 3 +- static/js/editor/panels/panels.js | 4 +- static/js/editor/panels/studio-panels.js | 6 +- static/js/editor/state.js | 7 +- static/js/editor/stroke-pipeline.js | 10 +- static/js/editor/submenu-toggles.js | 10 + static/js/editor/tools/transform-session.js | 7 + static/js/editor/wire-topbar.js | 4 +- static/js/galleryEditor.js | 500 +++++++++++++----- static/js/toolWindowZOrder.js | 2 +- static/js/ui.js | 4 +- static/sw.js | 26 +- tests/e2e/README.md | 9 +- .../photo-editor/adjustment-layers.spec.js | 19 +- .../photo-editor/app-compare-mobile.spec.js | 4 + tests/e2e/photo-editor/brush-engine.spec.js | 14 +- .../photo-editor/direct-manipulation.spec.js | 14 +- .../e2e/photo-editor/export-fidelity.spec.js | 4 +- tests/e2e/photo-editor/group-effects.spec.js | 8 +- tests/e2e/photo-editor/group-reorder.spec.js | 3 +- tests/e2e/photo-editor/helpers.js | 97 ++-- .../photo-editor/interaction-contract.spec.js | 171 +++++- .../e2e/photo-editor/layer-selection.spec.js | 6 - tests/e2e/photo-editor/merge-fidelity.spec.js | 7 +- .../e2e/photo-editor/mobile-fit-zoom.spec.js | 57 ++ .../photo-editor/mobile-layer-sheet.spec.js | 6 + .../e2e/photo-editor/offline-upgrade.spec.js | 87 +++ .../photo-editor/panel-text-security.spec.js | 95 ++++ .../photo-editor/phone-footer-gaps.spec.js | 42 ++ .../e2e/photo-editor/preflight-mobile.spec.js | 17 + .../photo-editor/rasterize-confirm.spec.js | 47 +- .../photo-editor/selection-boundary.spec.js | 3 + tests/e2e/photo-editor/tablet-options.spec.js | 46 ++ tests/e2e/photo-editor/text-shapes.spec.js | 14 +- .../e2e/photo-editor/transform-frame.spec.js | 53 +- tests/e2e/playwright.config.js | 19 +- tests/e2e/setup.js | 16 + tests/e2e/teardown.js | 83 ++- tests/test_agent_editor_routes.py | 4 +- tests/test_editor_actions_panel_security.py | 24 +- tests/test_editor_adj_popup_slider_params.py | 43 ++ tests/test_editor_bucket_tool.py | 17 +- tests/test_editor_clipboard_paste_static.py | 5 +- tests/test_editor_color_picker_contract.py | 29 + tests/test_editor_mobile_footer_quickedit.py | 19 +- tests/test_editor_mobile_phone_chrome.py | 7 +- tests/test_editor_model_discovery.py | 163 ++++++ .../test_editor_planner_ssrf_authorization.py | 458 ++++++++++++++++ tests/test_editor_quick_edit_static.py | 8 +- tests/test_editor_toolbar_flyout_hint.py | 5 +- tests/test_gallery_endpoint_enforcement.py | 327 ++++++++++++ tests/test_service_worker_static_cache.py | 99 ++++ tests/test_trace_artifact_sanitizer.py | 244 +++++++++ 68 files changed, 3630 insertions(+), 426 deletions(-) create mode 100644 scripts/sanitize_trace_artifacts.py create mode 100644 tests/e2e/photo-editor/mobile-fit-zoom.spec.js create mode 100644 tests/e2e/photo-editor/offline-upgrade.spec.js create mode 100644 tests/e2e/photo-editor/panel-text-security.spec.js create mode 100644 tests/e2e/photo-editor/preflight-mobile.spec.js create mode 100644 tests/e2e/photo-editor/tablet-options.spec.js create mode 100644 tests/test_editor_adj_popup_slider_params.py create mode 100644 tests/test_editor_color_picker_contract.py create mode 100644 tests/test_editor_model_discovery.py create mode 100644 tests/test_editor_planner_ssrf_authorization.py create mode 100644 tests/test_gallery_endpoint_enforcement.py create mode 100644 tests/test_service_worker_static_cache.py create mode 100644 tests/test_trace_artifact_sanitizer.py diff --git a/.gitignore b/.gitignore index 1ae2878b5..d51361f8d 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,8 @@ services/data/ # Test capture artifacts (browser session dumps may contain personal content) .playwright-mcp/ +test-results/ +playwright-report/ # OS .DS_Store diff --git a/routes/editor_agent_routes.py b/routes/editor_agent_routes.py index 410bab501..51af63c41 100644 --- a/routes/editor_agent_routes.py +++ b/routes/editor_agent_routes.py @@ -25,7 +25,7 @@ import re import uuid from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple -from fastapi import APIRouter, Request +from fastapi import APIRouter, Request, HTTPException from fastapi.responses import JSONResponse, StreamingResponse from src import editor_style_guide as style_guide @@ -1173,12 +1173,35 @@ def _normalize_steps(body: Dict[str, Any]) -> List[Dict[str, Any]]: return out -def _user(request: Request) -> Optional[str]: +def _is_admin(request: Request) -> bool: try: - from src.auth_helpers import get_current_user - return get_current_user(request) + from core.middleware import require_admin + require_admin(request) + return True except Exception: + return False + + +def _user(request: Request) -> Optional[str]: + # Handle API bearer tokens explicitly: require 'chat' scope and attribute to real owner + if getattr(request.state, "api_token", False): + from src.auth_helpers import require_api_token_scope + return require_api_token_scope(request, "chat") + + from src.auth_helpers import get_current_user, _auth_disabled + u = get_current_user(request) + if u: + return u + if _auth_disabled(): return None + auth_mgr = getattr(request.app.state, "auth_manager", None) + if auth_mgr is not None and getattr(auth_mgr, "is_configured", False): + raise HTTPException(401, "Not authenticated") + client = getattr(request, "client", None) + host = (client.host if client else "") or "" + if host in ("127.0.0.1", "::1", "localhost", "testclient") or not host: + return None + raise HTTPException(401, "Not authenticated") async def _default_editor_planner(messages: List[dict], owner: Optional[str]) -> Optional[str]: @@ -1297,9 +1320,13 @@ def setup_editor_agent_routes() -> APIRouter: except Exception: raw = None _used = editor_agent_model.get_last_model() - model_used = (_used[0] if _used and _used[0] - else (agent_model or editor_agent_model.local_model())) parsed = _parse_plan(raw) + if _used and _used[0]: + model_used = _used[0] + elif parsed: + model_used = agent_model or editor_agent_model.local_model() + else: + model_used = "fallback" if agent_model else editor_agent_model.local_model() if isinstance(parsed, dict): parsed["agent_model"] = model_used if mode != "check": @@ -1494,6 +1521,12 @@ def setup_editor_agent_routes() -> APIRouter: overwritten. """ owner = _user(request) + from src.settings import get_setting + disabled = get_setting("disabled_tools", []) or [] + if not isinstance(disabled, (list, tuple, set)): + disabled = [] + if "plan_edit" in disabled: + raise HTTPException(403, "Editor tool is disabled") try: body = await request.json() except Exception: @@ -1506,7 +1539,7 @@ def setup_editor_agent_routes() -> APIRouter: model_choice = str(body.get("model") or body.get("image_model") or "").strip() # Optional user-picked PLANNER model (the editor agent/planner model). agent_model = str(body.get("agent_model") or "").strip() - tools_offered = _scoped_editor_tools() + tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled] scoped = _scoped(session, owner) trace: List[Dict[str, Any]] = [ {"type": "session", "session": scoped, "tools_offered": tools_offered}, @@ -1540,9 +1573,14 @@ def setup_editor_agent_routes() -> APIRouter: except Exception: raw = None _used = editor_agent_model.get_last_model() - model_used = (_used[0] if _used and _used[0] - else (agent_model or editor_agent_model.local_model())) - plan = _parse_plan(raw) or _fallback_plan(prompt) + parsed = _parse_plan(raw) + if _used and _used[0]: + model_used = _used[0] + elif parsed: + model_used = agent_model or editor_agent_model.local_model() + else: + model_used = "fallback" if agent_model else editor_agent_model.local_model() + plan = parsed or _fallback_plan(prompt) _strengthened = style_guide.strengthen(prompt, list(plan.get("steps") or [])) if _strengthened != plan.get("steps"): plan = {**plan, "steps": _strengthened, @@ -1618,6 +1656,12 @@ def setup_editor_agent_routes() -> APIRouter: "results": [], "agent_model": model_used, }) + needed = {"plan_edit"} + for step in steps: + op = step.get("op") + needed.add(op if op in {"ai_edit_image", "generate_image"} else "run_editor_command") + if needed.intersection(disabled): + raise HTTPException(403, "Editor tool is disabled") results: List[Dict[str, Any]] = [] for step in steps: op = step.get("op") @@ -1726,6 +1770,8 @@ def setup_editor_agent_routes() -> APIRouter: ``edit`` (editing/inpainting models) or ``generate`` (text-to-image). """ owner = _user(request) + from src.tool_security import owner_is_admin_or_single_user + is_admin = owner_is_admin_or_single_user(owner) models: List[Dict[str, Any]] = [] seen: set = set() try: @@ -1748,22 +1794,25 @@ def setup_editor_agent_routes() -> APIRouter: # recognisably image-capable — the same test the request # router uses. image_endpoint = getattr(ep, "model_type", None) == "image" - provider = getattr(ep, "name", None) or getattr(ep, "base_url", "") or "image" + endpoint_id = getattr(ep, "id", None) + provider = getattr(ep, "name", None) or (f"Endpoint {endpoint_id}" if endpoint_id else "image") for name in _endpoint_enabled_models(ep): if not image_endpoint and not looks_like_image_generation_model(name): continue - key = (provider, name) + key = (str(endpoint_id), name) if key in seen: continue seen.add(key) - models.append({ + entry = { "id": name, "label": name, "kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate", "provider": provider, - "endpoint_id": getattr(ep, "id", None), - "base_url": getattr(ep, "base_url", "") or "", - }) + "endpoint_id": endpoint_id, + } + if is_admin: + entry["base_url"] = getattr(ep, "base_url", "") or "" + models.append(entry) finally: db.close() except Exception: @@ -1774,21 +1823,39 @@ def setup_editor_agent_routes() -> APIRouter: async def editor_agent_models(request: Request) -> JSONResponse: """List the models the editor agent/planner can run on. - Each entry is ``{id, label, source, vision, base_url}`` where ``id`` is + Each entry is ``{id, label, source, vision}`` where ``id`` is passed as ``agent_model`` to /api/editor/plan or /api/editor/agent, and ``source`` is ``local`` (the local Qwen3.5-9B vision server) or ``hosted`` (the configured default/utility chat model that the planner falls back to). + For non-admin users, private URLs and internal probe status are omitted. """ owner = _user(request) + from src.tool_security import owner_is_admin_or_single_user + is_admin = owner_is_admin_or_single_user(owner) try: models = await editor_agent_model.list_editor_agent_models(owner) except Exception: logger.warning("editor agent-models lookup failed", exc_info=True) models = [] + + safe_models = [] + for m in models: + entry = dict(m) + if not is_admin: + entry.pop("base_url", None) + safe_models.append(entry) + + probe_info = {} + if is_admin: + probe_info = editor_agent_model.probe_status() + if not probe_info: + editor_agent_model.startup_probe() + probe_info = editor_agent_model.probe_status() + return JSONResponse({ - "models": models, - "probe": editor_agent_model.probe_status(), + "models": safe_models, + "probe": probe_info, }) @router.post("/api/editor/self-check") diff --git a/routes/gallery/gallery_routes.py b/routes/gallery/gallery_routes.py index c856223c3..8c90dddc6 100644 --- a/routes/gallery/gallery_routes.py +++ b/routes/gallery/gallery_routes.py @@ -683,9 +683,15 @@ def _visible_image_endpoint_for_base(db, base: str, owner: str | None, model: st configured.update(v for v in values if isinstance(v, str)) except (TypeError, ValueError): pass - if model not in configured or not looks_like_image_generation_model(model): - continue - if _normalize_image_endpoint_base(getattr(ep, "base_url", "")) == target: + if model: + if model not in configured or not looks_like_image_generation_model(model): + continue + else: + if not any(looks_like_image_generation_model(v) for v in configured): + continue + ep_id = str(getattr(ep, "id", "") or "") + ep_base = _normalize_image_endpoint_base(getattr(ep, "base_url", "")) + if (ep_id and (base == ep_id or target == ep_id)) or (ep_base and ep_base == target): if owner and getattr(ep, "owner", None) == owner: return ep if fallback is None: @@ -712,7 +718,7 @@ async def _fetch_result_image_b64(url: str) -> Optional[str]: ) if not ok: raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}") - async with httpx.AsyncClient(timeout=60) as c2: + async with httpx.AsyncClient(timeout=60, follow_redirects=False) as c2: ir = await c2.get(url) if ir.status_code == 200: return base64.b64encode(ir.content).decode() @@ -1727,7 +1733,7 @@ def setup_gallery_routes() -> APIRouter: requested_base = (body.pop("_endpoint", "") or "").rstrip("/") # SSRF hardening: validate a client-supplied endpoint before any # outbound request (mirrors routes/embedding_routes.py). - if requested_base: + if requested_base and "://" in requested_base: from src.url_safety import check_outbound_url ok, reason = check_outbound_url( requested_base, @@ -2073,7 +2079,7 @@ def setup_gallery_routes() -> APIRouter: runs off the event loop (large images). """ requested_base = (body.pop("_endpoint", "") or "").rstrip("/") - if requested_base: + if requested_base and "://" in requested_base: from src.url_safety import check_outbound_url ok, reason = check_outbound_url( requested_base, @@ -2166,26 +2172,34 @@ def setup_gallery_routes() -> APIRouter: try: ep = None if endpoint_id: - ep = owner_filter( - db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id), - ModelEndpoint, user, - ).first() - if ep is None and requested_base: + ep = _visible_image_endpoint_for_base(db, endpoint_id, user) + if ep is None: + return {"available": False, "reason": "endpoint not found or disabled"} + elif requested_base: ep = _visible_image_endpoint_for_base(db, requested_base, user) - if ep is None: + if ep is None: + return {"available": False, "reason": "endpoint not found or disabled"} + else: ep = _first_visible_image_endpoint(db, user) if ep is None: return {"available": False, "reason": "no image endpoint configured"} base = ep.base_url.rstrip("/") finally: db.close() + + from src.url_safety import check_outbound_url + block_private = os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true" + ok, reason = check_outbound_url(base, block_private=block_private) + if not ok: + return {"available": False, "reason": f"endpoint URL rejected: {reason}"} + if not base.endswith("/v1"): base += "/v1" # Dynamic path segment (validated token above), so not routed through # _join_checked_gallery_endpoint's exact-match whitelist. url = f"{base}/images/progress/{request_id}" try: - async with httpx.AsyncClient(timeout=10) as client: + async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client: r = await client.get(url) if r.status_code == 200: data = r.json() @@ -2223,7 +2237,7 @@ def setup_gallery_routes() -> APIRouter: # below, so validate it first (mirrors routes/embedding_routes.py). # Local-first means loopback/LAN is allowed by default; the cloud # metadata range and non-HTTP(S) schemes are always rejected. - if requested_base: + if requested_base and "://" in requested_base: from src.url_safety import check_outbound_url ok, reason = check_outbound_url( requested_base, @@ -2250,7 +2264,7 @@ def setup_gallery_routes() -> APIRouter: # so the outbound URL never depends directly on request-body input. db = SessionLocal() try: - ep = _visible_image_endpoint_for_base(db, requested_base, user) + ep = _visible_image_endpoint_for_base(db, requested_base, user, model) if not ep: raise HTTPException(403, "Choose a registered image endpoint") base = ep.base_url.rstrip("/") diff --git a/scripts/sanitize_trace_artifacts.py b/scripts/sanitize_trace_artifacts.py new file mode 100644 index 000000000..ea975b3b5 --- /dev/null +++ b/scripts/sanitize_trace_artifacts.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +"""Sanitize Playwright trace archives to strip sensitive credentials and tokens. + +Structured, format-aware fail-closed security: +Replaces naive regex-based replacement with structured JSON / HAR parsing, +recursive key/value redaction, URL credential scrubbing, DOM snapshot sanitization, +safe source code masking, and strict fail-closed purge on corruption or error. +""" +from __future__ import annotations + +import json +import logging +import os +import re +import sys +import urllib.parse +import zipfile +from typing import Any, Dict, List, Set + +logger = logging.getLogger(__name__) + +BINARY_IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.ico') + +SENSITIVE_KEY_PATTERN = re.compile( + r'(?i)(password|passwd|secret|token|api_key|apikey|access_token|refresh_token|private_key|auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)' +) + +SENSITIVE_PARAM_NAMES = frozenset({ + "token", "session", "auth", "password", "passwd", "key", "secret", "api_key", + "apikey", "access_token", "refresh_token", "jwt", "sid", "code", "sig", + "signature", "client_secret", "credential" +}) + +SENSITIVE_HEADER_NAMES = frozenset({ + "authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key", + "api-key", "x-auth-token", "x-access-token" +}) + + +def sanitize_url(url: str) -> str: + """Sanitize query parameters and authority credentials in URLs.""" + if not isinstance(url, str) or not url.strip(): + return url + try: + parts = urllib.parse.urlsplit(url) + except Exception: + return url + + netloc = parts.netloc + if "@" in netloc: + userinfo, host = netloc.split("@", 1) + if ":" in userinfo: + user, _ = userinfo.split(":", 1) + netloc = f"{user}:[REDACTED]@{host}" + else: + netloc = f"[REDACTED]@{host}" + + query = parts.query + if query: + q_pairs = urllib.parse.parse_qsl(query, keep_blank_values=True) + sanitized_pairs = [] + for k, v in q_pairs: + k_lower = k.lower().replace("-", "_") + if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower): + sanitized_pairs.append((k, "[REDACTED]")) + else: + sanitized_pairs.append((k, v)) + query = urllib.parse.urlencode(sanitized_pairs) + + return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, query, parts.fragment)) + + +def sanitize_body_text(text: str) -> str: + """Sanitize request bodies formatted as JSON or URL-encoded form data.""" + if not isinstance(text, str) or not text.strip(): + return text + + # 1. Try JSON + trimmed = text.strip() + if (trimmed.startswith("{") and trimmed.endswith("}")) or (trimmed.startswith("[") and trimmed.endswith("]")): + try: + parsed = json.loads(trimmed) + sanitized = sanitize_structured_obj(parsed) + return json.dumps(sanitized, ensure_ascii=False) + except Exception: + pass + + # 2. Try URL-encoded form data + if "=" in trimmed and not trimmed.startswith("<"): + try: + pairs = urllib.parse.parse_qsl(trimmed, keep_blank_values=True) + if pairs and any(k.lower() in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k) for k, _ in pairs): + out_pairs = [] + for k, v in pairs: + k_lower = k.lower().replace("-", "_") + if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower): + out_pairs.append((k, "[REDACTED]")) + else: + out_pairs.append((k, v)) + return urllib.parse.urlencode(out_pairs) + except Exception: + pass + + return text + + +def sanitize_structured_obj(obj: Any) -> Any: + """Recursively sanitize Python objects decoded from JSON or HAR payloads.""" + if isinstance(obj, dict): + new_dict: Dict[str, Any] = {} + + # Check for header entry with inverted or standard keys: {"name": ..., "value": ...} + name_val = obj.get("name") + val_val = obj.get("value") + if isinstance(name_val, str) and isinstance(val_val, str): + name_lower = name_val.lower().strip() + if name_lower in SENSITIVE_HEADER_NAMES or SENSITIVE_KEY_PATTERN.search(name_lower): + for k, v in obj.items(): + if k == "value": + new_dict[k] = "[REDACTED]" + else: + new_dict[k] = sanitize_structured_obj(v) + return new_dict + + # Process standard dictionary keys + for k, v in obj.items(): + k_lower = str(k).lower().replace("-", "_") + + # Action parameters in Playwright traces (e.g. page.fill, page.type) + if k == "params" and isinstance(v, dict): + sanitized_params = sanitize_structured_obj(v) + # If this action involves fill, type, or sensitive inputs, redact value/text + if "value" in sanitized_params: + sanitized_params["value"] = "[REDACTED]" + if "text" in sanitized_params: + sanitized_params["text"] = "[REDACTED]" + new_dict[k] = sanitized_params + continue + + # DOM snapshot nodes + if k == "attributes" and isinstance(v, (dict, list)): + if isinstance(v, dict): + attr_dict = dict(v) + type_val = str(attr_dict.get("type") or "").lower() + if type_val == "password" or "password" in str(attr_dict.get("name") or "").lower(): + if "value" in attr_dict: + attr_dict["value"] = "[REDACTED]" + new_dict[k] = attr_dict + continue + elif isinstance(v, list): + # List of attribute strings or pairs + new_list = [] + is_pw = any("password" in str(x).lower() for x in v) + for x in v: + if is_pw and isinstance(x, str) and not x.startswith("type="): + new_list.append("[REDACTED]") + else: + new_list.append(sanitize_structured_obj(x)) + new_dict[k] = new_list + continue + + # Request postData / text + if k in ("postData", "postDataText") and isinstance(v, str): + new_dict[k] = sanitize_body_text(v) + continue + + # URL fields + if k == "url" and isinstance(v, str): + new_dict[k] = sanitize_url(v) + continue + + # Cookie arrays + if k == "cookies" and isinstance(v, list): + sanitized_cookies = [] + for c in v: + if isinstance(c, dict): + c_copy = dict(c) + if "value" in c_copy: + c_copy["value"] = "[REDACTED]" + sanitized_cookies.append(c_copy) + else: + sanitized_cookies.append(sanitize_structured_obj(c)) + new_dict[k] = sanitized_cookies + continue + + # Direct sensitive key matches + if SENSITIVE_KEY_PATTERN.search(k_lower) and isinstance(v, str): + new_dict[k] = "[REDACTED]" + continue + + new_dict[k] = sanitize_structured_obj(v) + + return new_dict + + elif isinstance(obj, list): + return [sanitize_structured_obj(item) for item in obj] + + elif isinstance(obj, str): + # Check for inline credentials/tokens + if obj.startswith("Bearer ") or obj.startswith("bearer "): + return "Bearer [REDACTED]" + if obj.startswith("Basic ") or obj.startswith("basic "): + return "Basic [REDACTED]" + return obj + + return obj + + +def sanitize_jsonl_content(data: bytes) -> bytes: + """Sanitize newline-delimited JSON Lines (.trace, .network, .stacks).""" + lines = data.split(b"\n") + out_lines = [] + for line in lines: + if not line.strip(): + out_lines.append(line) + continue + try: + parsed = json.loads(line.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + # Not valid JSON, keep as-is + out_lines.append(line) + continue + sanitized = sanitize_structured_obj(parsed) + out_lines.append(json.dumps(sanitized, ensure_ascii=False).encode("utf-8")) + return b"\n".join(out_lines) + + +def sanitize_trace_archive(zp: str) -> bool: + """Sanitize a single zip archive in a strict fail-closed manner. + + If the archive is malformed, partial, unreadable, or encounters an exception + during processing, the archive is permanently deleted so that raw traces + cannot leak. + Returns True if successfully sanitized, False if deleted due to error. + """ + if not os.path.exists(zp): + return False + + temp_zp = zp + ".tmp" + try: + if os.path.getsize(zp) == 0: + raise zipfile.BadZipFile("Empty archive") + + with zipfile.ZipFile(zp, "r") as zin: + infolist = zin.infolist() + if not infolist: + raise zipfile.BadZipFile("Archive has no entries") + + corrupt_file = zin.testzip() + if corrupt_file is not None: + raise zipfile.BadZipFile(f"Corrupt entry detected: {corrupt_file}") + + with zipfile.ZipFile(temp_zp, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for item in infolist: + filename_lower = item.filename.lower() + + # 1. Binary image screenshots/diagnostics: preserve as-is + if filename_lower.endswith(BINARY_IMAGE_EXTENSIONS): + content = zin.read(item.filename) + zout.writestr(item, content) + continue + + # 2. Embedded test source code (e.g. resources/src@... or .js/.ts): + # Mask safely without syntax corruption! + if "src@" in filename_lower or filename_lower.endswith((".js", ".ts", ".mjs", ".py")): + safe_placeholder = b"// [SOURCE CODE REDACTED FOR TRACE CONFIDENTIALITY]\n" + zout.writestr(item, safe_placeholder) + continue + + # 3. JSON Lines and structured JSON/HAR traces + content = zin.read(item.filename) + if filename_lower.endswith((".trace", ".network", ".stacks")): + sanitized_content = sanitize_jsonl_content(content) + zout.writestr(item, sanitized_content) + elif filename_lower.endswith((".json", ".har")): + try: + parsed = json.loads(content.decode("utf-8")) + sanitized = sanitize_structured_obj(parsed) + zout.writestr(item, json.dumps(sanitized, ensure_ascii=False).encode("utf-8")) + except Exception: + # If invalid JSON, treat as text line-by-line + zout.writestr(item, sanitize_jsonl_content(content)) + else: + # Unknown binary or text member: exclude to fail closed + continue + + # Atomic replace + os.replace(temp_zp, zp) + return True + + except Exception as exc: + logger.warning("Sanitizing trace %s failed (%s); removing fail-closed", zp, exc) + for path_to_remove in (temp_zp, zp): + if os.path.exists(path_to_remove): + try: + os.remove(path_to_remove) + except OSError as remove_err: + logger.error("Failed to delete unredacted trace %s: %s", path_to_remove, remove_err) + return False + + +def sanitize_directory(results_dir: str) -> int: + """Walk results_dir and sanitize every .zip archive fail-closed. + + Returns the number of archives processed. Exits with 1 if any archive fails. + """ + if not os.path.exists(results_dir): + return 0 + + count = 0 + all_ok = True + for root, _, files in os.walk(results_dir): + for f in files: + if f.endswith(".zip"): + zp = os.path.join(root, f) + ok = sanitize_trace_archive(zp) + if not ok: + all_ok = False + count += 1 + + if not all_ok: + sys.exit(1) + return count + + +def main() -> None: + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + + target_dir = sys.argv[1] + sanitize_directory(target_dir) + + +if __name__ == "__main__": + main() diff --git a/src/editor_agent_model.py b/src/editor_agent_model.py index 88b638b29..0eff06621 100644 --- a/src/editor_agent_model.py +++ b/src/editor_agent_model.py @@ -339,14 +339,222 @@ async def self_check( return out +def _resolve_override_candidate(override: str, owner: Optional[str]) -> Optional[Dict[str, Any]]: + """Resolve an explicit agent_model override under server authority. + + Guarantees: + - Client-provided raw URLs never determine arbitrary outbound network destinations. + - Registered endpoint IDs resolve only to enabled endpoints visible to the owner. + - Shared endpoints (owner IS NULL) remain visible to authorized users. + - Disabled, foreign-owned, forged, or unconfigured destinations fail closed (return None). + - Admin privileges do not allow arbitrary unconfigured destinations. + """ + raw = str(override or "").strip() + if not raw: + return None + + if "::" in raw: + target, requested_model = raw.split("::", 1) + target = target.strip() + requested_model = requested_model.strip() + else: + target = "" + requested_model = raw + + # 1. Explicit local selection or matching a configured local base + if target == "local": + bases = local_bases() + if not bases: + return None + return { + "base": bases[0], + "model": requested_model or local_model(), + "headers": None, + "source": "override", + } + for lb in local_bases(): + if target and target.rstrip("/") == lb.rstrip("/"): + return { + "base": lb, + "model": requested_model or local_model(), + "headers": None, + "source": "override", + } + + # 2. Explicit hosted fallback selection + if target in ("default", "hosted"): + try: + from src.endpoint_resolver import resolve_endpoint + + for prefix in ("utility", "default"): + try: + url, model, headers = resolve_endpoint(prefix, owner=owner) + except Exception: + continue + if url and model: + return { + "base": url, + "model": requested_model or model, + "headers": headers, + "source": "hosted", + } + except Exception: + pass + return None + + # 3. Bare model id without endpoint prefix + if not target: + if requested_model == "local": + bases = local_bases() + return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"} if bases else None + if requested_model in ("default", "hosted"): + return _resolve_override_candidate("default::", owner) + + # Exact match with local model + if requested_model == local_model(): + bases = local_bases() + if bases: + return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"} + + # Exact match with hosted default/utility + try: + from src.endpoint_resolver import resolve_endpoint + + for prefix in ("utility", "default"): + try: + url, model, headers = resolve_endpoint(prefix, owner=owner) + except Exception: + continue + if url and model and model == requested_model: + return {"base": url, "model": model, "headers": headers, "source": "hosted"} + except Exception: + pass + + # Check registered database endpoints for this bare model + try: + from core.database import SessionLocal, ModelEndpoint + from src.auth_helpers import owner_filter, _auth_disabled + from src.endpoint_resolver import ( + resolve_endpoint_runtime, + build_chat_url, + build_headers, + _endpoint_enabled_models, + _endpoint_hidden_models, + ) + + db = SessionLocal() + try: + if not owner and not _auth_disabled(): + q = db.query(ModelEndpoint).filter( + ModelEndpoint.is_enabled == True, + ModelEndpoint.owner == None, + ) + else: + q = owner_filter( + db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True), + ModelEndpoint, + owner, + ) + matching_eps = [] + for ep in q.all(): + enabled_models = _endpoint_enabled_models(ep) + hidden_models = _endpoint_hidden_models(ep) + if requested_model in enabled_models and requested_model not in hidden_models: + matching_eps.append(ep) + + # Avoid model ID collision across providers: only accept if unique + if len(matching_eps) == 1: + ep = matching_eps[0] + base, api_key = resolve_endpoint_runtime(ep, owner=owner) + return { + "base": build_chat_url(base), + "model": requested_model, + "headers": build_headers(api_key, base), + "source": "registered", + "endpoint_id": ep.id, + } + finally: + db.close() + except Exception: + pass + return None + + # 4. Target is an endpoint_id or a registered legacy endpoint URL + try: + from core.database import SessionLocal, ModelEndpoint + from src.auth_helpers import owner_filter, _auth_disabled + from src.endpoint_resolver import ( + resolve_endpoint_runtime, + build_chat_url, + build_headers, + _endpoint_enabled_models, + _endpoint_hidden_models, + _first_chat_model, + ) + + db = SessionLocal() + try: + if not owner and not _auth_disabled(): + q = db.query(ModelEndpoint).filter( + ModelEndpoint.is_enabled == True, + ModelEndpoint.owner == None, + ) + else: + q = owner_filter( + db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True), + ModelEndpoint, + owner, + ) + # Lookup by ID + ep = q.filter(ModelEndpoint.id == target).first() + + # If not found by ID, match legacy registered base URL (strictly registered rows only) + if ep is None and ("://" in target or target.startswith("http")): + target_norm = target.rstrip("/") + for cand_ep in q.all(): + cand_base = (cand_ep.base_url or "").rstrip("/") + if ( + cand_base == target_norm + or cand_base + "/v1" == target_norm + or cand_base == target_norm + "/v1" + ): + ep = cand_ep + break + + if ep is None: + # Target is disabled, foreign-owned, nonexistent, or arbitrary unconfigured URL -> fail closed! + return None + + enabled_models = _endpoint_enabled_models(ep) + hidden_models = _endpoint_hidden_models(ep) + if requested_model: + if requested_model in hidden_models: + return None + if enabled_models and requested_model not in enabled_models: + # Model not served by this endpoint -> fail closed against forged model IDs + return None + model = requested_model + else: + model = _first_chat_model(enabled_models) + if not model: + return None + + base, api_key = resolve_endpoint_runtime(ep, owner=owner) + return { + "base": build_chat_url(base), + "model": model, + "headers": build_headers(api_key, base), + "source": "registered", + "endpoint_id": ep.id, + } + finally: + db.close() + except Exception: + return None + + def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]: cands: List[Dict[str, Any]] = [] - if override: - base, model = _split_override(override) - if model: - cands.append( - {"base": base or local_bases()[0], "model": model, "headers": None, "source": "override"} - ) for base in local_bases(): cands.append({"base": base, "model": local_model(), "headers": None, "source": "local"}) try: @@ -362,6 +570,16 @@ def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, break except Exception: pass + + if override: + cand = _resolve_override_candidate(override, owner) + if cand is not None: + cands.insert(0, cand) + return cands + # Unauthorized, invalid, foreign, disabled, or forged override: + # Fail closed! Never silently dispatch sensitive user images/state to an unexpected fallback. + return [] + return cands @@ -433,9 +651,11 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str "source": "local", "vision": True, "base_url": base, + "endpoint_id": "local", }) try: from src.endpoint_resolver import resolve_endpoint + from src.settings import get_user_setting, load_settings for prefix in ("utility", "default"): try: @@ -446,12 +666,19 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str key = ("hosted", model) if key not in seen: seen.add(key) + ep_id = None + try: + settings = load_settings() + ep_id = (get_user_setting(f"{prefix}_endpoint_id", owner or "", settings.get(f"{prefix}_endpoint_id", "")) or "").strip() or None + except Exception: + pass models.append({ "id": model, "label": model, "source": "hosted", "vision": None, "base_url": url, + "endpoint_id": ep_id or prefix, }) break except Exception: diff --git a/static/css/13-image-editor.css b/static/css/13-image-editor.css index b70bfc069..dc77b5dad 100644 --- a/static/css/13-image-editor.css +++ b/static/css/13-image-editor.css @@ -111,7 +111,9 @@ flex: 0 0 auto; flex-wrap: nowrap; row-gap: 4px; - min-height: 40px; + height: 44px; + min-height: 44px; + max-height: 44px; padding: 4px 10px; box-sizing: border-box; border-bottom: 1px solid var(--border); @@ -206,9 +208,19 @@ pushed to the right edge. */ flex: 1 1 auto; } +/* When transform section is active in controls, suppress any underlying tool section + so transform controls (Cancel, Apply, X, Y, W, H) have the full width and are not + overlapped or crowded by tool options (e.g. marquee invert/erase buttons). */ +.ge-controls.ge-transform-active > div:not(#ge-transform-section), +.ge-controls:has(#ge-transform-section:not([style*="display:none"]):not([style*="display: none"])) > div:not(#ge-transform-section), +.ge-controls--in-options:has(#ge-transform-section:not([style*="display:none"]):not([style*="display: none"])) > div:not(#ge-transform-section) { + display: none !important; +} /* The Text tool's option set is too wide for the thin strip: let the bar grow and wrap onto extra rows instead of clipping the trailing fields. */ .ge-options-bar.ge-options-tall { + height: auto; + max-height: none; flex-wrap: wrap; align-items: center; overflow-y: visible; @@ -254,7 +266,7 @@ right after the caret instead of leaving a wide gap. */ .ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; } .ge-controls--in-options .ge-control-row { - margin: 0; + margin: 0 !important; flex: 0 0 auto; display: flex; flex-direction: row; @@ -488,8 +500,12 @@ } .ge-opt-empty { font-size: 11px; opacity: 0.5; } .ge-options-empty .ge-options-sep { display: none; } +/* An editor opened at phone width keeps its tool controls in their own bottom + sheet, so the strip has nothing to show. One opened wider than 700px moves + the controls into this strip; hiding it then would strand every tool control + (tablet widths of 701-820px, or a window narrowed after opening). */ @media (max-width: 820px) { - .ge-options-bar { display: none; } + .ge-options-bar:not(:has(.ge-controls--in-options)) { display: none; } } /* Document tab strip (Photoshop-style) — names the open document and @@ -4198,7 +4214,11 @@ body.ge-editor-open textarea.ge-direct-text-editor { flex-direction: column; gap: 2px; } -.ge-save-state-icon { display: none; } +.ge-save-state-icon { display: inline-block; vertical-align: middle; } +.ge-save-state-icon[hidden] { display: none !important; } +.ge-save-state-saved { color: var(--fg); } +.ge-save-state-dirty { color: var(--accent, var(--red)); } +.ge-save-state-error { color: var(--danger, #ef4444); } .ge-save-label { white-space: nowrap; min-width: 0; } /* Save button: glyph on its own row, then the SAVE action label and the live draft-status chip share the second row. */ @@ -6341,6 +6361,10 @@ button .spinner-whirlpool { box-shadow: 0 0 0 1px rgba(0,0,0,0.72); display: none; box-sizing: border-box; + contain: strict; + will-change: transform; + top: 0; + left: 0; } /* Pixel brush: a hard square footprint instead of a feathered circle. */ .ge-brush-cursor.ge-brush-cursor--pixel { @@ -6622,6 +6646,7 @@ button .spinner-whirlpool { } .ge-right-panel.expanded { transform: translateY(0); + z-index: 100; } /* Minimized: panel slides almost fully off-screen — only a thin handle strip remains visible so the user can swipe back up. */ @@ -8134,7 +8159,12 @@ input.ge-ctl-check:focus-visible { } .ge-setup-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; } @media (max-width: 700px) { + /* Cover the phone viewport rather than the small, scrollable canvas area; + the layers sheet otherwise hides the checklist's lower controls. */ + .ge-preflight { position: fixed; } .ge-preflight-card { padding: 13px 14px; } + .ge-preflight-foot { flex-wrap: wrap; } + .ge-preflight-start { white-space: nowrap; } } /* ── Quick Edit — premium look ────────────────────────────────────────── */ @@ -8296,17 +8326,37 @@ input.ge-ctl-check:focus-visible { gap: 6px; padding: 3px 6px; overflow: hidden; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + flex-wrap: nowrap; + white-space: nowrap; min-width: 0; } + .ge-editor-footer::-webkit-scrollbar { + display: none; + } .ge-editor-footer .ge-status-tool, .ge-editor-footer .ge-status-model, .ge-editor-footer .ge-canvas-size { display: none; } - .ge-footer-history { margin-right: 2px; } - .ge-footer-zoom { gap: 3px; min-width: 0; } + .ge-footer-history { + margin-right: 2px; + flex-shrink: 0; + } + .ge-footer-zoom { + gap: 3px; + min-width: 0; + flex-shrink: 0; + margin-left: 0; + } .ge-footer-zoom > .ge-btn, .ge-footer-history > .ge-btn { min-width: 26px; } + .ge-setup-btn { + flex-shrink: 0; + } /* Float the footer above the peek/expanded layers sheet so its zoom/setup controls stay tappable (position/left/right/bottom set in JS). */ .ge-editor-footer.ge-footer-floating { @@ -8317,13 +8367,42 @@ input.ge-ctl-check:focus-visible { } } -/* Phones: the floating zoom/Setup footer collides with the peek/expanded - layers sheet and the Quick Edit card, so remove it entirely on phones. - Undo/Redo/History already live in the layers-sheet header, and pinch-zoom - replaces the zoom buttons. */ +/* Mobile layers header layout */ @media (max-width: 700px) { - .ge-editor-footer { - display: none !important; + .ge-layers-header { + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + flex-wrap: nowrap; + white-space: nowrap; + gap: 4px; + padding-right: 8px; + } + .ge-layers-header::-webkit-scrollbar { + display: none; + } + .ge-layers-header .ge-footer-zoom { + display: inline-flex; + align-items: center; + gap: 3px; + flex-shrink: 0; + } + .ge-layers-header .ge-footer-zoom > .ge-btn, + .ge-layers-header .ge-footer-history > .ge-btn { + min-width: 28px; + min-height: 28px; + } + .ge-layers-header .ge-setup-btn { + flex-shrink: 0; + min-height: 28px; + padding: 3px 8px; + font-size: 11px; + border-radius: 6px; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--panel) 85%, var(--fg) 15%); + color: var(--fg); + cursor: pointer; } /* The Quick Edit card lifts over the layers sheet / keyboard; keep it above every other editor asset (sheet headers, tool rail, popups) while lifted. */ @@ -8579,6 +8658,7 @@ body.ge-editor-open .toast.toast-transform-hint { /* Keep the floating editor footer from stacking on the Quick Edit card. The card owns the bottom of the canvas while it is open, so the footer (zoom / Setup / AI settings) hides until it closes. */ + body.ge-qe-open .ge-editor-footer.ge-footer-floating, body:has(.ge-ai-command:not(.ge-ai-command-collapsed)) .ge-editor-footer.ge-footer-floating { display: none; } @@ -8657,16 +8737,14 @@ body.ge-editor-open .toast.toast-transform-hint { } } -/* Phone menu bar: tighten the Photoshop menus and keep the Save control - icon-only so the twelve menus + Import + Save are not crowded. */ +/* Phone menu bar: tighten the Photoshop menus. The compact topbar already + drops the Save action label; the draft status stays visible because it is + the save button's only announced (role="status") save state. */ @media (max-width: 700px) { .ge-topbar-left .ge-stacked-btn { font-size: 11px; padding: 0 6px !important; } - .ge-topbar.ge-topbar-compact #ge-draft-status { - display: none; - } } /* Phone editor: dock the zoom/Setup footer flush to the Layers sheet. The @@ -9675,12 +9753,15 @@ body.ge-editor-open .tour-hint { } /* The dropdowns clip overflow to draw the custom scroll thumb; release the clip while a flyout is open/hovered so it can extend past the menu edge. */ +.ge-filter-menu.ge-flyout-open, .ge-filter-menu:has(.ge-menu-parent.open), .ge-filter-menu:has(.ge-menu-parent:hover), .ge-filter-menu:has(.ge-submenu:hover), +.ge-image-menu.ge-flyout-open, .ge-image-menu:has(.ge-menu-parent.open), .ge-image-menu:has(.ge-menu-parent:hover), .ge-image-menu:has(.ge-submenu:hover), +.ge-selection-menu.ge-flyout-open, .ge-selection-menu:has(.ge-menu-parent.open), .ge-selection-menu:has(.ge-menu-parent:hover), .ge-selection-menu:has(.ge-submenu:hover) { @@ -9689,6 +9770,7 @@ body.ge-editor-open .tour-hint { mask-image: none; } /* The Window menu's Workspace flyout needs the same clip release. */ +.ge-window-menu.ge-flyout-open, .ge-window-menu:has(.ge-menu-parent.open), .ge-window-menu:has(.ge-menu-parent:hover), .ge-window-menu:has(.ge-submenu:hover) { diff --git a/static/js/colorPicker.js b/static/js/colorPicker.js index e28424a3b..f1784c74e 100644 --- a/static/js/colorPicker.js +++ b/static/js/colorPicker.js @@ -72,7 +72,12 @@ function hexToHsv(hex) { const { r, g, b } = hexToRgb(hex); return rgbToHsv(r, g // ── Storage ─────────────────────────────────────────────────────────── function getRecents() { - try { return JSON.parse(localStorage.getItem(LS_RECENT) || '[]'); } + try { + const values = JSON.parse(localStorage.getItem(LS_RECENT) || '[]'); + return Array.isArray(values) + ? values.filter(value => typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)).slice(0, MAX_RECENT) + : []; + } catch { return []; } } @@ -85,17 +90,26 @@ function addRecent(hex) { } // ── Suggestions based on current color (5 harmony swatches) ────────── -function computeSuggestions() { +function suggestionsForHsv(h, s, v) { // Complement, analogous ±30°, split-complement (+150), tone shift return [ - { hex: hsvToHex(_h + 180, _s, _v), label: 'Complement' }, - { hex: hsvToHex(_h + 30, _s, _v), label: 'Analogous +30°' }, - { hex: hsvToHex(_h - 30, _s, _v), label: 'Analogous -30°' }, - { hex: hsvToHex(_h + 150, _s, _v), label: 'Split-complement' }, - { hex: hsvToHex(_h, _s, clamp(_v > 50 ? _v - 30 : _v + 30, 10, 95)), label: 'Tone shift' }, + { hex: hsvToHex(h + 180, s, v), label: 'Complement' }, + { hex: hsvToHex(h + 30, s, v), label: 'Analogous +30°' }, + { hex: hsvToHex(h - 30, s, v), label: 'Analogous -30°' }, + { hex: hsvToHex(h + 150, s, v), label: 'Split-complement' }, + { hex: hsvToHex(h, s, clamp(v > 50 ? v - 30 : v + 30, 10, 95)), label: 'Tone shift' }, ]; } +function computeSuggestions() { return suggestionsForHsv(_h, _s, _v); } + +export function getRecentColors() { return getRecents(); } +export function recordRecentColor(hex) { addRecent(hex); } +export function suggestColorsFor(hex) { + const { h, s, v } = hexToHsv(hex); + return suggestionsForHsv(h, s, v); +} + // ── Popover build ───────────────────────────────────────────────────── function buildPopover() { const p = document.createElement('div'); diff --git a/static/js/editor/ai-models.js b/static/js/editor/ai-models.js index 6da7d0e03..57debeba5 100644 --- a/static/js/editor/ai-models.js +++ b/static/js/editor/ai-models.js @@ -82,10 +82,9 @@ export function resolveQuickEditAgentModel() { return { agent, endpoint, label: ((opt && opt.textContent) || '').trim() }; } -// Quick Edit agent (planner) model options. Kept small + explicit; the local -// Qwen3.5-9B VL is the default. `value` encodes "::". +// Quick Edit agent (planner) model options. Kept small + explicit; +// dynamically populated from /api/editor/agent-models. export const QUICK_EDIT_AGENT_OPTIONS = [ - { value: 'http://100.69.120.65:8097/v1::Qwen/Qwen3.5-9B', label: 'Qwen3.5-9B VL · local' }, { value: 'default', label: 'Server default (hosted planner)' }, ]; @@ -167,12 +166,29 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model'); if (aiInpaintSelect) aiInpaintSelect.addEventListener('change', () => updateFooterEditModel()); if (aiCommandSelect) aiCommandSelect.addEventListener('change', () => updateFooterEditModel()); - // The Quick Edit agent (planner) picker is static for now (local VL default, - // server default option); restore/save its choice from localStorage. - const populateAgentSelect = () => { + // The Quick Edit agent (planner) picker: dynamically populates from + // /api/editor/agent-models; restore/save choice from localStorage. + const populateAgentSelect = async () => { if (!aiCommandAgentSelect) return; + const prevVal = aiCommandAgentSelect.value; + let agentOptions = [...QUICK_EDIT_AGENT_OPTIONS]; + try { + const res = await fetch(`${apiBase}/api/editor/agent-models`); + if (res.ok) { + const data = await res.json(); + const serverModels = data.models || []; + for (const m of serverModels) { + const optVal = `${m.source === 'local' ? 'local' : (m.endpoint_id ? String(m.endpoint_id) : (m.base_url || ''))}::${m.id}`; + const label = m.label || m.id; + if (!agentOptions.some(o => o.value === optVal)) { + agentOptions.push({ value: optVal, label }); + } + } + } + } catch (_) {} + aiCommandAgentSelect.innerHTML = ''; - for (const opt of QUICK_EDIT_AGENT_OPTIONS) { + for (const opt of agentOptions) { const el = document.createElement('option'); el.value = opt.value; el.textContent = opt.label; @@ -182,6 +198,8 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im const saved = localStorage.getItem('ge-ai-command-agent-model'); if (saved && [...aiCommandAgentSelect.options].some(o => o.value === saved)) { aiCommandAgentSelect.value = saved; + } else if (prevVal && [...aiCommandAgentSelect.options].some(o => o.value === prevVal)) { + aiCommandAgentSelect.value = prevVal; } } catch {} if (!aiCommandAgentSelect._agentWired) { @@ -196,16 +214,17 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im // select nor the inpaint select nor any per-tool select). if (!aiGenSelect && !aiInpaintSelect && !aiCommandSelect && !aiCommandAgentSelect && !document.querySelector('select.ge-tool-model')) return; - populateAgentSelect(); + void populateAgentSelect(); async function loadAIModels(opts = {}) { try { const selectBaseUrl = opts.selectBaseUrl || ''; const prevGenValue = aiGenSelect?.value || ''; const prevInpaintValue = aiInpaintSelect?.value || ''; - const res = await fetch(`${apiBase}/api/model-endpoints`); + const res = await fetch(`${apiBase}/api/editor/image-models`); if (!res.ok) throw new Error(`Could not load image models (${res.status})`); - const endpoints = await res.json(); + const data = await res.json(); + const modelsList = data.models || []; if (aiGenSelect) aiGenSelect.innerHTML = ''; if (aiInpaintSelect) aiInpaintSelect.innerHTML = ''; if (aiCommandSelect) aiCommandSelect.innerHTML = ''; @@ -215,59 +234,52 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im let selectedGen = null; let selectedInpaint = null; const loaded = []; - for (const ep of endpoints) { - if (!ep.is_enabled) continue; - const hasListedModels = Array.isArray(ep.models) && ep.models.length; - const models = hasListedModels ? sortModelIds(ep.models) : ['']; - const isImageEndpoint = (ep.model_type || '').toLowerCase() === 'image'; - // Image/inpaint endpoints can be called by URL even when their - // /models cache is still empty, so don't strand a freshly served - // Cookbook model as "(offline)" in the editor picker. - const epUsable = !!ep.online || isImageEndpoint; - for (const modelId of models) { - const caps = modelCaps(modelId || ep.name, ep.name, ep.model_type); - if (!caps.gen && !caps.inpaint) continue; - // Encode "::" so the value carries both pieces. - const value = `${ep.base_url}::${modelId}`; - const shortModel = modelId ? String(modelId).split('/').pop() : (ep.name || ep.base_url); - const epHint = modelId && ep.name && ep.name !== modelId ? ` · ${ep.name}` : ''; - const label = `${shortModel}${epHint}${epUsable ? '' : ' (offline)'}`; - loaded.push({ value, label, gen: caps.gen, inpaint: caps.inpaint, usable: epUsable, endpoint: ep.base_url, model: modelId }); - if (caps.gen && aiGenSelect) { + for (const m of modelsList) { + const modelId = m.id || ''; + const caps = modelCaps(modelId, m.provider, m.kind); + const canGen = Boolean(caps.gen || m.kind === 'generate'); + const canInpaint = Boolean(caps.inpaint || m.kind === 'edit'); + if (!canGen && !canInpaint) continue; + + // Use opaque endpoint_id if available; fallback to provider name or base_url for admin + const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.base_url || ''); + const value = endpointKey ? `${endpointKey}::${modelId}` : modelId; + const shortModel = modelId ? String(modelId).split('/').pop() : (m.label || m.provider); + const epHint = m.provider && m.provider !== modelId && m.provider !== 'image' ? ` · ${m.provider}` : ''; + const label = `${shortModel}${epHint}`; + const epUsable = true; + loaded.push({ value, label, gen: canGen, inpaint: canInpaint, usable: epUsable, endpoint: endpointKey, model: modelId }); + if (canGen && aiGenSelect) { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = label; + opt.disabled = !epUsable; + aiGenSelect.appendChild(opt); + if (epUsable && !firstGen) firstGen = value; + if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedGen) selectedGen = value; + } + if (canInpaint && aiInpaintSelect) { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = label; + opt.disabled = !epUsable; + aiInpaintSelect.appendChild(opt); + if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedInpaint) selectedInpaint = value; + } + if (canInpaint || canGen) { + for (const ts of perToolSelects) { const opt = document.createElement('option'); opt.value = value; opt.textContent = label; opt.disabled = !epUsable; - aiGenSelect.appendChild(opt); - if (epUsable && !firstGen) firstGen = value; - if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedGen) selectedGen = value; + ts.appendChild(opt); } - if (caps.inpaint && aiInpaintSelect) { + if (aiCommandSelect) { const opt = document.createElement('option'); opt.value = value; opt.textContent = label; opt.disabled = !epUsable; - aiInpaintSelect.appendChild(opt); - if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedInpaint) selectedInpaint = value; - } - // Per-tool selectors get every img2img-capable entry. Both - // caps.inpaint AND caps.gen models work for harmonize / - // style / upscale (anything that can do img2img). - if (caps.inpaint || caps.gen) { - for (const ts of perToolSelects) { - const opt = document.createElement('option'); - opt.value = value; - opt.textContent = label; - opt.disabled = !epUsable; - ts.appendChild(opt); - } - if (aiCommandSelect) { - const opt = document.createElement('option'); - opt.value = value; - opt.textContent = label; - opt.disabled = !epUsable; - aiCommandSelect.appendChild(opt); - } + aiCommandSelect.appendChild(opt); } } } @@ -374,7 +386,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im wireServe(aiInpaintSelect); wireServe(aiCommandSelect); document.querySelectorAll('select.ge-tool-model').forEach(wireServe); - populateAgentSelect(); + void populateAgentSelect(); updateFooterEditModel(); _aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() }; try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {} @@ -387,6 +399,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im return; } loadAIModels({ selectBaseUrl: e.detail?.baseUrl || '' }); + void populateAgentSelect(); }; window.addEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated); // Re-fetch the model list when the user opens the inpaint dropdown, diff --git a/static/js/editor/build/right-panel.js b/static/js/editor/build/right-panel.js index 52e55cfb2..ca140c8d0 100644 --- a/static/js/editor/build/right-panel.js +++ b/static/js/editor/build/right-panel.js @@ -181,7 +181,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = [] // entries in a compact ` @@ -544,6 +551,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g `; + }; if (type === 'brightness-contrast') { const bSlider = Math.round((p.brightness - 1) * 100); const cSlider = Math.round((p.contrast - 1) * 100); @@ -692,7 +700,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g }); } else if (type === 'color-balance') { // Color-tinted slider ends so the user sees what direction does what. - const cbRow = (key, leftCol, rightCol, label, value) => ` + const cbRow = (key, leftCol, rightCol, label, rawValue, value = finite(rawValue)) => `
@@ -749,6 +757,9 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g buildAdjBody(layer, type, body, popEl); }); } else if (type === 'gradient-map') { + const gradientDefaults = defaultAdjParams('gradient-map'); + p.shadows = hexColor(p.shadows, gradientDefaults.shadows); + p.highlights = hexColor(p.highlights, gradientDefaults.highlights); body.innerHTML = `
@@ -789,7 +800,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g body.innerHTML = `
- +
${sliderRow('density', 'Density', 0, 100, Math.round(p.density ?? 25), '%')} @@ -838,7 +849,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g const presetLabels = { none: 'None', teal_orange: 'Teal & Orange', cold: 'Cold', warm: 'Warm', high_contrast: 'High Contrast', sepia: 'Sepia' }; const strength = p.strength == null ? 100 : p.strength; const hasCube = !!(p.cube && p.cube.size && p.cube.data); - const lutName = hasCube ? `Loaded LUT · ${p.cube.size}³` : 'No .cube loaded'; + const lutName = hasCube ? `Loaded LUT · ${finite(p.cube.size)}³` : 'No .cube loaded'; body.innerHTML = `