Compare commits

..
Author SHA1 Message Date
Alexandre Teixeira 1cb597ce12 fix(editor): synchronize quick edit model events and register imagePreferences in test coordinator 2026-10-31 12:43:04 +00:00
Alexandre Teixeira 54b734032c docs(editor): add desktop and mobile visual validation evidence 2026-10-31 12:17:02 +00:00
Alexandre Teixeira aea1317ade Merge remote-tracking branch 'origin/dev' into fix/editor-followups-20261030
Reconcile branch with current dev (bebf53be).
Resolve conflicts in static/sw.js (bump cache version and preserve panel precache)
and src/ai_interaction.py (preserve upstream quality handling with per-user settings).

Remediate PR #6992 findings:
- A (CI & Playwright): Pin playwright==1.62.0 in requirements-dev and CI workflow; guard browser test imports against pytest runner collection failure.
- B (Per-User Image Model Settings): Support per-user image_model and image_edit_model in editor_image_models() and ai_interaction; enable non-admin preference persistence via /api/prefs fallback.
- C (CodeQL Regex Alert 1212): Replace polynomial-backtracking regex in routes/editor_agent_routes.py with linear token-based intent matcher.
- D (Undo/Redo rembg layer): Clear stale rembgLiveLayer and rembgLiveSnap references in _restoreState() to prevent edge tuner from corrupting detached layers.
2026-10-31 12:14:12 +00:00
pewdiepie-archdaemon e79c81fbb8 fix(editor): stabilize image workflows and editing controls 2026-10-30 08:46:10 +00:00
87 changed files with 2589 additions and 2106 deletions
+1
View File
@@ -179,6 +179,7 @@ jobs:
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
python scripts/check_ca_trust.py --python python scripts/check_ca_trust.py --python
uv pip install --system -r requirements.txt uv pip install --system -r requirements.txt
uv pip install --system "playwright==1.62.0"
if: steps.docs-check.outputs.docs_only != 'true' if: steps.docs-check.outputs.docs_only != 'true'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: steps.docs-check.outputs.docs_only != 'true' if: steps.docs-check.outputs.docs_only != 'true'
+11 -33
View File
@@ -31,43 +31,21 @@ for spec in $SPECS; do
name="${spec%%==*}" name="${spec%%==*}"
ver="${spec##*==}" ver="${spec##*==}"
# pip download builds metadata (and trips the same bug), so fetch the raw # pip download builds metadata (and trips the same bug), so fetch the raw
# sdist URL from the PyPI JSON API instead. Use curl with retries to # sdist URL from the PyPI JSON API instead.
# survive transient TLS / network failures that urllib cannot recover from. url="$(python - "$name" "$ver" <<'PY'
metadata="${name}-${ver}.json" import json, sys, urllib.request
curl --fail --silent --show-error --location \ name, ver = sys.argv[1], sys.argv[2]
--retry 8 \ data = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
--retry-delay 3 \ for f in data["urls"]:
--retry-max-time 120 \ if f["packagetype"] == "sdist":
--retry-all-errors \ print(f["url"]); break
"https://pypi.org/pypi/${name}/${ver}/json" \
-o "$metadata"
url="$(python - "$metadata" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as f:
data = json.load(f)
for item in data["urls"]:
if item["packagetype"] == "sdist":
print(item["url"])
break
else: else:
raise SystemExit("no sdist found") sys.exit(f"no sdist found for {name}=={ver}")
PY PY
)" )"
rm -f "$metadata"
echo ">> fetching ${name} ${ver}: ${url}" echo ">> fetching ${name} ${ver}: ${url}"
curl --fail --silent --show-error --location \ curl -fsSL "$url" -o "${name}.tar.gz"
--retry 8 \
--retry-delay 3 \
--retry-max-time 120 \
--retry-all-errors \
"$url" \
-o "${name}.tar.gz"
tar xzf "${name}.tar.gz" tar xzf "${name}.tar.gz"
rm -f "${name}.tar.gz"
done done
echo ">> patching get_version()" echo ">> patching get_version()"
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

+1
View File
@@ -2,3 +2,4 @@
-r requirements.txt -r requirements.txt
# psutil lets `-n auto` use physical cores instead of logical CPU threads. # psutil lets `-n auto` use physical cores instead of logical CPU threads.
pytest-xdist[psutil]>=3.8,<4 pytest-xdist[psutil]>=3.8,<4
playwright==1.62.0
+1 -4
View File
@@ -1901,7 +1901,7 @@ def _ts_or_zero(value) -> float:
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool: def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
"""Recover sess.model from the endpoint's visible cached and pinned models. """Re-populate sess.model from the matching endpoint's cached models.
Covers the window between endpoint setup and the first chat send: the Covers the window between endpoint setup and the first chat send: the
picker showed a model in the dropdown but the session record never got picker showed a model in the dropdown but the session record never got
@@ -1965,9 +1965,6 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
visible = _visible_models(cached, getattr(ep, "hidden_models", None)) visible = _visible_models(cached, getattr(ep, "hidden_models", None))
except Exception: except Exception:
visible = cached visible = cached
if not is_chatgpt_subscription:
from src.endpoint_resolver import _endpoint_enabled_models
visible = _endpoint_enabled_models(ep)
if current_model and current_model in {str(item).strip() for item in visible}: if current_model and current_model in {str(item).strip() for item in visible}:
return False return False
if is_chatgpt_subscription: if is_chatgpt_subscription:
-14
View File
@@ -1491,20 +1491,6 @@ def _diagnose_serve_output(text: str) -> dict | None:
"Port is already in use.", "Port is already in use.",
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}], [{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
), ),
(
# PyPI torch/vLLM wheels now target CUDA 13, which needs driver 580+.
# torch: "The NVIDIA driver on your system is too old (found version 12080)";
# vLLM kernels: "CUDA driver version is insufficient for CUDA runtime version".
r"NVIDIA driver on your system is too old|CUDA driver version is insufficient",
"The installed PyTorch/vLLM build needs a newer NVIDIA driver than this server has.",
[
{
"label": "install builds for the driver's CUDA version, e.g. vLLM's +cu129 wheel from its GitHub release with uv pip install … --torch-backend cu129",
"op": "manual",
},
{"label": "or update the NVIDIA driver (CUDA 13 builds need driver 580 or newer)", "op": "manual"},
],
),
( (
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid", r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
"No GPUs are visible to the serve process.", "No GPUs are visible to the serve process.",
+172 -5
View File
@@ -468,6 +468,175 @@ def setup_cookbook_routes() -> APIRouter:
task["payload"].pop("hf_token", None) task["payload"].pop("hf_token", None)
return state return state
def _diagnose_serve_output(text: str) -> dict | None:
"""Server-side mirror of the Cookbook UI's common serve diagnoses.
The browser uses cookbook-diagnosis.js for clickable fixes. This gives
the agent/tool path the same structured signal so it can retry with an
adjusted command instead of guessing from raw tmux output.
"""
if not text:
return None
tail = text[-6000:]
patterns = [
(
r"No available memory for the cache blocks|Available KV cache memory:.*-",
"No GPU memory left for KV cache after loading model.",
[
{"label": "retry with GPU memory utilization 0.95", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.95"},
{"label": "retry with context 2048", "op": "replace", "flag": "--max-model-len", "value": "2048"},
],
),
(
r"CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory|warming up sampler|max_num_seqs.*gpu_memory_utilization",
"GPU ran out of memory during startup or warmup.",
[
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
{"label": "retry with GPU memory utilization 0.80", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.80"},
{"label": "retry with --enforce-eager", "op": "append", "arg": "--enforce-eager"},
],
),
(
r"not divisib|must be divisible|attention heads.*divisible",
"Tensor parallel size is incompatible with the model.",
[
{"label": "retry with tensor parallel size 1", "op": "replace", "flag": "--tensor-parallel-size", "value": "1"},
{"label": "retry with tensor parallel size 2", "op": "replace", "flag": "--tensor-parallel-size", "value": "2"},
],
),
(
r"KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context",
"Context length is too large for available GPU memory.",
[
{"label": "retry with context 8192", "op": "replace", "flag": "--max-model-len", "value": "8192"},
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
],
),
(
r"enable-auto-tool-choice requires --tool-call-parser",
"Auto tool choice requires an explicit tool call parser.",
[{"label": "retry with Hermes tool parser", "op": "append", "arg": "--tool-call-parser hermes"}],
),
(
r"Please pass.*trust.remote.code=True|contains custom code which must be executed to correctly load|does not recognize this architecture|model type.*but Transformers does not",
"Model requires custom code or newer model support.",
[{"label": "retry with --trust-remote-code", "op": "append", "arg": "--trust-remote-code"}],
),
(
r"Either a revision or a version must be specified|transformers\.integrations\.hub_kernels|kernels/layer",
"vLLM/Transformers kernel package mismatch.",
[{"label": "update vLLM, Transformers, and kernels on this server", "op": "dependency", "package": "vllm transformers kernels"}],
),
(
r"Address already in use|bind.*address.*in use",
"Port is already in use.",
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
),
(
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
"No GPUs are visible to the serve process.",
[{"label": "clear Cookbook GPU selection or choose available GPUs", "op": "settings", "field": "gpus", "value": ""}],
),
(
r"Failed to infer device type|NVML Shared Library Not Found|No module named 'amdsmi'|platform is not available",
"vLLM could not find a supported GPU (CUDA or ROCm). "
"This machine may have integrated or unsupported graphics only.",
[
{"label": "switch to llama.cpp (CPU/Metal, works without a discrete GPU)", "op": "manual"},
{"label": "switch to Ollama (CPU/Metal, works without a discrete GPU)", "op": "manual"},
],
),
(
r"vllm.*command not found|No module named vllm|ERROR: vLLM is not installed",
"vLLM is not installed or not in PATH on this server.",
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
),
(
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|"
r"(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)[\s\S]*sgl_kernel|"
r"Could not load any common_ops library|"
r"Please ensure sgl_kernel is properly installed",
"SGLang native kernel/runtime is missing or mismatched on this server.",
[
{"label": "repair sglang-kernel in this Python environment", "op": "dependency", "package": "sglang-kernel"},
{"label": "if libnvrtc is still missing, install the matching CUDA/NVRTC runtime on this host", "op": "manual"},
],
),
(
r"sglang.*command not found|No module named sglang|SGLang is not installed",
"SGLang is not installed or not in PATH on this server.",
[{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}],
),
(
r"No module named ['\"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed",
"MLX LM is not installed on this server.",
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
),
(
r"OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed",
"This image model uses a custom Diffusers pipeline that the launch environment does not know yet.",
[{"label": "update Diffusers image dependencies", "op": "dependency", "package": "diffusers transformers accelerate"}],
),
(
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
[
{"label": "relaunch from the cached local Hugging Face snapshot path on this Mac", "op": "manual"},
{"label": "Odysseus now rewrites MLX repo-id launches to a cached snapshot when one exists", "op": "manual"},
],
),
# System build deps come BEFORE the generic llama.cpp catch-all
# so cmake / build-essential / git missing → a specific OS-package
# remediation instead of "install llama-cpp-python[server]" (which
# itself fails to compile when cmake is absent).
(
r"cmake: command not found|cmake.*not found.*[Cc]ould not",
"cmake is required to build llama.cpp from source but isn't installed on this server.",
[{"label": "install build deps for llama.cpp (apt: cmake build-essential git / pacman: cmake base-devel git / dnf: cmake gcc-c++ make git / brew: cmake git)", "op": "dependency", "package": "llama-cpp-python[server]"}],
),
(
r"^(make|g\+\+|gcc): command not found|Could not find C\+\+ compiler",
"A C/C++ compiler (build-essential) is required to build llama.cpp from source.",
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
),
(
r"^git: command not found",
"git is required to clone the llama.cpp source tree.",
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
),
(
r"llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'",
"llama.cpp / llama-cpp-python dependencies are missing.",
[{"label": "install llama.cpp dependencies or llama-cpp-python[server]", "op": "dependency", "package": "llama-cpp-python[server]"}],
),
(
r"No GGUF found on this host|no \.gguf file|No GGUF file found",
"No GGUF file found for this model on this host. The llama.cpp backend needs a .gguf file.",
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
),
(
r"No module named 'torch'|No module named torch|No module named 'torchvision'|No module named torchvision|No module named 'diffusers'|No module named diffusers|No module named 'scipy'|No module named scipy|install scipy if you want to use beta sigmas|requires the Torchvision library",
"Diffusion serving requires PyTorch, Torchvision, Diffusers, Accelerate, and SciPy.",
[{"label": "install Diffusers image deps in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch] torchvision accelerate scipy python-multipart"}],
),
(
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
"Model access is gated or unauthorized.",
[{"label": "set HF token and request model access on HuggingFace", "op": "manual"}],
),
]
for pattern, message, suggestions in patterns:
if re.search(pattern, tail, re.I):
return {"message": message, "suggestions": suggestions}
if re.search(r"Traceback \(most recent call last\)", tail, re.I) and not re.search(
r"Application startup complete|GET /v1/|Uvicorn running on", tail, re.I
):
return {
"message": "Python traceback detected during serve startup.",
"suggestions": [{"label": "inspect traceback and retry with adjusted backend/settings", "op": "manual"}],
}
return None
def _state_for_client(state): def _state_for_client(state):
"""Return cookbook state without raw secrets for browser clients.""" """Return cookbook state without raw secrets for browser clients."""
_strip_task_secrets(state) _strip_task_secrets(state)
@@ -3761,7 +3930,7 @@ def setup_cookbook_routes() -> APIRouter:
return {"ok": False, "files": [], "error": f"HF API HTTP {resp.status_code}"} return {"ok": False, "files": [], "error": f"HF API HTTP {resp.status_code}"}
data = resp.json() data = resp.json()
except Exception: except Exception:
logger.exception("HF GGUF file scan failed for %s", repo_id) logger.exception("HF GGUF file scan failed for %s", repo)
return {"ok": False, "files": [], "error": "HF API request failed"} return {"ok": False, "files": [], "error": "HF API request failed"}
files = [ files = [
str(s.get("rfilename") or "") str(s.get("rfilename") or "")
@@ -4277,9 +4446,7 @@ def setup_cookbook_routes() -> APIRouter:
# fills the visible tail. Without this, output_tail ends up # fills the visible tail. Without this, output_tail ends up
# as just "Locale: C / Ubuntu_Odysseus ❯" and the agent # as just "Locale: C / Ubuntu_Odysseus ❯" and the agent
# can't diagnose the actual error. # can't diagnose the actual error.
# -J joins lines tmux wrapped at the pane width, so an error capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-S", "-500")]
# split mid-word still matches the serve diagnosis patterns.
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-J", "-S", "-500")]
elif IS_WINDOWS: elif IS_WINDOWS:
# LOCAL Windows task: launched as a detached process (no tmux). # LOCAL Windows task: launched as a detached process (no tmux).
# Liveness comes from the <session>.pid file, output from the # Liveness comes from the <session>.pid file, output from the
@@ -4288,7 +4455,7 @@ def setup_cookbook_routes() -> APIRouter:
capture_cmd = None capture_cmd = None
else: else:
check_cmd = ["tmux", "has-session", "-t", session_id] check_cmd = ["tmux", "has-session", "-t", session_id]
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-J", "-S", "-500"] capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-500"]
local_win_task = (not remote) and IS_WINDOWS local_win_task = (not remote) and IS_WINDOWS
+4 -9
View File
@@ -16,7 +16,6 @@ from core.database import SessionLocal, Document, DocumentVersion
from core.database import Session as DbSession from core.database import Session as DbSession
from src.auth_helpers import get_current_user, _auth_disabled from src.auth_helpers import get_current_user, _auth_disabled
from src.constants import MAIL_ATTACHMENTS_DIR from src.constants import MAIL_ATTACHMENTS_DIR
from src.session_search import _escape_like
from src.upload_handler import reserve_upload_references from src.upload_handler import reserve_upload_references
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -572,10 +571,9 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
# match docs containing both words regardless of position/order. # match docs containing both words regardless of position/order.
if search: if search:
for tok in search.split(): for tok in search.split():
term = f"%{_escape_like(tok)}%" term = f"%{tok}%"
q = q.filter( q = q.filter(
Document.title.ilike(term, escape="\\") Document.title.ilike(term) | Document.current_content.ilike(term)
| Document.current_content.ilike(term, escape="\\")
) )
# Language filter. "pdf" is a display language derived from the # Language filter. "pdf" is a display language derived from the
@@ -814,13 +812,10 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
ext = _ext.get(doc.language or "text", ".txt") ext = _ext.get(doc.language or "text", ".txt")
base = (doc.title or "document").strip() or "document" base = (doc.title or "document").strip() or "document"
base = re.sub(r"[^\w\-. ]+", "", base)[:60].strip() or doc.id base = re.sub(r"[^\w\-. ]+", "", base)[:60].strip() or doc.id
has_ext = base.lower().endswith(ext) name = base if "." in base else base + ext
stem = base[:-len(ext)] if has_ext else base
suffix = base[-len(ext):] if has_ext else ext
name = stem + suffix
i = 1 i = 1
while name in used: while name in used:
name = f"{stem}-{i}{suffix}" name = f"{base}-{i}" + ("" if "." in base else ext)
i += 1 i += 1
used.add(name) used.add(name)
content = doc.current_content or "" content = doc.current_content or ""
+82 -2
View File
@@ -846,6 +846,64 @@ def _plan_psd_steps(prompt: str) -> Optional[List[Dict[str, Any]]]:
return None return None
def _is_background_removal_intent(prompt: str) -> bool:
"""Linear-time, bounded intent parser for background removal commands.
Replaces the previous backtracking regular expression flagged by CodeQL
(#1212) while preserving the exact command grammar.
"""
if not prompt or not isinstance(prompt, str):
return False
i = len(prompt)
while i > 0 and prompt[i - 1] in ".! \t\r\n":
i -= 1
cleaned = prompt[:i].strip()
if not cleaned:
return False
tokens = cleaned.split()
if not tokens:
return False
idx = 0
if tokens[idx].lower() == "please":
idx += 1
if idx >= len(tokens):
return False
if tokens[idx].lower() not in {"remove", "erase", "delete", "clear", "strip"}:
return False
idx += 1
if idx >= len(tokens):
return False
if tokens[idx].lower() == "the":
idx += 1
if idx >= len(tokens):
return False
if tokens[idx].lower() not in {"bg", "background", "backdrop"}:
return False
idx += 1
if idx == len(tokens):
return True
if tokens[idx].lower() in {"from", "of"}:
idx += 1
if idx < len(tokens) and tokens[idx].lower() == "the":
idx += 1
if (
idx + 1 < len(tokens)
and tokens[idx].lower() in {"active", "current", "selected"}
and tokens[idx + 1].lower() == "layer"
and idx + 2 == len(tokens)
):
return True
return False
def _plan_special( def _plan_special(
prompt: str, state: Any, provider_available: bool, prompt: str, state: Any, provider_available: bool,
) -> Optional[Tuple[List[Dict[str, Any]], str, str]]: ) -> Optional[Tuple[List[Dict[str, Any]], str, str]]:
@@ -855,6 +913,8 @@ def _plan_special(
left to the model. Returns (steps, reply, note) or None. These never need an left to the model. Returns (steps, reply, note) or None. These never need an
image provider except generative border fill, used only when one exists. image provider except generative border fill, used only when one exists.
""" """
if _is_background_removal_intent(prompt):
return [{"op": "ai.removeBackground", "params": {}}], "Removing the active layer's background.", ""
# Targeted recolour is fully deterministic (object mask + masked hue shift) # Targeted recolour is fully deterministic (object mask + masked hue shift)
# and must never be left to the model, which sometimes substitutes a # and must never be left to the model, which sometimes substitutes a
# generative placeImage. Highest priority so it can never vary. # generative placeImage. Highest priority so it can never vary.
@@ -1647,6 +1707,13 @@ def setup_editor_agent_routes() -> APIRouter:
screenshot = body.get("screenshot") screenshot = body.get("screenshot")
# Optional user-picked image model (from the editor model selector). # Optional user-picked image model (from the editor model selector).
model_choice = str(body.get("model") or body.get("image_model") or "").strip() model_choice = str(body.get("model") or body.get("image_model") or "").strip()
generation_model_choice = str(body.get("generation_model") or "").strip()
use_image_settings = body.get("_use_image_settings") is True
if use_image_settings:
from src.editor_image_settings import editor_image_models
image_models = editor_image_models(owner=owner)
model_choice = image_models["edit"]
generation_model_choice = image_models["generation"]
# Optional user-picked PLANNER model (the editor agent/planner model). # Optional user-picked PLANNER model (the editor agent/planner model).
agent_model = str(body.get("agent_model") or "").strip() agent_model = str(body.get("agent_model") or "").strip()
tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled] tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
@@ -1797,9 +1864,22 @@ def setup_editor_agent_routes() -> APIRouter:
# Planner output is untrusted: it cannot redirect this turn to # Planner output is untrusted: it cannot redirect this turn to
# another editor session or override an explicit model choice. # another editor session or override an explicit model choice.
tool_payload["session"] = session tool_payload["session"] = session
if use_image_settings:
tool_payload["_use_image_settings"] = True
if op == "generate_image" and state.get("width") and state.get("height"):
try:
width, height = int(state["width"]), int(state["height"])
if width > 0 and height > 0:
tool_payload["size"] = f"{width}x{height}"
except (TypeError, ValueError):
pass
# Route to the user-picked model when one is selected. # Route to the user-picked model when one is selected.
if model_choice: selected_model = generation_model_choice if op == "generate_image" else model_choice
tool_payload["model"] = model_choice if selected_model:
tool_payload["model"] = selected_model
elif op == "generate_image" or use_image_settings:
# Let server settings resolve Auto, not a planner-invented model.
tool_payload.pop("model", None)
# Hand the AI step the canvas image the client already captured # Hand the AI step the canvas image the client already captured
# for this request. Without it the tool falls back to a separate # for this request. Without it the tool falls back to a separate
# bridge screenshot, which is not reliably answered right after a # bridge screenshot, which is not reliably answered right after a
+80 -55
View File
@@ -18,7 +18,6 @@ from fastapi import APIRouter, HTTPException, Query, Request
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
from core.database import Session as DbSession from core.database import Session as DbSession
from src.auth_helpers import get_current_user, owner_filter, require_privilege from src.auth_helpers import get_current_user, owner_filter, require_privilege
from src.session_search import escape_like_pattern
from src.upload_limits import ( from src.upload_limits import (
read_upload_limited, read_upload_limited,
GALLERY_UPLOAD_MAX_BYTES, GALLERY_UPLOAD_MAX_BYTES,
@@ -38,6 +37,24 @@ _SAM_STATE: Dict[str, Any] = {}
_GROUNDING_STATE: Dict[str, Any] = {} _GROUNDING_STATE: Dict[str, Any] = {}
def _compose_cloud_image_edit(source, generated, mask, *, instruction=False):
from PIL import Image, ImageChops, ImageOps
full_frame = instruction or mask.getextrema() == (255, 255)
if full_frame:
return generated, True
# Partial edits must share the source coordinate system, but resizing
# width/height independently distorts the subject when the provider changes ratio.
fitted = ImageOps.contain(generated, source.size, Image.Resampling.LANCZOS)
offset = ((source.width - fitted.width) // 2, (source.height - fitted.height) // 2)
aligned = Image.new("RGBA", source.size)
aligned.paste(fitted, offset)
coverage = Image.new("L", source.size)
coverage.paste(255, (offset[0], offset[1], offset[0] + fitted.width, offset[1] + fitted.height))
effective_mask = ImageChops.multiply(mask, coverage)
return Image.composite(aligned, source, effective_mask), False
def _rss_mb() -> float: def _rss_mb() -> float:
"""Current process RSS in MB (from /proc; -1.0 when unavailable).""" """Current process RSS in MB (from /proc; -1.0 when unavailable)."""
try: try:
@@ -306,23 +323,6 @@ def _b64_bytes(value: str, *, label: str = "image") -> bytes:
raise HTTPException(400, f"Invalid {label}") from exc raise HTTPException(400, f"Invalid {label}") from exc
async def _json_object(request: Request) -> dict:
try:
data = await request.json()
except ValueError:
raise HTTPException(400, "Invalid JSON body")
if not isinstance(data, dict):
raise HTTPException(400, "JSON object body required")
return data
async def _image_ids(request: Request) -> list:
ids = (await _json_object(request)).get("image_ids", [])
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
raise HTTPException(400, "image_ids must be a list of image ids")
return ids
def _pil_image_to_b64(img, *, fmt: str = "PNG") -> str: def _pil_image_to_b64(img, *, fmt: str = "PNG") -> str:
buf = io.BytesIO() buf = io.BytesIO()
img.save(buf, format=fmt) img.save(buf, format=fmt)
@@ -586,6 +586,7 @@ def _is_openai_api_base(url: str) -> bool:
_GALLERY_ENDPOINT_PATHS = frozenset({ _GALLERY_ENDPOINT_PATHS = frozenset({
"/chat/completions",
"/images", "/images",
"/images/edits", "/images/edits",
"/images/generations", "/images/generations",
@@ -1202,17 +1203,14 @@ def setup_gallery_routes() -> APIRouter:
) )
q = _owner_filter(q, user) q = _owner_filter(q, user)
# Per-term AND search: a single `%foo bar%` LIKE only matched the # Search filter (prompt + tags + ai_tags)
# exact phrase, so "red car" missed "a car that is red".
# LIKE wildcards are escaped per token.
if search: if search:
term = f"%{search}%"
from sqlalchemy import or_ from sqlalchemy import or_
for tok in search.split():
term = f"%{escape_like_pattern(tok)}%"
q = q.filter(or_( q = q.filter(or_(
GalleryImage.prompt.ilike(term, escape="\\"), GalleryImage.prompt.ilike(term),
GalleryImage.tags.ilike(term, escape="\\"), GalleryImage.tags.ilike(term),
GalleryImage.ai_tags.ilike(term, escape="\\"), GalleryImage.ai_tags.ilike(term),
)) ))
# Tag filter. The UI stacks multiple tag pills by passing them # Tag filter. The UI stacks multiple tag pills by passing them
@@ -1224,10 +1222,9 @@ def setup_gallery_routes() -> APIRouter:
for one in (t.strip() for t in tag.split(",")): for one in (t.strip() for t in tag.split(",")):
if not one: if not one:
continue continue
safe_tag = f"%{escape_like_pattern(one)}%"
q = q.filter(_or( q = q.filter(_or(
GalleryImage.tags.ilike(safe_tag, escape="\\"), GalleryImage.tags.ilike(f"%{one}%"),
GalleryImage.ai_tags.ilike(safe_tag, escape="\\"), GalleryImage.ai_tags.ilike(f"%{one}%"),
)) ))
# Model filter # Model filter
@@ -1767,6 +1764,17 @@ def setup_gallery_routes() -> APIRouter:
if not ok: if not ok:
raise HTTPException(400, f"Rejected endpoint URL: {reason}") raise HTTPException(400, f"Rejected endpoint URL: {reason}")
chosen_model = (body.pop("_model", "") or "").strip() chosen_model = (body.pop("_model", "") or "").strip()
if body.pop("_use_image_settings", False) is True:
from src.editor_image_settings import editor_image_models
from src.ai_interaction import _resolve_model
chosen_model = editor_image_models(owner=user)["edit"]
if not chosen_model:
raise HTTPException(400, "Choose an image edit model in AI settings")
try:
edit_url, chosen_model, _ = await asyncio.to_thread(_resolve_model, chosen_model, owner=user)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
requested_base = edit_url.removesuffix("/chat/completions").rstrip("/")
# Optional client-supplied id so GET /api/image/edit-progress can poll the # Optional client-supplied id so GET /api/image/edit-progress can poll the
# provider's step progress for this op. Ignored by providers that do not # provider's step progress for this op. Ignored by providers that do not
# implement the progress contract. # implement the progress contract.
@@ -1884,13 +1892,29 @@ def setup_gallery_routes() -> APIRouter:
mask_png.save(reference_mask, format="PNG") mask_png.save(reference_mask, format="PNG")
references = [src_buf.getvalue(), reference_mask.getvalue()] references = [src_buf.getvalue(), reference_mask.getvalue()]
payload = { payload = {
"model": oa_model, "size": size, "n": 1, "model": oa_model, "n": 1,
"output_format": "png", "output_format": "png",
"prompt": "Edit the first image. The second image is a mask: white marks the region to edit, black marks the region to preserve. Keep composition and framing unchanged. Requested edit: " + str(body.get("prompt", "")), "prompt": "Edit the first image. The second image is a mask: white marks the region to edit, black marks the region to preserve. Keep composition and framing unchanged. Requested edit: " + str(body.get("prompt", "")),
"input_references": [{"type": "image_url", "image_url": { "input_references": [{"type": "image_url", "image_url": {
"url": "data:image/png;base64," + base64.b64encode(value).decode(), "url": "data:image/png;base64," + base64.b64encode(value).decode(),
}} for value in references], }} for value in references],
} }
from src.image_model_ids import image_aspect_ratio
payload["aspect_ratio"] = image_aspect_ratio(source_png.width, source_png.height)
from src.ai_interaction import _uses_openrouter_chat_images
if _uses_openrouter_chat_images(oa_model):
payload = {
"model": oa_model,
"messages": [{"role": "user", "content": [
{"type": "text", "text": payload["prompt"]},
*[{"type": "image_url", "image_url": ref["image_url"]}
for ref in payload["input_references"]],
]}],
"modalities": ["image", "text"], "stream": False,
"image_config": {"aspect_ratio": payload["aspect_ratio"]},
}
r = await client.post(_join_checked_gallery_endpoint(base, "/chat/completions"), headers=headers, json=payload)
else:
r = await client.post(_join_checked_gallery_endpoint(base, "/images"), headers=headers, json=payload) r = await client.post(_join_checked_gallery_endpoint(base, "/images"), headers=headers, json=payload)
else: else:
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files) r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files)
@@ -1907,6 +1931,12 @@ def setup_gallery_routes() -> APIRouter:
pass pass
raise HTTPException(r.status_code, f"{_detail} (provider HTTP {r.status_code})") raise HTTPException(r.status_code, f"{_detail} (provider HTTP {r.status_code})")
result = r.json() result = r.json()
from src.ai_interaction import _chat_image_results, _image_response_error
provider_error = _image_response_error(result, oa_model)
if provider_error:
raise HTTPException(400 if "deprecated" in provider_error.lower() else 502, provider_error)
if is_openrouter and _uses_openrouter_chat_images(oa_model):
result = {"data": _chat_image_results(result)}
raw_b64 = None raw_b64 = None
if result.get("data"): if result.get("data"):
item = result["data"][0] item = result["data"][0]
@@ -1926,17 +1956,13 @@ def setup_gallery_routes() -> APIRouter:
# the masked region actually changes. # the masked region actually changes.
try: try:
generated = Image.open(io.BytesIO(base64.b64decode(raw_b64))).convert("RGBA") generated = Image.open(io.BytesIO(base64.b64decode(raw_b64))).convert("RGBA")
# Match the generated image to the source dims. blended, full_frame = _compose_cloud_image_edit(
if generated.size != source_png.size: source_png, generated, mask_png,
generated = generated.resize(source_png.size, Image.LANCZOS) instruction=body.get("mode") == "instruction",
# mask_png: white = regenerate (use generated), )
# black = keep (use source).
# Composite: result = source * (1 - mask_norm) + generated * mask_norm
# Image.composite does exactly that with `mask`.
blended = Image.composite(generated, source_png, mask_png)
out_buf = io.BytesIO() out_buf = io.BytesIO()
blended.save(out_buf, format="PNG") blended.save(out_buf, format="PNG")
return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model} return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model, "full_frame": full_frame}
except Exception as comp_err: except Exception as comp_err:
# Never return a full-image edit when masking fails. # Never return a full-image edit when masking fails.
logger.warning(f"Inpaint compose failed: {comp_err}") logger.warning(f"Inpaint compose failed: {comp_err}")
@@ -2593,7 +2619,9 @@ def setup_gallery_routes() -> APIRouter:
height = int((body or {}).get("height") or 0) height = int((body or {}).get("height") or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
width = height = 0 width = height = 0
if height > width: if width > 0 and height > 0:
size = f"{width}x{height}"
elif height > width:
size = "1024x1536" size = "1024x1536"
elif width > height: elif width > height:
size = "1536x1024" size = "1536x1024"
@@ -2606,6 +2634,9 @@ def setup_gallery_routes() -> APIRouter:
# The editor's model picker may choose a specific (cloud) image model; # The editor's model picker may choose a specific (cloud) image model;
# forward it so settings defaults only apply when none is selected. # forward it so settings defaults only apply when none is selected.
model_choice = str((body or {}).get("model") or "").strip() model_choice = str((body or {}).get("model") or "").strip()
if body.get("_use_image_settings") is True:
from src.editor_image_settings import editor_image_models
model_choice = editor_image_models(owner=user)["generation"]
payload = {"prompt": prompt, "size": size} payload = {"prompt": prompt, "size": size}
if model_choice: if model_choice:
payload["model"] = model_choice payload["model"] = model_choice
@@ -2961,22 +2992,14 @@ def setup_gallery_routes() -> APIRouter:
@router.put("/api/gallery/albums/{album_id}") @router.put("/api/gallery/albums/{album_id}")
async def update_album(request: Request, album_id: str): async def update_album(request: Request, album_id: str):
user = get_current_user(request) user = get_current_user(request)
data = await _json_object(request) data = await request.json()
name = data.get("name")
if name is not None:
if not isinstance(name, str) or not name.strip():
raise HTTPException(400, "Album name required")
name = name.strip()
description = data.get("description")
if description is not None and not isinstance(description, str):
raise HTTPException(400, "Album description must be a string")
db = SessionLocal() db = SessionLocal()
try: try:
album = _get_or_404_album(db, album_id, user) album = _get_or_404_album(db, album_id, user)
if name is not None: if data.get("name") is not None:
album.name = name album.name = data["name"]
if description is not None: if data.get("description") is not None:
album.description = description album.description = data["description"]
if data.get("cover_id") is not None: if data.get("cover_id") is not None:
cover_id = data["cover_id"] or None cover_id = data["cover_id"] or None
if cover_id: if cover_id:
@@ -3006,7 +3029,8 @@ def setup_gallery_routes() -> APIRouter:
@router.post("/api/gallery/albums/{album_id}/add") @router.post("/api/gallery/albums/{album_id}/add")
async def add_to_album(request: Request, album_id: str): async def add_to_album(request: Request, album_id: str):
user = get_current_user(request) user = get_current_user(request)
ids = await _image_ids(request) data = await request.json()
ids = data.get("image_ids", [])
db = SessionLocal() db = SessionLocal()
try: try:
_get_or_404_album(db, album_id, user) _get_or_404_album(db, album_id, user)
@@ -3023,7 +3047,8 @@ def setup_gallery_routes() -> APIRouter:
@router.post("/api/gallery/albums/{album_id}/remove") @router.post("/api/gallery/albums/{album_id}/remove")
async def remove_from_album(request: Request, album_id: str): async def remove_from_album(request: Request, album_id: str):
user = get_current_user(request) user = get_current_user(request)
ids = await _image_ids(request) data = await request.json()
ids = data.get("image_ids", [])
db = SessionLocal() db = SessionLocal()
try: try:
_get_or_404_album(db, album_id, user) _get_or_404_album(db, album_id, user)
+3 -4
View File
@@ -659,7 +659,7 @@ _NON_CHAT_CONTAINS = (
"gliner", "riva", "-parse", "-embedqa", "-nemoretriever", "gliner", "riva", "-parse", "-embedqa", "-nemoretriever",
"topic-control", "calibration", "topic-control", "calibration",
"ai-synthetic-video", "cosmos-reason2", "ai-synthetic-video", "cosmos-reason2",
"bge", "llama-guard", "-embed", "rerank", "bge", "llama-guard",
) )
_NON_CHAT_EXACT_PREFIXES = ( _NON_CHAT_EXACT_PREFIXES = (
"gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat) "gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat)
@@ -2846,12 +2846,11 @@ def setup_model_routes(model_discovery):
return {"endpoint_id": "", "endpoint_url": "", "model": ""} return {"endpoint_id": "", "endpoint_url": "", "model": ""}
base = _normalize_base(ep.base_url) base = _normalize_base(ep.base_url)
chat_url = build_chat_url(base) chat_url = build_chat_url(base)
if model and not _is_chat_model(model):
model = ""
if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)): if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
try: try:
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None)) visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
model = next((mid for mid in visible if _is_chat_model(mid)), "") if visible:
model = visible[0]
except Exception: except Exception:
pass pass
return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model} return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model}
+14 -5
View File
@@ -183,7 +183,6 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty. OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
""" """
from src.endpoint_resolver import ( from src.endpoint_resolver import (
_endpoint_enabled_models,
build_chat_url, build_chat_url,
build_headers, build_headers,
resolve_endpoint_runtime as resolve_model_endpoint_runtime, resolve_endpoint_runtime as resolve_model_endpoint_runtime,
@@ -198,7 +197,7 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
ep_model = (model or "").strip() ep_model = (model or "").strip()
if not ep_model: if not ep_model:
try: try:
models = _endpoint_enabled_models(ep) models = json.loads(ep.cached_models) if ep.cached_models else []
if models: if models:
ep_model = _first_chat_model(models) ep_model = _first_chat_model(models)
except Exception: except Exception:
@@ -825,13 +824,23 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
if not ep_url or not ep_model: if not ep_url or not ep_model:
# Last resort: this user's enabled endpoint, plus legacy shared rows. # Last resort: this user's enabled endpoint, plus legacy shared rows.
from src.database import SessionLocal from src.database import SessionLocal
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
db = SessionLocal() db = SessionLocal()
try: try:
ep = _owned_enabled_endpoint(db, user) ep = _owned_enabled_endpoint(db, user)
if ep: if ep:
resolved = _resolve_endpoint_runtime(ep, owner=user) base = normalize_base(ep.base_url)
if resolved: fallback_url = build_chat_url(base)
_merge(*resolved) fallback_headers = build_headers(ep.api_key, base)
fallback_model = ""
if ep.cached_models:
try:
models = json.loads(ep.cached_models)
if models:
fallback_model = _first_chat_model(models)
except Exception:
pass
_merge(fallback_url, fallback_model, fallback_headers)
finally: finally:
db.close() db.close()
-5
View File
@@ -15,7 +15,6 @@ import httpx
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from services.memory.skill_format import slugify
from services.memory.skills import SkillsManager from services.memory.skills import SkillsManager
from src.auth_helpers import get_current_user from src.auth_helpers import get_current_user
from services.skills import evaluation as _evaluation from services.skills import evaluation as _evaluation
@@ -837,10 +836,6 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
updates = body.dict(exclude_none=True) updates = body.dict(exclude_none=True)
if not updates: if not updates:
return {"ok": True} return {"ok": True}
if "name" in updates:
new_name = slugify(updates["name"] or match.get("name"))
if new_name != match.get("name") and skills_manager.has_skill(new_name, owner=user):
raise HTTPException(409, f"A skill named '{new_name}' already exists")
ok = skills_manager.update_skill(match.get("name"), updates, owner=user) ok = skills_manager.update_skill(match.get("name"), updates, owner=user)
if not ok: if not ok:
raise HTTPException(404, "Skill not found") raise HTTPException(404, "Skill not found")
+8 -8
View File
@@ -289,15 +289,15 @@ def _resolve_run_endpoint(db, task: ScheduledTask, run: TaskRun) -> str:
try: try:
from core.database import ModelEndpoint from core.database import ModelEndpoint
from src.auth_helpers import owner_filter eps = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
from src.endpoint_resolver import _endpoint_enabled_models
eps = owner_filter(
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
ModelEndpoint, getattr(task, "owner", None),
).all()
for ep in eps: for ep in eps:
if model in _endpoint_enabled_models(ep): cached = []
if ep.cached_models:
try:
cached = json.loads(ep.cached_models) or []
except Exception:
cached = []
if model in cached:
return ep.base_url or "" return ep.base_url or ""
except Exception: except Exception:
pass pass
+1 -6
View File
@@ -130,15 +130,10 @@ def _parse_scalar(raw: str, *, string_only: bool = False) -> Any:
def _split_top_level(s: str, sep: str) -> List[str]: def _split_top_level(s: str, sep: str) -> List[str]:
"""Split `s` on `sep` ignoring separators inside [] or quotes.""" """Split `s` on `sep` ignoring separators inside [] or quotes."""
out, buf, depth, quote = [], [], 0, None out, buf, depth, quote = [], [], 0, None
escaped = False
for ch in s: for ch in s:
if quote: if quote:
buf.append(ch) buf.append(ch)
if escaped: if ch == quote:
escaped = False
elif ch == "\\" and quote == '"':
escaped = True
elif ch == quote:
quote = None quote = None
continue continue
if ch in ("'", '"'): if ch in ("'", '"'):
-16
View File
@@ -500,19 +500,6 @@ class SkillsManager:
sk.path = self._skill_file(cat, nm) sk.path = self._skill_file(cat, nm)
return sk.to_dict() return sk.to_dict()
def has_skill(self, name: str, owner: Optional[str] = None) -> bool:
"""Whether `owner` already has a skill called `name`, in any category.
Update and delete find a skill by name within its owner, so two of
one owner's skills sharing a name would make them act on whichever
file is walked first.
"""
for path in self._iter_skill_files():
sk = self._read_skill(path)
if sk and sk.name == name and (sk.owner or "") == (owner or ""):
return True
return False
def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool: def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool:
"""`skill_id` is the slug name. Allows updating any field plus """`skill_id` is the slug name. Allows updating any field plus
renames if `name` changes (file is moved on disk). renames if `name` changes (file is moved on disk).
@@ -564,9 +551,6 @@ class SkillsManager:
# Rename # Rename
new_name = slugify(updates.get("name") or sk.name) new_name = slugify(updates.get("name") or sk.name)
if new_name != sk.name: if new_name != sk.name:
if self.has_skill(new_name, owner=owner):
logger.warning(f"Skill rename target name in use: {new_name}")
return False
sk.name = new_name sk.name = new_name
# Write to potentially new path # Write to potentially new path
+1 -13
View File
@@ -24193,14 +24193,11 @@ async def stream_agent_loop(
budget_is_explicit, budget_is_explicit,
hard_max=hard_max, hard_max=hard_max,
) )
if 0 < candidate_context <= 8192: if candidate_context <= 8192:
# Small local servers often tokenize chat wrappers and tool # Small local servers often tokenize chat wrappers and tool
# results much more generously than our rough estimator. Keep # results much more generously than our rough estimator. Keep
# substantial headroom for those wrappers and generation so a # substantial headroom for those wrappers and generation so a
# follow-up tool round cannot exceed the server's n_ctx. # follow-up tool round cannot exceed the server's n_ctx.
# 0 means the window is unknown, which already gets the
# conservative default budget; clamping it as well cut every
# request to 1200 tokens and dropped open documents (#6678).
effective_budget = min( effective_budget = min(
effective_budget, effective_budget,
max(1200, int(candidate_context * 0.40)), max(1200, int(candidate_context * 0.40)),
@@ -27836,16 +27833,7 @@ async def stream_agent_loop(
if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text): if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text):
_streamed_tool_markup += _delta_text _streamed_tool_markup += _delta_text
if not _streamed_tool_markup_complete(_streamed_tool_markup): if not _streamed_tool_markup_complete(_streamed_tool_markup):
if _streamed_tool_markup_starts(_streamed_tool_markup):
continue continue
# False alarm: a bare "<" (e.g. ``x < 2``) looked like
# a tag prefix, but the buffer can no longer become a
# tool tag. Release it as prose instead of holding the
# rest of the answer forever.
_delta_text = _streamed_tool_markup
_streamed_tool_markup = ""
data["delta"] = _delta_text
else:
_visible_markup_tail = strip_tool_blocks( _visible_markup_tail = strip_tool_blocks(
_streamed_tool_markup, _streamed_tool_markup,
skip_fenced=True, skip_fenced=True,
+8 -2
View File
@@ -191,7 +191,7 @@ def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[
db.close() db.close()
async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None) -> bool: async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None, native_size: bool = False) -> bool:
params: Dict[str, Any] = { params: Dict[str, Any] = {
"dataUrl": data_url, "dataUrl": data_url,
"name": name, "name": name,
@@ -199,6 +199,8 @@ async def _place_layer(data_url: str, name: str, owner: Optional[str], session:
"blend": blend or "normal", "blend": blend or "normal",
"opacity": opacity, "opacity": opacity,
} }
if native_size:
params["nativeSize"] = True
# Forward the edit mode so the client can build a difference mask for # Forward the edit mode so the client can build a difference mask for
# instruction-mode results: the whole image was regenerated under an # instruction-mode results: the whole image was regenerated under an
# instruction prompt, so untouched pixels must stay pixel-identical (the # instruction prompt, so untouched pixels must stay pixel-identical (the
@@ -506,6 +508,8 @@ class AiEditImageTool:
} }
_t["image"] = time.perf_counter() _t["image"] = time.perf_counter()
payload: Dict[str, Any] = {"image": image} payload: Dict[str, Any] = {"image": image}
if args.get("_use_image_settings") is True:
payload["_use_image_settings"] = True
model_choice = str(args.get("model") or "").strip() model_choice = str(args.get("model") or "").strip()
mask_used = False mask_used = False
place_mode: Optional[str] = None place_mode: Optional[str] = None
@@ -609,10 +613,11 @@ class AiEditImageTool:
f"AI {op}", f"AI {op}",
owner, owner,
session, session,
masked=mask_used, masked=mask_used and data.get("full_frame") is not True,
blend=str(args.get("blend") or "normal"), blend=str(args.get("blend") or "normal"),
opacity=float(args.get("opacity") or 100), opacity=float(args.get("opacity") or 100),
mode=place_mode, mode=place_mode,
native_size=data.get("full_frame") is True,
) )
info["placed"] = placed info["placed"] = placed
if placed: if placed:
@@ -687,6 +692,7 @@ class GenerateImageLayerTool:
str(args.get("name") or "Generated image"), str(args.get("name") or "Generated image"),
owner, owner,
session, session,
native_size=True,
masked=bool(args.get("mask")), masked=bool(args.get("mask")),
blend=str(args.get("blend") or "normal"), blend=str(args.get("blend") or "normal"),
opacity=float(args.get("opacity") or 100), opacity=float(args.get("opacity") or 100),
+124 -15
View File
@@ -179,8 +179,13 @@ def _resolve_model(spec: str, owner: Optional[str] = None, model_type: Optional[
# served internal id. Include pinned/cached ids in the match set # served internal id. Include pinned/cached ids in the match set
# so chat sessions using the HF repo id still resolve. Do not use # so chat sessions using the HF repo id still resolve. Do not use
# stale cached aliases when the endpoint itself is unreachable. # stale cached aliases when the endpoint itself is unreachable.
if model_type == "image" and endpoint_reachable: if endpoint_reachable:
for extra in _json_list(getattr(ep, "pinned_models", None)) + _json_list(getattr(ep, "cached_models", None)): # Gateway image models may live in a dedicated catalogue,
# so explicitly pinned IDs are valid on mixed endpoints too.
extras = _json_list(getattr(ep, "pinned_models", None))
if model_type == "image":
extras += _json_list(getattr(ep, "cached_models", None))
for extra in extras:
if extra not in model_ids: if extra not in model_ids:
model_ids.append(extra) model_ids.append(extra)
@@ -1200,6 +1205,39 @@ def image_editing_provider_available(owner: Optional[str] = None) -> bool:
return False return False
def _uses_openrouter_chat_images(model_id: str) -> bool:
# GPT-5 Image is an LLM wrapper, unlike native gpt-image-* image models.
leaf = model_id_leaf(model_id).lower()
return leaf.startswith("gpt-") and "-image" in leaf and not leaf.startswith("gpt-image-")
def _chat_image_results(data: dict) -> list:
images = []
for choice in data.get("choices") or []:
message = choice.get("message") or {}
for item in message.get("images") or []:
url = (item.get("image_url") or {}).get("url")
if not isinstance(url, str):
continue
if url.startswith("data:image/") and ";base64," in url:
images.append({"b64_json": url.split(",", 1)[1]})
elif url.startswith(("https://", "http://")):
images.append({"url": url})
return images
def _image_response_error(data: dict, model_id: str) -> Optional[str]:
errors = [data.get("error")]
errors.extend(choice.get("error") for choice in data.get("choices") or [])
for error in errors:
if not error:
continue
message = str(error.get("message") or "Image provider failed") if isinstance(error, dict) else str(error)
hint = " Choose a currently supported image model in AI settings." if "deprecated" in message.lower() else ""
return f"Image provider failed for '{model_id}': {message}{hint}"
return None
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict: async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict:
"""Generate an image using an image-capable model (e.g. gpt-image-1). """Generate an image using an image-capable model (e.g. gpt-image-1).
@@ -1236,21 +1274,23 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
if not prompt: if not prompt:
return {"error": "Image prompt is required (line 1)"} return {"error": "Image prompt is required (line 1)"}
# Load admin settings for defaults # Load settings for defaults
try: try:
from src.settings import load_settings from src.settings import get_user_setting, load_settings
_settings = load_settings() _settings = load_settings()
except Exception: except Exception:
_settings = {} _settings = {}
def get_user_setting(key, owner="", default=None):
return default
if not _settings.get("image_gen_enabled", True): if not get_user_setting("image_gen_enabled", owner=owner, default=True):
return {"error": "Image generation is disabled by the administrator."} return {"error": "Image generation is disabled by the administrator."}
# Use admin-configured model/quality if not specified by the tool call # Use user- or admin-configured model/quality if not specified by the tool call
if not model_spec: if not model_spec:
model_spec = _settings.get("image_model", "") model_spec = get_user_setting("image_model", owner=owner, default="") or ""
if not quality: if not quality:
quality = _settings.get("image_quality") or "medium" quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
# Auto-detect best available image model if still not set # Auto-detect best available image model if still not set
if not model_spec: if not model_spec:
@@ -1342,8 +1382,19 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/") base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
images_url = base_url + "/images/generations" images_url = base_url + "/images/generations"
from src.model_capability_readers.base import detect_vendor from src.model_capability_readers.base import detect_vendor
chat_images = detect_vendor(url) == "openrouter" and _uses_openrouter_chat_images(model_id)
if detect_vendor(url) == "openrouter": if detect_vendor(url) == "openrouter":
images_url = base_url + "/images" images_url = base_url + "/images"
if chat_images:
images_url = base_url + "/chat/completions"
# Capture the requested geometry before older OpenAI size restrictions.
from src.image_model_ids import image_aspect_ratio
try:
requested_width, requested_height = (int(value) for value in size.lower().split("x"))
requested_ratio = image_aspect_ratio(requested_width, requested_height)
except (TypeError, ValueError):
requested_ratio = "1:1"
# Validate size for cloud image models (local diffusion accepts any WxH) # Validate size for cloud image models (local diffusion accepts any WxH)
valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"} valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"}
@@ -1359,6 +1410,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
"n": 1, "n": 1,
"size": size, "size": size,
} }
if detect_vendor(url) == "openrouter":
payload.pop("size", None)
payload["aspect_ratio"] = requested_ratio
# GPT image models and local diffusion support quality; DALL-E does not # GPT image models and local diffusion support quality; DALL-E does not
if is_gpt_image or is_local_diffusion: if is_gpt_image or is_local_diffusion:
@@ -1372,7 +1426,16 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
try: try:
# GPT image models can take 30-120s+ depending on quality # GPT image models can take 30-120s+ depending on quality
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)) as client: async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)) as client:
resp = await client.post(images_url, json=payload, headers=headers) request_payload = payload
if chat_images:
request_payload = {
"model": model_id,
"messages": [{"role": "user", "content": prompt}],
"modalities": ["image", "text"],
"stream": False,
"image_config": {"aspect_ratio": requested_ratio},
}
resp = await client.post(images_url, json=request_payload, headers=headers)
if resp.status_code != 200: if resp.status_code != 200:
error_text = resp.text[:500] error_text = resp.text[:500]
@@ -1387,9 +1450,15 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
} }
data = resp.json() data = resp.json()
images = data.get("data", []) provider_error = _image_response_error(data, model_id)
if provider_error:
return {"error": provider_error, "untrusted_content": True}
images = _chat_image_results(data) if chat_images else data.get("data", [])
if not images: if not images:
return {"error": "No images returned from API"} return {"error": (
f"Image model '{model_id}' returned no image. "
"Choose another image generation model in AI settings."
)}
img = images[0] img = images[0]
image_url = None image_url = None
@@ -1502,21 +1571,27 @@ async def do_edit_image(
return {"error": "Attached image file was not found"} return {"error": "Attached image file was not found"}
try: try:
from src.settings import load_settings from src.settings import get_user_setting, load_settings
_settings = load_settings() _settings = load_settings()
except Exception: except Exception:
_settings = {} _settings = {}
def get_user_setting(key, owner="", default=None):
return default
if not model_spec: if not model_spec:
model_spec = _settings.get("image_model", "") model_spec = get_user_setting("image_edit_model", owner=owner) or get_user_setting("image_model", owner=owner, default="")
if quality is None: if quality is None:
quality = _settings.get("image_quality") or "medium" quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
if not model_spec: if not model_spec:
return {"error": "No image model selected for image editing"} return {"error": "No image model selected for image editing"}
try: try:
try: try:
def _call(): def _call():
try:
return _resolve_model(model_spec, owner=owner)
except ValueError:
pass
try: try:
return _resolve_model(model_spec, owner=owner, model_type="image") return _resolve_model(model_spec, owner=owner, model_type="image")
except TypeError as exc: except TypeError as exc:
@@ -1529,6 +1604,7 @@ async def do_edit_image(
except ValueError: except ValueError:
return {"error": f"No endpoint found with image model '{model_spec}'."} return {"error": f"No endpoint found with image model '{model_spec}'."}
requested_edit_size = size
if not size or size == "auto": if not size or size == "auto":
from PIL import Image from PIL import Image
from src.image_model_ids import image_edit_size from src.image_model_ids import image_edit_size
@@ -1704,6 +1780,34 @@ async def do_edit_image(
"image_url": {"url": f"data:{mime};base64,{image_b64}"}, "image_url": {"url": f"data:{mime};base64,{image_b64}"},
}], }],
} }
from src.image_model_ids import image_aspect_ratio
from PIL import Image as ReferenceImage
with ReferenceImage.open(path) as reference:
reference_width, reference_height = reference.size
if reference.getexif().get(274) in {5, 6, 7, 8}:
reference_width, reference_height = reference_height, reference_width
if requested_edit_size and requested_edit_size != "auto":
try:
reference_width, reference_height = (int(value) for value in requested_edit_size.lower().split("x"))
except (TypeError, ValueError):
pass
edit_payload.pop("size", None)
edit_payload["aspect_ratio"] = image_aspect_ratio(reference_width, reference_height)
if _uses_openrouter_chat_images(model_id):
edit_payload = {
"model": model_id,
"messages": [{"role": "user", "content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {
"url": f"data:{mime};base64,{image_b64}",
}},
]}],
"modalities": ["image", "text"],
"stream": False,
"image_config": {"aspect_ratio": edit_payload["aspect_ratio"]},
}
resp = await client.post(base_url + "/chat/completions", json=edit_payload, headers=headers)
else:
resp = await client.post(base_url + "/images", json=edit_payload, headers=headers) resp = await client.post(base_url + "/images", json=edit_payload, headers=headers)
else: else:
with path.open("rb") as f: with path.open("rb") as f:
@@ -1747,7 +1851,12 @@ async def do_edit_image(
} }
data = resp.json() data = resp.json()
images = data.get("data", []) provider_error = _image_response_error(data, model_id)
if provider_error:
return {"error": provider_error, "untrusted_content": True}
images = (_chat_image_results(data)
if is_openrouter and _uses_openrouter_chat_images(model_id)
else data.get("data", []))
if not images: if not images:
return {"error": "No image returned from edit API"} return {"error": "No image returned from edit API"}
+3 -35
View File
@@ -10,7 +10,7 @@ import os
import json import json
import re import re
import time import time
from datetime import datetime, timezone from datetime import datetime
from typing import Tuple from typing import Tuple
from src.auth_helpers import owner_filter from src.auth_helpers import owner_filter
@@ -1957,23 +1957,6 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
return str(e), False return str(e), False
def _daily_brief_utc_boundary(local_dt, tz=None):
"""Convert a local wall-clock boundary using the offset at that boundary."""
if local_dt.tzinfo is None and tz is not None:
local_dt = local_dt.replace(tzinfo=tz)
return local_dt.astimezone(timezone.utc).replace(tzinfo=None)
def _daily_brief_local_start(event, tz=None):
"""Keep date-only/floating rows local; convert only stored UTC instants."""
start = event.dtstart
if event.is_utc and not event.all_day:
if start.tzinfo is None:
start = start.replace(tzinfo=timezone.utc)
return start.astimezone(tz).replace(tzinfo=None)
return start
async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]: async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
"""Build a short morning digest: today's calendar events, unread email count """Build a short morning digest: today's calendar events, unread email count
+ top-N senders/subjects, active todos.""" + top-N senders/subjects, active todos."""
@@ -1983,13 +1966,10 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
from core.database import SessionLocal, CalendarEvent, CalendarCal, Note from core.database import SessionLocal, CalendarEvent, CalendarCal, Note
from routes.email_helpers import _imap_connect, _decode_header from routes.email_helpers import _imap_connect, _decode_header
from sqlalchemy import and_, or_
# ----- Calendar: today's events ----- # ----- Calendar: today's events -----
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0) today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
tomorrow = today + _td(days=1) tomorrow = today + _td(days=1)
utc_today = _daily_brief_utc_boundary(today)
utc_tomorrow = _daily_brief_utc_boundary(tomorrow)
# v2 review HIGH-12: gate the OR-null branch on single-user # v2 review HIGH-12: gate the OR-null branch on single-user
# (unconfigured) deploys only. In a multi-user deploy, one # (unconfigured) deploys only. In a multi-user deploy, one
# user's daily brief must not include another user's notes or # user's daily brief must not include another user's notes or
@@ -2002,25 +1982,13 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
db = SessionLocal() db = SessionLocal()
try: try:
ev_q = db.query(CalendarEvent).join(CalendarCal).filter( ev_q = db.query(CalendarEvent).join(CalendarCal).filter(
or_(
and_(
CalendarEvent.is_utc.is_(True),
CalendarEvent.all_day.is_not(True),
CalendarEvent.dtstart < utc_tomorrow,
CalendarEvent.dtend > utc_today,
),
and_(
or_(CalendarEvent.is_utc.is_not(True), CalendarEvent.all_day.is_(True)),
CalendarEvent.dtstart < tomorrow, CalendarEvent.dtstart < tomorrow,
CalendarEvent.dtend > today, CalendarEvent.dtend > today,
),
),
CalendarEvent.status != "cancelled", CalendarEvent.status != "cancelled",
) )
if owner: if owner:
ev_q = owner_filter(ev_q, CalendarCal, owner, include_shared=_allow_null) ev_q = owner_filter(ev_q, CalendarCal, owner, include_shared=_allow_null)
events = ev_q.all() events = ev_q.order_by(CalendarEvent.dtstart).all()
events.sort(key=_daily_brief_local_start)
# ----- Notes: pinned + non-archived todos with at least one undone item ----- # ----- Notes: pinned + non-archived todos with at least one undone item -----
n_q = db.query(Note).filter(Note.archived == False) # noqa: E712 n_q = db.query(Note).filter(Note.archived == False) # noqa: E712
if owner: if owner:
@@ -2090,7 +2058,7 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
if events: if events:
plain.append("Calendar:") plain.append("Calendar:")
for e in events: for e in events:
t = _daily_brief_local_start(e).strftime("%H:%M") if not e.all_day else "all day" t = e.dtstart.strftime("%H:%M") if not e.all_day else "all day"
loc = f" @ {e.location}" if e.location else "" loc = f" @ {e.location}" if e.location else ""
plain.append(f" {t} {e.summary}{loc}") plain.append(f" {t} {e.summary}{loc}")
plain.append("") plain.append("")
+1 -7
View File
@@ -282,13 +282,7 @@ class ChatHandler:
_sync_upload_vision_to_gallery(file_info, owner, vl_desc) _sync_upload_vision_to_gallery(file_info, owner, vl_desc)
except Exception: except Exception:
pass pass
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n" enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n{vl_desc}"
if vl_desc:
# A separate vision call wrote this, but the chat model sees
# one message and answers the prose as if the user wrote it
# (#5573). Label it below the marker line — the client strips
# the block by matching `[Image: <name>]\n`.
enhanced_message += f"[Image description — context only, not part of the user's message]:\n{vl_desc}"
# Surface the description to the client live so it renders as a # Surface the description to the client live so it renders as a
# collapsible "image description" on the user bubble (not just # collapsible "image description" on the user bubble (not just
# after a refresh that re-parses the stored message). # after a refresh that re-parses the stored message).
+1 -4
View File
@@ -142,10 +142,7 @@ def _process_pdf(
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
temp_img_path = tmp.name temp_img_path = tmp.name
try: try:
image = img.image img.image.save(temp_img_path, "PNG") # pypdf -> PIL image
if image.mode == "CMYK":
image = image.convert("RGB")
image.save(temp_img_path, "PNG")
ocr_text = analyze_image_with_vl(temp_img_path, owner=owner) ocr_text = analyze_image_with_vl(temp_img_path, owner=owner)
if ocr_text and "unavailable" not in ocr_text.lower(): if ocr_text and "unavailable" not in ocr_text.lower():
pdf_text += f"\n\n[Page {page_num + 1} image {img_index + 1} text]: {ocr_text}" pdf_text += f"\n\n[Page {page_num + 1} image {img_index + 1} text]: {ocr_text}"
+19
View File
@@ -0,0 +1,19 @@
"""The shared model defaults used by Quick Edit's image operations."""
from src.settings import get_user_setting, load_settings
def editor_image_models(owner: str = "") -> dict[str, str]:
settings = load_settings()
generation = ""
edit_model = ""
if owner:
generation = str(get_user_setting("image_model", owner=owner, default="") or "").strip()
edit_model = str(get_user_setting("image_edit_model", owner=owner, default="") or "").strip()
if not generation:
generation = str(settings.get("image_model") or "").strip()
if not edit_model:
edit_model = str(settings.get("image_edit_model") or generation).strip()
return {
"generation": generation,
"edit": edit_model or generation,
}
+9
View File
@@ -40,6 +40,15 @@ def image_edit_size(model_id: str, width: int, height: int) -> str:
return f"{width}x{height}" return f"{width}x{height}"
def image_aspect_ratio(width: int, height: int) -> str:
"""Choose the nearest common Image API ratio, without distorting pixels."""
if width <= 0 or height <= 0:
raise ValueError("Image dimensions must be positive")
ratios = ((1, 1), (16, 9), (9, 16), (3, 2), (2, 3), (4, 3), (3, 4), (21, 9))
w, h = min(ratios, key=lambda ratio: abs(math.log((ratio[0] / ratio[1]) / (width / height))))
return f"{w}:{h}"
def looks_like_image_generation_model(model_id: str) -> bool: def looks_like_image_generation_model(model_id: str) -> bool:
"""Return True when a model id should use image generation routes. """Return True when a model id should use image generation routes.
+8 -63
View File
@@ -76,23 +76,9 @@ _ERROR_DETAIL = {
"auth_or_protocol_error": "authentication or protocol error", "auth_or_protocol_error": "authentication or protocol error",
"no_models": "endpoint returned no models", "no_models": "endpoint returned no models",
"no_host": "no host configured", "no_host": "no host configured",
"config_source_error": "configuration source could not be read",
"error": "probe failed", "error": "probe failed",
} }
# Config/inventory source behind each network subsystem. `_gather_inputs()`
# fails soft, so a source that could not be read hands its probe an empty
# inventory — indistinguishable from "nothing configured", which every probe
# answers with `disabled`. The collector therefore reports the source failure
# instead of running a probe whose verdict would be a lie.
CONFIG_SOURCE_ERROR = "config_source_error"
_SUBSYSTEM_SOURCE = {
"searxng": "settings",
"ntfy": "integrations",
"email": "accounts",
"providers": "endpoints",
}
def _svc(name: str, status: str, detail: str, **meta: Any) -> Dict[str, Any]: def _svc(name: str, status: str, detail: str, **meta: Any) -> Dict[str, Any]:
return {"name": name, "status": status, "detail": detail, "meta": dict(meta)} return {"name": name, "status": status, "detail": detail, "meta": dict(meta)}
@@ -425,33 +411,26 @@ def _gather_inputs() -> Dict[str, Any]:
"""Pull live config/account/endpoint lists from the app's data sources. """Pull live config/account/endpoint lists from the app's data sources.
Each lookup fails soft: a broken source yields an empty/neutral value so a Each lookup fails soft: a broken source yields an empty/neutral value so a
single failure can't take down the whole health report. The failure is also single failure can't take down the whole health report.
*recorded* under `failed` (source -> controlled category), because the empty
value alone cannot be told apart from a source that loaded and is genuinely
empty — and the probes read empty as `disabled`.
""" """
settings: Dict[str, Any] = {} settings: Dict[str, Any] = {}
integrations: List[Dict[str, Any]] = [] integrations: List[Dict[str, Any]] = []
accounts: List[Dict[str, Any]] = [] accounts: List[Dict[str, Any]] = []
endpoints: List[Dict[str, Any]] = [] endpoints: List[Dict[str, Any]] = []
failed: Dict[str, str] = {}
try: try:
from src.settings import load_settings from src.settings import load_settings
settings = load_settings() or {} settings = load_settings() or {}
except Exception as e: except Exception as e:
failed["settings"] = _classify_error(e)
logger.debug(f"service_health: settings load failed: {e}") logger.debug(f"service_health: settings load failed: {e}")
try: try:
from src.integrations import load_integrations from src.integrations import load_integrations
integrations = load_integrations() or [] integrations = load_integrations() or []
except Exception as e: except Exception as e:
failed["integrations"] = _classify_error(e)
logger.debug(f"service_health: integrations load failed: {e}") logger.debug(f"service_health: integrations load failed: {e}")
try: try:
from routes.email_helpers import _list_email_accounts from routes.email_helpers import _list_email_accounts
accounts = _list_email_accounts() or [] accounts = _list_email_accounts() or []
except Exception as e: except Exception as e:
failed["accounts"] = _classify_error(e)
logger.debug(f"service_health: email accounts load failed: {e}") logger.debug(f"service_health: email accounts load failed: {e}")
try: try:
from core.database import SessionLocal, ModelEndpoint from core.database import SessionLocal, ModelEndpoint
@@ -464,10 +443,9 @@ def _gather_inputs() -> Dict[str, Any]:
finally: finally:
db.close() db.close()
except Exception as e: except Exception as e:
failed["endpoints"] = _classify_error(e)
logger.debug(f"service_health: endpoint load failed: {e}") logger.debug(f"service_health: endpoint load failed: {e}")
return {"settings": settings, "integrations": integrations, return {"settings": settings, "integrations": integrations,
"accounts": accounts, "endpoints": endpoints, "failed": failed} "accounts": accounts, "endpoints": endpoints}
async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]: async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]:
@@ -486,24 +464,6 @@ async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]:
return _svc(name, DOWN, _detail_for(category), error=category) return _svc(name, DOWN, _detail_for(category), error=category)
async def _source_unavailable(name: str, source: str,
category: str) -> Dict[str, Any]:
"""Controlled entry for a subsystem whose config source could not be read.
Deliberately not `disabled`: the configured state is *unknown*, not known to
be off. `degraded` rather than `down` because the failure is in reading the
inventory, not a probe that reached the subsystem and failed — but it still
carries non-zero severity, so a report containing one can never roll up to
`ok`. `meta` names the source and the controlled category of the underlying
exception; never its text.
"""
return _svc(name, DEGRADED,
f"Could not read configuration "
f"({_detail_for(CONFIG_SOURCE_ERROR)}).",
error=CONFIG_SOURCE_ERROR, source=source,
source_error=category)
async def collect_service_health(rag_manager: Any = None, async def collect_service_health(rag_manager: Any = None,
memory_vector: Any = None) -> Dict[str, Any]: memory_vector: Any = None) -> Dict[str, Any]:
"""Run every probe and return {overall, services, timestamp}. """Run every probe and return {overall, services, timestamp}.
@@ -512,10 +472,6 @@ async def collect_service_health(rag_manager: Any = None,
four network subsystems run concurrently, each under `_SUBSYSTEM_DEADLINE`, four network subsystems run concurrently, each under `_SUBSYSTEM_DEADLINE`,
with an overall `_AGGREGATE_DEADLINE` backstop. Per-item probes inside with an overall `_AGGREGATE_DEADLINE` backstop. Per-item probes inside
providers/email are themselves bounded by `_FANOUT_BUDGET`. providers/email are themselves bounded by `_FANOUT_BUDGET`.
A subsystem whose config/inventory source failed to load is reported via
`_source_unavailable()` instead of being probed with an empty inventory,
which every probe would otherwise report as `disabled`.
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -526,23 +482,12 @@ async def collect_service_health(rag_manager: Any = None,
chroma = chromadb_health(rag_manager, memory_vector) chroma = chromadb_health(rag_manager, memory_vector)
names = ["searxng", "ntfy", "email", "providers"] names = ["searxng", "ntfy", "email", "providers"]
probes = { coros = [
"searxng": (searxng_health, (settings,)), _run_subsystem("searxng", searxng_health, settings),
"ntfy": (ntfy_health, (inputs["integrations"], settings)), _run_subsystem("ntfy", ntfy_health, inputs["integrations"], settings),
"email": (email_health, (inputs["accounts"],)), _run_subsystem("email", email_health, inputs["accounts"]),
"providers": (providers_health, (inputs["endpoints"],)), _run_subsystem("providers", providers_health, inputs["endpoints"]),
} ]
# A subsystem whose inventory could not be read is reported as such. The
# others are still probed normally — one broken source degrades one entry.
failed = inputs.get("failed") or {}
coros = []
for name in names:
source = _SUBSYSTEM_SOURCE[name]
if source in failed:
coros.append(_source_unavailable(name, source, failed[source]))
else:
fn, args = probes[name]
coros.append(_run_subsystem(name, fn, *args))
try: try:
results = await asyncio.wait_for(asyncio.gather(*coros), results = await asyncio.wait_for(asyncio.gather(*coros),
timeout=_AGGREGATE_DEADLINE) timeout=_AGGREGATE_DEADLINE)
+5 -33
View File
@@ -57,17 +57,8 @@ def _message_to_context(msg: DBChatMessage) -> dict[str, Any]:
} }
def escape_like_pattern(value: str) -> str:
"""Escape SQL LIKE wildcards (``%``, ``_``) and the escape char itself.
Public shared helper for building ``ilike(f"%{...}%", escape="\\\\")``
patterns from user input. Must escape backslash first.
"""
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _escape_like(value: str) -> str: def _escape_like(value: str) -> str:
return escape_like_pattern(value) return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _snippet(content: str, query: str, radius: int = 60) -> str: def _snippet(content: str, query: str, radius: int = 60) -> str:
@@ -76,34 +67,15 @@ def _snippet(content: str, query: str, radius: int = 60) -> str:
if not query: if not query:
return content[: radius * 2] return content[: radius * 2]
match_start = -1 idx = content.lower().find(query.lower())
match_length = 0 if idx == -1:
lowered_content = content.lower()
for candidate in _query_terms(query):
idx = lowered_content.find(candidate.lower())
if idx != -1 and (match_start == -1 or idx < match_start):
match_start = idx
match_length = len(candidate)
if match_start == -1:
return content[: radius * 2] return content[: radius * 2]
start = max(0, match_start - radius) start = max(0, idx - radius)
end = min(len(content), match_start + match_length + radius) end = min(len(content), idx + len(query) + radius)
return ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "") return ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "")
def _query_terms(query: str) -> list[str]:
"""Return meaningful quoted phrases and tokens in user-entered order."""
terms: list[str] = []
for match in re.finditer(r'"([^"]+)"|[\w][\w._-]*', query, flags=re.UNICODE):
term = match.group(1) if match.group(1) is not None else match.group(0).strip("._-")
term = term.strip()
if term:
terms.append(term)
return terms
def _sanitize_fts_query(query: str) -> str | None: def _sanitize_fts_query(query: str) -> str | None:
"""Convert free text into a conservative FTS5 MATCH query. """Convert free text into a conservative FTS5 MATCH query.
+2 -1
View File
@@ -46,6 +46,7 @@ DEFAULT_SETTINGS = {
"agent_email_confirm": True, "agent_email_confirm": True,
"image_gen_enabled": False, "image_gen_enabled": False,
"image_model": "", "image_model": "",
"image_edit_model": "",
"image_quality": "medium", "image_quality": "medium",
# Optional editor planner/caption endpoints. Empty uses configured chat # Optional editor planner/caption endpoints. Empty uses configured chat
# fallback without probing a developer-specific host. # fallback without probing a developer-specific host.
@@ -294,7 +295,7 @@ def is_setting_overridden(key: str) -> bool:
# resolved by FastAPI deps; an empty/None owner falls through to the global. # resolved by FastAPI deps; an empty/None owner falls through to the global.
_PER_USER_KEYS = { _PER_USER_KEYS = {
"vision_model", "vision_enabled", "vision_model_fallbacks", "vision_model", "vision_enabled", "vision_model_fallbacks",
"image_model", "image_gen_enabled", "image_quality", "image_model", "image_edit_model", "image_gen_enabled", "image_quality",
# Default chat endpoint / model — without per-user resolution every new # Default chat endpoint / model — without per-user resolution every new
# account inherited whatever the most-recent admin picked, which then # account inherited whatever the most-recent admin picked, which then
# got injected into the chat composer on first open. # got injected into the chat composer on first open.
+63 -1
View File
@@ -264,7 +264,42 @@
/* The brush controls (Color/Size/preset/caret) hug their content; only the /* The brush controls (Color/Size/preset/caret) hug their content; only the
trailing slider section grows, so the Opacity/Flow/Softness group sits trailing slider section grows, so the Opacity/Flow/Softness group sits
right after the caret instead of leaving a wide gap. */ 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-brush-controls { flex: 0 0 auto; flex-direction: row; align-items: center; gap: 8px; }
.ge-controls--in-options #ge-brush-softness-row input[type="range"] { width: 88px; flex: 0 0 88px; }
#ge-brush-softness-row {
display: grid !important;
grid-template-columns: 14px max-content 88px;
column-gap: 8px;
align-items: center;
}
#ge-brush-softness-row:has(> .ge-slider-value) {
grid-template-columns: 14px max-content 88px 6ch;
}
#ge-brush-softness-row label {
flex: 0 0 auto;
min-width: max-content;
gap: 6px;
}
#ge-brush-softness-row #ge-brush-softness-label {
position: static;
min-width: 6ch;
margin: 0;
padding: 0;
white-space: nowrap;
text-align: right;
font-variant-numeric: tabular-nums;
}
.ge-controls--in-options #ge-brush-controls .ge-brush-size-row {
display: grid;
grid-template-columns: minmax(6ch, max-content) 96px auto;
column-gap: 8px;
}
.ge-controls--in-options .ge-brush-size-row label,
.ge-controls--in-options .ge-brush-size-row .ge-size-label {
min-width: 6ch;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.ge-controls--in-options .ge-control-row { .ge-controls--in-options .ge-control-row {
margin: 0 !important; margin: 0 !important;
flex: 0 0 auto; flex: 0 0 auto;
@@ -4607,6 +4642,8 @@ body.ge-editor-open textarea.ge-direct-text-editor {
pointer-events: auto; pointer-events: auto;
} }
.ge-filter-modal { .ge-filter-modal {
max-height: calc(100dvh - 40px);
overflow-y: auto;
width: 240px; width: 240px;
background: color-mix(in srgb, var(--panel) 95%, transparent); background: color-mix(in srgb, var(--panel) 95%, transparent);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -4631,6 +4668,9 @@ body.ge-editor-open textarea.ge-direct-text-editor {
gap: 2px; gap: 2px;
} }
.ge-filter-row label { .ge-filter-row label {
flex: 0 0 auto;
min-width: 0;
width: 100%;
font-size: 10px; font-size: 10px;
opacity: 0.7; opacity: 0.7;
display: flex; display: flex;
@@ -9732,6 +9772,28 @@ body.ge-editor-open .tour-hint {
margin-left: auto; margin-left: auto;
color: var(--color-muted); color: var(--color-muted);
} }
.ge-submenu .dropdown-item-compact {
box-sizing: border-box;
display: flex;
align-items: center;
height: 28px;
min-height: 28px;
max-height: 28px;
padding: 0 10px;
line-height: 1.2;
white-space: nowrap;
}
.ge-submenu .dropdown-item-compact svg {
flex-shrink: 0;
max-height: 16px;
}
@media (max-width: 820px) {
.ge-submenu .dropdown-item-compact {
height: 32px;
min-height: 32px;
max-height: 32px;
}
}
.ge-submenu { .ge-submenu {
display: none; display: none;
position: absolute; position: absolute;
+4
View File
@@ -1677,6 +1677,10 @@
<label class="settings-label">Model</label> <label class="settings-label">Model</label>
<select id="set-imgModelSelect" class="settings-select"><option value="">Auto-detect</option></select> <select id="set-imgModelSelect" class="settings-select"><option value="">Auto-detect</option></select>
</div> </div>
<div style="display:flex;align-items:center;gap:0.75rem;">
<label class="settings-label" for="set-imgEditModelSelect">Image edit model</label>
<select id="set-imgEditModelSelect" class="settings-select"><option value="">Same as generation</option></select>
</div>
<div style="display:flex;align-items:center;gap:0.75rem;"> <div style="display:flex;align-items:center;gap:0.75rem;">
<label class="settings-label">Quality</label> <label class="settings-label">Quality</label>
<select id="set-imgQualitySelect" class="settings-select"> <select id="set-imgQualitySelect" class="settings-select">
+25 -6
View File
@@ -41,6 +41,7 @@
* }} deps * }} deps
*/ */
import { state } from './state.js'; import { state } from './state.js';
import { imageResultGeometry } from './image-result-geometry.js';
import { beginAIOperation, decodeAIImage } from './ai-operation.js'; import { beginAIOperation, decodeAIImage } from './ai-operation.js';
export function wireInpaintButtons({ export function wireInpaintButtons({
@@ -184,17 +185,30 @@ export function wireInpaintButtons({
if (!state.editorOpen) return; // user closed mid-decode if (!state.editorOpen) return; // user closed mid-decode
try { try {
saveState('Inpaint result'); saveState('Inpaint result');
// OpenAI returns at one of its allowed sizes (1024², // Whole-image edits keep the provider's native frame; only masked
// 1024×1536, 1536×1024) which often differs from our // edits need registration with the source document's coordinates.
// canvas. Scale to canvas size with smoothing so the
// inpaint blends in regardless of source dims.
const shortPrompt = (prompt || '').trim().replace(/\s+/g, ' ').slice(0, 40); const shortPrompt = (prompt || '').trim().replace(/\s+/g, ' ').slice(0, 40);
const layerName = shortPrompt ? `Inpaint: ${shortPrompt}` : 'Inpaint Result'; const layerName = shortPrompt ? `Inpaint: ${shortPrompt}` : 'Inpaint Result';
const resultLayer = createLayer(layerName, state.imgWidth, state.imgHeight); const fullFrame = data.full_frame === true;
const geometry = imageResultGeometry(resultImg.naturalWidth, resultImg.naturalHeight, state.imgWidth, state.imgHeight);
const resultLayer = createLayer(layerName,
fullFrame ? geometry.width : state.imgWidth,
fullFrame ? geometry.height : state.imgHeight);
resultLayer.ctx.imageSmoothingEnabled = true; resultLayer.ctx.imageSmoothingEnabled = true;
resultLayer.ctx.imageSmoothingQuality = 'high'; resultLayer.ctx.imageSmoothingQuality = 'high';
resultLayer.ctx.drawImage(resultImg, 0, 0, state.imgWidth, state.imgHeight); if (fullFrame) {
resultLayer.ctx.drawImage(resultImg, geometry.drawX, geometry.drawY, geometry.drawWidth, geometry.drawHeight);
state.layerOffsets.set(resultLayer.id, {
x: geometry.offsetX,
y: geometry.offsetY,
});
} else {
const scale = Math.min(state.imgWidth / resultImg.naturalWidth, state.imgHeight / resultImg.naturalHeight);
const width = resultImg.naturalWidth * scale, height = resultImg.naturalHeight * scale;
resultLayer.ctx.drawImage(resultImg, (state.imgWidth - width) / 2, (state.imgHeight - height) / 2, width, height);
}
// Snapshot the AI result + hard mask used for this run. // Snapshot the AI result + hard mask used for this run.
if (!fullFrame) {
const aiSnap = document.createElement('canvas'); const aiSnap = document.createElement('canvas');
aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight; aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight;
aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0); aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0);
@@ -205,6 +219,7 @@ export function wireInpaintButtons({
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx }; resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
// Apply initial alpha = hard mask (no feather, no edge shift). // Apply initial alpha = hard mask (no feather, no edge shift).
applyInpaintFeather(resultLayer, 0, 0); applyInpaintFeather(resultLayer, 0, 0);
}
state.layers.push(resultLayer); state.layers.push(resultLayer);
state.activeLayerId = resultLayer.id; state.activeLayerId = resultLayer.id;
state.lastInpaintLayerId = resultLayer.id; state.lastInpaintLayerId = resultLayer.id;
@@ -220,6 +235,10 @@ export function wireInpaintButtons({
} }
composite(); composite();
renderLayerPanel(); renderLayerPanel();
if (fullFrame) {
if (uiModule) uiModule.showToast('Image edit complete', 4500);
return;
}
// Reveal post-generation Feather + Edge Stroke sliders. // Reveal post-generation Feather + Edge Stroke sliders.
// Cap Edge Stroke at ±padPx so the slider can't ask for // Cap Edge Stroke at ±padPx so the slider can't ask for
// more AI buffer than we generated. // more AI buffer than we generated.
+23 -54
View File
@@ -54,16 +54,16 @@ export function resolveQuickEditModel() {
label: ((opt && opt.textContent) || '').trim(), label: ((opt && opt.textContent) || '').trim(),
}; };
} }
const inp = document.getElementById('ge-ai-inpaint'); const generation = document.getElementById('ge-ai-model');
const opt = resolveInpaintModel(inp); if (generation?.value && generation.value !== '__serve_cookbook__') {
if (!opt) return { endpoint: '', model: '', label: '' }; const index = generation.value.indexOf('::');
const raw = opt.value || ''; const model = index < 0 ? '' : generation.value.slice(index + 2);
const idx = raw.indexOf('::'); if (model && modelCaps(model, '', '').inpaint) {
return { return { endpoint: generation.value.slice(0, index), model,
endpoint: idx < 0 ? raw : raw.slice(0, idx), label: generation.options[generation.selectedIndex]?.textContent?.trim() || model };
model: idx < 0 ? '' : raw.slice(idx + 2), }
label: ((opt.textContent || '').trim().replace(/^Auto\s*\((.*)\)$/, '$1')), }
}; return { endpoint: '', model: '', label: 'Auto (saved settings)' };
} }
// Resolve the Quick Edit AGENT (planner) model — the vision-language model // Resolve the Quick Edit AGENT (planner) model — the vision-language model
@@ -144,21 +144,19 @@ export function modelCaps(modelId, endpointName, endpointType) {
function syncCommandModel(value) { function syncCommandModel(value) {
const select = document.getElementById('ge-ai-command-model'); const select = document.getElementById('ge-ai-command-model');
if (!select || !select._modelsReady) return; if (!select || !select._modelsReady) return;
if (value && ![...select.options].some(o => !o.disabled && o.value === value && value !== '__serve_cookbook__')) {
if (localStorage.getItem('ge-ai-command-model') === value) localStorage.removeItem('ge-ai-command-model');
value = '';
}
if (select.value !== value) { if (select.value !== value) {
select.value = value; select.value = value;
if (select.value !== value) select.value = ''; if (select.value !== value) select.value = '';
updateFooterEditModel(); updateFooterEditModel();
} }
} }
window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || '')); if (typeof window !== 'undefined') {
window.addEventListener('storage', e => { window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || ''));
window.addEventListener('storage', e => {
if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || ''); if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || '');
}); });
window.addEventListener('ge:model-endpoints-updated', e => { }
globalThis.window?.addEventListener('ge:model-endpoints-updated', e => {
document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || ''); document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || '');
}); });
@@ -256,9 +254,9 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
const data = await res.json(); const data = await res.json();
if (listeners.signal.aborted || !root.isConnected) return; if (listeners.signal.aborted || !root.isConnected) return;
const modelsList = data.models || []; const modelsList = data.models || [];
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>'; if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">Auto-detect</option>';
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>'; if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>'; if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Same as generation</option>';
const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model')); const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model'));
for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>'; for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>';
let firstGen = null; let firstGen = null;
@@ -346,45 +344,15 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
// Sentinel changes are handled by the component's delegated listener. // Sentinel changes are handled by the component's delegated listener.
if (aiCommandSelect) { if (aiCommandSelect) {
aiCommandSelect._modelsReady = true; aiCommandSelect._modelsReady = true;
try { // Legacy URL migration check: legacy endpoints with raw URLs are scrubbed.
const rawSaved = localStorage.getItem('ge-ai-command-model') || ''; const scrubModelUrl = (saved) => (saved && saved.startsWith('http://') ? '' : saved);
let saved = rawSaved;
if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) {
const modelPart = saved.includes('::') ? saved.split('::').pop() : '';
const matching = loaded.filter(l => l.model === modelPart);
if (matching.length === 1) {
saved = matching[0].value;
} else {
saved = '';
}
try {
if (saved) localStorage.setItem('ge-ai-command-model', saved);
else localStorage.removeItem('ge-ai-command-model');
} catch {}
}
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) {
aiCommandSelect.value = saved;
} else {
aiCommandSelect.value = '';
}
if (aiCommandSelect.value !== rawSaved) {
if (aiCommandSelect.value) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
else localStorage.removeItem('ge-ai-command-model');
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: aiCommandSelect.value } }));
}
} catch {}
if (aiCommandSelect._commandWired !== listeners) { if (aiCommandSelect._commandWired !== listeners) {
aiCommandSelect._commandWired = listeners; aiCommandSelect._commandWired = listeners;
listen(aiCommandSelect, 'change', () => { listen(aiCommandSelect, 'change', () => {
if (aiCommandSelect.value === '__serve_cookbook__') return; if (aiCommandSelect.value === '__serve_cookbook__') return;
const val = aiCommandSelect.value || ''; const val = scrubModelUrl(aiCommandSelect.value) || '';
try {
if (val) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
else localStorage.removeItem('ge-ai-command-model');
} catch {}
updateFooterEditModel(); updateFooterEditModel();
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } })); globalThis.window?.dispatchEvent?.(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
}); });
} }
} }
@@ -409,6 +377,7 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
}); });
} }
_aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() }; _aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() };
container.dispatchEvent(new Event('ge-image-models-ready'));
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {} try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
} catch (e) { } catch (e) {
if (listeners.signal.aborted || !root.isConnected) return; if (listeners.signal.aborted || !root.isConnected) return;
+5 -30
View File
@@ -2,11 +2,10 @@
* Background Remove (rembg) + Sharpen wiring + the live edge-cleanup * Background Remove (rembg) + Sharpen wiring + the live edge-cleanup
* tuner that runs on the most-recent rembg cutout. * tuner that runs on the most-recent rembg cutout.
* *
* rembg-run button: flatten + POST to /api/image/remove-bg with an * rembg-run button: active layer + POST to /api/image/remove-bg with an
* optional hint_mask if the user has a wand/lasso selection * optional hint_mask if the user has a wand/lasso selection
* active. After the new layer lands, hides every previously- * active. Updates only that layer and binds its live edge tuner;
* visible layer so the cutout reads cleanly, and binds the * other layers and their visibility remain unchanged.
* live-tuner to the new layer.
* *
* Live edge-cleanup tuner: snapshots the pristine cutout the * Live edge-cleanup tuner: snapshots the pristine cutout the
* moment it lands; subsequent feather/grow slider tweaks * moment it lands; subsequent feather/grow slider tweaks
@@ -65,37 +64,13 @@ export function wireRembgAndSharpen({
// NB: edge_feather / edge_grow are applied CLIENT-side so the // NB: edge_feather / edge_grow are applied CLIENT-side so the
// sliders can re-tune the cutout without re-running the model. // sliders can re-tune the cutout without re-running the model.
const btn = document.getElementById('ge-rembg-run'); const btn = document.getElementById('ge-rembg-run');
const before = state.layers.length; const editedLayer = await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
// Snapshot which layers were visible BEFORE the run so we know
// which to hide after a successful cutout.
const prevVisible = state.layers.filter(l => l.visible).map(l => l.id);
await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
// applyImageTool resolves after the returned image is decoded and the
// new layer is inserted. Keep a short poll for defensive compatibility
// with alternate runners that may still commit on the next frame.
let frames = 0;
while (state.layers.length <= before && frames < 60) {
await new Promise(r => requestAnimationFrame(r));
frames++;
}
if (state.layers.length > before) {
const newLayer = state.layers[state.layers.length - 1];
bindRembgLiveTuner(newLayer);
// Auto-hide underlying layers so the user sees just the
// cutout — the eye toggles back on if they re-enable manually.
for (const layer of state.layers) {
if (prevVisible.includes(layer.id) && layer.id !== newLayer.id) {
layer.visible = false;
}
}
composite();
renderLayerPanel();
}
// Reset sliders so the new cutout starts clean. // Reset sliders so the new cutout starts clean.
const f = document.getElementById('ge-rembg-feather'); const f = document.getElementById('ge-rembg-feather');
const g = document.getElementById('ge-rembg-grow'); const g = document.getElementById('ge-rembg-grow');
if (f) { f.value = 0; document.getElementById('ge-rembg-feather-label').textContent = '0px'; syncRembgFeather(0); } if (f) { f.value = 0; document.getElementById('ge-rembg-feather-label').textContent = '0px'; syncRembgFeather(0); }
if (g) { g.value = 0; document.getElementById('ge-rembg-grow-label').textContent = '0px'; syncRembgGrow(0); } if (g) { g.value = 0; document.getElementById('ge-rembg-grow-label').textContent = '0px'; syncRembgGrow(0); }
if (editedLayer) bindRembgLiveTuner(editedLayer);
}); });
// ── Live edge-cleanup tuner ── // ── Live edge-cleanup tuner ──
+61 -3
View File
@@ -5,6 +5,9 @@
* "Double-check AI edits" toggle. Opened from the Quick Edit gear button and * "Double-check AI edits" toggle. Opened from the Quick Edit gear button and
* from the editor setup checklist; keeps the Quick Edit box itself uncluttered. * from the editor setup checklist; keeps the Quick Edit box itself uncluttered.
*/ */
import { readImagePreferences, saveImagePreferences } from '../settings/imagePreferences.js';
import { updateFooterEditModel } from './ai-models.js';
export function createAiSettings({ container }) { export function createAiSettings({ container }) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'ge-ai-settings'; el.className = 'ge-ai-settings';
@@ -19,11 +22,14 @@ export function createAiSettings({ container }) {
<button type="button" class="ge-ai-settings-close" data-ai-settings-close aria-label="Close">×</button> <button type="button" class="ge-ai-settings-close" data-ai-settings-close aria-label="Close">×</button>
</div> </div>
<div class="ge-ai-settings-body"> <div class="ge-ai-settings-body">
<label class="ge-ai-settings-label" for="ge-ai-model">Image generation model</label>
<select class="ge-ai-command-model" id="ge-ai-model" title="Image generation model" aria-label="Image generation model"></select>
<label class="ge-ai-settings-label" for="ge-ai-command-model">Image edit model</label> <label class="ge-ai-settings-label" for="ge-ai-command-model">Image edit model</label>
<select class="ge-ai-command-model" id="ge-ai-command-model" title="Image edit model" aria-label="Image edit model"></select> <select class="ge-ai-command-model" id="ge-ai-command-model" title="Image edit model" aria-label="Image edit model"></select>
<label class="ge-ai-settings-label" for="ge-ai-command-agent-model">AI agent (vision) model</label> <label class="ge-ai-settings-label" for="ge-ai-command-agent-model">AI agent (vision) model</label>
<select class="ge-ai-command-model" id="ge-ai-command-agent-model" title="Quick Edit agent model" aria-label="Quick Edit agent model"></select> <select class="ge-ai-command-model" id="ge-ai-command-agent-model" title="Quick Edit agent model" aria-label="Quick Edit agent model"></select>
<p class="ge-ai-settings-note">Recommended, not required — the vision agent plans edits and can double-check results.</p> <p class="ge-ai-settings-note">Recommended, not required — the vision agent plans edits and can double-check results.</p>
<p class="ge-ai-settings-note" id="ge-ai-settings-save-status" role="status"></p>
<label class="ge-ai-settings-label" for="ge-look-strength">Look strength</label> <label class="ge-ai-settings-label" for="ge-look-strength">Look strength</label>
<div class="ge-ai-settings-strength"> <div class="ge-ai-settings-strength">
<input type="range" id="ge-look-strength" min="0.5" max="2" step="0.1" value="1.4" aria-label="Look grade strength" /> <input type="range" id="ge-look-strength" min="0.5" max="2" step="0.1" value="1.4" aria-label="Look grade strength" />
@@ -41,6 +47,7 @@ export function createAiSettings({ container }) {
function open(focusId) { function open(focusId) {
el.hidden = false; el.hidden = false;
syncModels();
if (focusId) { if (focusId) {
const target = document.getElementById(focusId); const target = document.getElementById(focusId);
if (target) { if (target) {
@@ -56,10 +63,59 @@ export function createAiSettings({ container }) {
close(); close();
} }
}); });
const onKey = (e) => { if (e.key === 'Escape' && !el.hidden) close(); }; const onKey = (e) => {
document.addEventListener('keydown', onKey); if (e.key !== 'Escape' || el.hidden) return;
e.preventDefault();
e.stopImmediatePropagation();
close();
};
window.addEventListener('keydown', onKey, true);
container.appendChild(el); container.appendChild(el);
const status = el.querySelector('#ge-ai-settings-save-status');
const modelFields = [['ge-ai-model', 'image_model'], ['ge-ai-command-model', 'image_edit_model']];
function applyModels(settings) {
for (const [id, key] of modelFields) {
if (!(key in settings)) continue;
const select = el.querySelector(`#${id}`);
const model = settings[key] || '';
let option = [...select.options].find(option => {
const value = option.value;
return model ? value.slice(value.indexOf('::') + 2) === model : value === '';
});
if (!option && model && select.options.length) {
option = document.createElement('option');
option.value = `::${model}`;
option.textContent = `${model} (unavailable)`;
option.disabled = true;
select.append(option);
}
if (option) select.value = option.value;
}
updateFooterEditModel();
}
async function syncModels() {
try { applyModels(await readImagePreferences()); }
catch (error) { status.textContent = error.message; }
}
for (const [id, key] of modelFields) {
el.querySelector(`#${id}`).addEventListener('change', async (event) => {
const raw = event.target.value;
if (raw === '__serve_cookbook__') return;
const model = raw.includes('::') ? raw.slice(raw.indexOf('::') + 2) : '';
try {
status.textContent = 'Saving…';
await saveImagePreferences({ [key]: model });
status.textContent = 'Saved';
} catch (error) {
await syncModels();
status.textContent = error.message;
}
});
}
const onPreferencesChanged = event => applyModels(event.detail);
window.addEventListener('image-preferences-changed', onPreferencesChanged);
container.addEventListener('ge-image-models-ready', syncModels);
const strengthInput = el.querySelector('#ge-look-strength'); const strengthInput = el.querySelector('#ge-look-strength');
const strengthVal = el.querySelector('#ge-look-strength-val'); const strengthVal = el.querySelector('#ge-look-strength-val');
@@ -77,7 +133,9 @@ export function createAiSettings({ container }) {
} }
function destroy() { function destroy() {
document.removeEventListener('keydown', onKey); window.removeEventListener('keydown', onKey, true);
window.removeEventListener('image-preferences-changed', onPreferencesChanged);
container.removeEventListener('ge-image-models-ready', syncModels);
el.remove(); el.remove();
} }
+23 -1
View File
@@ -76,7 +76,12 @@ export function createApplyImageTool({
if (sel.endpoint) extraPayload._endpoint = sel.endpoint; if (sel.endpoint) extraPayload._endpoint = sel.endpoint;
if (sel.model && !extraPayload._model) extraPayload._model = sel.model; if (sel.model && !extraPayload._model) extraPayload._model = sel.model;
} }
const flatCanvas = flatten(); const removeBackground = endpoint === '/api/image/remove-bg';
const targetLayer = removeBackground ? state.layers.find(layer => layer.id === state.activeLayerId) : null;
if (removeBackground && (!targetLayer?.canvas || targetLayer.kind === 'adjustment' || targetLayer.locks?.pixels)) {
throw new Error('Select an unlocked image layer first');
}
const flatCanvas = removeBackground ? targetLayer.canvas : flatten();
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1]; const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
const body = { image: imageB64, ...extraPayload }; const body = { image: imageB64, ...extraPayload };
const res = await fetch(endpoint, { const res = await fetch(endpoint, {
@@ -96,6 +101,23 @@ export function createApplyImageTool({
const img = await decodeAIImage(data.image, operation.signal); const img = await decodeAIImage(data.image, operation.signal);
operation.signal.throwIfAborted(); operation.signal.throwIfAborted();
if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4) if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4)
if (removeBackground) {
if (!state.layers.includes(targetLayer)) return;
saveState('Remove background');
// The returned pixels replace the editable source, not just its preview.
targetLayer.kind = 'raster';
targetLayer.placed = null;
targetLayer.text = null;
targetLayer.shape = null;
targetLayer.ctx.clearRect(0, 0, targetLayer.canvas.width, targetLayer.canvas.height);
targetLayer.ctx.drawImage(img, 0, 0, targetLayer.canvas.width, targetLayer.canvas.height);
targetLayer._adjCacheKey = null;
targetLayer._adjFinalKey = null;
composite();
renderLayerPanel();
uiModule?.showToast('Background removed');
return targetLayer;
}
{ {
saveState(); saveState();
const layer = createLayer(layerName, state.imgWidth, state.imgHeight); const layer = createLayer(layerName, state.imgWidth, state.imgHeight);
+5 -5
View File
@@ -55,6 +55,11 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<label>Color</label> <label>Color</label>
<input type="color" class="ge-color-picker" value="${color}" /> <input type="color" class="ge-color-picker" value="${color}" />
</div> </div>
<div class="ge-control-row ge-eraser-row" id="ge-brush-softness-row">
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
<input type="range" id="ge-brush-softness" min="0" max="1200" value="100" title="Brush edge softness and feathered falloff" />
</div>
<div class="ge-control-row ge-brush-size-row"> <div class="ge-control-row ge-brush-size-row">
<label><span class="ge-size-label">${brushSize}px</span></label> <label><span class="ge-size-label">${brushSize}px</span></label>
<input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" /> <input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" />
@@ -94,11 +99,6 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<label>Flow <span id="ge-brush-flow-label">100%</span></label> <label>Flow <span id="ge-brush-flow-label">100%</span></label>
<input type="range" id="ge-brush-flow" min="5" max="100" value="100" /> <input type="range" id="ge-brush-flow" min="5" max="100" value="100" />
</div> </div>
<div class="ge-control-row ge-eraser-row" id="ge-brush-softness-row">
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
<input type="range" id="ge-brush-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha for a feathered fade at the perimeter." />
</div>
</div> </div>
<div class="ge-brush-stroke-group" data-stroke-tool="eraser"> <div class="ge-brush-stroke-group" data-stroke-tool="eraser">
<div class="ge-section-title">Eraser</div> <div class="ge-section-title">Eraser</div>
+35 -5
View File
@@ -78,6 +78,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
const UI_CHROME_SELECTOR = '.ge-topbar, .ge-toolbar, .ge-controls, .ge-right-panel, ' const UI_CHROME_SELECTOR = '.ge-topbar, .ge-toolbar, .ge-controls, .ge-right-panel, '
+ '.ge-dock, .ge-options-bar, .ge-canvas-toolbar, button, input, select, textarea'; + '.ge-dock, .ge-options-bar, .ge-canvas-toolbar, button, input, select, textarea';
const isUiChrome = e => !!e.target?.closest?.(UI_CHROME_SELECTOR); const isUiChrome = e => !!e.target?.closest?.(UI_CHROME_SELECTOR);
const canStartOutside = () => ['marquee', 'lasso', 'crop'].includes(state.tool);
// Only in-progress selection/crop drags are affected by the chrome // Only in-progress selection/crop drags are affected by the chrome
// guard. A committed cropRect/selection is a resting state — clicking // guard. A committed cropRect/selection is a resting state — clicking
// its Apply button must not be treated as "strayed onto chrome". // its Apply button must not be treated as "strayed onto chrome".
@@ -143,15 +144,16 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
}; };
state.mainCanvas.addEventListener('pointerup', endPen); state.mainCanvas.addEventListener('pointerup', endPen);
state.mainCanvas.addEventListener('pointercancel', endPen); state.mainCanvas.addEventListener('pointercancel', endPen);
// Lasso and Crop can start OUTSIDE the canvas — fallback mousedown on the // Selection and Crop can start OUTSIDE the canvas — fallback mousedown on the
// surrounding canvas-area so the user can begin a path/rect in the empty // surrounding canvas-area so the user can begin a path/rect in the empty
// space around the image. Other tools stay canvas-only. // space around the image. Other tools stay canvas-only.
canvasArea.addEventListener('mousedown', (e) => { canvasArea.addEventListener('mousedown', (e) => {
if (state.tool !== 'lasso' && state.tool !== 'crop') return; if (!canStartOutside() || Date.now() < suppressMouseUntil) return;
if (e.button !== 0 || isDirectPanIntent(state.tool, state.spacePanActive, e.button)) return;
if (e.target === state.mainCanvas) return; // already handled if (e.target === state.mainCanvas) return; // already handled
// Ignore presses on editor chrome inside the area (the crop Apply panel, // Ignore presses on editor chrome inside the area (the crop Apply panel,
// popups, buttons) so clicking Apply does not start a fresh crop. // popups, buttons) so clicking Apply does not start a fresh crop.
if (e.target.closest?.('button, input, select, textarea, .ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return; if (isUiChrome(e) || e.target.closest?.('.ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-fx-menu')) return;
beginDraw(e); beginDraw(e);
}); });
state.mainCanvas.addEventListener('mouseenter', (e) => { state.mainCanvas.addEventListener('mouseenter', (e) => {
@@ -305,6 +307,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
let panning = false; let panning = false;
let pid = null; let pid = null;
let transformPointerId = null; let transformPointerId = null;
let outsideDrawPointerId = null;
let startX = 0, startY = 0; let startX = 0, startY = 0;
let startPanX = 0, startPanY = 0; let startPanX = 0, startPanY = 0;
const getOffset = () => ({ x: state.panX || 0, y: state.panY || 0 }); const getOffset = () => ({ x: state.panX || 0, y: state.panY || 0 });
@@ -315,10 +318,23 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
}; };
canvasArea.addEventListener('pointerdown', (e) => { canvasArea.addEventListener('pointerdown', (e) => {
const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button); const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button);
// With the crop (or lasso) tool, an empty-area press draws a rect/path // With a selection or crop tool, an empty-area press draws a rect/path
// rather than panning — so the crop can be pulled out past the image. // rather than panning — so the crop can be pulled out past the image.
// Space-held / middle-mouse (directPan) still pans. // Space-held / middle-mouse (directPan) still pans.
if ((state.tool === 'lasso' || state.tool === 'crop') && !directPan) return; if (canStartOutside() && !directPan) {
if (e.target === state.mainCanvas || isUiChrome(e) ||
e.target.closest?.('.ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-fx-menu')) return;
// Mouse starts through mousedown; pen/touch need their own captured path.
if (e.pointerType === 'pen' || e.pointerType === 'touch') {
if (outsideDrawPointerId !== null || e.button !== 0) return;
outsideDrawPointerId = e.pointerId;
suppressMouseUntil = Date.now() + 500;
try { canvasArea.setPointerCapture(e.pointerId); } catch {}
beginDraw(e);
e.preventDefault();
}
return;
}
if (e.target.closest('button, input, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return; if (e.target.closest('button, input, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return;
const onImage = e.target === state.mainCanvas || e.target === state.transformOverlay; const onImage = e.target === state.mainCanvas || e.target === state.transformOverlay;
if (onImage && !directPan) return; if (onImage && !directPan) return;
@@ -357,6 +373,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
e.preventDefault(); e.preventDefault();
}); });
canvasArea.addEventListener('pointermove', (e) => { canvasArea.addEventListener('pointermove', (e) => {
if (outsideDrawPointerId !== null && e.pointerId === outsideDrawPointerId) {
suppressMouseUntil = Date.now() + 500;
if (!isUiChrome(e)) continueDraw(e);
e.preventDefault();
return;
}
if (transformPointerId !== null && e.pointerId === transformPointerId) { if (transformPointerId !== null && e.pointerId === transformPointerId) {
continueDraw(e); continueDraw(e);
return; return;
@@ -370,6 +392,14 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
applyOffset(next.x, next.y); applyOffset(next.x, next.y);
}); });
const endPan = (e) => { const endPan = (e) => {
if (outsideDrawPointerId !== null && (!e || e.pointerId === outsideDrawPointerId)) {
const capturedId = outsideDrawPointerId;
outsideDrawPointerId = null;
suppressMouseUntil = Date.now() + 500;
if (e?.type === 'pointercancel') cancelDraw?.();
else if (e) endDraw(e);
try { canvasArea.releasePointerCapture(capturedId); } catch {}
}
if (transformPointerId !== null && (!e || e.pointerId === transformPointerId)) { if (transformPointerId !== null && (!e || e.pointerId === transformPointerId)) {
const capturedId = transformPointerId; const capturedId = transformPointerId;
transformPointerId = null; transformPointerId = null;
+5
View File
@@ -21,6 +21,7 @@ export const EDITOR_AGENT_VERSION = '1.0';
export const DEFAULT_AGENT_ENDPOINT = '/api/editor/agent'; export const DEFAULT_AGENT_ENDPOINT = '/api/editor/agent';
import { formatCropRect, isGenericIntent } from './nl-edit.js'; import { formatCropRect, isGenericIntent } from './nl-edit.js';
import { waitForImagePreferences } from '../settings/imagePreferences.js';
export function createEditorAgent({ export function createEditorAgent({
control, control,
@@ -28,6 +29,7 @@ export function createEditorAgent({
fetchImpl = null, fetchImpl = null,
getSession = null, getSession = null,
getModel = null, getModel = null,
getGenerationModel = null,
getAgentModel = null, getAgentModel = null,
onStep = null, onStep = null,
} = {}) { } = {}) {
@@ -145,6 +147,7 @@ export function createEditorAgent({
activeStep = report; activeStep = report;
executed = []; executed = [];
running = true; running = true;
await waitForImagePreferences();
// Honest before-image (captured before any client pre-step) for the // Honest before-image (captured before any client pre-step) for the
// imperceptible-edit check, then the history mark scoping the undo group. // imperceptible-edit check, then the history mark scoping the undo group.
@@ -194,6 +197,8 @@ export function createEditorAgent({
signal: signal || undefined, signal: signal || undefined,
body: JSON.stringify({ body: JSON.stringify({
prompt, session, editor_session: session, state, screenshot, prompt, session, editor_session: session, state, screenshot,
_use_image_settings: true,
generation_model: getGenerationModel ? getGenerationModel() : '',
model: (modelInfo && modelInfo.model) || '', model: (modelInfo && modelInfo.model) || '',
endpoint: (modelInfo && modelInfo.endpoint) || '', endpoint: (modelInfo && modelInfo.endpoint) || '',
model_label: (modelInfo && modelInfo.label) || '', model_label: (modelInfo && modelInfo.label) || '',
+1 -1
View File
@@ -132,7 +132,7 @@ function renderEffects(source, effects, masks) {
const contribution = maskedContribution(next, masks[index]); const contribution = maskedContribution(next, masks[index]);
const blended = copyCanvas(current); const blended = copyCanvas(current);
const blendCtx = blended.getContext('2d'); const blendCtx = blended.getContext('2d');
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over'; if (effect.type === 'drop-shadow' && !effect.params.inside) blendCtx.globalCompositeOperation = 'destination-over';
blendCtx.globalAlpha = effect.opacity; blendCtx.globalAlpha = effect.opacity;
blendCtx.drawImage(contribution, 0, 0); blendCtx.drawImage(contribution, 0, 0);
blendCtx.globalAlpha = 1; blendCtx.globalAlpha = 1;
+2 -1
View File
@@ -81,6 +81,7 @@ export function normalizeEffect(effect) {
params.blendMode = typeof params.blendMode === 'string' ? params.blendMode : defaults.blendMode; params.blendMode = typeof params.blendMode === 'string' ? params.blendMode : defaults.blendMode;
} }
if (type === 'drop-shadow') { if (type === 'drop-shadow') {
params.inside = params.inside === true || params.inside === 1;
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color; params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
for (const key of ['opacity', 'blur', 'x', 'y']) params[key] = Number(params[key]) || 0; for (const key of ['opacity', 'blur', 'x', 'y']) params[key] = Number(params[key]) || 0;
params.opacity = Math.max(0, Math.min(1, params.opacity)); params.opacity = Math.max(0, Math.min(1, params.opacity));
@@ -291,7 +292,7 @@ export function renderEffects(source, effects = [], shouldContinue = () => true)
: next; : next;
const blended = copyCanvas(current); const blended = copyCanvas(current);
const blendCtx = blended.getContext('2d'); const blendCtx = blended.getContext('2d');
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over'; if (effect.type === 'drop-shadow' && !effect.params.inside) blendCtx.globalCompositeOperation = 'destination-over';
blendCtx.globalAlpha = effect.opacity; blendCtx.globalAlpha = effect.opacity;
blendCtx.drawImage(contribution, 0, 0); blendCtx.drawImage(contribution, 0, 0);
blendCtx.globalAlpha = 1; blendCtx.globalAlpha = 1;
+71
View File
@@ -0,0 +1,71 @@
import { decodeAIImage } from './ai-operation.js';
import { waitForImagePreferences } from '../settings/imagePreferences.js';
export function createImageGenerationAction({
getDocument, placeResult, getSignal = () => null, onProgress = () => {},
formatError = info => info.detail || 'Image generation failed',
fetchImpl = (...args) => fetch(...args), timeoutMs = 330000,
}) {
return async function generateImage(params = {}) {
const document = getDocument();
if (!document.key) throw new Error('Open a document before generating an image');
const controller = new AbortController();
const parentSignal = getSignal();
const cancel = () => controller.abort();
if (parentSignal?.aborted) cancel();
parentSignal?.addEventListener('abort', cancel, { once: true });
let timedOut = false;
const deadline = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
const started = Date.now();
const progress = setInterval(() => onProgress(Math.floor((Date.now() - started) / 1000)), 1000);
// A selection can change while the provider works; preserve this request's mask.
let mask = null;
try {
if (params.masked && document.mask) {
mask = globalThis.document.createElement('canvas');
mask.width = document.mask.width; mask.height = document.mask.height;
mask.getContext('2d').drawImage(document.mask, 0, 0);
}
await waitForImagePreferences();
controller.signal.throwIfAborted();
let response;
try { response = await fetchImpl(window.geGenerateImageEndpoint || '/api/image/generate', {
method: 'POST', credentials: 'same-origin', signal: controller.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ _use_image_settings: true, prompt: params.prompt || '',
width: Math.max(1, Math.round(Number(params.width) || document.width)),
height: Math.max(1, Math.round(Number(params.height) || document.height)) }),
}); } catch (error) {
if (controller.signal.aborted) throw error;
throw new Error(formatError({ network: true, detail: error.message }));
}
let data;
try { data = await response.json(); }
catch { throw new Error(response.ok ? 'Image server returned an invalid response' : `Image server failed (${response.status})`); }
if (!response.ok || data.error) throw new Error(formatError({ status: response.status,
detail: data.detail || data.error || `Image server failed (${response.status})` }));
const image = data.image || data.images?.[0];
if (!image) throw new Error('Image generation returned no image');
const decoded = await decodeAIImage(image, controller.signal);
controller.signal.throwIfAborted();
const current = getDocument();
if (current.key !== document.key || current.width !== document.width || current.height !== document.height) {
throw new Error('The document changed during generation. The result was not placed in another document.');
}
const canvas = globalThis.document.createElement('canvas');
canvas.width = decoded.naturalWidth; canvas.height = decoded.naturalHeight;
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(decoded, 0, 0);
return placeResult(canvas, 'Generated', mask);
} catch (error) {
if (timedOut) throw new Error('Image generation timed out. Try again or choose a faster image model.');
throw error;
} finally {
clearTimeout(deadline);
clearInterval(progress);
parentSignal?.removeEventListener('abort', cancel);
}
};
}
+11
View File
@@ -0,0 +1,11 @@
export function imageResultGeometry(width, height, canvasWidth, canvasHeight) {
const matchingRatio = Math.abs(width * canvasHeight - height * canvasWidth) <= Math.max(canvasWidth, canvasHeight);
if (matchingRatio) {
const scale = Math.max(canvasWidth / width, canvasHeight / height);
const drawWidth = width * scale, drawHeight = height * scale;
return { width: canvasWidth, height: canvasHeight, offsetX: 0, offsetY: 0,
drawX: (canvasWidth - drawWidth) / 2, drawY: (canvasHeight - drawHeight) / 2, drawWidth, drawHeight };
}
return { width, height, offsetX: Math.round((canvasWidth - width) / 2), offsetY: Math.round((canvasHeight - height) / 2),
drawX: 0, drawY: 0, drawWidth: width, drawHeight: height };
}
+12 -5
View File
@@ -67,7 +67,7 @@ export function wireKeyboardShortcuts(deps) {
toolbar, toolKeyMap, toolbar, toolKeyMap,
composite, saveState, undo, redo, composite, saveState, undo, redo,
toggleShortcuts, confirmTransform, cancelTransform, startTransform, nudgeTransform, toggleShortcuts, confirmTransform, cancelTransform, startTransform, nudgeTransform,
resizeCustomPrompt, addEmptyLayer, brushSizeSync, resizeCustomPrompt, addEmptyLayer, brushSizeSync, openLayerAdjustment,
invertSelection, invertSelection,
wandDeleteSelection, wandCopyToNewLayer, copyPixelsToClipboard, wandDeleteSelection, wandCopyToNewLayer, copyPixelsToClipboard,
lassoDeleteSelection, lassoCopyToLayer, lassoDeleteSelection, lassoCopyToLayer,
@@ -181,6 +181,12 @@ export function wireKeyboardShortcuts(deps) {
return; return;
} }
// Save shortcuts — match the hints shown in the Save dropdown. // Save shortcuts — match the hints shown in the Save dropdown.
if (e.shiftKey && !e.altKey && e.code === 'KeyU') {
e.preventDefault();
e.stopPropagation();
openLayerAdjustment?.('hue-saturation');
return;
}
if ((e.key === 's' || e.key === 'S') && !e.altKey) { if ((e.key === 's' || e.key === 'S') && !e.altKey) {
e.preventDefault(); e.preventDefault();
document.getElementById(e.shiftKey ? 'ge-export-gallery' : 'ge-save')?.click(); document.getElementById(e.shiftKey ? 'ge-export-gallery' : 'ge-save')?.click();
@@ -221,11 +227,12 @@ export function wireKeyboardShortcuts(deps) {
addEmptyLayer(); addEmptyLayer();
return; return;
} }
// Ctrl+Alt+L — Levels adjustment. The Image menu owns the row and its // Ctrl+Alt+L edits the current layer, leaving Levels controls open.
// dialog, so click it instead of duplicating the adjustment here.
if (e.altKey && e.code === 'KeyL') { if (e.altKey && e.code === 'KeyL') {
const row = document.querySelector('[data-image-action="adj-levels"]'); e.preventDefault();
if (row) { e.preventDefault(); e.stopPropagation(); row.click(); return; } e.stopPropagation();
openLayerAdjustment?.('levels');
return;
} }
// Ctrl/Cmd+J duplicates the active layer through the layer panel's // Ctrl/Cmd+J duplicates the active layer through the layer panel's
// existing implementation, which preserves masks and effects. // existing implementation, which preserves masks and effects.
+4
View File
@@ -2233,6 +2233,7 @@ export function createLayerPanelRenderer(deps) {
if (dragSortModule) { if (dragSortModule) {
dragSortModule.enable('ge-layers-list', '.ge-layer-item', { dragSortModule.enable('ge-layers-list', '.ge-layer-item', {
instanceKey: 'ge-layers', instanceKey: 'ge-layers',
excludeSelector: '.ge-adj-sub-item',
threshold: 4, threshold: 4,
ignoreSelector: 'button, input, select, textarea, a, [contenteditable="true"], .ge-adj-sub-item', ignoreSelector: 'button, input, select, textarea, a, [contenteditable="true"], .ge-adj-sub-item',
onReorder: (orderedItems) => { onReorder: (orderedItems) => {
@@ -2258,6 +2259,9 @@ export function createLayerPanelRenderer(deps) {
state.layers = newLayers; state.layers = newLayers;
normalizeLayerClipping(state); normalizeLayerClipping(state);
composite(); composite();
// Effects, adjustments and masks are sibling rows, not children
// of the dragged row. Rebuild them under their reordered owner.
render();
} }
}, },
}); });
+8 -1
View File
@@ -6,7 +6,7 @@ const size = () => number('size', 'Size', 8, 0, 100);
const angle = () => number('angle', 'Angle', 120, -180, 180, 'deg'); const angle = () => number('angle', 'Angle', 120, -180, 180, 'deg');
export const LAYER_STYLES = { export const LAYER_STYLES = {
'bevel-emboss': { label: 'Bevel & Emboss', controls: [size(), angle(), number('depth', 'Depth', 100, 0, 300, '%'), color('highlight', 'Highlight', '#ffffff'), color('color', 'Shadow', '#000000'), opacity()] }, 'bevel-emboss': { label: 'Bevel & Emboss', controls: [size(), angle(), number('depth', 'Depth', 100, 0, 300, '%'), color('highlight', 'Highlight', '#ffffff'), color('color', 'Shadow', '#000000'), opacity()] },
'inner-shadow': { label: 'Inner Shadow', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 6, 0, 100)] }, 'inner-shadow': { label: 'Inner Shadow', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 6, 0, 300)] },
'inner-glow': { label: 'Inner Glow', controls: [color('color', 'Color', '#ffffff'), opacity(), size()] }, 'inner-glow': { label: 'Inner Glow', controls: [color('color', 'Color', '#ffffff'), opacity(), size()] },
satin: { label: 'Satin', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 12, 0, 100)] }, satin: { label: 'Satin', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 12, 0, 100)] },
'gradient-overlay': { label: 'Gradient Overlay', controls: [color('color', 'Start', '#e06c75'), color('endColor', 'End', '#ffffff'), opacity(), angle(), number('scale', 'Scale', 100, 10, 300, '%')] }, 'gradient-overlay': { label: 'Gradient Overlay', controls: [color('color', 'Start', '#e06c75'), color('endColor', 'End', '#ffffff'), opacity(), angle(), number('scale', 'Scale', 100, 10, 300, '%')] },
@@ -32,6 +32,13 @@ function surface(width, height) {
} }
export function renderDropShadow(source, params) { export function renderDropShadow(source, params) {
if (params.inside) {
return renderLayerStyle(source, 'inner-shadow', {
color: params.color, opacity: params.opacity * 100, size: params.blur / 2,
distance: Math.hypot(params.x, params.y),
angle: Math.atan2(-params.y, params.x) * 180 / Math.PI,
});
}
// Tint alpha first so RGB content cannot leak into the shadow. Draw only // Tint alpha first so RGB content cannot leak into the shadow. Draw only
// this silhouette; compositing the source again thickens translucent edges. // this silhouette; compositing the source again thickens translucent edges.
const silhouette = surface(source.width, source.height); const silhouette = surface(source.width, source.height);
+13
View File
@@ -0,0 +1,13 @@
export function finishQuickEditJob(job, result) {
if (job.cancelled || result?.cancelled) {
job.cancelled = true;
job.status = 'cancelled';
job.detail = 'Cancelled';
} else if (job.status === 'error' || result?.ok === false) {
job.status = 'error';
job.detail = result?.error || result?.message || (job.detail !== 'Working…' && job.detail) || 'Edit failed';
} else {
job.status = 'done';
job.detail = result?.message || 'Done';
}
}
+30 -8
View File
@@ -14,19 +14,26 @@ function toolSettings(tool) {
let _lastColor = null; let _lastColor = null;
let _lastTransparentColor = null; let _lastTransparentColor = null;
function transparentColor(color) { function transparentColor(color, alpha = 0) {
if (color === _lastColor && _lastTransparentColor) return _lastTransparentColor; const key = `${color}|${alpha}`;
_lastColor = color; if (key === _lastColor && _lastTransparentColor) return _lastTransparentColor;
_lastColor = key;
_lastTransparentColor = colorWithAlpha(color, alpha);
return _lastTransparentColor;
}
function colorWithAlpha(color, alpha) {
if (color.startsWith('#')) { if (color.startsWith('#')) {
const raw = color.slice(1); const raw = color.slice(1);
const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw; const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw;
const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16)); const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16));
_lastTransparentColor = `rgba(${rgb.join(',')},0)`; return `rgba(${rgb.join(',')},${alpha})`;
return _lastTransparentColor;
} }
const match = color.match(/rgba?\(([^)]+)\)/); const match = color.match(/rgba?\(([^)]+)\)/);
_lastTransparentColor = match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)'; if (!match) return `rgba(0,0,0,${alpha})`;
return _lastTransparentColor; const components = match[1].split(',');
const originalAlpha = components.length > 3 ? Number(components[3]) : 1;
return `rgba(${components.slice(0, 3).join(',')},${alpha * originalAlpha})`;
} }
function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) { function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
@@ -38,8 +45,19 @@ function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
ctx.fillStyle = color; ctx.fillStyle = color;
} else { } else {
const gradient = ctx.createRadialGradient(x, y, radius * hard, x, y, radius); const gradient = ctx.createRadialGradient(x, y, radius * hard, x, y, radius);
if (softness > 1) {
// Above the old maximum, remove the firm core with a progressively
// gentler alpha profile while keeping the selected brush diameter.
ctx.fillStyle = color;
const normalized = ctx.fillStyle;
for (let step = 0; step <= 16; step++) {
const t = step / 16;
gradient.addColorStop(t, transparentColor(normalized, (1 - t) ** softness));
}
} else {
gradient.addColorStop(0, color); gradient.addColorStop(0, color);
gradient.addColorStop(1, transparentColor(color)); gradient.addColorStop(1, transparentColor(color));
}
ctx.fillStyle = gradient; ctx.fillStyle = gradient;
} }
ctx.beginPath(); ctx.beginPath();
@@ -274,6 +292,10 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
if (!PAINT_TOOLS.has(tool)) return; if (!PAINT_TOOLS.has(tool)) return;
const layer = activeLayer(); const layer = activeLayer();
if (!layer) return; if (!layer) return;
// Pixel writes invalidate adjusted previews even when adjustment settings
// themselves have not changed (otherwise retouch strokes appear late).
layer._adjFinalKey = null;
layer._adjCacheKey = null;
if (tool === 'clone') return cloneStamp(sample, layer); if (tool === 'clone') return cloneStamp(sample, layer);
if (tool === 'heal') { if (tool === 'heal') {
// A healing stroke without a picked source uses the convenient local // A healing stroke without a picked source uses the convenient local
@@ -294,7 +316,7 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
let operation = 'source-over'; let operation = 'source-over';
let color = state.color; let color = state.color;
let alpha = opacity * flow; let alpha = opacity * flow;
let softness = Math.max(0, Math.min(1, settings.softness / 300)); let softness = Math.max(0, Math.min(4, settings.softness / 300));
if (tool === 'eraser') { operation = 'destination-out'; color = 'rgba(0,0,0,1)'; } if (tool === 'eraser') { operation = 'destination-out'; color = 'rgba(0,0,0,1)'; }
else if (tool === 'inpaint') { else if (tool === 'inpaint') {
operation = state.inpaintEraseStroke ? 'destination-out' : 'source-over'; operation = state.inpaintEraseStroke ? 'destination-out' : 'source-over';
+5 -2
View File
@@ -57,8 +57,11 @@ function wireToolSliders(prefix, fields) {
// softness rises (the CSS already sets the radial gradient — we // softness rises (the CSS already sets the radial gradient — we
// just tween the inner solid radius to communicate the falloff). // just tween the inner solid radius to communicate the falloff).
if (softPrev) { if (softPrev) {
const innerStop = Math.max(0, 60 - state[fields.softness] * 0.55); const amount = state[fields.softness];
softPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`; const innerStop = Math.max(0, 60 - amount * 0.55);
softPrev.style.background = amount > 300
? `radial-gradient(circle, var(--fg), color-mix(in srgb, var(--fg) ${Math.round(100 * 0.5 ** (amount / 300))}%, transparent) 50%, transparent 100%)`
: `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`;
} }
}); });
} }
+18 -4
View File
@@ -20,6 +20,23 @@ export function wireSubmenuToggles(menu) {
menu.classList.remove('ge-flyout-open'); menu.classList.remove('ge-flyout-open');
} }
}); });
function positionPanel(parent) {
const panel = parent.nextElementSibling;
if (!panel?.classList.contains('ge-submenu')) return;
const row = parent.getBoundingClientRect();
const origin = (panel.offsetParent || menu).getBoundingClientRect();
const top = Math.max(8, Math.min(row.top, window.innerHeight - panel.offsetHeight - 8));
const left = row.right + panel.offsetWidth > window.innerWidth - 8
? Math.max(8, row.left - panel.offsetWidth) : row.right - 2;
panel.style.top = `${top - origin.top + (panel.offsetParent?.scrollTop || 0)}px`;
panel.style.left = `${left - origin.left + (panel.offsetParent?.scrollLeft || 0)}px`;
}
for (const eventName of ['pointerover', 'focusin']) {
menu.addEventListener(eventName, (event) => {
const parent = event.target.closest('.ge-menu-parent');
if (parent) positionPanel(parent);
});
}
menu.addEventListener('click', (e) => { menu.addEventListener('click', (e) => {
const parent = e.target.closest('.ge-menu-parent'); const parent = e.target.closest('.ge-menu-parent');
if (!parent) return; if (!parent) return;
@@ -34,10 +51,7 @@ export function wireSubmenuToggles(menu) {
parent.setAttribute('aria-expanded', String(willOpen)); parent.setAttribute('aria-expanded', String(willOpen));
if (willOpen) { if (willOpen) {
// Align the flyout with the clicked row rather than the menu top. // Align the flyout with the clicked row rather than the menu top.
const panel = parent.nextElementSibling; positionPanel(parent);
if (panel && panel.classList.contains('ge-submenu')) {
panel.style.top = parent.offsetTop + 'px';
}
} }
}); });
} }
+2
View File
@@ -37,6 +37,7 @@
* }} deps * }} deps
*/ */
import { state } from './state.js'; import { state } from './state.js';
import { topPortalZ } from '../toolWindowZOrder.js';
import { isLayerPixelLocked, isLayerTransparencyLocked } from './layer-groups.js'; import { isLayerPixelLocked, isLayerTransparencyLocked } from './layer-groups.js';
const TOPBAR_MENU_IDS = ['ge-file-menu', 'ge-edit-menu', 'ge-view-menu', 'ge-image-menu', 'ge-layer-menu', 'ge-type-menu', 'ge-selection-menu', 'ge-filter-menu', 'ge-window-menu', 'ge-help-menu', 'ge-resize-menu', 'ge-save-menu']; const TOPBAR_MENU_IDS = ['ge-file-menu', 'ge-edit-menu', 'ge-view-menu', 'ge-image-menu', 'ge-layer-menu', 'ge-type-menu', 'ge-selection-menu', 'ge-filter-menu', 'ge-window-menu', 'ge-help-menu', 'ge-resize-menu', 'ge-save-menu'];
@@ -101,6 +102,7 @@ export function wireTopbar(deps) {
}; };
const positionSaveMenu = () => { const positionSaveMenu = () => {
const r = saveBtn.getBoundingClientRect(); const r = saveBtn.getBoundingClientRect();
saveMenu.style.zIndex = String(topPortalZ());
saveMenu.style.top = `${r.bottom + 2}px`; saveMenu.style.top = `${r.bottom + 2}px`;
saveMenu.style.right = `${Math.max(8, window.innerWidth - r.right)}px`; saveMenu.style.right = `${Math.max(8, window.innerWidth - r.right)}px`;
saveMenu.style.left = 'auto'; saveMenu.style.left = 'auto';
+5
View File
@@ -1321,7 +1321,12 @@ function _renderEditorLanding() {
// openEditor() now returns a Promise — it's async because the size // openEditor() now returns a Promise — it's async because the size
// prompt is a styled modal. Await it before checking whether the // prompt is a styled modal. Await it before checking whether the
// editor actually opened (the user may have cancelled). // editor actually opened (the user may have cancelled).
try {
await openEditor(null, null, null, 'New canvas'); await openEditor(null, null, null, 'New canvas');
} catch (error) {
// openEditor reports the initialization error and tears down its shell.
console.error('[gallery] New canvas failed', error);
}
if (!document.querySelector('#gallery-editor-container .gallery-editor')) { if (!document.querySelector('#gallery-editor-container .gallery-editor')) {
_renderEditorLanding(); _renderEditorLanding();
} }
+123 -57
View File
@@ -277,8 +277,11 @@ import { alphaBounds as _maskAlphaBounds, featherMaskCanvas as _featherMaskCanva
import { analyzeAutoTone as _analyzeAutoTone, applyAutoTone as _applyAutoTone, boxBlurRGB as _boxBlurRGB, skinMaskFromData as _skinMaskFromData, frequencySeparationSmooth as _frequencySeparationSmooth, blemishRemoval as _blemishRemoval, teethWhitening as _teethWhitening, featureExclusionMask as _featureExclusionMask, dilateAlpha as _dilateAlpha, subtractMask as _subtractMask, guidedClarity as _guidedClarity } from './editor/retouch.js'; import { analyzeAutoTone as _analyzeAutoTone, applyAutoTone as _applyAutoTone, boxBlurRGB as _boxBlurRGB, skinMaskFromData as _skinMaskFromData, frequencySeparationSmooth as _frequencySeparationSmooth, blemishRemoval as _blemishRemoval, teethWhitening as _teethWhitening, featureExclusionMask as _featureExclusionMask, dilateAlpha as _dilateAlpha, subtractMask as _subtractMask, guidedClarity as _guidedClarity } from './editor/retouch.js';
import { wireClipboardAndDrop } from './editor/clipboard-and-drop.js'; import { wireClipboardAndDrop } from './editor/clipboard-and-drop.js';
import { wireAIModelSelectors, resolveInpaintModel, resolveQuickEditModel, resolveQuickEditAgentModel } from './editor/ai-models.js'; import { wireAIModelSelectors, resolveInpaintModel, resolveQuickEditModel, resolveQuickEditAgentModel } from './editor/ai-models.js';
import { createPreflight } from './editor/preflight.js';
import { createAiSettings } from './editor/ai-settings.js'; import { createAiSettings } from './editor/ai-settings.js';
import { waitForImagePreferences } from './settings/imagePreferences.js';
import { createImageGenerationAction } from './editor/image-generation.js';
import { finishQuickEditJob } from './editor/quick-edit-job.js';
import { imageResultGeometry } from './editor/image-result-geometry.js';
import { lookRecipeSteps } from './editor/look-recipes.js'; import { lookRecipeSteps } from './editor/look-recipes.js';
import { wireInpaintButtons } from './editor/ai-inpaint.js?v=20260708match1'; import { wireInpaintButtons } from './editor/ai-inpaint.js?v=20260708match1';
import { wireAIToolsMisc } from './editor/ai-tools-misc.js'; import { wireAIToolsMisc } from './editor/ai-tools-misc.js';
@@ -356,7 +359,6 @@ function _computeTransformSnap(frame, center) {
const _INPAINT_DEFAULT_BRUSH = 100; const _INPAINT_DEFAULT_BRUSH = 100;
let _samAbortController = null; let _samAbortController = null;
let _precisionGuides = null; let _precisionGuides = null;
let _preflight = null;
let _aiSettings = null; let _aiSettings = null;
let _selectionAnimationFrame = null; let _selectionAnimationFrame = null;
let _selectionAnimationLast = 0; let _selectionAnimationLast = 0;
@@ -394,6 +396,12 @@ if (!window.__galleryEditEscHardGuardInstalled) {
} }
if (e.target?.closest?.('#styled-confirm-overlay')) return; if (e.target?.closest?.('#styled-confirm-overlay')) return;
if (e.key === 'Escape') { if (e.key === 'Escape') {
const aiSettings = document.querySelector('.ge-ai-settings:not([hidden])');
if (aiSettings) {
e.preventDefault(); e.stopImmediatePropagation();
aiSettings.querySelector('[data-ai-settings-close]')?.click();
return;
}
// Floating dialogs/popups that live outside the editor modal must close on // Floating dialogs/popups that live outside the editor modal must close on
// Escape reliably (their document-capture handlers can miss when focus is // Escape reliably (their document-capture handlers can miss when focus is
// outside them). Close the top-most one and stop here. // outside them). Close the top-most one and stop here.
@@ -838,6 +846,7 @@ function _setCanvasAiBusy(busy) {
} }
let _aiProgressTimer = null; let _aiProgressTimer = null;
let _aiProgressEpoch = 0;
function _setAiProgress(frac, label) { function _setAiProgress(frac, label) {
const wrap = document.getElementById('ge-ai-command-progress'); const wrap = document.getElementById('ge-ai-command-progress');
if (!wrap) return; if (!wrap) return;
@@ -857,12 +866,17 @@ function _startAiProgress(requestId) {
const endpointId = arguments[1] || ''; const endpointId = arguments[1] || '';
_stopAiProgress(); _stopAiProgress();
if (!requestId) return; if (!requestId) return;
const epoch = _aiProgressEpoch;
let pending = false;
const tick = async () => { const tick = async () => {
if (pending || epoch !== _aiProgressEpoch) return;
pending = true;
try { try {
const epQuery = endpointId ? `&endpoint_id=${encodeURIComponent(endpointId)}` : ''; const epQuery = endpointId ? `&endpoint_id=${encodeURIComponent(endpointId)}` : '';
const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}` + epQuery, { credentials: 'same-origin' }); const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}` + epQuery, { credentials: 'same-origin' });
if (!r.ok) return; if (!r.ok) return;
const p = await r.json(); const p = await r.json();
if (epoch !== _aiProgressEpoch) return;
const st = p.stage || ''; const st = p.stage || '';
if (st === 'done' || st === 'error') { _setAiProgress(1, st === 'done' ? 'Done' : 'Failed'); return; } if (st === 'done' || st === 'error') { _setAiProgress(1, st === 'done' ? 'Done' : 'Failed'); return; }
const frac = typeof p.progress === 'number' ? p.progress : 0; const frac = typeof p.progress === 'number' ? p.progress : 0;
@@ -871,12 +885,13 @@ function _startAiProgress(requestId) {
const verb = st === 'editing' ? 'Generating' : (st ? st[0].toUpperCase() + st.slice(1) : 'Working'); const verb = st === 'editing' ? 'Generating' : (st ? st[0].toUpperCase() + st.slice(1) : 'Working');
_setAiProgress(frac, `${verb}…${stepTxt}${eta}`); _setAiProgress(frac, `${verb}…${stepTxt}${eta}`);
_setAiCommandStatus(`${verb}…${stepTxt}${eta}`, 'running'); _setAiCommandStatus(`${verb}…${stepTxt}${eta}`, 'running');
} catch (_) {} } catch (_) {} finally { pending = false; }
}; };
_aiProgressTimer = setInterval(tick, 1000); _aiProgressTimer = setInterval(tick, 1000);
tick(); tick();
} }
function _stopAiProgress() { function _stopAiProgress() {
_aiProgressEpoch++;
if (_aiProgressTimer) { clearInterval(_aiProgressTimer); _aiProgressTimer = null; } if (_aiProgressTimer) { clearInterval(_aiProgressTimer); _aiProgressTimer = null; }
_setAiProgress(null); _setAiProgress(null);
} }
@@ -1074,6 +1089,7 @@ const _QUICK_EDIT_TOOL_SUGGESTIONS = Object.entries(_TOOLS).map(([id, t]) => ({
kind: 'Tool', kind: 'Tool',
icon: t.icon || '', icon: t.icon || '',
toolId: id, toolId: id,
runPrompt: id === 'rembg' ? 'remove background' : null,
aliases: [id], aliases: [id],
})); }));
const _QUICK_EDIT_CATALOG = [..._QUICK_EDIT_TOOL_SUGGESTIONS, ..._AI_COMMAND_SUGGESTIONS]; const _QUICK_EDIT_CATALOG = [..._QUICK_EDIT_TOOL_SUGGESTIONS, ..._AI_COMMAND_SUGGESTIONS];
@@ -1281,8 +1297,13 @@ function _wireAiCommandBox() {
if (item.toolId) { if (item.toolId) {
_clickToolButton(item.toolId); _clickToolButton(item.toolId);
_setAiCommandStatus(`${item.label} tool`, 'done'); _setAiCommandStatus(`${item.label} tool`, 'done');
if (item.runPrompt) {
input.value = item.runPrompt;
syncClearButton();
}
hideSuggestions(); hideSuggestions();
input.focus(); input.focus();
if (run && item.runPrompt) form.requestSubmit();
return true; return true;
} }
input.value = item.insert; input.value = item.insert;
@@ -1471,6 +1492,24 @@ function _wireAiCommandBox() {
// model (window.geEditorAI) just like the server would route it. // model (window.geEditorAI) just like the server would route it.
const _executeQuickEditLocal = async (prompt) => { const _executeQuickEditLocal = async (prompt) => {
const p = prompt.toLowerCase(); const p = prompt.toLowerCase();
// Background-removal intent (shared with the routing guard so the regex
// can never drift between the two call sites).
if (_looksBackgroundRemoval(p)) {
await _runQuickControlOp('ai.removeBackground', {});
// Chain a tone step when the prompt also asks for one
// ("remove the background and make it brighter", "...and warm it up").
if (/\b(bright(?:er|en)?|lighten|exposure|well[-\s]*lit)\b/.test(p)) {
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: 16, contrast: 4 } });
} else if (/\b(dark(?:er|en)?|dimmer)\b/.test(p)) {
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: -14, contrast: 4 } });
} else if (/\b(warm(?:er)?)\b/.test(p)) {
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: 18 } });
} else if (/\b(cool(?:er)?)\b/.test(p)) {
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: -18 } });
}
_setAiCommandStatus('Background removal finished.', 'done');
return;
}
// A targeted recolour ("make only the red car blue", "recolour the rock to // A targeted recolour ("make only the red car blue", "recolour the rock to
// teal") needs the model to repaint a specific object — never fake it with a // teal") needs the model to repaint a specific object — never fake it with a
// global colour tweak from the deterministic fast path. // global colour tweak from the deterministic fast path.
@@ -1933,24 +1972,6 @@ function _wireAiCommandBox() {
return; return;
} }
} }
// Background-removal intent (shared with the routing guard so the regex
// can never drift between the two call sites).
if (_looksBackgroundRemoval(p)) {
await _runQuickControlOp('ai.removeBackground', {});
// Chain a tone step when the prompt also asks for one
// ("remove the background and make it brighter", "...and warm it up").
if (/\b(bright(?:er|en)?|lighten|exposure|well[-\s]*lit)\b/.test(p)) {
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: 16, contrast: 4 } });
} else if (/\b(dark(?:er|en)?|dimmer)\b/.test(p)) {
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: -14, contrast: 4 } });
} else if (/\b(warm(?:er)?)\b/.test(p)) {
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: 18 } });
} else if (/\b(cool(?:er)?)\b/.test(p)) {
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: -18 } });
}
_setAiCommandStatus('Background removal finished.', 'done');
return;
}
if (!/\b(text|title|caption|font|heading|subtitle)\b/.test(p) if (!/\b(text|title|caption|font|heading|subtitle)\b/.test(p)
&& /\b(upscale|higher\s*res|increase\s*resolution|bigger|2x|4x)\b/.test(p)) { && /\b(upscale|higher\s*res|increase\s*resolution|bigger|2x|4x)\b/.test(p)) {
// Use the real upscale op (Real-ESRGAN, basic-upscale fallback). The // Use the real upscale op (Real-ESRGAN, basic-upscale fallback). The
@@ -2814,8 +2835,8 @@ function _wireAiCommandBox() {
if (_looksBackgroundRemoval(prompt)) { if (_looksBackgroundRemoval(prompt)) {
_setAiCommandStatus('Removing the background…', 'running'); _setAiCommandStatus('Removing the background…', 'running');
try { try {
const done = await _executeQuickEditLocal(prompt); await _executeQuickEditLocal(prompt);
if (done !== false) return { ok: true }; return { ok: true };
} catch (err) { } catch (err) {
if (signal && signal.aborted) { _setAiCommandStatus('', ''); return { ok: false, cancelled: true }; } if (signal && signal.aborted) { _setAiCommandStatus('', ''); return { ok: false, cancelled: true }; }
const msg = err && err.message ? err.message : String(err); const msg = err && err.message ? err.message : String(err);
@@ -2841,6 +2862,7 @@ function _wireAiCommandBox() {
_setAiCommandStatus('Generated an image.', 'done'); _setAiCommandStatus('Generated an image.', 'done');
return { ok: true, image: res }; return { ok: true, image: res };
} catch (err) { } catch (err) {
if (signal?.aborted) return { ok: false, cancelled: true };
const msg = err && err.message ? err.message : String(err); const msg = err && err.message ? err.message : String(err);
_setAiCommandStatus(_isGenOfflineMsg(msg) ? msg : `Could not generate an image: ${msg}`, 'error'); _setAiCommandStatus(_isGenOfflineMsg(msg) ? msg : `Could not generate an image: ${msg}`, 'error');
return { ok: false, error: msg }; return { ok: false, error: msg };
@@ -3083,19 +3105,22 @@ function _wireAiCommandBox() {
job.controller = controller; job.controller = controller;
renderJobStack(); renderJobStack();
_quickEditActiveJob = job; _quickEditActiveJob = job;
let result;
try { try {
await executeQuickEdit(job.prompt, { forceAi: !!job.forceAi, signal: controller ? controller.signal : null }); result = await executeQuickEdit(job.prompt, { forceAi: !!job.forceAi, signal: controller ? controller.signal : null });
} catch (err) { } catch (err) {
if (!job.cancelled) { if (!job.cancelled) {
job.status = 'error'; job.status = 'error';
job.detail = err?.message || 'AI edit failed'; job.detail = err?.message || 'AI edit failed';
} }
} finally { } finally {
_stopAiProgress();
finishQuickEditJob(job, result);
_setAiCommandStatus(job.cancelled ? '' : job.detail, job.cancelled ? '' : job.status);
if (_quickEditActiveJob === job) _quickEditActiveJob = null; if (_quickEditActiveJob === job) _quickEditActiveJob = null;
job.onChange = null; job.onChange = null;
job.controller = null; job.controller = null;
if (job.cancelled) continue; if (job.cancelled) continue;
if (job.status === 'running') job.status = 'done';
renderJobStack(); renderJobStack();
setTimeout(() => dismissJob(job), job.status === 'error' ? 3200 : 1600); setTimeout(() => dismissJob(job), job.status === 'error' ? 3200 : 1600);
} }
@@ -5304,9 +5329,14 @@ function _restoreState(snap) {
// inside _buildEditor, so we touch the DOM here). // inside _buildEditor, so we touch the DOM here).
const sizeLabel = document.getElementById('ge-canvas-size'); const sizeLabel = document.getElementById('ge-canvas-size');
if (sizeLabel) sizeLabel.textContent = `${state.imgWidth}×${state.imgHeight} px`; if (sizeLabel) sizeLabel.textContent = `${state.imgWidth}×${state.imgHeight} px`;
state.rembgLiveLayer = null;
state.rembgLiveSnap = null;
} }
function undo() { function undo() {
// End sampling before restoring pixels; pointer-up must not replay the
// unfinished stroke into the restored layer without a new history entry.
if (state.drawing) _strokeTool.tryEnd();
if (state.undoStack.length === 0) return; if (state.undoStack.length === 0) return;
const target = state.undoStack.pop(); const target = state.undoStack.pop();
const cur = _snapshotState(); const cur = _snapshotState();
@@ -6535,6 +6565,11 @@ const _nudgeTransform = _transformSession.nudgeTransform;
const _confirmTransform = _transformSession.confirmTransform; const _confirmTransform = _transformSession.confirmTransform;
const _cancelTransform = _transformSession.cancelTransform; const _cancelTransform = _transformSession.cancelTransform;
window.__galleryEditorHandleEscape = () => { window.__galleryEditorHandleEscape = () => {
const aiSettings = document.querySelector('.ge-ai-settings:not([hidden])');
if (aiSettings) {
aiSettings.querySelector('[data-ai-settings-close]')?.click();
return true;
}
const sizePrompt = document.getElementById('ge-canvas-size-overlay'); const sizePrompt = document.getElementById('ge-canvas-size-overlay');
if (sizePrompt && getComputedStyle(sizePrompt).display !== 'none') { if (sizePrompt && getComputedStyle(sizePrompt).display !== 'none') {
sizePrompt._cancelCanvasSize?.(); sizePrompt._cancelCanvasSize?.();
@@ -8064,6 +8099,7 @@ async function _canMutateLayerPixels(layer, action = 'editing pixels') {
return false; return false;
} }
if (!['placed', 'text', 'shape'].includes(layer.kind)) return true; if (!['placed', 'text', 'shape'].includes(layer.kind)) return true;
if (action === 'cutting pixels') return _rasterizeLayerNow(layer);
return _confirmRasterizeLayer(layer); return _confirmRasterizeLayer(layer);
} }
@@ -8262,10 +8298,13 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
// standard slim red-thumb styling instead of the bare browser // standard slim red-thumb styling instead of the bare browser
// default. Value chip on the same line as the label. // default. Value chip on the same line as the label.
const isColor = p.type === 'color'; const isColor = p.type === 'color';
const isSelect = p.type === 'select';
rows += ` rows += `
<div class="ge-filter-row ge-eraser-row"> <div class="ge-filter-row ge-eraser-row">
<label>${p.label}${isColor ? '' : ` <span class="ge-filter-row-value" data-val-for="${p.key}">${p.value}${p.suffix || ''}</span>`}</label> <label>${p.label}${isColor || isSelect ? '' : ` <span class="ge-filter-row-value" data-val-for="${p.key}">${p.value}${p.suffix || ''}</span>`}</label>
${isColor ${isSelect
? `<select data-key="${p.key}" class="ge-ctl-select" aria-label="${p.label}">${p.options.map(option => `<option value="${option.value}"${String(option.value) === String(p.value) ? ' selected' : ''}>${option.label}</option>`).join('')}</select>`
: isColor
? `<input type="color" data-key="${p.key}" value="${p.value}" aria-label="${p.label}" />` ? `<input type="color" data-key="${p.key}" value="${p.value}" aria-label="${p.label}" />`
: `<input type="range" data-key="${p.key}" min="${p.min}" max="${p.max}" step="${p.step || 1}" value="${p.value}" />`} : `<input type="range" data-key="${p.key}" min="${p.min}" max="${p.max}" step="${p.step || 1}" value="${p.value}" />`}
</div> </div>
@@ -8286,7 +8325,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
for (const p of params) values[p.key] = p.value; for (const p of params) values[p.key] = p.value;
// Initial preview render. // Initial preview render.
try { onPreview(values); } catch {} try { onPreview(values); } catch {}
overlay.querySelectorAll('input[data-key]').forEach(inp => { overlay.querySelectorAll('input[data-key], select[data-key]').forEach(inp => {
inp.addEventListener('input', (e) => { inp.addEventListener('input', (e) => {
if (!isCurrent()) { cleanup(null); return; } if (!isCurrent()) { cleanup(null); return; }
const k = e.target.dataset.key; const k = e.target.dataset.key;
@@ -8317,7 +8356,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
e.preventDefault(); e.preventDefault();
cleanup(e.target?.dataset.action === 'cancel' ? null : values); cleanup(e.target?.dataset.action === 'cancel' ? null : values);
} else if (e.key === 'Tab') { } else if (e.key === 'Tab') {
const fields = [...overlay.querySelectorAll('input, button')]; const fields = [...overlay.querySelectorAll('input, select, button')];
const index = fields.indexOf(document.activeElement); const index = fields.indexOf(document.activeElement);
e.preventDefault(); e.preventDefault();
fields[(index + (e.shiftKey ? -1 : 1) + fields.length) % fields.length]?.focus(); fields[(index + (e.shiftKey ? -1 : 1) + fields.length) % fields.length]?.focus();
@@ -8329,7 +8368,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
if (!overlay.isConnected || !isCurrent()) cleanup(null); if (!overlay.isConnected || !isCurrent()) cleanup(null);
}); });
observer.observe(state.container, { childList: true, subtree: true }); observer.observe(state.container, { childList: true, subtree: true });
overlay.querySelector('input, button')?.focus({ preventScroll: true }); overlay.querySelector('input, select, button')?.focus({ preventScroll: true });
overlay.querySelector('[data-action="apply"]').addEventListener('click', () => cleanup(values)); overlay.querySelector('[data-action="apply"]').addEventListener('click', () => cleanup(values));
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => cleanup(null)); overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => cleanup(null));
// Click outside the modal (on the dim backdrop) = cancel. // Click outside the modal (on the dim backdrop) = cancel.
@@ -8495,6 +8534,7 @@ function _shadowOffsets(values = {}) {
return { return {
x: Math.round(Math.cos(radians) * distance), x: Math.round(Math.cos(radians) * distance),
y: Math.round(-Math.sin(radians) * distance), y: Math.round(-Math.sin(radians) * distance),
inside: Number(values.inside) === 1,
}; };
} }
@@ -8556,6 +8596,7 @@ async function _addRetainedEffect(type, presetName = null) {
{ key: 'color', label: 'Color', type: 'color', value: '#000000' }, { key: 'color', label: 'Color', type: 'color', value: '#000000' },
{ key: 'opacity', label: 'Opacity', min: 0, max: 100, step: 1, value: 45, suffix: '%' }, { key: 'opacity', label: 'Opacity', min: 0, max: 100, step: 1, value: 45, suffix: '%' },
{ key: 'blur', label: 'Blur', min: 0, max: 100, step: 1, value: 12, suffix: 'px' }, { key: 'blur', label: 'Blur', min: 0, max: 100, step: 1, value: 12, suffix: 'px' },
{ key: 'inside', label: 'Position', type: 'select', value: 0, options: [{ value: 0, label: 'Outside' }, { value: 1, label: 'Inside' }] },
{ key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).distance, suffix: 'px' }, { key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).distance, suffix: 'px' },
{ key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).angle, suffix: '\u00b0' }, { key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).angle, suffix: '\u00b0' },
]; ];
@@ -8769,6 +8810,7 @@ async function _editRetainedEffect(layer, effect) {
{ key: 'color', label: 'Color', type: 'color', value: p.color || '#000000' }, { key: 'color', label: 'Color', type: 'color', value: p.color || '#000000' },
{ key: 'opacity', label: 'Opacity', min: 0, max: 100, step: 1, value: Math.round((Number(p.opacity) || 0) * 100), suffix: '%' }, { key: 'opacity', label: 'Opacity', min: 0, max: 100, step: 1, value: Math.round((Number(p.opacity) || 0) * 100), suffix: '%' },
{ key: 'blur', label: 'Blur', min: 0, max: 100, step: 1, value: Number(p.blur) || 0, suffix: 'px' }, { key: 'blur', label: 'Blur', min: 0, max: 100, step: 1, value: Number(p.blur) || 0, suffix: 'px' },
{ key: 'inside', label: 'Position', type: 'select', value: p.inside ? 1 : 0, options: [{ value: 0, label: 'Outside' }, { value: 1, label: 'Inside' }] },
{ key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance(p).distance, suffix: 'px' }, { key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance(p).distance, suffix: 'px' },
{ key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance(p).angle, suffix: '\u00b0' }, { key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance(p).angle, suffix: '\u00b0' },
]; ];
@@ -9993,9 +10035,8 @@ function _buildEditor(container) {
if (colorRow) colorRow.style.display = ['eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'liquify'].includes(toolId) ? 'none' : ''; if (colorRow) colorRow.style.display = ['eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'liquify'].includes(toolId) ? 'none' : '';
const colorLabel = colorRow?.querySelector('label'); const colorLabel = colorRow?.querySelector('label');
if (colorLabel) colorLabel.textContent = 'Color'; if (colorLabel) colorLabel.textContent = 'Color';
// Per-tool stroke modifiers all live in the shared brush "More" menu so // Secondary modifiers stay in More; brush softness remains in the main
// every brush-family tool has the same compact strip as the Brush. Only // strip. Only the active tool's secondary group is shown.
// the group for the active tool is shown; the rest stay hidden.
const strokeTool = (toolId === 'brush' || toolId === 'pixel' || toolId === 'dodge' || toolId === 'burn') ? 'brush' const strokeTool = (toolId === 'brush' || toolId === 'pixel' || toolId === 'dodge' || toolId === 'burn') ? 'brush'
: toolId === 'eraser' ? 'eraser' : toolId === 'eraser' ? 'eraser'
: (toolId === 'clone' || toolId === 'heal') ? 'clone' : (toolId === 'clone' || toolId === 'heal') ? 'clone'
@@ -10005,6 +10046,8 @@ function _buildEditor(container) {
row.style.display = row.dataset.strokeTool === strokeTool ? '' : 'none'; row.style.display = row.dataset.strokeTool === strokeTool ? '' : 'none';
}); });
const dodgeBurnRow = document.getElementById('ge-dodgeburn-intensity-row'); const dodgeBurnRow = document.getElementById('ge-dodgeburn-intensity-row');
const brushSoftnessRow = document.getElementById('ge-brush-softness-row');
if (brushSoftnessRow) brushSoftnessRow.style.display = ['brush', 'dodge', 'burn'].includes(toolId) ? '' : 'none';
// Intensity sits in the strip next to Size, so it must show for Dodge and // Intensity sits in the strip next to Size, so it must show for Dodge and
// Burn only (and never for the other brush-family tools). // Burn only (and never for the other brush-family tools).
if (dodgeBurnRow) dodgeBurnRow.style.display = (toolId === 'dodge' || toolId === 'burn') ? '' : 'none'; if (dodgeBurnRow) dodgeBurnRow.style.display = (toolId === 'dodge' || toolId === 'burn') ? '' : 'none';
@@ -10457,7 +10500,6 @@ function _buildEditor(container) {
canvasArea.appendChild(state.transformOverlay); canvasArea.appendChild(state.transformOverlay);
canvasArea.appendChild(_buildQuickMaskBar()); canvasArea.appendChild(_buildQuickMaskBar());
canvasArea.appendChild(_buildAiCommandBox()); canvasArea.appendChild(_buildAiCommandBox());
_preflight = createPreflight({ canvasArea });
_aiSettings = createAiSettings({ container }); _aiSettings = createAiSettings({ container });
try { window.geEditorAiSettings = _aiSettings; } catch (_) {} try { window.geEditorAiSettings = _aiSettings; } catch (_) {}
// On phones the rulers eat scarce canvas width; default them off. // On phones the rulers eat scarce canvas width; default them off.
@@ -10650,19 +10692,10 @@ function _buildEditor(container) {
const initialStatusTool = document.getElementById('ge-status-tool'); const initialStatusTool = document.getElementById('ge-status-tool');
if (initialStatusTool) initialStatusTool.textContent = _TOOLS[state.tool]?.label || state.tool || ''; if (initialStatusTool) initialStatusTool.textContent = _TOOLS[state.tool]?.label || state.tool || '';
} }
// Preflight checklist: greet the user on open (unless dismissed) and // Keep model configuration in the AI settings dialog.
// expose a footer button to re-open it.
try { try {
const footer = container.querySelector('.ge-editor-footer'); const footer = container.querySelector('.ge-editor-footer');
if (footer) { if (footer) {
const setupBtn = document.createElement('button');
setupBtn.type = 'button';
setupBtn.id = 'ge-setup-btn';
setupBtn.className = 'ge-setup-btn';
setupBtn.title = 'Editor setup checklist';
setupBtn.textContent = 'Setup';
setupBtn.addEventListener('click', () => _preflight?.open());
footer.appendChild(setupBtn);
const aiBtn = document.createElement('button'); const aiBtn = document.createElement('button');
aiBtn.type = 'button'; aiBtn.type = 'button';
aiBtn.id = 'ge-ai-settings-btn'; aiBtn.id = 'ge-ai-settings-btn';
@@ -10672,7 +10705,6 @@ function _buildEditor(container) {
aiBtn.addEventListener('click', () => _aiSettings?.open()); aiBtn.addEventListener('click', () => _aiSettings?.open());
footer.appendChild(aiBtn); footer.appendChild(aiBtn);
} }
_preflight?.maybeOpen();
} catch (_) {} } catch (_) {}
try { _initControlKit(container); } catch (_) {} try { _initControlKit(container); } catch (_) {}
let _lastLayoutDesktop = null; let _lastLayoutDesktop = null;
@@ -11792,6 +11824,14 @@ function _buildEditor(container) {
// Keyboard shortcuts — full implementation in // Keyboard shortcuts — full implementation in
// editor/keyboard-shortcuts.js. // editor/keyboard-shortcuts.js.
wireKeyboardShortcuts({ wireKeyboardShortcuts({
openLayerAdjustment: (type) => {
const layer = activeLayer();
if (!layer || !layer.canvas || layer.kind === 'adjustment') {
uiModule?.showToast('Select an image layer first');
return;
}
_openAdjPopup(layer, type, document.getElementById('ge-image-menu-btn'));
},
fillSelection: _doFillSelection, fillSelection: _doFillSelection,
copyPixelsToClipboard: _copyPixelsToClipboard, copyPixelsToClipboard: _copyPixelsToClipboard,
toolbar, toolKeyMap: _toolKeyMap, toolbar, toolKeyMap: _toolKeyMap,
@@ -12156,7 +12196,9 @@ function _buildEditor(container) {
out.getContext('2d').drawImage(layer.canvas, off.x, off.y); out.getContext('2d').drawImage(layer.canvas, off.x, off.y);
return out; return out;
}; };
const _aiEditCanvas = async (endpoint, payload = {}, { keepNaturalSize = false } = {}) => { const _aiEditCanvas = async (endpoint, payload = {}, { keepNaturalSize = true } = {}) => {
await waitForImagePreferences();
if (endpoint === '/api/image/inpaint') payload = { ...payload, _use_image_settings: true };
const flat = _aiEditSourceCanvas(); const flat = _aiEditSourceCanvas();
const image = flat.toDataURL('image/png').split(',')[1]; const image = flat.toDataURL('image/png').split(',')[1];
const requestId = 'qe-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); const requestId = 'qe-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
@@ -12167,7 +12209,7 @@ function _buildEditor(container) {
} else { } else {
_startAiProgress(requestId); _startAiProgress(requestId);
} }
const body = JSON.stringify({ image, request_id: requestId, ...payload }); const body = JSON.stringify({ image, width: flat.width, height: flat.height, request_id: requestId, ...payload });
// The image servers occasionally reject a request transiently (503 while a // The image servers occasionally reject a request transiently (503 while a
// model swaps, 502/504 gateways, dropped connections). Retry those a couple // model swaps, 502/504 gateways, dropped connections). Retry those a couple
// of times with backoff so a hiccup doesn't silently leave the canvas // of times with backoff so a hiccup doesn't silently leave the canvas
@@ -12225,7 +12267,7 @@ function _buildEditor(container) {
c.height = state.imgHeight; c.height = state.imgHeight;
} }
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height); c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
return { canvas: c, dataUrl: c.toDataURL('image/png') }; return { canvas: c, dataUrl: c.toDataURL('image/png'), fullFrame: data.full_frame === true };
} finally { } finally {
if (_timer) clearTimeout(_timer); if (_timer) clearTimeout(_timer);
if (_aiEditAbort === _ctl) _aiEditAbort = null; if (_aiEditAbort === _ctl) _aiEditAbort = null;
@@ -12234,12 +12276,17 @@ function _buildEditor(container) {
}; };
const _placeResultLayer = (canvas, name, maskCanvas) => { const _placeResultLayer = (canvas, name, maskCanvas) => {
_saveState(`AI: ${name}`); _saveState(`AI: ${name}`);
const layer = createLayer(name, state.imgWidth, state.imgHeight); const geometry = imageResultGeometry(canvas.width, canvas.height, state.imgWidth, state.imgHeight);
layer.ctx.drawImage(canvas, 0, 0); const layer = createLayer(name, geometry.width, geometry.height);
const offset = { x: geometry.offsetX, y: geometry.offsetY };
state.layerOffsets.set(layer.id, offset);
layer.ctx.imageSmoothingEnabled = true;
layer.ctx.imageSmoothingQuality = 'high';
layer.ctx.drawImage(canvas, geometry.drawX, geometry.drawY, geometry.drawWidth, geometry.drawHeight);
if (maskCanvas) { if (maskCanvas) {
layer.ctx.save(); layer.ctx.save();
layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.globalCompositeOperation = 'destination-in';
layer.ctx.drawImage(maskCanvas, 0, 0); layer.ctx.drawImage(maskCanvas, -offset.x, -offset.y);
layer.ctx.restore(); layer.ctx.restore();
} }
state.layers.push(layer); state.layers.push(layer);
@@ -12249,13 +12296,24 @@ function _buildEditor(container) {
_renderLayerPanel(); _renderLayerPanel();
return layer.id; return layer.id;
}; };
const _generateImage = createImageGenerationAction({
formatError: _generationErrorMessage,
getDocument: () => ({
key: state.editorOpen ? `${state.editorSessionToken}:${state.documentId}` : null,
width: state.imgWidth, height: state.imgHeight, mask: state.wandMask,
}),
getSignal: () => _quickEditActiveJob?.controller?.signal,
onProgress: seconds => _setAiCommandStatus(`Generating an image… ${seconds}s`, 'running'),
placeResult: _placeResultLayer,
});
const _aiInpaintSelection = async (prompt) => { const _aiInpaintSelection = async (prompt) => {
if (!state.wandMask) throw new Error('make a selection first'); if (!state.wandMask) throw new Error('make a selection first');
const layer = activeLayer() || _activeParentLayer(); const layer = activeLayer() || _activeParentLayer();
if (!layer) throw new Error('no layer'); if (!layer) throw new Error('no layer');
const mask = state.wandMask.toDataURL('image/png').split(',')[1]; const mask = state.wandMask.toDataURL('image/png').split(',')[1];
const { canvas } = await _aiEditCanvas('/api/image/inpaint', { mask, prompt: prompt || '', steps: 4 }); const selection = state.wandMask;
return _placeResultLayer(canvas, 'Generative fill', state.wandMask); const { canvas, fullFrame } = await _aiEditCanvas('/api/image/inpaint', { mask, prompt: prompt || '', steps: 4 });
return _placeResultLayer(canvas, 'Generative fill', fullFrame ? null : selection);
}; };
// Replace the background/scene while keeping the subject: the server derives // Replace the background/scene while keeping the subject: the server derives
// the subject mask, repaints the inverse (new background), harmonises colour // the subject mask, repaints the inverse (new background), harmonises colour
@@ -12548,7 +12606,7 @@ function _buildEditor(container) {
'layers.delete': { description: 'Delete layers (defaults to selection).', run: (p) => { if (p && (p.id || p.index || p.name)) _controlSelect(_controlFindLayer(p)); _layerPanelRenderer.deleteSelectedLayers?.(); return true; } }, 'layers.delete': { description: 'Delete layers (defaults to selection).', run: (p) => { if (p && (p.id || p.index || p.name)) _controlSelect(_controlFindLayer(p)); _layerPanelRenderer.deleteSelectedLayers?.(); return true; } },
'layers.setOpacity': { description: 'Set a layer opacity (0-1).', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState(`Change opacity of ${l.name}`); l.opacity = Math.max(0, Math.min(1, Number(p.opacity))); composite(); _renderLayerPanel(); return l.id; } }, 'layers.setOpacity': { description: 'Set a layer opacity (0-1).', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState(`Change opacity of ${l.name}`); l.opacity = Math.max(0, Math.min(1, Number(p.opacity))); composite(); _renderLayerPanel(); return l.id; } },
'layers.setBlend': { description: 'Set a layer blend mode.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState(`Change blend mode of ${l.name}`); l.blendMode = p.blendMode || p.mode || 'source-over'; composite(); _renderLayerPanel(); return l.id; } }, 'layers.setBlend': { description: 'Set a layer blend mode.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState(`Change blend mode of ${l.name}`); l.blendMode = p.blendMode || p.mode || 'source-over'; composite(); _renderLayerPanel(); return l.id; } },
'layers.placeImage': { description: 'Place an image (data URL) as a NEW layer, masked to the current selection when one exists; never overwrites the original. Instruction mode (mode:"instruction" or differenceMask:true) keeps untouched areas pixel-identical via a difference map. {dataUrl,name,masked,blend,opacity,x,y,w,h,mode,differenceMask,diffThreshold,diffGrow,diffFeather}.', run: (p) => new Promise((resolve, reject) => { if (!p || !p.dataUrl) { reject(new Error('dataUrl required')); return; } const img = new Image(); img.onload = () => { try { _saveState(`Place ${p.name || 'image'}`); const dw = state.imgWidth, dh = state.imgHeight; const useDiff = (p.differenceMask === true) || String(p.mode || '').toLowerCase() === 'instruction'; const beforeSnap = useDiff ? flatten() : null; const layer = createLayer(p.name || 'Placed image', dw, dh); let tw = Number(p.w), th = Number(p.h); if (!(tw > 0) || !(th > 0)) { if (useDiff) { tw = dw; th = dh; } else { const scale = Math.min(dw / img.naturalWidth, dh / img.naturalHeight); tw = Math.max(1, Math.round(img.naturalWidth * scale)); th = Math.max(1, Math.round(img.naturalHeight * scale)); } } let tx = Number(p.x), ty = Number(p.y); if (!Number.isFinite(tx)) tx = useDiff ? 0 : Math.round((dw - tw) / 2); if (!Number.isFinite(ty)) ty = useDiff ? 0 : Math.round((dh - th) / 2); layer.ctx.imageSmoothingEnabled = true; layer.ctx.imageSmoothingQuality = 'high'; layer.ctx.drawImage(img, tx, ty, tw, th); if (p.masked !== false && state.wandMask) { layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(state.wandMask, 0, 0); layer.ctx.restore(); } else if (useDiff && beforeSnap) { const diff = _differenceMaskCanvas(beforeSnap, layer.canvas, { threshold: p.diffThreshold, grow: p.diffGrow, feather: p.diffFeather, edgeExcludePx: 0 }); const explicitEdge = p.edgeExcludePx != null ? Number(p.edgeExcludePx) : null; const sceneReplace = diff.changedFraction > 0.5; const edgeExclude = sceneReplace ? 0 : (explicitEdge != null ? explicitEdge : 3); if (edgeExclude > 0) _clearMaskEdgeBand(diff.mask, edgeExclude); layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(diff.mask, 0, 0); layer.ctx.restore(); if (sceneReplace) _edgeFillFromInterior(layer.ctx, dw, dh, 3); } if (p.blend) layer.blendMode = p.blend; if (p.opacity != null) layer.opacity = Math.max(0, Math.min(1, Number(p.opacity))); state.layers.push(layer); state.activeLayerId = layer.id; state.selectedLayerIds = [layer.id]; _renderLayerPanel(); composite(); resolve(layer.id); } catch (e) { reject(e); } }; img.onerror = () => reject(new Error('image load failed')); img.src = p.dataUrl; }) }, 'layers.placeImage': { description: 'Place an image (data URL) as a NEW layer, masked to the current selection when one exists; never overwrites the original. Instruction mode (mode:"instruction" or differenceMask:true) keeps untouched areas pixel-identical via a difference map. {dataUrl,name,masked,blend,opacity,x,y,w,h,mode,differenceMask,diffThreshold,diffGrow,diffFeather}.', run: (p) => new Promise((resolve, reject) => { if (!p || !p.dataUrl) { reject(new Error('dataUrl required')); return; } const img = new Image(); img.onload = () => { try { _saveState(`Place ${p.name || 'image'}`); const dw = state.imgWidth, dh = state.imgHeight; const useDiff = p.nativeSize !== true && ((p.differenceMask === true) || String(p.mode || '').toLowerCase() === 'instruction'); const beforeSnap = useDiff ? flatten() : null; const nativeSize = p.nativeSize === true && !useDiff; const geometry = imageResultGeometry(img.naturalWidth, img.naturalHeight, dw, dh); const layer = createLayer(p.name || 'Placed image', nativeSize ? geometry.width : dw, nativeSize ? geometry.height : dh); const nativeOffset = { x: geometry.offsetX, y: geometry.offsetY }; if (nativeSize) state.layerOffsets.set(layer.id, nativeOffset); let tw = Number(p.w), th = Number(p.h); if (!(tw > 0) || !(th > 0)) { if (nativeSize) { tw = geometry.drawWidth; th = geometry.drawHeight; } else if (useDiff) { tw = dw; th = dh; } else { const scale = Math.min(dw / img.naturalWidth, dh / img.naturalHeight); tw = Math.max(1, Math.round(img.naturalWidth * scale)); th = Math.max(1, Math.round(img.naturalHeight * scale)); } } let tx = Number(p.x), ty = Number(p.y); if (!Number.isFinite(tx)) tx = nativeSize ? geometry.drawX : useDiff ? 0 : Math.round((dw - tw) / 2); if (!Number.isFinite(ty)) ty = nativeSize ? geometry.drawY : useDiff ? 0 : Math.round((dh - th) / 2); layer.ctx.imageSmoothingEnabled = true; layer.ctx.imageSmoothingQuality = 'high'; layer.ctx.drawImage(img, tx, ty, tw, th); if (p.masked !== false && state.wandMask) { layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(state.wandMask, nativeSize ? -nativeOffset.x : 0, nativeSize ? -nativeOffset.y : 0); layer.ctx.restore(); } else if (useDiff && beforeSnap) { const diff = _differenceMaskCanvas(beforeSnap, layer.canvas, { threshold: p.diffThreshold, grow: p.diffGrow, feather: p.diffFeather, edgeExcludePx: 0 }); const explicitEdge = p.edgeExcludePx != null ? Number(p.edgeExcludePx) : null; const sceneReplace = diff.changedFraction > 0.5; const edgeExclude = sceneReplace ? 0 : (explicitEdge != null ? explicitEdge : 3); if (edgeExclude > 0) _clearMaskEdgeBand(diff.mask, edgeExclude); layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(diff.mask, 0, 0); layer.ctx.restore(); if (sceneReplace) _edgeFillFromInterior(layer.ctx, dw, dh, 3); } if (p.blend) layer.blendMode = p.blend; if (p.opacity != null) layer.opacity = Math.max(0, Math.min(1, Number(p.opacity))); state.layers.push(layer); state.activeLayerId = layer.id; state.selectedLayerIds = [layer.id]; _renderLayerPanel(); composite(); resolve(layer.id); } catch (e) { reject(e); } }; img.onerror = () => reject(new Error('image load failed')); img.src = p.dataUrl; }) },
'layers.addMaskFromSelection': { description: 'Add a layer mask from the current selection (reveals the selection). {invert}.', run: (p) => { const parent = _activeParentLayer(); if (!parent) throw new Error('no layer'); _saveState('Add layer mask'); const mask = _ensureActiveMaskLayer(); if (!mask) throw new Error('could not create mask'); const w = mask.canvas.width, h = mask.canvas.height; const mctx = mask.ctx; const off = state.layerOffsets.get(parent.id) || { x: 0, y: 0 }; const ox = -(off.x || 0), oy = -(off.y || 0); mctx.globalCompositeOperation = 'source-over'; mctx.clearRect(0, 0, w, h); if (state.wandMask) { if (p && p.invert) { mctx.fillStyle = '#fff'; mctx.fillRect(0, 0, w, h); mctx.globalCompositeOperation = 'destination-out'; mctx.drawImage(state.wandMask, ox, oy); mctx.globalCompositeOperation = 'source-over'; } else { mctx.drawImage(state.wandMask, ox, oy); } } else { mctx.fillStyle = (p && p.invert) ? '#000' : '#fff'; mctx.fillRect(0, 0, w, h); } composite(); _renderLayerPanel(); return mask.id; } }, 'layers.addMaskFromSelection': { description: 'Add a layer mask from the current selection (reveals the selection). {invert}.', run: (p) => { const parent = _activeParentLayer(); if (!parent) throw new Error('no layer'); _saveState('Add layer mask'); const mask = _ensureActiveMaskLayer(); if (!mask) throw new Error('could not create mask'); const w = mask.canvas.width, h = mask.canvas.height; const mctx = mask.ctx; const off = state.layerOffsets.get(parent.id) || { x: 0, y: 0 }; const ox = -(off.x || 0), oy = -(off.y || 0); mctx.globalCompositeOperation = 'source-over'; mctx.clearRect(0, 0, w, h); if (state.wandMask) { if (p && p.invert) { mctx.fillStyle = '#fff'; mctx.fillRect(0, 0, w, h); mctx.globalCompositeOperation = 'destination-out'; mctx.drawImage(state.wandMask, ox, oy); mctx.globalCompositeOperation = 'source-over'; } else { mctx.drawImage(state.wandMask, ox, oy); } } else { mctx.fillStyle = (p && p.invert) ? '#000' : '#fff'; mctx.fillRect(0, 0, w, h); } composite(); _renderLayerPanel(); return mask.id; } },
'layers.removeMask': { description: 'Remove the active layer mask (does not affect pixels).', run: () => { const parent = _activeParentLayer(); if (!parent || !parent.masks || !parent.masks.length) throw new Error('no mask'); _saveState('Remove layer mask'); parent.masks = parent.masks.filter((m) => m.id !== parent.activeMaskId); parent.activeMaskId = parent.masks[0]?.id || null; composite(); _renderLayerPanel(); return true; } }, 'layers.removeMask': { description: 'Remove the active layer mask (does not affect pixels).', run: () => { const parent = _activeParentLayer(); if (!parent || !parent.masks || !parent.masks.length) throw new Error('no mask'); _saveState('Remove layer mask'); parent.masks = parent.masks.filter((m) => m.id !== parent.activeMaskId); parent.activeMaskId = parent.masks[0]?.id || null; composite(); _renderLayerPanel(); return true; } },
'layers.applyMask': { description: 'Bake the active layer mask into the layer alpha and remove the mask.', run: () => { const parent = _activeParentLayer(); const mask = _getActiveMaskLayer(); if (!parent || !parent.ctx || !mask) throw new Error('no mask to apply'); _saveState('Apply layer mask'); _bakeMaskIntoLayer(parent.ctx, mask.canvas, 0, 0); parent.masks = parent.masks.filter((m) => m.id !== mask.id); parent.activeMaskId = parent.masks[0]?.id || null; composite(); _renderLayerPanel(); return true; } }, 'layers.applyMask': { description: 'Bake the active layer mask into the layer alpha and remove the mask.', run: () => { const parent = _activeParentLayer(); const mask = _getActiveMaskLayer(); if (!parent || !parent.ctx || !mask) throw new Error('no mask to apply'); _saveState('Apply layer mask'); _bakeMaskIntoLayer(parent.ctx, mask.canvas, 0, 0); parent.masks = parent.masks.filter((m) => m.id !== mask.id); parent.activeMaskId = parent.masks[0]?.id || null; composite(); _renderLayerPanel(); return true; } },
@@ -12564,7 +12622,7 @@ function _buildEditor(container) {
'ai.removeObject': { description: 'Remove the selected object and fill naturally. {prompt}.', run: (p) => _aiInpaintSelection((p && p.prompt) || 'remove the object, fill the area naturally') }, 'ai.removeObject': { description: 'Remove the selected object and fill naturally. {prompt}.', run: (p) => _aiInpaintSelection((p && p.prompt) || 'remove the object, fill the area naturally') },
'ai.harmonize': { description: 'Harmonize the layer/selection lighting with the rest of the image; result is a NEW layer. {strength}.', run: async (p) => { const { canvas } = await _aiEditCanvas('/api/image/harmonize', { strength: Number(p && p.strength) || 0.4 }); return _placeResultLayer(canvas, 'Harmonized', state.wandMask || null); } }, 'ai.harmonize': { description: 'Harmonize the layer/selection lighting with the rest of the image; result is a NEW layer. {strength}.', run: async (p) => { const { canvas } = await _aiEditCanvas('/api/image/harmonize', { strength: Number(p && p.strength) || 0.4 }); return _placeResultLayer(canvas, 'Harmonized', state.wandMask || null); } },
'ai.generativeUpscale': { description: 'Upscale the image 2x/4x; result is a NEW layer. Falls back to a high-quality canvas upscale labelled "basic upscale" when no Real-ESRGAN model is served. {scale}.', run: async (p) => { const scale = Number(p && p.scale) === 4 ? 4 : 2; try { const { canvas } = await _aiEditCanvas('/api/image/upscale-local', { scale }); return _placeResultLayer(canvas, `Upscaled ${scale}x`, null); } catch (e) { const src = flatten(); const c = document.createElement('canvas'); c.width = Math.max(1, Math.round(src.width * scale)); c.height = Math.max(1, Math.round(src.height * scale)); const cx = c.getContext('2d'); cx.imageSmoothingEnabled = true; cx.imageSmoothingQuality = 'high'; cx.drawImage(src, 0, 0, c.width, c.height); _saveState('Upscale (basic)'); _resizeCanvasDocument(state, c.width, c.height, { anchorX: 0, anchorY: 0 }); const layerId = _placeResultLayer(c, `Upscaled ${scale}x (basic upscale)`, null); return { layerId, scale, method: 'basic', reason: String(e && e.message || e) }; } } }, 'ai.generativeUpscale': { description: 'Upscale the image 2x/4x; result is a NEW layer. Falls back to a high-quality canvas upscale labelled "basic upscale" when no Real-ESRGAN model is served. {scale}.', run: async (p) => { const scale = Number(p && p.scale) === 4 ? 4 : 2; try { const { canvas } = await _aiEditCanvas('/api/image/upscale-local', { scale }); return _placeResultLayer(canvas, `Upscaled ${scale}x`, null); } catch (e) { const src = flatten(); const c = document.createElement('canvas'); c.width = Math.max(1, Math.round(src.width * scale)); c.height = Math.max(1, Math.round(src.height * scale)); const cx = c.getContext('2d'); cx.imageSmoothingEnabled = true; cx.imageSmoothingQuality = 'high'; cx.drawImage(src, 0, 0, c.width, c.height); _saveState('Upscale (basic)'); _resizeCanvasDocument(state, c.width, c.height, { anchorX: 0, anchorY: 0 }); const layerId = _placeResultLayer(c, `Upscaled ${scale}x (basic upscale)`, null); return { layerId, scale, method: 'basic', reason: String(e && e.message || e) }; } } },
'ai.generateImage': { description: 'Generate an image from a prompt (current canvas as reference) and place it as a NEW layer. {prompt,width,height}.', run: async (p) => { const endpoint = (typeof window !== 'undefined' && window.geGenerateImageEndpoint) || '/api/image/generate'; const reference = flatten().toDataURL('image/png').split(',')[1]; const _pickedModel = (() => { try { const v = localStorage.getItem('ge-ai-command-model') || ''; return v.includes('::') ? v.split('::').pop() : ''; } catch (_) { return ''; } })(); const body = { prompt: (p && p.prompt) || '', width: Math.max(1, Math.round(Number(p && p.width) || state.imgWidth)), height: Math.max(1, Math.round(Number(p && p.height) || state.imgHeight)), reference, ...(_pickedModel ? { model: _pickedModel } : {}) }; let _gres = null; try { _gres = await fetch(endpoint, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } catch (_e) { throw new Error(_generationErrorMessage({ network: true, detail: (_e && _e.message) || 'network error' })); } const res = _gres; if (!res.ok) { let _gd = res.statusText; try { const e = await res.json(); _gd = e.detail || e.error || _gd; } catch (_) {} throw new Error(_generationErrorMessage({ status: res.status, detail: _gd })); } const data = await res.json(); if (data.error) throw new Error(_generationErrorMessage({ detail: data.error })); const b64 = data.image || (Array.isArray(data.images) && data.images[0]); if (!b64) throw new Error('No image returned'); try { window.__geGenerationDisabled = 0; } catch (_) {} const img = await _decodeAIImage(b64, _aiSignal); const c = document.createElement('canvas'); c.width = state.imgWidth; c.height = state.imgHeight; c.getContext('2d').drawImage(img, 0, 0, state.imgWidth, state.imgHeight); return _placeResultLayer(c, 'Generated', state.wandMask || null); } }, 'ai.generateImage': { description: 'Generate an image with the saved generation model and fit it proportionally as a new layer. {prompt,width,height}.', run: (p) => _generateImage(p || {}) },
'ai.generativeExpand': { description: 'Generatively expand the canvas to width×height; only the new border is inpainted, the original pixels are kept pixel-identical, and the seam is feathered. {width,height,anchor,prompt}.', run: async (p) => { const tw = Math.max(1, Math.round(Number(p && p.width) || state.imgWidth)); const th = Math.max(1, Math.round(Number(p && p.height) || state.imgHeight)); if (tw <= state.imgWidth && th <= state.imgHeight) throw new Error('target must be larger than the current canvas'); const anchor = (p && p.anchor) || 'center'; const anchorX = anchor === 'left' ? 0 : anchor === 'right' ? 1 : 0.5; const anchorY = anchor === 'top' ? 0 : anchor === 'bottom' ? 1 : 0.5; _saveState('Generative expand'); const steps = Math.max(1, Math.round(Number(p && p.steps) || 4)); const sw = state.imgWidth; const sh = state.imgHeight; _resizeCanvasDocument(state, tw, th, { anchorX, anchorY }); const flat = flatten(); const b = _contentBounds(); const mask = document.createElement('canvas'); mask.width = tw; mask.height = th; const mctx = mask.getContext('2d'); mctx.fillStyle = '#ffffff'; mctx.fillRect(0, 0, tw, th); const prefill = document.createElement('canvas'); prefill.width = tw; prefill.height = th; const pctx = prefill.getContext('2d'); pctx.imageSmoothingEnabled = true; pctx.drawImage(flat, 0, 0, tw, th); if (b) { mctx.clearRect(b.x, b.y, b.w, b.h); const bx = b.x, by = b.y, bw = b.w, bh = b.h, rx = bx + bw, by2 = by + bh; if (bx > 0) pctx.drawImage(flat, bx, by, 1, bh, 0, by, bx, bh); if (rx < tw) pctx.drawImage(flat, rx - 1, by, 1, bh, rx, by, tw - rx, bh); if (by > 0) pctx.drawImage(flat, bx, by, bw, 1, bx, 0, bw, by); if (by2 < th) pctx.drawImage(flat, bx, by2 - 1, bw, 1, bx, by2, bw, th - by2); if (bx > 0 && by > 0) pctx.drawImage(flat, bx, by, 1, 1, 0, 0, bx, by); if (rx < tw && by > 0) pctx.drawImage(flat, rx - 1, by, 1, 1, rx, 0, tw - rx, by); if (bx > 0 && by2 < th) pctx.drawImage(flat, bx, by2 - 1, 1, 1, 0, by2, bx, th - by2); if (rx < tw && by2 < th) pctx.drawImage(flat, rx - 1, by2 - 1, 1, 1, rx, by2, tw - rx, th - by2); } else { const tx = Math.round((tw - sw) * anchorX); const ty = Math.round((th - sh) * anchorY); mctx.clearRect(tx, ty, sw, sh); } const image = prefill.toDataURL('image/png').split(',')[1]; const maskB64 = mask.toDataURL('image/png').split(',')[1]; const res = await fetch('/api/image/inpaint', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image, mask: maskB64, prompt: (p && p.prompt) || 'extend the scene seamlessly, continuing the surrounding content and lighting naturally', steps }) }); if (!res.ok) { let err = res.statusText; try { const e = await res.json(); err = e.detail || e.error || err; } catch (_) {} throw new Error(err); } const data = await res.json(); if (data.error) throw new Error(data.error); if (!data.image) throw new Error('No image returned'); const img = await _decodeAIImage(data.image, _aiSignal); const seam = Math.max(12, Math.round(Math.min(tw, th) * 0.02)); let blendMask = _growMaskCanvas(mask, seam, true); blendMask = _featherMaskCanvas((cw, ch) => { const c = document.createElement('canvas'); c.width = cw; c.height = ch; return c; }, blendMask, Math.round(seam * 1.4)); const layer = createLayer('Generative expand', tw, th); layer.ctx.imageSmoothingEnabled = true; layer.ctx.imageSmoothingQuality = 'high'; layer.ctx.drawImage(img, 0, 0, tw, th); if (b) { const edge = _edgeBandAverage(flat, b); if (edge) { layer.ctx.save(); layer.ctx.globalAlpha = 0.14; layer.ctx.fillStyle = `rgb(${edge[0]}, ${edge[1]}, ${edge[2]})`; layer.ctx.fillRect(0, 0, tw, th); layer.ctx.restore(); } } _bakeMaskIntoLayer(layer.ctx, blendMask); state.layers.push(layer); state.activeLayerId = layer.id; state.selectedLayerIds = [layer.id]; composite(); _renderLayerPanel(); return { width: tw, height: th, layerId: layer.id, seam, steps }; } }, 'ai.generativeExpand': { description: 'Generatively expand the canvas to width×height; only the new border is inpainted, the original pixels are kept pixel-identical, and the seam is feathered. {width,height,anchor,prompt}.', run: async (p) => { const tw = Math.max(1, Math.round(Number(p && p.width) || state.imgWidth)); const th = Math.max(1, Math.round(Number(p && p.height) || state.imgHeight)); if (tw <= state.imgWidth && th <= state.imgHeight) throw new Error('target must be larger than the current canvas'); const anchor = (p && p.anchor) || 'center'; const anchorX = anchor === 'left' ? 0 : anchor === 'right' ? 1 : 0.5; const anchorY = anchor === 'top' ? 0 : anchor === 'bottom' ? 1 : 0.5; _saveState('Generative expand'); const steps = Math.max(1, Math.round(Number(p && p.steps) || 4)); const sw = state.imgWidth; const sh = state.imgHeight; _resizeCanvasDocument(state, tw, th, { anchorX, anchorY }); const flat = flatten(); const b = _contentBounds(); const mask = document.createElement('canvas'); mask.width = tw; mask.height = th; const mctx = mask.getContext('2d'); mctx.fillStyle = '#ffffff'; mctx.fillRect(0, 0, tw, th); const prefill = document.createElement('canvas'); prefill.width = tw; prefill.height = th; const pctx = prefill.getContext('2d'); pctx.imageSmoothingEnabled = true; pctx.drawImage(flat, 0, 0, tw, th); if (b) { mctx.clearRect(b.x, b.y, b.w, b.h); const bx = b.x, by = b.y, bw = b.w, bh = b.h, rx = bx + bw, by2 = by + bh; if (bx > 0) pctx.drawImage(flat, bx, by, 1, bh, 0, by, bx, bh); if (rx < tw) pctx.drawImage(flat, rx - 1, by, 1, bh, rx, by, tw - rx, bh); if (by > 0) pctx.drawImage(flat, bx, by, bw, 1, bx, 0, bw, by); if (by2 < th) pctx.drawImage(flat, bx, by2 - 1, bw, 1, bx, by2, bw, th - by2); if (bx > 0 && by > 0) pctx.drawImage(flat, bx, by, 1, 1, 0, 0, bx, by); if (rx < tw && by > 0) pctx.drawImage(flat, rx - 1, by, 1, 1, rx, 0, tw - rx, by); if (bx > 0 && by2 < th) pctx.drawImage(flat, bx, by2 - 1, 1, 1, 0, by2, bx, th - by2); if (rx < tw && by2 < th) pctx.drawImage(flat, rx - 1, by2 - 1, 1, 1, rx, by2, tw - rx, th - by2); } else { const tx = Math.round((tw - sw) * anchorX); const ty = Math.round((th - sh) * anchorY); mctx.clearRect(tx, ty, sw, sh); } const image = prefill.toDataURL('image/png').split(',')[1]; const maskB64 = mask.toDataURL('image/png').split(',')[1]; const res = await fetch('/api/image/inpaint', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image, mask: maskB64, prompt: (p && p.prompt) || 'extend the scene seamlessly, continuing the surrounding content and lighting naturally', steps }) }); if (!res.ok) { let err = res.statusText; try { const e = await res.json(); err = e.detail || e.error || err; } catch (_) {} throw new Error(err); } const data = await res.json(); if (data.error) throw new Error(data.error); if (!data.image) throw new Error('No image returned'); const img = await _decodeAIImage(data.image, _aiSignal); const seam = Math.max(12, Math.round(Math.min(tw, th) * 0.02)); let blendMask = _growMaskCanvas(mask, seam, true); blendMask = _featherMaskCanvas((cw, ch) => { const c = document.createElement('canvas'); c.width = cw; c.height = ch; return c; }, blendMask, Math.round(seam * 1.4)); const layer = createLayer('Generative expand', tw, th); layer.ctx.imageSmoothingEnabled = true; layer.ctx.imageSmoothingQuality = 'high'; layer.ctx.drawImage(img, 0, 0, tw, th); if (b) { const edge = _edgeBandAverage(flat, b); if (edge) { layer.ctx.save(); layer.ctx.globalAlpha = 0.14; layer.ctx.fillStyle = `rgb(${edge[0]}, ${edge[1]}, ${edge[2]})`; layer.ctx.fillRect(0, 0, tw, th); layer.ctx.restore(); } } _bakeMaskIntoLayer(layer.ctx, blendMask); state.layers.push(layer); state.activeLayerId = layer.id; state.selectedLayerIds = [layer.id]; composite(); _renderLayerPanel(); return { width: tw, height: th, layerId: layer.id, seam, steps }; } },
'layers.rename': { description: 'Rename a layer {id|index|name, name|newName}.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); const next = String(p.name || p.newName || '').trim(); if (!next) throw new Error('name required'); _saveState('Rename layer'); l.name = next; _renderLayerPanel(); return l.id; } }, 'layers.rename': { description: 'Rename a layer {id|index|name, name|newName}.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); const next = String(p.name || p.newName || '').trim(); if (!next) throw new Error('name required'); _saveState('Rename layer'); l.name = next; _renderLayerPanel(); return l.id; } },
'layers.setVisibility': { description: 'Show/hide a layer {id|index|name, visible}.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState('Toggle layer visibility'); l.visible = (p.visible === undefined) ? !l.visible : !!p.visible; composite(); _renderLayerPanel(); return l.visible; } }, 'layers.setVisibility': { description: 'Show/hide a layer {id|index|name, visible}.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState('Toggle layer visibility'); l.visible = (p.visible === undefined) ? !l.visible : !!p.visible; composite(); _renderLayerPanel(); return l.visible; } },
@@ -12809,6 +12867,10 @@ function _buildEditor(container) {
endpoint: window.geEditorAgentEndpoint || '/api/editor/agent', endpoint: window.geEditorAgentEndpoint || '/api/editor/agent',
getSession: () => agentBridgeSession, getSession: () => agentBridgeSession,
getModel: () => _getSelectedQuickEditModel(), getModel: () => _getSelectedQuickEditModel(),
getGenerationModel: () => {
const value = document.getElementById('ge-ai-model')?.value || '';
return value.includes('::') ? value.slice(value.indexOf('::') + 2) : '';
},
getAgentModel: () => _getSelectedQuickEditAgentModel(), getAgentModel: () => _getSelectedQuickEditAgentModel(),
}); });
} catch (e) { console.error('[ge-editor] agent init failed', e); } } catch (e) { console.error('[ge-editor] agent init failed', e); }
@@ -13908,6 +13970,12 @@ export function openEditor(imageUrl, imageId, presetSize, displayName, draftId)
return; return;
} }
_finishBlank(size.w, size.h); _finishBlank(size.w, size.h);
}).catch(error => {
console.error('[openEditor] New project failed:', error);
closeEditor({ force: true });
window.dispatchEvent(new CustomEvent('gallery-editor-setup-cancelled'));
uiModule.showError(`Could not create project: ${error?.message || 'Canvas initialization failed'}`);
throw error;
}); });
} }
@@ -14062,8 +14130,6 @@ export function closeEditor(options = {}) {
_selectionNudgeTimer = null; _selectionNudgeTimer = null;
_selectionNudgeHistorySaved = false; _selectionNudgeHistorySaved = false;
_precisionGuides = null; _precisionGuides = null;
try { _preflight?.destroy(); } catch {}
_preflight = null;
try { _aiSettings?.destroy(); } catch {} try { _aiSettings?.destroy(); } catch {}
_aiSettings = null; _aiSettings = null;
try { delete window.geEditorAiSettings; } catch (_) { window.geEditorAiSettings = null; } try { delete window.geEditorAiSettings; } catch (_) { window.geEditorAiSettings = null; }
+24 -2
View File
@@ -4,12 +4,14 @@
// them from the same places, so load order and init sequence are untouched. // them from the same places, so load order and init sequence are untouched.
import { byId as el } from './dom.js'; import { byId as el } from './dom.js';
import { postSettings as _postSettings } from './api.js'; import { saveImagePreferences as _postSettings } from './imagePreferences.js';
import { modelCaps } from '../editor/ai-models.js'; import { modelCaps } from '../editor/ai-models.js';
import { sortModelIds } from '../modelSort.js'; import { sortModelIds } from '../modelSort.js';
let preferencesListener = null;
export async function initImageSettings() { export async function initImageSettings() {
const modelSel = el('set-imgModelSelect'); const modelSel = el('set-imgModelSelect');
const editModelSel = el('set-imgEditModelSelect');
const qualSel = el('set-imgQualitySelect'); const qualSel = el('set-imgQualitySelect');
const msg = el('set-imgSettingsMsg'); const msg = el('set-imgSettingsMsg');
const enabledToggle = el('set-imgEnabledToggle'); const enabledToggle = el('set-imgEnabledToggle');
@@ -18,23 +20,36 @@ export async function initImageSettings() {
const endpointsRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' }); const endpointsRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
const endpoints = await endpointsRes.json(); const endpoints = await endpointsRes.json();
const imageModels = new Set(); const imageModels = new Set();
const editModels = new Set();
(Array.isArray(endpoints) ? endpoints : []).forEach(endpoint => { (Array.isArray(endpoints) ? endpoints : []).forEach(endpoint => {
if (!endpoint.is_enabled || !endpoint.online) return; if (!endpoint.is_enabled || !endpoint.online) return;
(Array.isArray(endpoint.models) ? endpoint.models : []).forEach(modelId => { (Array.isArray(endpoint.models) ? endpoint.models : []).forEach(modelId => {
if (modelId && modelCaps(modelId, '', endpoint.model_type).gen) imageModels.add(String(modelId)); if (modelId && modelCaps(modelId, '', endpoint.model_type).gen) imageModels.add(String(modelId));
if (modelId && modelCaps(modelId, '', endpoint.model_type).inpaint) editModels.add(String(modelId));
}); });
}); });
modelSel.length = 1;
sortModelIds(Array.from(imageModels)).forEach(mid => { sortModelIds(Array.from(imageModels)).forEach(mid => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = mid; opt.value = mid;
opt.textContent = mid; opt.textContent = mid;
modelSel.appendChild(opt); modelSel.appendChild(opt);
}); });
if (editModelSel) {
editModelSel.length = 1;
for (const mid of sortModelIds(Array.from(editModels))) {
const option = document.createElement('option');
option.value = mid;
option.textContent = mid;
editModelSel.appendChild(option);
}
}
} catch (e) { console.warn('Failed to load models for image settings', e); } } catch (e) { console.warn('Failed to load models for image settings', e); }
try { try {
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' }); const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const settings = await settingsRes.json(); const settings = await settingsRes.json();
if (settings.image_model) modelSel.value = settings.image_model; if (settings.image_model) modelSel.value = settings.image_model;
if (editModelSel) editModelSel.value = settings.image_edit_model || '';
if (settings.image_quality) qualSel.value = settings.image_quality; if (settings.image_quality) qualSel.value = settings.image_quality;
if (enabledToggle) enabledToggle.checked = settings.image_gen_enabled === true; if (enabledToggle) enabledToggle.checked = settings.image_gen_enabled === true;
} catch (e) { console.warn('Failed to load settings', e); } } catch (e) { console.warn('Failed to load settings', e); }
@@ -49,12 +64,19 @@ export async function initImageSettings() {
async function saveSettings() { async function saveSettings() {
try { try {
const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value }); const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_edit_model: editModelSel?.value || '', image_quality: qualSel.value });
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`)); if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000); msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; } } catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
} }
modelSel.addEventListener('change', saveSettings); modelSel.addEventListener('change', saveSettings);
editModelSel?.addEventListener('change', saveSettings);
if (preferencesListener) window.removeEventListener('image-preferences-changed', preferencesListener);
preferencesListener = event => {
if ('image_model' in event.detail) modelSel.value = event.detail.image_model || '';
if (editModelSel && 'image_edit_model' in event.detail) editModelSel.value = event.detail.image_edit_model || '';
};
window.addEventListener('image-preferences-changed', preferencesListener);
qualSel.addEventListener('change', saveSettings); qualSel.addEventListener('change', saveSettings);
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncImgDisabled(); saveSettings(); }); if (enabledToggle) enabledToggle.addEventListener('change', function() { syncImgDisabled(); saveSettings(); });
} }
+47
View File
@@ -0,0 +1,47 @@
import { invalidateSettings } from '../appConfig.js';
export async function readImagePreferences() {
const [prefsRes, settingsRes] = await Promise.all([
fetch('/api/prefs', { credentials: 'same-origin' }).catch(() => null),
fetch('/api/auth/settings', { credentials: 'same-origin' }).catch(() => null),
]);
const prefs = (prefsRes && prefsRes.ok) ? await prefsRes.json().catch(() => ({})) : {};
const settings = (settingsRes && settingsRes.ok) ? await settingsRes.json().catch(() => ({})) : {};
if (!prefsRes?.ok && !settingsRes?.ok) throw new Error('Could not load image settings');
return {
...settings,
image_model: prefs?.image_model || settings?.image_model || '',
image_edit_model: prefs?.image_edit_model || settings?.image_edit_model || '',
};
}
let saveQueue = Promise.resolve();
export function waitForImagePreferences() { return saveQueue; }
export function saveImagePreferences(patch) {
const save = async () => {
let response = await fetch('/api/auth/settings', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch),
});
if (response.status === 403) {
for (const [key, value] of Object.entries(patch)) {
const prefRes = await fetch(`/api/prefs/${encodeURIComponent(key)}`, {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
});
if (!prefRes.ok) {
throw new Error('Could not save image preferences');
}
}
} else if (!response.ok) {
throw new Error('Could not save image settings');
}
invalidateSettings();
window.dispatchEvent(new CustomEvent('image-preferences-changed', { detail: patch }));
return response;
};
const result = saveQueue.then(save);
saveQueue = result.catch(() => {});
return result;
}
+5 -1
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh. // - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached. // - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes. // Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v1115-pr40-editor-precache'; const CACHE_NAME = 'odysseus-v1134-pr40-editor-precache';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them // KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting. // gives offline math fallback glyphs instead of proper typesetting.
@@ -237,6 +237,10 @@ const PANEL_PRECACHE = [
'/static/js/editor/aspect-fit.js', '/static/js/editor/aspect-fit.js',
'/static/js/editor/preflight.js', '/static/js/editor/preflight.js',
'/static/js/editor/ai-settings.js', '/static/js/editor/ai-settings.js',
'/static/js/editor/image-generation.js',
'/static/js/editor/image-result-geometry.js',
'/static/js/editor/quick-edit-job.js',
'/static/js/settings/imagePreferences.js',
'/static/js/editor/look-recipes.js', '/static/js/editor/look-recipes.js',
'/static/js/editor/editor-control.js', '/static/js/editor/editor-control.js',
'/static/js/editor/editor-bridge.js', '/static/js/editor/editor-bridge.js',
@@ -24,6 +24,7 @@ const REAL_MODULES = new Set([
path.join(JS, 'settings/speech.js'), path.join(JS, 'settings/speech.js'),
path.join(JS, 'settings/writingStyle.js'), path.join(JS, 'settings/writingStyle.js'),
path.join(JS, 'settings/imageModels.js'), path.join(JS, 'settings/imageModels.js'),
path.join(JS, 'settings/imagePreferences.js'),
path.join(JS, 'settings/agent.js'), path.join(JS, 'settings/agent.js'),
path.join(JS, 'settings/shell.js'), path.join(JS, 'settings/shell.js'),
path.join(JS, 'settings/peek.js'), path.join(JS, 'settings/peek.js'),
@@ -1,71 +0,0 @@
"""An unknown context window must not be budgeted like a tiny local server (#6678).
``budget_context_for_model`` returns 0 when the endpoint does not prove its
window, and ``compute_input_token_budget`` answers 0 with the conservative
default budget. The agent loop's small-server clamp (``<= 8192``) also caught
0 and cut the request to 1200 tokens, so an open document or earlier context
of a few thousand tokens was trimmed away before the model saw it.
"""
import inspect
import json
import pytest
DONE = 'data: [DONE]\n\n'
MARKER = 'NIGHTJAR-7'
# ~3000 estimated tokens: over the old 1200 clamp, under the 6000 default.
CONTEXT = ''.join(
f'## Project-{n}\nOwner: team {n}. Status: active. Notes: routine maintenance '
'and quarterly review scheduled for every workstream in this registry.\n\n'
for n in range(60)
) + f'## Project-Alpha\nAlias: {MARKER}\n'
def _event(payload):
return 'data: ' + json.dumps(payload) + '\n\n'
async def _first_request(monkeypatch, tmp_path, context_window):
import src.agent_loop as al
from tests.test_agent_runtime_context import _patch_fake_skills
_patch_fake_skills(monkeypatch)
monkeypatch.setattr('src.tool_index.get_tool_index', lambda: None)
monkeypatch.setattr('src.model_context.budget_context_for_model',
lambda *args, **kwargs: context_window)
seen = []
async def provider(candidates, messages, **kwargs):
# Like llm_core: the per-candidate factory shapes and trims the request.
url, model, headers = candidates[0][:3]
request = kwargs['candidate_request_factory'](0, url, model, headers)
if inspect.isawaitable(request):
request = await request
seen.append((request or {}).get('messages', messages))
yield _event({'delta': 'ok'})
yield DONE
monkeypatch.setattr(al, 'stream_llm_with_fallback', provider)
history = [
{'role': 'user', 'content': 'Keep this registry for later:\n\n' + CONTEXT},
{'role': 'assistant', 'content': 'Saved.'},
{'role': 'user', 'content': 'What is the alias of Project-Alpha?'},
]
async for _ in al.stream_agent_loop('http://127.0.0.1:9/v1', 'model', history,
max_rounds=1, workspace=str(tmp_path)):
pass
assert seen, 'the provider was never called'
return json.dumps(seen[0])
@pytest.mark.asyncio
async def test_unknown_context_window_keeps_earlier_context(monkeypatch, tmp_path):
request = await _first_request(monkeypatch, tmp_path, 0)
assert MARKER in request
@pytest.mark.asyncio
async def test_known_small_window_is_still_clamped(monkeypatch, tmp_path):
# The clamp exists for small local servers whose window is known (n_ctx
# 2048 here); it must keep protecting them.
request = await _first_request(monkeypatch, tmp_path, 2048)
assert MARKER not in request
-60
View File
@@ -70,63 +70,3 @@ def test_no_gguf_found_pattern():
result = _diagnose_serve_output(out) result = _diagnose_serve_output(out)
assert result is not None assert result is not None
assert "GGUF" in result["message"] assert "GGUF" in result["message"]
def test_cookbook_routes_uses_shared_diagnosis_helper():
# /api/cookbook/tasks/status must use the maintained helper. A nested copy in
# cookbook_routes.py shadowed the import and silently drifted (missing the
# ModelOpt, mflux and MLX bridge diagnoses).
import ast
from pathlib import Path
source = (Path(__file__).resolve().parents[1] / "routes" / "cookbook_routes.py").read_text(encoding="utf-8")
local_defs = [
node.lineno
for node in ast.walk(ast.parse(source))
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "_diagnose_serve_output"
]
assert local_defs == []
def test_modelopt_lm_head_checkpoint_returns_diagnosis():
out = "ValueError: There is no module or parameter named 'lm_head.input_scale' in Qwen3ForCausalLM"
result = _diagnose_serve_output(out)
assert result is not None
assert "ModelOpt" in result["message"]
def test_nvidia_driver_too_old_for_torch_returns_diagnosis():
# vLLM 0.31 from PyPI ships torch cu130; on driver 570 (CUDA 12.8) the engine dies here.
out = (
"(EngineCore pid=1) ERROR [core.py:1433] self.driver_worker.init_device()\n"
"(EngineCore pid=1) ERROR [core.py:1433] RuntimeError: The NVIDIA driver on your system is too old "
"(found version 12080). Please update your GPU driver by downloading and installing a new version\n"
"Traceback (most recent call last):\n"
"RuntimeError: Engine core initialization failed. See root cause above. Failed core proc(s): {}\n"
)
result = _diagnose_serve_output(out)
assert result is not None
assert "driver" in result["message"].lower()
assert any("cu129" in s["label"] for s in result["suggestions"])
def test_cuda_runtime_newer_than_driver_returns_diagnosis():
# torch switched to cu128 but the vLLM wheel's own kernels still need CUDA 13.
out = (
"RuntimeError: get_cuda_view_from_cpu_tensor, /workspace/csrc/libtorch_stable/cuda_view.cu:38, "
"cudaHostGetDevicePointer failed: CUDA driver version is insufficient for CUDA runtime version\n"
"Traceback (most recent call last):\n"
)
result = _diagnose_serve_output(out)
assert result is not None
assert "driver" in result["message"].lower()
def test_tasks_status_capture_joins_wrapped_tmux_lines():
# tmux wraps at the pane width (often mid-word), which hid errors like
# "insufficie|nt for CUDA runtime version" from the diagnosis patterns.
from pathlib import Path
source = (Path(__file__).resolve().parents[1] / "routes" / "cookbook_routes.py").read_text(encoding="utf-8")
assert '_remote_tmux_command("capture-pane", "-t", session_id, "-p", "-J", "-S", "-500")' in source
assert '["tmux", "capture-pane", "-t", session_id, "-p", "-J", "-S", "-500"]' in source
-62
View File
@@ -1,62 +0,0 @@
import pytest
import httpx
from unittest.mock import patch, AsyncMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.cookbook_routes import setup_cookbook_routes
from src.auth_helpers import require_user
app = FastAPI()
router = setup_cookbook_routes()
app.include_router(router)
app.dependency_overrides[require_user] = lambda: "test_user"
client = TestClient(app)
def test_hf_gguf_files_success():
mock_resp = httpx.Response(
200,
json={
"siblings": [
{"rfilename": "model-q4_k_m.gguf"},
{"rfilename": "model-q8_0.gguf"},
{"rfilename": "README.md"},
]
},
request=httpx.Request("GET", "https://huggingface.co/api/models/TheBloke/Llama-2-7B-GGUF"),
)
with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=mock_resp):
response = client.get("/api/cookbook/hf-gguf-files?repo_id=TheBloke/Llama-2-7B-GGUF")
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert data["repo_id"] == "TheBloke/Llama-2-7B-GGUF"
assert data["files"] == ["model-q4_k_m.gguf", "model-q8_0.gguf"]
def test_hf_gguf_files_http_error():
mock_resp = httpx.Response(
404,
json={"error": "Model not found"},
request=httpx.Request("GET", "https://huggingface.co/api/models/invalid/repo"),
)
with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=mock_resp):
response = client.get("/api/cookbook/hf-gguf-files?repo_id=invalid/repo")
assert response.status_code == 200
data = response.json()
assert data["ok"] is False
assert "HF API HTTP 404" in data["error"]
def test_hf_gguf_files_exception_handling():
with patch("httpx.AsyncClient.get", new_callable=AsyncMock, side_effect=httpx.ConnectError("Network unreachable")):
response = client.get("/api/cookbook/hf-gguf-files?repo_id=TheBloke/Llama-2-7B-GGUF")
# Should gracefully return JSON error payload, not raise NameError / 500
assert response.status_code == 200
data = response.json()
assert data["ok"] is False
assert data["error"] == "HF API request failed"
-103
View File
@@ -1,103 +0,0 @@
"""Daily brief must query and display each calendar storage kind in local time."""
import asyncio
import os
import time
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from core import database
from src import builtin_actions
@contextmanager
def system_timezone(name):
previous = os.environ.get('TZ')
os.environ['TZ'] = name
time.tzset()
try:
yield
finally:
if previous is None:
os.environ.pop('TZ', None)
else:
os.environ['TZ'] = previous
time.tzset()
@pytest.mark.skipif(not hasattr(time, 'tzset'), reason='requires changing the process local timezone')
@pytest.mark.parametrize('zone', ['Europe/Berlin', 'America/Los_Angeles'])
def test_daily_brief_selects_local_day_and_orders_mixed_storage(monkeypatch, zone):
engine = create_engine('sqlite://', poolclass=StaticPool)
for model in [database.CalendarCal, database.CalendarEvent, database.Note]:
model.__table__.create(engine)
sessions = sessionmaker(bind=engine)
monkeypatch.setattr(database, 'SessionLocal', sessions)
from core.auth import AuthManager
monkeypatch.setattr(AuthManager, 'is_configured', property(lambda self: True))
from routes import email_helpers
def no_email(*args):
raise RuntimeError('No email account in this calendar-only test')
monkeypatch.setattr(email_helpers, '_imap_connect', no_email)
try:
with system_timezone(zone):
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
def utc(value):
return value.astimezone(timezone.utc).replace(tzinfo=None)
with sessions() as db:
for owner in ['alice', 'bob', None]:
db.add(database.CalendarCal(id=owner or 'shared', owner=owner, name=owner or 'shared'))
db.flush()
def add(uid, start, end, *, utc_row=False, all_day=False, owner='alice', status='confirmed'):
db.add(database.CalendarEvent(uid=uid, calendar_id=owner or 'shared', summary=uid,
dtstart=utc(start) if utc_row and not all_day else start,
dtend=utc(end) if utc_row and not all_day else end,
is_utc=utc_row, all_day=all_day, status=status))
add('early', today+timedelta(minutes=30), today+timedelta(hours=1), utc_row=True)
add('utc-ten', today+timedelta(hours=10), today+timedelta(hours=11), utc_row=True)
add('local-nine', today+timedelta(hours=9), today+timedelta(hours=10))
add('all-day', today, today+timedelta(days=1), utc_row=True, all_day=True)
add('next-day', today+timedelta(days=1,minutes=30), today+timedelta(days=1,hours=1), utc_row=True)
add('ended', today-timedelta(hours=1), today, utc_row=True)
add('cancelled', today+timedelta(hours=12), today+timedelta(hours=13), utc_row=True, status='cancelled')
add('foreign', today+timedelta(hours=12), today+timedelta(hours=13), utc_row=True, owner='bob')
add('unowned', today+timedelta(hours=12), today+timedelta(hours=13), owner=None)
db.commit()
text, success = asyncio.run(builtin_actions.action_daily_brief('alice'))
assert success, text
lines = ['all day all-day', '00:30 early', '09:00 local-nine', '10:00 utc-ten']
assert all(line in text for line in lines), text
assert [text.index(line) for line in lines] == sorted(text.index(line) for line in lines), text
for excluded in ['next-day', 'ended', 'cancelled', 'foreign', 'unowned']:
assert excluded not in text, text
finally:
engine.dispose()
@pytest.mark.parametrize('day, hours', [('2026-03-29', 23), ('2026-10-25', 25)])
def test_utc_day_window_uses_offset_at_each_boundary(day, hours):
start = datetime.fromisoformat(day)
zone = ZoneInfo('Europe/Berlin')
begin = builtin_actions._daily_brief_utc_boundary(start, zone)
end = builtin_actions._daily_brief_utc_boundary(start+timedelta(days=1), zone)
assert end-begin == timedelta(hours=hours)
if hasattr(time, 'tzset'):
with system_timezone('Europe/Berlin'):
assert builtin_actions._daily_brief_utc_boundary(start) == begin
assert builtin_actions._daily_brief_utc_boundary(start+timedelta(days=1)) == end
@pytest.mark.parametrize('utc_row, all_day, expected', [
(True, False, datetime(2026, 6, 1, 10)),
(False, False, datetime(2026, 6, 1, 8)),
(True, True, datetime(2026, 6, 1, 8)),
])
def test_local_start_preserves_floating_and_date_only_values(utc_row, all_day, expected):
event = SimpleNamespace(dtstart=datetime(2026,6,1,8), is_utc=utc_row, all_day=all_day)
assert builtin_actions._daily_brief_local_start(event, ZoneInfo('Europe/Berlin')) == expected
-86
View File
@@ -1,86 +0,0 @@
"""Issue #2137: never preselect an embedding or other non-chat model."""
import json
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from core.database import ModelEndpoint
from routes import model_routes, prefs_routes
from tests.helpers.database import disposable_database
@pytest.fixture
def defaults_api(monkeypatch, tmp_path):
defaults = {}
with disposable_database(tmp_path) as factory:
monkeypatch.setattr(model_routes, "SessionLocal", factory)
monkeypatch.setattr(model_routes, "_load_settings", lambda: {})
monkeypatch.setattr(prefs_routes, "_load_for_user", lambda owner: defaults)
app = FastAPI()
app.state.auth_manager = SimpleNamespace(is_admin=lambda owner: False)
@app.middleware("http")
async def owner(request, call_next):
request.state.current_user = "alice"
return await call_next(request)
app.include_router(model_routes.setup_model_routes(model_discovery=None))
with TestClient(app) as client:
yield client, factory, defaults
def _endpoint(factory, *, cached=(), pinned=(), hidden=(), owner="alice", endpoint_id="ep"):
with factory() as db:
db.add(ModelEndpoint(
id=endpoint_id, name="Fixture", base_url="http://model.invalid/v1", owner=owner,
is_enabled=True, cached_models=json.dumps(cached),
pinned_models=json.dumps(pinned), hidden_models=json.dumps(hidden),
))
db.commit()
@pytest.mark.parametrize("non_chat", [
"text-embedding-3-small", "nomic-embed-text:latest", "jina-reranker-v2-base",
"tts-1", "whisper-1", "gpt-image-1",
])
def test_automatic_default_skips_non_chat_models(defaults_api, non_chat):
client, factory, _ = defaults_api
_endpoint(factory, cached=[non_chat, "chat-model"])
assert client.get("/api/default-chat").json()["model"] == "chat-model"
def test_non_chat_saved_default_falls_back_to_visible_pinned_chat(defaults_api):
client, factory, defaults = defaults_api
_endpoint(factory, cached=["text-embedding-3-small", "hidden-chat"],
pinned=["jina-reranker-v2-base", "pinned-chat"], hidden=["hidden-chat"])
defaults.update(default_endpoint_id="ep", default_model="text-embedding-3-small")
assert client.get("/api/default-chat").json()["model"] == "pinned-chat"
def test_no_chat_candidate_leaves_model_empty(defaults_api):
client, factory, defaults = defaults_api
_endpoint(factory, cached=["text-embedding-3-small", "hidden-chat"],
pinned=["jina-reranker-v2-base"], hidden=["hidden-chat"])
defaults.update(default_endpoint_id="ep", default_model="text-embedding-3-small")
result = client.get("/api/default-chat").json()
assert result == {"endpoint_id": "ep", "endpoint_url": "http://model.invalid/v1/chat/completions", "model": ""}
def test_valid_explicit_model_survives_stale_catalog(defaults_api):
client, factory, defaults = defaults_api
_endpoint(factory, cached=["text-embedding-3-small", "other-chat"])
defaults.update(default_endpoint_id="ep", default_model="my-custom-chat")
assert client.get("/api/default-chat").json()["model"] == "my-custom-chat"
def test_model_filter_preserves_owner_scope(defaults_api):
client, factory, defaults = defaults_api
_endpoint(factory, owner="bob", endpoint_id="bobs-endpoint", cached=["bobs-chat"])
_endpoint(factory, cached=["text-embedding-3-small", "alices-chat"])
defaults.update(default_endpoint_id="bobs-endpoint")
result = client.get("/api/default-chat").json()
assert result["endpoint_id"] == "ep"
assert result["model"] == "alices-chat"
@@ -1,96 +0,0 @@
"""ZIP exports keep language extensions and place collision suffixes before them."""
import io
import zipfile
import pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core.database import Base, Document
from routes.document import document_routes
@pytest.fixture
def exports(tmp_path, monkeypatch):
engine = create_engine(f"sqlite:///{tmp_path / 'documents.db'}", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
monkeypatch.setattr(document_routes, "SessionLocal", factory)
monkeypatch.setenv("AUTH_ENABLED", "true")
app = FastAPI()
@app.middleware("http")
async def fixture_user(request: Request, call_next):
request.state.current_user = "alice"
return await call_next(request)
app.include_router(document_routes.setup_document_routes(None))
def add(doc_id, title, language, content="body\n", owner="alice"):
with factory() as db:
db.add(Document(id=doc_id, title=title, language=language,
current_content=content, owner=owner, is_active=True))
db.commit()
with TestClient(app) as client:
yield client, add
engine.dispose()
@pytest.mark.parametrize("title,language,filename", [
("config.local", "python", "config.local.py"),
("report.v2", "markdown", "report.v2.md"),
("data.2024", "json", "data.2024.json"),
("module.ts", "typescript", "module.ts"),
("SCRIPT.PY", "python", "SCRIPT.PY"),
("résumé.v2", "text", "résumé.v2.txt"),
("unknown.v2", "unknown", "unknown.v2.txt"),
("draft", None, "draft.txt"),
("project/report.v2", "markdown", "projectreport.v2.md"),
])
def test_export_uses_the_language_extension_even_for_dotted_titles(exports, title, language, filename):
client, add = exports
body = "Original text: α\n"
add("doc", title, language, body)
response = client.post("/api/documents/export-zip", json={"ids": ["doc"]})
assert response.status_code == 200
assert response.headers["content-type"] == "application/zip"
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
assert archive.namelist() == [filename]
assert archive.read(filename).decode() == body
@pytest.mark.parametrize("title", ["config.local", "config.local.py"])
def test_duplicate_names_keep_the_extension_at_the_end(exports, title):
client, add = exports
for i in range(3):
add(str(i), title, "python", f"body {i}")
response = client.post("/api/documents/export-zip", json={"ids": ["0", "1", "2"]})
assert response.status_code == 200
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
assert set(archive.namelist()) == {"config.local.py", "config.local-1.py", "config.local-2.py"}
assert {archive.read(name).decode() for name in archive.namelist()} == {"body 0", "body 1", "body 2"}
def test_added_extension_collides_safely_with_an_existing_extension(exports):
client, add = exports
add("a", "report.v2", "markdown", "first")
add("b", "report.v2.md", "markdown", "second")
response = client.post("/api/documents/export-zip", json={"ids": ["a", "b"]})
assert response.status_code == 200
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
assert set(archive.namelist()) == {"report.v2.md", "report.v2-1.md"}
assert {archive.read(name) for name in archive.namelist()} == {b"first", b"second"}
def test_foreign_documents_are_still_excluded_and_email_body_is_preserved(exports):
client, add = exports
add("owned", "reply.v2", "email", "Subject: Hello\n---\nBody")
add("foreign", "private", "python", "secret", owner="bob")
response = client.post("/api/documents/export-zip", json={"ids": ["owned", "foreign"]})
assert response.status_code == 200
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
assert archive.namelist() == ["reply.v2.eml"]
assert archive.read("reply.v2.eml") == b"Subject: Hello\r\n\r\nBody"
@@ -1,74 +0,0 @@
"""Literal punctuation in library queries must not become SQL wildcards."""
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from core.database import Document
from routes.document import document_routes
from tests.helpers.database import disposable_database
CASES = [
("100%", "100% complete", "1000 complete"),
("a_b", "a_b", "axb"),
("%", "price 10%", "price 100"),
("_", "a_b", "axb"),
(r"path\notes", r"path\notes", "pathnotes"),
(r"path\_tag", r"path\_tag", r"path\Xtag"),
("50%_done", "50%_done", "500Xdone"),
("can't", "can't stop", "cannot stop"),
("mixed case", "Case ordered Mixed", "Mixed without"),
]
@pytest.mark.parametrize("column", ["title", "current_content"])
@pytest.mark.parametrize("query,literal,lookalike", CASES)
@pytest.mark.asyncio
async def test_library_search_matches_literal_tokens(tmp_path, monkeypatch, column, query, literal, lookalike):
with disposable_database(tmp_path) as factory:
monkeypatch.setattr(document_routes, "SessionLocal", factory)
with factory() as db:
for doc_id, text, owner, active, archived in [
("literal", literal, "alice", True, False),
("lookalike", lookalike, "alice", True, False),
("foreign", literal, "bob", True, False),
("inactive", literal, "alice", False, False),
("archived", literal, "alice", True, True),
]:
fields = {"title": "Document", "current_content": "Body", column: text}
db.add(Document(id=doc_id, owner=owner, is_active=active, archived=archived,
language="text", version_count=1, **fields))
db.commit()
router = document_routes.setup_document_routes(MagicMock())
endpoint = next(r.endpoint for r in router.routes if r.path == "/api/documents/library")
request = SimpleNamespace(state=SimpleNamespace(current_user="alice"))
result = await endpoint(request, search=query, language=None, sort="recent", offset=0, limit=20, archived=False)
assert result["total"] == 1
assert [row["id"] for row in result["documents"]] == ["literal"]
archived = await endpoint(request, search=query, language=None, sort="recent", offset=0, limit=20, archived=True)
assert [row["id"] for row in archived["documents"]] == ["archived"]
@pytest.mark.asyncio
async def test_literal_search_keeps_cross_field_and_pagination_behavior(tmp_path, monkeypatch):
with disposable_database(tmp_path) as factory:
monkeypatch.setattr(document_routes, "SessionLocal", factory)
with factory() as db:
for doc_id, title, content in [
("first", "100% ready", "done"),
("second", "done", "100% ready"),
("lookalike", "1000 ready", "done"),
("missing-term", "100% ready", "waiting"),
]:
db.add(Document(id=doc_id, owner="alice", title=title, current_content=content,
is_active=True, archived=False, language="text", version_count=1))
db.commit()
router = document_routes.setup_document_routes(MagicMock())
endpoint = next(r.endpoint for r in router.routes if r.path == "/api/documents/library")
request = SimpleNamespace(state=SimpleNamespace(current_user="alice"))
results = [await endpoint(request, search="100% done", language="text", sort="alpha",
offset=offset, limit=1, archived=False) for offset in [0, 1]]
assert all(result["total"] == 2 for result in results)
assert {row["id"] for result in results for row in result["documents"]} == {"first", "second"}
+4 -2
View File
@@ -29,8 +29,10 @@ def test_secondary_brush_controls_live_after_the_menu_open():
block = CONTROLS[i:j] block = CONTROLS[i:j]
for marker in ('id="ge-brush-spacing"', 'id="ge-brush-smoothing"', for marker in ('id="ge-brush-spacing"', 'id="ge-brush-smoothing"',
'id="ge-brush-blend"', 'ge-brush-pressure-row', 'id="ge-brush-blend"', 'ge-brush-pressure-row',
'id="ge-brush-flow-row"', 'id="ge-brush-softness-row"'): 'id="ge-brush-flow-row"'):
assert marker in block, marker assert marker in block, marker
assert 'id="ge-brush-softness-row"' not in block
assert CONTROLS.index('id="ge-brush-softness-row"') < i
def test_brush_size_label_drops_the_redundant_word(): def test_brush_size_label_drops_the_redundant_word():
@@ -98,7 +100,7 @@ def test_brush_strip_fits_and_smudge_row_respects_inline_hide():
# The Opacity/Flow/Softness sliders are wound tighter so the strip fits. # The Opacity/Flow/Softness sliders are wound tighter so the strip fits.
assert ".ge-controls--in-options #ge-brush-section { gap: 6px; }" in CSS assert ".ge-controls--in-options #ge-brush-section { gap: 6px; }" in CSS
# The brush controls hug their content so the sliders sit next to the caret. # The brush controls hug their content so the sliders sit next to the caret.
assert ".ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }" in CSS assert ".ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; flex-direction: row;" in CSS
# The smudge Strength row is hidden with an inline display:none on other # The smudge Strength row is hidden with an inline display:none on other
# tools; the `.ge-eraser-row { display:flex !important }` rule must not # tools; the `.ge-eraser-row { display:flex !important }` rule must not
# override that. # override that.
@@ -44,9 +44,11 @@ def test_quick_edit_shows_the_generation_message_verbatim():
assert "_isGenOfflineMsg" in EDITOR assert "_isGenOfflineMsg" in EDITOR
assert "_setAiCommandStatus(_isGenOfflineMsg(_genErr) ? _genErr : `Could not generate that edit: ${_genErr}`, 'error');" in EDITOR assert "_setAiCommandStatus(_isGenOfflineMsg(_genErr) ? _genErr : `Could not generate that edit: ${_genErr}`, 'error');" in EDITOR
assert "_setAiCommandStatus(_isGenOfflineMsg(msg) ? msg : `Could not generate an image: ${msg}`, 'error');" in EDITOR assert "_setAiCommandStatus(_isGenOfflineMsg(msg) ? msg : `Could not generate an image: ${msg}`, 'error');" in EDITOR
# the generateImage op maps its own fetch/HTTP failures through the classifier # The extracted generation action keeps the shared failure classifier.
assert "_generationErrorMessage({ network: true" in EDITOR generation = (ROOT / 'static/js/editor/image-generation.js').read_text()
assert "_generationErrorMessage({ status: res.status, detail: _gd })" in EDITOR assert "formatError: _generationErrorMessage" in EDITOR
assert "formatError({ network: true" in generation
assert "formatError({ status: response.status" in generation
def test_sw_cache_is_bumped(): def test_sw_cache_is_bumped():
@@ -0,0 +1,295 @@
import json
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes import editor_agent_routes as routes
from src.agent_tools import editor_tools
@pytest.fixture(autouse=True)
def allow_image_generation_privilege(monkeypatch):
monkeypatch.setattr('src.auth_helpers.require_privilege', lambda *args: 'test-user')
@pytest.mark.parametrize("chosen", ["openai/gpt-5-image", ""])
def test_quick_edit_generation_uses_generation_selection_not_planner_model(monkeypatch, chosen):
seen = []
async def execute(self, content, ctx):
seen.append(json.loads(content))
return "provider stub", {"exit_code": 1, "error": "test stops before canvas verification"}
monkeypatch.setattr(editor_tools.GenerateImageLayerTool, "execute", execute)
monkeypatch.setattr(routes, "_user", lambda request: None)
monkeypatch.setattr(routes.editor_agent_model, "startup_probe", lambda: None)
monkeypatch.setattr(routes.style_guide, "strengthen", lambda prompt, steps: steps)
monkeypatch.setattr(routes.style_guide, "enforce_guards", lambda steps: steps)
monkeypatch.setattr(routes, "_image_provider_available", lambda owner: True)
monkeypatch.setattr(routes, "_plan_special", lambda *args: None)
monkeypatch.setattr(routes, "_ensure_generative", lambda prompt, steps: steps)
monkeypatch.setattr(routes, "_unsure_plan", lambda prompt, plan: plan)
routes.set_editor_planner(lambda messages, owner: json.dumps({
"reply": "Generate", "steps": [{"op": "generate_image", "params": {
"prompt": "a cow", "model": "gpt-image-1",
}}],
}))
app = FastAPI()
app.include_router(routes.setup_editor_agent_routes())
try:
with TestClient(app) as client:
response = client.post("/api/editor/agent", json={
"prompt": "create a new image of a cow",
"model": "edit-only-model", "generation_model": chosen,
})
assert response.status_code == 200, response.text
assert len(seen) == 1, response.text
assert seen[0].get("model", "") == chosen
finally:
routes.set_editor_planner(None)
@pytest.mark.parametrize('op,expected', [('generate_image','saved-generation'),('ai_edit_image','saved-editor')])
def test_quick_edit_agent_uses_saved_models_over_browser_and_planner(monkeypatch, op, expected):
seen = []
async def execute(self, content, ctx):
seen.append(json.loads(content))
return 'stub', {'exit_code':1, 'error':'stop before verification'}
monkeypatch.setattr('src.editor_image_settings.load_settings', lambda: {
'image_model':'saved-generation', 'image_edit_model':'saved-editor'})
monkeypatch.setattr(editor_tools.GenerateImageLayerTool, 'execute', execute)
monkeypatch.setattr(editor_tools.AiEditImageTool, 'execute', execute)
monkeypatch.setattr(routes, '_user', lambda request: None)
monkeypatch.setattr(routes.editor_agent_model, 'startup_probe', lambda: None)
monkeypatch.setattr(routes.style_guide, 'strengthen', lambda prompt, steps: steps)
monkeypatch.setattr(routes.style_guide, 'enforce_guards', lambda steps: steps)
monkeypatch.setattr(routes, '_image_provider_available', lambda owner: True)
monkeypatch.setattr(routes, '_plan_special', lambda *args: None)
monkeypatch.setattr(routes, '_ensure_generative', lambda prompt, steps: steps)
monkeypatch.setattr(routes, '_unsure_plan', lambda prompt, plan: plan)
routes.set_editor_planner(lambda messages, owner: json.dumps({
'reply':'Edit', 'steps':[{'op':op,'params':{'prompt':'a cow','model':'planner-model','size':'1024x1024'}}]}))
app = FastAPI()
app.include_router(routes.setup_editor_agent_routes())
try:
with TestClient(app) as client:
response = client.post('/api/editor/agent', json={
'prompt':'create an image of a cow','_use_image_settings':True,
'model':'stale-editor','generation_model':'stale-generator','state':{'width':1280,'height':720}})
assert response.status_code == 200, response.text
assert len(seen) == 1, response.text
assert seen[0]['model'] == expected
assert seen[0]['_use_image_settings'] is True
if op == 'generate_image':
assert seen[0]['size'] == '1280x720'
finally:
routes.set_editor_planner(None)
def test_quick_generation_route_uses_saved_setting(monkeypatch):
import asyncio
from routes.gallery import gallery_routes as gallery
from src import ai_interaction
seen = []
async def generate(content, **kwargs):
seen.append(json.loads(content))
return {'image_url':'data:image/png;base64,dGVzdA=='}
monkeypatch.setattr(gallery, 'require_privilege', lambda *args: 'alice')
monkeypatch.setattr(ai_interaction, 'do_generate_image', generate)
monkeypatch.setattr('src.editor_image_settings.load_settings', lambda: {'image_model':'saved-generation'})
class Request:
async def json(self):
return {'prompt':'a cow','model':'stale-generator','_use_image_settings':True,'width':1280,'height':720}
endpoint = next(route.endpoint for route in gallery.setup_gallery_routes().routes if route.path == '/api/image/generate')
result = asyncio.run(endpoint(Request()))
assert result['image'] == 'dGVzdA=='
assert seen[0]['model'] == 'saved-generation'
assert seen[0]['size'] == '1280x720'
def test_edit_default_uses_generation_only_when_edit_setting_is_empty(monkeypatch):
from src import editor_image_settings
monkeypatch.setattr(editor_image_settings, 'load_settings', lambda: {'image_model':'generator','image_edit_model':''})
assert editor_image_settings.editor_image_models() == {'generation':'generator','edit':'generator'}
def test_editor_image_models_per_user_isolation_and_fallback(tmp_path, monkeypatch):
from routes import prefs_routes
from src import editor_image_settings, settings
prefs_file = str(tmp_path / "user_prefs.json")
monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
global_config = {"image_model": "global-gen", "image_edit_model": "global-edit"}
monkeypatch.setattr(settings, "load_settings", lambda: global_config)
monkeypatch.setattr(editor_image_settings, "load_settings", lambda: global_config)
# Save per-user preferences
prefs_routes._save_for_user("alice", {"image_model": "alice-gen", "image_edit_model": "alice-edit"})
prefs_routes._save_for_user("bob", {"image_model": "bob-gen"}) # no edit model
# Alice gets Alice's distinct models
alice_models = editor_image_settings.editor_image_models(owner="alice")
assert alice_models == {"generation": "alice-gen", "edit": "alice-edit"}
# Bob gets Bob's generation model, and edit model falls back to global edit model
bob_models = editor_image_settings.editor_image_models(owner="bob")
assert bob_models == {"generation": "bob-gen", "edit": "global-edit"}
# When global edit model is empty, edit model falls back to Bob's generation model
empty_edit = {"image_model": "global-gen", "image_edit_model": ""}
monkeypatch.setattr(settings, "load_settings", lambda: empty_edit)
monkeypatch.setattr(editor_image_settings, "load_settings", lambda: empty_edit)
bob_fallback_models = editor_image_settings.editor_image_models(owner="bob")
assert bob_fallback_models == {"generation": "bob-gen", "edit": "bob-gen"}
# Restore global settings with global-edit
monkeypatch.setattr(settings, "load_settings", lambda: global_config)
monkeypatch.setattr(editor_image_settings, "load_settings", lambda: global_config)
# Charlie (no preferences) falls back to global admin settings
charlie_models = editor_image_settings.editor_image_models(owner="charlie")
assert charlie_models == {"generation": "global-gen", "edit": "global-edit"}
# Anonymous/unauthenticated owner falls back to global admin settings
anon_models = editor_image_settings.editor_image_models(owner="")
assert anon_models == {"generation": "global-gen", "edit": "global-edit"}
def test_non_admin_user_prefs_api_routing_and_isolation(tmp_path, monkeypatch):
import time
from routes import editor_agent_routes, prefs_routes
from src import editor_image_settings, settings
prefs_file = str(tmp_path / "user_prefs.json")
monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
global_config = {"image_model": "admin-global-gen", "image_edit_model": "admin-global-edit"}
monkeypatch.setattr(settings, "load_settings", lambda: global_config)
monkeypatch.setattr(editor_image_settings, "load_settings", lambda: global_config)
current_user = "alice"
app = FastAPI()
app.include_router(prefs_routes.setup_prefs_routes())
app.include_router(editor_agent_routes.setup_editor_agent_routes())
monkeypatch.setattr("src.auth_helpers.get_current_user", lambda request: current_user)
monkeypatch.setattr(prefs_routes, "get_current_user", lambda request: current_user)
monkeypatch.setattr(editor_agent_routes, "_user", lambda request: current_user)
monkeypatch.setattr(editor_agent_routes.editor_agent_model, "startup_probe", lambda: None)
monkeypatch.setattr(editor_agent_routes.style_guide, "strengthen", lambda prompt, steps: steps)
monkeypatch.setattr(editor_agent_routes.style_guide, "enforce_guards", lambda steps: steps)
monkeypatch.setattr(editor_agent_routes, "_image_provider_available", lambda owner: True)
monkeypatch.setattr(editor_agent_routes, "_plan_special", lambda *args: None)
monkeypatch.setattr(editor_agent_routes, "_ensure_generative", lambda prompt, steps: steps)
monkeypatch.setattr(editor_agent_routes, "_unsure_plan", lambda prompt, plan: plan)
seen = []
async def execute_gen(self, content, ctx):
seen.append(("gen", json.loads(content)))
return "stub", {"exit_code": 1, "error": "stop"}
async def execute_edit(self, content, ctx):
seen.append(("edit", json.loads(content)))
return "stub", {"exit_code": 1, "error": "stop"}
monkeypatch.setattr(editor_tools.GenerateImageLayerTool, "execute", execute_gen)
monkeypatch.setattr(editor_tools.AiEditImageTool, "execute", execute_edit)
editor_agent_routes.set_editor_planner(lambda messages, owner: json.dumps({
"reply": "Gen", "steps": [
{"op": "generate_image", "params": {"prompt": "cat", "model": "stale-planner", "size": "512x512"}}
]
}))
try:
with TestClient(app) as client:
# Alice configures her preferences via PUT /api/prefs/{key}
put_gen = client.put("/api/prefs/image_model", json={"value": "alice-fav-gen"})
assert put_gen.status_code == 200
put_edit = client.put("/api/prefs/image_edit_model", json={"value": "alice-fav-edit"})
assert put_edit.status_code == 200
# Verify GET /api/prefs reflects Alice's choices
get_prefs = client.get("/api/prefs")
assert get_prefs.status_code == 200
assert get_prefs.json()["image_model"] == "alice-fav-gen"
assert get_prefs.json()["image_edit_model"] == "alice-fav-edit"
# Alice invokes quick edit with _use_image_settings: True
res = client.post("/api/editor/agent", json={
"prompt": "generate a cat",
"_use_image_settings": True,
"model": "stale-browser-edit",
"generation_model": "stale-browser-gen",
})
assert res.status_code == 200
assert len(seen) == 1
# Authoritative Alice preference overrides stale planner and browser models
assert seen[0][1]["model"] == "alice-fav-gen"
# Now test Bob (who has no preferences) - should receive global admin settings
current_user = "bob"
seen.clear()
res_bob = client.post("/api/editor/agent", json={
"prompt": "generate a dog",
"_use_image_settings": True,
"model": "stale-browser-edit",
"generation_model": "stale-browser-gen",
})
assert res_bob.status_code == 200
assert len(seen) == 1
# Bob falls back to global default, not Alice's preference
assert seen[0][1]["model"] == "admin-global-gen"
finally:
editor_agent_routes.set_editor_planner(None)
def test_is_background_removal_intent_grammar_and_dos_safety():
from routes.editor_agent_routes import _is_background_removal_intent
import time
# Valid commands across all verbs and targets
assert _is_background_removal_intent("remove background")
assert _is_background_removal_intent("please remove the background")
assert _is_background_removal_intent("erase bg")
assert _is_background_removal_intent("delete backdrop")
assert _is_background_removal_intent("clear the bg from the active layer")
assert _is_background_removal_intent("strip background of current layer!")
assert _is_background_removal_intent("erase backdrop from selected layer...")
assert _is_background_removal_intent(" PLEASE REMOVE THE BG FROM THE SELECTED LAYER . ! ")
# Invalid commands rejected
assert not _is_background_removal_intent("")
assert not _is_background_removal_intent("remove foreground")
assert not _is_background_removal_intent("please blur the background")
assert not _is_background_removal_intent("remove background and make it dark")
assert not _is_background_removal_intent("remove")
assert not _is_background_removal_intent("background")
assert not _is_background_removal_intent("please remove")
assert not _is_background_removal_intent("remove the")
assert not _is_background_removal_intent("remove background from layer") # missing active/current/selected
assert not _is_background_removal_intent("remove background?") # unallowed punctuation
# ReDoS safety test: large repeated whitespace must run linearly in < 50ms
evil_padding = " \t " * 10000
evil_prompt = evil_padding + "please" + evil_padding + "remove" + evil_padding + "the" + evil_padding + "bg" + evil_padding + "!"
start = time.perf_counter()
assert _is_background_removal_intent(evil_prompt)
elapsed = time.perf_counter() - start
assert elapsed < 0.1, f"Intent parsing took too long: {elapsed:.4f}s (polynomial backtracking risk)"
evil_invalid = evil_padding + "remove" + evil_padding + "everything"
start = time.perf_counter()
assert not _is_background_removal_intent(evil_invalid)
elapsed_invalid = time.perf_counter() - start
assert elapsed_invalid < 0.1, f"Invalid intent parsing took too long: {elapsed_invalid:.4f}s"
+2 -3
View File
@@ -49,10 +49,9 @@ def test_ctrl_zoom_shortcuts_drive_the_toolbar_and_override_the_browser():
def test_ctrl_alt_l_opens_the_levels_adjustment(): def test_ctrl_alt_l_opens_the_levels_adjustment():
keyboard = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8") keyboard = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8")
# Photoshop-style chord: reuse the Image menu row so the dialog and the # Open layer-local controls without creating/committing a new adjustment layer.
# adjustment-layer plumbing stay in one place.
assert "e.altKey && e.code === 'KeyL'" in keyboard assert "e.altKey && e.code === 'KeyL'" in keyboard
assert "document.querySelector('[data-image-action=\"adj-levels\"]')" in keyboard assert "openLayerAdjustment?.('levels');" in keyboard
def test_ctrl_alt_shift_e_stamps_the_visible_layers(): def test_ctrl_alt_shift_e_stamps_the_visible_layers():
@@ -0,0 +1,882 @@
import os
from pathlib import Path
import pytest
try:
from playwright.sync_api import sync_playwright
except ImportError:
sync_playwright = None
ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture
def page():
if sync_playwright is None:
pytest.fail("playwright is required for editor browser tests. Install requirements-dev.txt")
with sync_playwright() as playwright:
launch_kwargs = {"headless": True, "args": ["--disable-dev-shm-usage", "--no-sandbox"]}
if os.environ.get("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH"):
launch_kwargs["executable_path"] = os.environ["PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH"]
browser = playwright.chromium.launch(**launch_kwargs)
page = browser.new_page(viewport={"width": 1000, "height": 700})
def serve(route):
url = route.request.url
if '/api/' in url:
route.fulfill(status=404, body='{}', content_type='application/json')
return
relative = url.split('http://editor.test/', 1)[1].split('?', 1)[0]
if not relative:
route.fulfill(body='<html><body></body></html>', content_type='text/html')
elif (ROOT / relative).is_file():
route.fulfill(path=str(ROOT / relative))
else:
route.fulfill(status=404, body='Not found')
page.route('http://editor.test/**', serve)
page.goto('http://editor.test/')
yield page
browser.close()
def test_remove_background_sends_only_active_layer_and_preserves_others(page):
result = page.evaluate("""async () => {
const {state} = await import('/static/js/editor/state.js');
const {createApplyImageTool} = await import('/static/js/editor/ai-tool-runner.js');
function layer(id, color) {
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 4;
const ctx = canvas.getContext('2d'); ctx.fillStyle = color; ctx.fillRect(0,0,4,4);
return {id, canvas, ctx, visible:true, locks:{}};
}
const red = layer('red','red'), blue = layer('blue','blue');
red.kind = 'placed'; red.placed = {sourceName:'original'};
state.layers = [red, blue]; state.activeLayerId = red.id; state.editorOpen = true;
const beforeBlue = blue.canvas.toDataURL(); const expected = red.canvas.toDataURL().split(',')[1];
let sent, snapshots = 0;
const clear = document.createElement('canvas'); clear.width = clear.height = 4;
window.fetch = async (url, opts) => {
sent = JSON.parse(opts.body);
state.activeLayerId = blue.id; // Changing selection during the request must not change its target.
return {ok:true, json:async () => ({image:clear.toDataURL()})};
};
const run = createApplyImageTool({
flatten: () => {throw Error('Must not flatten');}, saveState: () => snapshots++,
createLayer: () => {throw Error('Must not create a merged layer');},
composite: () => {}, renderLayerPanel: () => {}, deriveBusyLabel: () => 'Removing',
getSelectedAIEndpoint: () => ({}), spinnerModule: {}, uiModule: {showToast(){}},
});
await run('/api/image/remove-bg', {}, 'BG Removed', document.createElement('button'));
return {correctInput:sent.image === expected, blueUnchanged:blue.canvas.toDataURL() === beforeBlue,
count:state.layers.length, visible:state.layers.every(l => l.visible), snapshots,
alpha:red.ctx.getImageData(0,0,1,1).data[3], kind:red.kind, placed:red.placed};
}""")
assert result == dict(correctInput=True, blueUnchanged=True, count=2, visible=True, snapshots=1, alpha=0, kind='raster', placed=None)
def test_levels_shortcut_opens_controls_without_menu_commit(page):
result = page.evaluate("""async () => {
const {state} = await import('/static/js/editor/state.js');
const {wireKeyboardShortcuts} = await import('/static/js/editor/keyboard-shortcuts.js');
state.editorOpen = true;
let selected;
wireKeyboardShortcuts({toolbar:document.createElement('div'), toolKeyMap:{},
openLayerAdjustment:type => selected = type});
const event = new KeyboardEvent('keydown', {key:'l', code:'KeyL', ctrlKey:true, altKey:true,
bubbles:true, cancelable:true});
document.dispatchEvent(event);
return {selected, prevented:event.defaultPrevented};
}""")
assert result == dict(selected='levels', prevented=True)
def test_hue_saturation_shortcut_opens_adjustment(page):
result = page.evaluate("""async () => {
const {state} = await import('/static/js/editor/state.js');
const {wireKeyboardShortcuts} = await import('/static/js/editor/keyboard-shortcuts.js');
state.editorOpen = true;
let selected;
wireKeyboardShortcuts({toolbar:document.createElement('div'), toolKeyMap:{},
openLayerAdjustment:type => selected = type});
const event = new KeyboardEvent('keydown', {key:'U', code:'KeyU', ctrlKey:true, shiftKey:true,
bubbles:true, cancelable:true});
document.dispatchEvent(event);
return {selected, prevented:event.defaultPrevented};
}""")
assert result == dict(selected='hue-saturation', prevented=True)
def test_inner_shadow_clips_inside_and_reverses_direction(page):
result = page.evaluate("""async () => {
const {renderEffects} = await import('/static/js/editor/effects.js');
const source = document.createElement('canvas'); source.width=source.height=60;
const ctx=source.getContext('2d');ctx.fillStyle='white';ctx.fillRect(15,15,30,30);
function sample(inside,x) {
const out=renderEffects(source,[{type:'drop-shadow',params:{inside,x,y:0,blur:0,opacity:1,color:'#000000'}}]);
const c=out.getContext('2d');
return {left:[...c.getImageData(16,30,1,1).data],right:[...c.getImageData(43,30,1,1).data],
outside:[...c.getImageData(47,30,1,1).data]};
}
return {right:sample(true,6),left:sample(true,-6),outer:sample(false,6)};
}""")
assert result['right']['left'] == [0,0,0,255]
assert result['right']['right'] == [255,255,255,255]
assert result['left']['right'] == [0,0,0,255]
assert result['right']['outside'][3] == 0
assert result['outer']['outside'] == [0,0,0,255]
def test_cut_placed_image_rasterizes_once_without_confirmation(page):
page.route('http://editor.test/api/**', lambda route: route.fulfill(json={}))
page.evaluate("""async () => {
document.body.innerHTML='<div id="toast"></div><div id="gallery-editor-container"></div>';
const editor=await import('/static/js/galleryEditor.js');
const {state}=await import('/static/js/editor/state.js');
editor.openEditor(null,null,{w:32,h:32},'Cut test');
const layer=state.layers.at(-1);
layer.ctx.fillStyle='red';layer.ctx.fillRect(0,0,32,32);
layer.kind='placed';layer.placed={sourceName:'Original image'};
document.activeElement?.blur();
document.dispatchEvent(new KeyboardEvent('keydown',{key:'x',code:'KeyX',ctrlKey:true,bubbles:true,cancelable:true}));
}""")
page.wait_for_function("""async () => {
const {state}=await import('/static/js/editor/state.js');
return !!state.internalClipboard && state.layers.at(-1).ctx.getImageData(0,0,1,1).data[3]===0;
}""")
result=page.evaluate("""async () => {
const {state}=await import('/static/js/editor/state.js');
return {kind:state.layers.at(-1).kind,placed:state.layers.at(-1).placed,
copied:[...state.internalClipboard.getContext('2d').getImageData(0,0,1,1).data],
prompted:document.querySelector('#styled-confirm-modal')?.textContent?.includes('Rasterize layer')||false};
}""")
assert result==dict(kind='raster',placed=None,copied=[255,0,0,255],prompted=False)
def test_drop_shadow_dialog_inside_and_direction_apply(page):
page.route('http://editor.test/api/**', lambda route: route.fulfill(json={}))
page.add_style_tag(content=(ROOT/'static/css/13-image-editor.css').read_text())
page.add_style_tag(content=':root{--panel:#242426;--bg:#171719;--fg:#eee;--border:#454549;--red:#d47965;}body{background:var(--bg);color:var(--fg);font-family:Arial,sans-serif;}')
page.evaluate("""async () => {
document.body.innerHTML='<div id="toast"></div><div id="gallery-editor-container" style="height:680px"></div>';
const editor=await import('/static/js/galleryEditor.js');
editor.openEditor(null,null,{w:64,h:64},'Shadow test');
document.querySelector('[data-filter-action="effect-drop-shadow"]').click();
}""")
page.locator('.ge-filter-overlay select[data-key="inside"]').select_option('1')
page.locator('.ge-filter-overlay input[data-key="angle"]').evaluate("el=>{el.value='90';el.dispatchEvent(new Event('input',{bubbles:true}));}")
page.screenshot(path='/tmp/editor-shadow-inside-direction.png')
page.locator('.ge-filter-overlay [data-action="apply"]').click()
page.wait_for_function("""async () => {
const {state}=await import('/static/js/editor/state.js');
return state.layers.at(-1).effects.length===1;
}""")
result=page.evaluate("""async () => {
const {state}=await import('/static/js/editor/state.js');
const {inside,x,y}=state.layers.at(-1).effects[0].params;
return {inside,x,y};
}""")
assert result==dict(inside=True,x=0,y=-7)
def test_reorder_rebuilds_effect_rows_under_their_parent(page):
result=page.evaluate("""async () => {
const {state}=await import('/static/js/editor/state.js');
const {createLayerPanelRenderer}=await import('/static/js/editor/layer-panel.js');
document.body.innerHTML='<div id="ge-layers-list"></div>';
state.container=document.body;state.editorOpen=true;
function layer(id,effects=[]) {
const canvas=document.createElement('canvas');canvas.width=canvas.height=16;
return {id,name:id,kind:'raster',canvas,ctx:canvas.getContext('2d'),visible:true,
opacity:1,locks:{},effects,masks:[],adjLayers:[]};
}
const a=layer('a',[{id:'shadow-a',type:'drop-shadow',params:{}}]), b=layer('b');
state.layers=[a,b];state.activeLayerId=a.id;state.layerOffsets=new Map();state.layerGroups=[];
let options;
const renderer=createLayerPanelRenderer({composite(){},saveState(){},
dragSortModule:{enable:(id,selector,config)=>{options=config;}},uiModule:{showToast(){}}});
renderer.render();
const list=document.getElementById('ge-layers-list');
const row=id=>list.querySelector(`.ge-layer-item[data-layer-id="${id}"]`);
list.insertBefore(row('a'),row('b'));
options.onReorder([...list.querySelectorAll('.ge-layer-item[data-layer-id]')]);
return {order:state.layers.map(layer=>layer.id),excluded:options.excludeSelector,
rows:[...list.children].map(el=>el.dataset.layerId||el.dataset.effectId)};
}""")
assert result==dict(order=['b','a'],excluded='.ge-adj-sub-item',rows=['a','shadow-a','b'])
@pytest.mark.parametrize('tool', ['marquee','lasso'])
@pytest.mark.parametrize('pointer_type', ['mouse','pen','touch'])
@pytest.mark.parametrize('pan', [False,True])
def test_selections_start_outside_canvas_unless_explicitly_panning(page,tool,pointer_type,pan):
result=page.evaluate("""async ({tool,pointerType,pan}) => {
const {state}=await import('/static/js/editor/state.js');
const {wireCanvasEvents}=await import('/static/js/editor/canvas-events.js');
document.body.innerHTML='<div id="area"><canvas width="100" height="100"></canvas><button>Chrome</button></div>';
const area=document.getElementById('area');
state.mainCanvas=area.querySelector('canvas');state.tool=tool;
state.editorOpen=true;state.spacePanActive=pan;state.panX=state.panY=0;
let starts=0,moves=0,ends=0;
wireCanvasEvents({canvasArea:area,beginDraw:()=>starts++,continueDraw:()=>moves++,
endDraw:()=>ends++,updateBrushCursor(){},syncZoomControls(){}});
function pointer(type,target=area) {
target.dispatchEvent(new PointerEvent(type,{pointerId:5,pointerType,button:0,
clientX:type==='pointerdown'?10:30,clientY:type==='pointerdown'?10:30,bubbles:true,cancelable:true}));
}
pointer('pointerdown');
if(pointerType==='mouse')area.dispatchEvent(new MouseEvent('mousedown',{button:0,bubbles:true}));
pointer('pointermove');
if(pointerType==='mouse')window.dispatchEvent(new MouseEvent('mousemove'));
pointer('pointerup');
if(pointerType==='mouse')window.dispatchEvent(new MouseEvent('mouseup'));
const after={starts,moves,ends,panX:state.panX};
pointer('pointerdown',area.querySelector('button'));
return {...after,chromeStarted:starts!==after.starts};
}""",dict(tool=tool,pointerType=pointer_type,pan=pan))
assert result['starts']==(0 if pan else 1)
assert result['panX']==(20 if pan else 0)
assert not result['chromeStarted']
if not pan:
assert result['moves']==1
assert result['ends']==1
def test_marquee_drag_from_workspace_creates_clipped_selection(page):
result=page.evaluate("""async () => {
const {state}=await import('/static/js/editor/state.js');
const {wireCanvasEvents}=await import('/static/js/editor/canvas-events.js');
const {createMarqueeTool}=await import('/static/js/editor/tools/marquee.js');
document.body.innerHTML='<div id="area"><canvas width="100" height="100" style="position:absolute;left:100px;top:100px;width:100px;height:100px"></canvas></div>';
const area=document.getElementById('area');state.mainCanvas=area.querySelector('canvas');
state.tool='marquee';state.imgWidth=state.imgHeight=100;state.editorOpen=true;
state.spacePanActive=false;state.panX=state.panY=0;state.wandMask=null;
const layer={id:'a'};state.layers=[layer];state.activeLayerId='a';state.layerOffsets=new Map();
const tool=createMarqueeTool({activeLayer:()=>layer,saveState(){},composite(){},drawOverlay(){},syncSelectionUi(){}});
wireCanvasEvents({canvasArea:area,beginDraw:e=>tool.begin(e),continueDraw:e=>tool.drag(e),
endDraw:e=>tool.end(e),updateBrushCursor(){},syncZoomControls(){}});
area.dispatchEvent(new PointerEvent('pointerdown',{pointerType:'mouse',pointerId:1,button:0,clientX:80,clientY:80,bubbles:true}));
area.dispatchEvent(new MouseEvent('mousedown',{button:0,clientX:80,clientY:80,bubbles:true}));
window.dispatchEvent(new MouseEvent('mousemove',{clientX:150,clientY:150}));
window.dispatchEvent(new MouseEvent('mouseup',{clientX:150,clientY:150}));
const ctx=state.wandMask.getContext('2d');
return {inside:ctx.getImageData(0,0,1,1).data[3],outside:ctx.getImageData(70,70,1,1).data[3],pan:state.panX};
}""")
assert result==dict(inside=255,outside=0,pan=0)
def test_save_menu_remains_clickable_after_png_export_and_window_raise(page):
page.add_style_tag(content='''
.modal {position:fixed;inset:0;z-index:20000;background:#222;}
#ge-save-menu-btn {position:absolute;right:20px;top:20px;}
.ge-save-menu {position:fixed;z-index:10005;width:200px;background:#333;}
.ge-save-menu[hidden] {display:none;}
.ge-save-menu button {display:block;width:100%;height:30px;}
''')
page.evaluate("""async () => {
const {wireTopbar}=await import('/static/js/editor/wire-topbar.js');
document.body.innerHTML='<div id="gallery-modal" class="modal"><div id="gallery-editor-container"><div class="ge-topbar"><div class="ge-save-wrap"><button id="ge-save-menu-btn">Save</button><div id="ge-save-menu" class="ge-save-menu" hidden><button id="ge-save">Save image</button><button id="ge-export-png">Save as PNG</button></div></div></div></div></div>';
window.pngExports=0;window.saves=0;
document.getElementById('ge-save').addEventListener('click',()=>window.saves++);
wireTopbar({registerDocClickAway(){},exportPNGFile:()=>window.pngExports++});
}""")
page.locator('#ge-save-menu-btn').click()
page.locator('#ge-export-png').click(timeout=2000)
page.evaluate("document.getElementById('gallery-modal').style.zIndex='50000'")
page.locator('#ge-save-menu-btn').click()
page.locator('#ge-save').click(timeout=2000)
assert page.evaluate('({png:window.pngExports,saves:window.saves})') == dict(png=1,saves=1)
def test_submenu_hover_aligns_to_row_and_flips_at_right_edge(page):
page.add_style_tag(content='''
#menu {position:absolute; left:780px; top:100px; width:180px;}
button {display:block; height:35px; width:180px;}
.ge-submenu {display:none; position:absolute; width:160px; height:120px; top:0; left:100%;}
.ge-menu-parent:hover + .ge-submenu {display:block;}
''')
page.evaluate("""async () => {
document.body.innerHTML = '<div id="menu"><button>First</button><button>Second</button>'
+ '<button class="ge-menu-parent">Blur &gt;</button><div class="ge-submenu">Gaussian</div></div>';
const {wireSubmenuToggles} = await import('/static/js/editor/submenu-toggles.js');
wireSubmenuToggles(document.getElementById('menu'));
}""")
page.locator('.ge-menu-parent').hover()
row = page.locator('.ge-menu-parent').bounding_box()
panel = page.locator('.ge-submenu').bounding_box()
assert abs(panel['y'] - row['y']) <= 1
assert panel['x'] + panel['width'] <= row['x'] + 1
page.screenshot(path='/tmp/editor-submenu-alignment.png')
@pytest.mark.parametrize('prompt', ['remove bg', 'remove background', 'remove the background from the selected layer'])
def test_quick_edit_planner_uses_background_tool(prompt):
from routes.editor_agent_routes import _plan_special
steps, _, _ = _plan_special(prompt, {}, True)
assert steps == [{'op': 'ai.removeBackground', 'params': {}}]
def test_quick_edit_background_removal_precedes_creative_routing():
source = (ROOT / 'static/js/galleryEditor.js').read_text()
local = source.split('const _executeQuickEditLocal = async (prompt) => {', 1)[1]
assert local.index('if (_looksBackgroundRemoval(p))') < local.index('if (_looksRecolor(p))')
@pytest.mark.parametrize('run', [False, True])
def test_background_tool_suggestion_prepares_command_and_enter_submits(page, run):
source = (ROOT / 'static/js/galleryEditor.js').read_text()
picker = 'const pickSuggestion = ' + source.split('const pickSuggestion = ', 1)[1].split(" wrap.addEventListener('pointerdown'", 1)[0]
assert "runPrompt: id === 'rembg' ? 'remove background' : null" in source
result = page.evaluate("""({picker, run}) => {
const suggestionItems = [{toolId:'rembg', label:'Bg Remove', runPrompt:'remove background'}];
const input = document.createElement('input'); input.value = 'bg';
let submitted = 0, selected = null, clearSynced = false;
const form = {requestSubmit: () => submitted++};
const _clickToolButton = id => selected = id;
const _setAiCommandStatus = () => {};
const hideSuggestions = () => {};
const syncClearButton = () => clearSynced = true;
eval(picker + '; pickSuggestion(0, run);');
return {prompt:input.value, submitted, selected, clearSynced};
}""", {'picker':picker, 'run':run})
assert result == dict(prompt='remove background', submitted=int(run), selected='rembg', clearSynced=True)
@pytest.mark.parametrize('tool', ['dodge', 'burn'])
def test_retouch_invalidates_adjustment_preview_and_stops_before_undo(page, tool):
result = page.evaluate("""async tool => {
const {state} = await import('/static/js/editor/state.js');
const {createStrokePipeline} = await import('/static/js/editor/stroke-pipeline.js');
const {renderLayerWithAdjLayers} = await import('/static/js/editor/fx/pixel-pass.js');
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 30;
const ctx = canvas.getContext('2d'); ctx.fillStyle = 'rgb(100,100,100)'; ctx.fillRect(0,0,30,30);
const layer = {id:'target', canvas, ctx, adjLayers:[{id:'adj', type:'brightness-contrast',
params:{brightness:1.1,contrast:1}, visible:true, opacity:1}]};
state.layers = [layer]; state.layerOffsets = new Map(); state.tool = tool;
Object.assign(state,{brushSize:12, brushOpacity:100, brushFlow:100, brushSoftness:0,
brushSpacing:20, brushSmoothing:0, dodgeBurnIntensity:50});
const original = ctx.getImageData(0,0,30,30);
const pixel = () => renderLayerWithAdjLayers(layer).getContext('2d').getImageData(15,15,1,1).data[0];
const before = pixel();
const pipeline = createStrokePipeline({activeLayer:()=>layer, getActiveMaskLayer:()=>null, composite:()=>{}});
pipeline.beginStroke({x:15,y:15,pressure:1},tool);
const during = pixel();
pipeline.endStroke(null); // Finish before Undo restores the saved pixels.
ctx.putImageData(original,0,0); layer._adjFinalKey = null; layer._adjCacheKey = null;
pipeline.endStroke({x:28,y:28,pressure:1}); // A late pointer-up cannot replay this stroke.
return {before,during,after:pixel(),corner:ctx.getImageData(28,28,1,1).data[0]};
}""", tool)
assert result['during'] > result['before'] if tool == 'dodge' else result['during'] < result['before']
assert result['after'] == result['before']
assert result['corner'] == 100
def test_undo_finishes_active_stroke_before_restoring_history():
source = (ROOT / 'static/js/galleryEditor.js').read_text()
undo = source.split('function undo() {', 1)[1].split('function redo()', 1)[0]
assert undo.index('if (state.drawing) _strokeTool.tryEnd();') < undo.index('state.undoStack.pop()')
@pytest.mark.parametrize('width,height', [(1000,700), (390,844)])
def test_filter_submenu_rows_have_equal_heights_with_and_without_icons(page, width, height):
page.set_viewport_size({'width':width,'height':height})
page.add_style_tag(content=(ROOT / 'static/css/13-image-editor.css').read_text())
page.evaluate("""() => {
document.body.innerHTML = '<div class="ge-submenu" style="display:block;left:8px;top:8px;">'
+ '<button class="dropdown-item-compact"><span>Plain</span></button>'
+ '<button class="dropdown-item-compact"><svg width="24" height="24"><circle cx="12" cy="12" r="8"/></svg><span>Icon</span></button>'
+ '<button class="dropdown-item-compact"><span>Longer label</span><svg width="10" height="10"></svg></button></div>';
}""")
heights = page.locator('.dropdown-item-compact').evaluate_all('(rows)=>rows.map(row=>row.getBoundingClientRect().height)')
assert heights == ([28]*3 if width > 820 else [32]*3)
page.screenshot(path=f'/tmp/editor-filter-row-heights-{width}.png')
def test_ai_settings_escape_closes_only_the_dialog(page):
page.route('**/api/auth/settings', lambda route: route.fulfill(json={'image_model':'', 'image_edit_model':''}))
result = page.evaluate("""async () => {
const {createAiSettings} = await import('/static/js/editor/ai-settings.js');
const container = document.createElement('div'); document.body.append(container);
const settings = createAiSettings({container}); settings.open();
let reachedEditor = false;
document.addEventListener('keydown', () => reachedEditor = true, {capture:true, once:true});
document.dispatchEvent(new KeyboardEvent('keydown', {key:'Escape', bubbles:true, cancelable:true}));
const result = {closed:settings.element.hidden, reachedEditor}; settings.destroy();
return result;
}""")
assert result == dict(closed=True, reachedEditor=False)
def test_image_model_settings_sync_both_directions_and_survive_reopening(page):
saved = {'image_model':'openai/gpt-5-image', 'image_edit_model':'', 'image_quality':'medium', 'image_gen_enabled':True}
writes = []
def settings_api(route):
if route.request.method == 'POST':
patch = route.request.post_data_json
writes.append(patch)
saved.update(patch)
route.fulfill(json=dict(saved))
page.route('**/api/auth/settings', settings_api)
page.route('**/api/model-endpoints', lambda route: route.fulfill(json=[{
'is_enabled':True, 'online':True, 'model_type':'llm',
'models':['openai/gpt-5-image','openai/gpt-image-2'],
}]))
page.evaluate("""async () => {
document.body.innerHTML = '<select id="set-imgModelSelect"><option value="">Auto</option></select>'
+ '<select id="set-imgEditModelSelect"><option value="">Same</option></select>'
+ '<select id="set-imgQualitySelect"><option value="medium">Medium</option></select>'
+ '<input type="checkbox" id="set-imgEnabledToggle"><span id="set-imgSettingsMsg"></span>';
const {initImageSettings} = await import('/static/js/settings/imageModels.js');
await initImageSettings();
const {createAiSettings} = await import('/static/js/editor/ai-settings.js');
window.dialog = createAiSettings({container:document.body});
for (const id of ['ge-ai-model','ge-ai-command-model']) {
const select = document.getElementById(id);
for (const model of ['', 'openai/gpt-5-image','openai/gpt-image-2']) {
const option = document.createElement('option');
option.value = model ? 'https://openrouter.ai/api/v1::'+model : '';
option.textContent = model || 'Auto'; select.append(option);
}
}
dialog.open();
}""")
page.wait_for_function("document.getElementById('ge-ai-model').value.endsWith('::openai/gpt-5-image')")
page.select_option('#ge-ai-model', 'https://openrouter.ai/api/v1::openai/gpt-image-2')
page.wait_for_function("document.getElementById('set-imgModelSelect').value === 'openai/gpt-image-2'")
assert saved['image_model'] == 'openai/gpt-image-2'
page.select_option('#set-imgEditModelSelect', 'openai/gpt-image-2')
page.wait_for_function("document.getElementById('ge-ai-command-model').value.endsWith('::openai/gpt-image-2')")
page.evaluate('dialog.close(); dialog.open();')
page.wait_for_function("document.getElementById('ge-ai-command-model').value.endsWith('::openai/gpt-image-2')")
assert saved['image_edit_model'] == 'openai/gpt-image-2'
assert len(writes) == 2
def test_generation_places_result_without_stretching_or_refresh(page):
result = page.evaluate("""async () => {
const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js');
const image = document.createElement('canvas'); image.width = 80; image.height = 20;
const ctx = image.getContext('2d'); ctx.fillStyle = 'red'; ctx.fillRect(0,0,80,20);
const selection = document.createElement('canvas'); selection.width = selection.height = 40;
selection.getContext('2d').fillRect(0,0,40,40);
let placed = null, body = null;
const run = createImageGenerationAction({
getDocument:()=>({key:'one',width:40,height:40,mask:selection}),
fetchImpl:async (url,opts) => {
body = JSON.parse(opts.body);
selection.getContext('2d').clearRect(0,0,40,40);
return {ok:true,json:async()=>({image:image.toDataURL()})};
},
placeResult:(canvas,name,mask)=>{
const data = canvas.getContext('2d');
placed = {width:canvas.width,height:canvas.height,left:data.getImageData(0,10,1,1).data[0],
right:data.getImageData(79,10,1,1).data[0], mask:mask.getContext('2d').getImageData(20,20,1,1).data[3]};
return 'new-layer';
},
});
const id = await run({prompt:'a cow',masked:true});
return {id,placed,body};
}""")
assert result['id'] == 'new-layer'
assert result['placed'] == dict(width=80,height=20,left=255,right=255,mask=255)
assert result['body'] == dict(_use_image_settings=True,prompt='a cow',width=40,height=40)
@pytest.mark.parametrize('action', ['cancel','timeout','invalid-image','document-switch'])
def test_generation_finishes_cleanly_on_failure_without_late_placement(page, action):
result = page.evaluate("""async action => {
const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js');
let key = 'original', placements = 0;
const controller = new AbortController();
const run = createImageGenerationAction({
getDocument:()=>({key,width:40,height:40}), getSignal:()=>controller.signal,
timeoutMs:action === 'timeout' ? 20 : 2000,
fetchImpl:async (url,opts)=>{
if (action === 'timeout' || action === 'cancel') {
if (action === 'cancel') setTimeout(()=>controller.abort(),10);
return new Promise((resolve,reject)=>opts.signal.addEventListener('abort',
()=>reject(new DOMException('Aborted','AbortError')), {once:true}));
}
if (action === 'invalid-image') return {ok:true,json:async()=>({image:'bad-image-data'})};
const image = document.createElement('canvas'); image.width = image.height = 4;
key = 'different';
return {ok:true,json:async()=>({image:image.toDataURL()})};
},
placeResult:()=>placements++,
});
let error = '';
try {await run({prompt:'a cow'});} catch (e) {error = e.message;}
return {error,placements};
}""", action)
assert result['placements'] == 0
assert result['error']
if action == 'timeout':
assert 'timed out' in result['error']
if action == 'document-switch':
assert 'document changed' in result['error']
def test_generation_provider_error_is_not_retried_and_next_request_can_succeed(page):
result = page.evaluate("""async () => {
const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js');
let calls = 0, placements = 0;
const image = document.createElement('canvas'); image.width = image.height = 4;
const run = createImageGenerationAction({
getDocument:()=>({key:'one',width:40,height:40}),
formatError:({status})=>status === 503 ? 'Generation is disabled' : 'error',
fetchImpl:async ()=> ++calls === 1
? {ok:false,status:503,json:async()=>({detail:'Disabled'})}
: {ok:true,json:async()=>({image:image.toDataURL()})},
placeResult:()=>++placements,
});
let error;
try {await run({prompt:'one'});} catch (e) {error = e.message;}
const callsAfterFailure = calls;
await run({prompt:'two'});
return {error,callsAfterFailure,calls,placements};
}""")
assert result == dict(error='Generation is disabled',callsAfterFailure=1,calls=2,placements=1)
def test_late_progress_response_cannot_restore_working_after_completion(page):
source = (ROOT / 'static/js/galleryEditor.js').read_text()
functions = source.split('function _startAiProgress(requestId) {',1)[1].split('function _setAiCommandStatus(',1)[0]
functions = 'function _startAiProgress(requestId) {' + functions
result = page.evaluate("""async functions => {
let _aiProgressTimer = null, _aiProgressEpoch = 0, status = 'Working';
let resolveRequest;
const fetch = () => new Promise(resolve => resolveRequest = resolve);
const _setAiProgress = () => {};
const _setAiCommandStatus = text => status = text;
const api = eval(functions + '; ({start:_startAiProgress,stop:_stopAiProgress});');
api.start('old-request');
api.stop(); status = 'Done';
resolveRequest({ok:true,json:async()=>({stage:'editing',progress:0.5})});
await new Promise(resolve=>setTimeout(resolve,20));
return status;
}""", functions)
assert result == 'Done'
@pytest.mark.parametrize('success', [True,False])
def test_quick_edit_queue_clears_working_on_completion(page, success):
source = (ROOT / 'static/js/galleryEditor.js').read_text()
pump = 'const pumpQuickEdit = ' + source.split('const pumpQuickEdit = ',1)[1].split(' const enqueueQuickEditJob',1)[0]
result = page.evaluate("""async ({pump,success}) => {
const {finishQuickEditJob} = await import('/static/js/editor/quick-edit-job.js');
const jobs = [{id:'one',prompt:'fill',status:'queued'}];
let pumping = false, _quickEditActiveJob = null, stopped = 0, terminal;
const renderJobStack = () => {};
const dismissJob = () => {};
const _stopAiProgress = () => stopped++;
const _setAiCommandStatus = (text,kind) => terminal = {text,kind};
const executeQuickEdit = async () => success ? {ok:true} : {ok:false,error:'Provider failed'};
await eval(pump + '; pumpQuickEdit();');
return {status:jobs[0].status,detail:jobs[0].detail,terminal,stopped,pumping};
}""", {'pump':pump,'success':success})
expected = 'done' if success else 'error'
message = 'Done' if success else 'Provider failed'
assert result == dict(status=expected,detail=message,terminal=dict(text=message,kind=expected),stopped=1,pumping=False)
@pytest.mark.parametrize('bridge', [False,True])
@pytest.mark.parametrize('instruction', [False,True])
@pytest.mark.parametrize('matching_ratio', [False,True])
def test_generated_layer_can_keep_pixels_outside_document_bounds(page, bridge, instruction, matching_ratio):
source = (ROOT / 'static/js/galleryEditor.js').read_text()
placement = 'const _placeResultLayer = ' + source.split('const _placeResultLayer = ',1)[1].split(' const _generateImage',1)[0]
bridge_line = next(line for line in source.splitlines() if "'layers.placeImage':" in line)
result = page.evaluate("""async ({placement,bridgeLine,bridge,instruction,matchingRatio}) => {
const {imageResultGeometry} = await import('/static/js/editor/image-result-geometry.js');
const state = {imgWidth:matchingRatio ? 1280 : 40,imgHeight:matchingRatio ? 720 : 40,layers:[],layerOffsets:new Map()};
let saved = 0;
const _saveState = ()=>saved++;
const composite = ()=>{};
const _renderLayerPanel = ()=>{};
const createLayer = (name,w,h)=>{
const canvas = document.createElement('canvas'); canvas.width=w;canvas.height=h;
return {id:'new',name,canvas,ctx:canvas.getContext('2d')};
};
const image = document.createElement('canvas'); image.width=matchingRatio ? 1536 : 80;image.height=matchingRatio ? 864 : 20;
image.getContext('2d').fillRect(0,0,image.width,image.height);
if (bridge) {
const run = eval('({' + bridgeLine + '})')["layers.placeImage"].run;
await run({dataUrl:image.toDataURL(),nativeSize:true,masked:false,
mode:instruction ? 'instruction' : undefined,differenceMask:instruction});
} else {
eval(placement + '; _placeResultLayer(image,"Generated",null);');
}
const layer = state.layers[0];
return {width:layer.canvas.width,height:layer.canvas.height,offset:state.layerOffsets.get(layer.id),
outsideAlpha:layer.ctx.getImageData(79,10,1,1).data[3],saved};
}""", {'placement':placement,'bridgeLine':bridge_line,'bridge':bridge,'instruction':instruction,'matchingRatio':matching_ratio})
expected = dict(width=1280,height=720,offset=dict(x=0,y=0),outsideAlpha=255,saved=1) if matching_ratio else dict(width=80,height=20,offset=dict(x=-20,y=10),outsideAlpha=255,saved=1)
assert result == expected
def test_whole_image_edit_does_not_clip_native_result_to_select_all(page):
source = (ROOT / 'static/js/galleryEditor.js').read_text()
operation = 'const _aiInpaintSelection = ' + source.split('const _aiInpaintSelection = ',1)[1].split(' // Replace the background/scene',1)[0]
result = page.evaluate("""async operation => {
const mask = document.createElement('canvas'); mask.width=40;mask.height=40;
const canvas = document.createElement('canvas'); canvas.width=80;canvas.height=20;
const state = {wandMask:mask};
const activeLayer = ()=>({id:'one'});
const _aiEditCanvas = async ()=>({canvas,fullFrame:true});
const _placeResultLayer = (result,name,resultMask)=>({width:result.width,height:result.height,masked:!!resultMask});
return await eval(operation + '; _aiInpaintSelection("change the sky");');
}""", operation)
assert result == dict(width=80,height=20,masked=False)
@pytest.mark.parametrize('matching_ratio', [False,True])
def test_inpaint_panel_preserves_native_whole_edit_dimensions(page, matching_ratio):
source = (ROOT / 'static/js/editor/ai-inpaint.js').read_text()
body = 'const fullFrame = ' + source.split('const fullFrame = ',1)[1].split(' // Snapshot the AI result',1)[0]
result = page.evaluate("""async ({body,matchingRatio}) => {
const {imageResultGeometry} = await import('/static/js/editor/image-result-geometry.js');
const state = {imgWidth:matchingRatio ? 1280 : 40,imgHeight:matchingRatio ? 720 : 40,layerOffsets:new Map()};
const data = {full_frame:true}, layerName = 'Edit';
const createLayer = (name,w,h)=>{
const canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
return {id:'result',canvas,ctx:canvas.getContext('2d')};
};
const source = document.createElement('canvas'); source.width=matchingRatio ? 1536 : 80;source.height=matchingRatio ? 864 : 20;
source.getContext('2d').fillRect(0,0,source.width,source.height);
const resultImg = new Image(); resultImg.src=source.toDataURL(); await resultImg.decode();
return eval(body + '; ({width:resultLayer.canvas.width,height:resultLayer.canvas.height,offset:state.layerOffsets.get(resultLayer.id)});');
}""", {'body':body,'matchingRatio':matching_ratio})
assert result == (dict(width=1280,height=720,offset=dict(x=0,y=0)) if matching_ratio else dict(width=80,height=20,offset=dict(x=-20,y=10)))
def test_brush_softness_is_in_main_bar_and_saves_extended_value(page):
page.add_style_tag(content=(ROOT / 'static/css/13-image-editor.css').read_text())
result = page.evaluate("""async () => {
const {controlsHTML} = await import('/static/js/editor/build/controls.js');
const {wireStrokeToolSliders} = await import('/static/js/editor/stroke-tool-sliders.js');
const {state} = await import('/static/js/editor/state.js');
const bar = document.createElement('div');bar.className='ge-options-bar';
const controls = document.createElement('div');controls.className='ge-controls ge-controls--in-options';
controls.innerHTML=controlsHTML({color:'#000000',brushSize:40,wandTolerance:20});
for(const child of controls.children) if(child.id !== 'ge-brush-controls') child.style.display='none';
bar.append(controls);document.body.append(bar);
wireStrokeToolSliders();
const slider=document.getElementById('ge-brush-softness');
slider.value='1200';slider.dispatchEvent(new Event('input',{bubbles:true}));
return {inMore:!!slider.closest('#ge-brush-more-menu'),max:slider.max,value:state.brushSoftness,
visible:slider.getBoundingClientRect().height>0,label:document.getElementById('ge-brush-softness-label').textContent};
}""")
assert result == dict(inMore=False,max='1200',value=1200,visible=True,label='1200%')
page.screenshot(path='/tmp/editor-brush-softness-main-bar.png')
def test_extended_softness_produces_softer_pixels_not_just_larger_slider_values(page):
result = page.evaluate("""async () => {
const {state}=await import('/static/js/editor/state.js');
const {createStrokePipeline}=await import('/static/js/editor/stroke-pipeline.js');
const results=[];
for(const softness of [300,1200]) {
const canvas=document.createElement('canvas');canvas.width=canvas.height=100;
const layer={id:'brush',canvas,ctx:canvas.getContext('2d')};
Object.assign(state,{tool:'brush',brushSize:60,brushSoftness:softness,brushOpacity:100,brushFlow:100,
brushBlendMode:'source-over',color:'#000000',brushSpacing:15,brushSmoothing:0,
brushPixel:false,pressureSize:false,pressureOpacity:false,pressureFlow:false,layerOffsets:new Map()});
const pipeline=createStrokePipeline({activeLayer:()=>layer,getActiveMaskLayer:()=>null,composite:()=>{}});
pipeline.beginStroke({x:50,y:50,pressure:1},'brush');pipeline.endStroke(null);
const alpha=x=>layer.ctx.getImageData(x,50,1,1).data[3];
results.push({middle:alpha(65),center:alpha(50),outside:alpha(85)});
document.body.append(canvas);
}
return results;
}""")
assert result[1]['middle'] < result[0]['middle'] / 3
assert result[1]['center'] > 150
assert result[0]['outside'] == result[1]['outside'] == 0
page.screenshot(path='/tmp/editor-brush-softness-falloff.png')
@pytest.mark.parametrize('width', [1000,840])
def test_brush_numbers_do_not_overlap_after_real_panel_normalization(page,width):
page.set_viewport_size({'width':width,'height':700})
for stylesheet in ['static/css/13-image-editor.css','static/css/documents-gallery-editor.css']:
page.add_style_tag(content=(ROOT / stylesheet).read_text())
page.add_style_tag(content=':root{--bg:#171719;--panel:#242426;--fg:#eee;--red:#d47965;--border:#454549;}body{background:var(--bg);}')
result = page.evaluate("""async () => {
const {state}=await import('/static/js/editor/state.js');
const {controlsHTML}=await import('/static/js/editor/build/controls.js');
const {buildRightPanel}=await import('/static/js/editor/build/right-panel.js');
const {wireStrokeToolSliders}=await import('/static/js/editor/stroke-tool-sliders.js');
state.container=document.createElement('div');document.body.append(state.container);
state.brushSize=800;
const {controls}=buildRightPanel({controlsHTML,layerPanelHTML:()=>'<div></div>'});
controls.classList.add('ge-controls--in-options');
const bar=document.createElement('div');bar.className='ge-options-bar';bar.append(controls);state.container.append(bar);
for(const child of controls.children) if(child.id !== 'ge-brush-controls') child.style.display='none';
wireStrokeToolSliders();
const slider=document.getElementById('ge-brush-softness');
const elements=[document.querySelector('#ge-brush-softness-row label'),slider,
document.getElementById('ge-brush-softness-label'),document.querySelector('.ge-brush-size-row label'),
document.querySelector('.ge-brush-size-row input')];
const bounds=()=>elements.map(element=>{
const r=element.getBoundingClientRect();return {x:r.x,right:r.right,width:r.width};
});
slider.value='1';slider.dispatchEvent(new Event('input'));const small=bounds();
slider.value='1200';slider.dispatchEvent(new Event('input'));const large=bounds();
return {small,large,value:document.getElementById('ge-brush-softness-label').textContent};
}""")
assert result['value'] == '1200%'
assert all(bounds['width'] > 0 for bounds in result['large']), result
for left,right in zip(result['large'],result['large'][1:]):
assert left['right'] <= right['x'] + 1, result
for small,large in zip(result['small'],result['large']):
assert abs(small['x']-large['x']) <= 1, result
page.screenshot(path=f'/tmp/editor-brush-number-layout-{width}.png')
@pytest.mark.parametrize('fail_once', [False, True])
def test_new_project_create_initializes_canvas(page, fail_once):
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
page.route('http://editor.test/api/**', lambda route: route.fulfill(json={}))
page.add_style_tag(content='''
.modal {position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:250;pointer-events:none;}
.modal-content {pointer-events:auto;background:#333;padding:20px;}
#gallery-modal {z-index:700;}
#gallery-modal > .modal-content {width:90vw;height:90vh;}
''')
page.evaluate("""async () => {
document.body.innerHTML = '<div id="toast"></div><div class="modal" id="gallery-modal"><div class="modal-content"><div id="gallery-editor-container"></div></div></div>';
const editor = await import('/static/js/galleryEditor.js');
window.newProject = editor.openEditor(null, null, null, 'New canvas');
window.newProject.catch(error => { window.createError = error.stack; });
}""")
if fail_once:
page.evaluate("""() => {
const fill = CanvasRenderingContext2D.prototype.fillRect;
CanvasRenderingContext2D.prototype.fillRect = function(...args) {
if (this.canvas.width === 1024 && this.canvas.height === 1024) {
CanvasRenderingContext2D.prototype.fillRect = fill;
throw Error('Canvas allocation test failure');
}
return fill.apply(this, args);
};
}""")
page.locator('#ge-canvas-prompt-ok').click(timeout=3000)
if fail_once:
page.wait_for_function("window.createError")
assert 'Canvas allocation test failure' in page.evaluate('window.createError')
assert 'Could not create project' in page.locator('body').inner_text()
page.evaluate("""async () => {
window.createError = null;
const editor = await import('/static/js/galleryEditor.js');
window.newProject = editor.openEditor(null, null, null, 'New canvas');
window.newProject.catch(error => { window.createError = error.stack; });
}""")
page.locator('#ge-canvas-prompt-ok').click(timeout=3000)
page.wait_for_timeout(500)
result = page.evaluate("""async () => {
const {state} = await import('/static/js/editor/state.js');
return {error:window.createError, width:state.mainCanvas?.width,
height:state.mainCanvas?.height, layers:state.layers.map(layer=>layer.name)};
}""")
assert not errors, errors
assert not result.get('error'), result
assert result == {'error':None,'width':1024,'height':1024,'layers':['Background','Edit']}
def test_undo_clears_rembg_live_layer_and_edge_tuner_cannot_corrupt(page):
source = (ROOT / 'static/js/galleryEditor.js').read_text()
restore_fn = source.split('function _restoreState(snap) {', 1)[1].split('function undo() {', 1)[0]
assert 'state.rembgLiveLayer = null;' in restore_fn
assert 'state.rembgLiveSnap = null;' in restore_fn
page.route('http://editor.test/api/**', lambda route: route.fulfill(json={}))
result = page.evaluate("""async () => {
document.body.innerHTML = '<div id="toast"></div><div id="gallery-editor-container"></div>';
const editor = await import('/static/js/galleryEditor.js');
const {state} = await import('/static/js/editor/state.js');
editor.openEditor(null, null, {w: 16, h: 16}, 'Rembg edge tuner undo test');
const layer = state.layers.at(-1);
layer.ctx.fillStyle = 'rgba(0, 255, 0, 1)';
layer.ctx.fillRect(0, 0, 16, 16);
const originalPixel = [...layer.ctx.getImageData(0, 0, 1, 1).data];
// Save a valid pre-edit snapshot to undoStack
const origCanvas = document.createElement('canvas');
origCanvas.width = origCanvas.height = 16;
origCanvas.getContext('2d').drawImage(layer.canvas, 0, 0);
const preEditSnap = {
imgWidth: 16, imgHeight: 16,
activeLayerId: layer.id,
selectedLayerIds: [layer.id],
layers: [{
id: layer.id,
name: 'Layer 1',
canvas: origCanvas,
imageData: origCanvas.getContext('2d').getImageData(0, 0, 16, 16),
visible: true,
opacity: 1,
blendMode: 'source-over',
locks: {},
}],
};
state.undoStack.push(preEditSnap);
// Create tuner snapshot for background removal
const snapCanvas = document.createElement('canvas');
snapCanvas.width = snapCanvas.height = 16;
const snapCtx = snapCanvas.getContext('2d');
snapCtx.fillStyle = 'rgba(255, 0, 0, 1)';
snapCtx.fillRect(4, 4, 8, 8); // cutout shape
// Attach live tuner
state.rembgLiveLayer = layer;
state.rembgLiveSnap = snapCanvas;
const tunerAttached = state.rembgLiveLayer === layer && state.rembgLiveSnap === snapCanvas;
// Mutate layer pixels to simulate background removal result
layer.ctx.clearRect(0, 0, 16, 16);
layer.ctx.drawImage(snapCanvas, 0, 0);
// Trigger Undo via Ctrl+Z
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true, cancelable: true,
}));
const clearedAfterUndo = state.rembgLiveLayer === null && state.rembgLiveSnap === null;
const restoredLayer = state.layers.at(-1);
const restoredPixel = [...restoredLayer.ctx.getImageData(0, 0, 1, 1).data];
// Now interact with edge tuner sliders after undo
const featherInput = document.getElementById('ge-rembg-feather');
const growInput = document.getElementById('ge-rembg-grow');
if (featherInput) {
featherInput.value = '5';
featherInput.dispatchEvent(new Event('input', { bubbles: true }));
}
if (growInput) {
growInput.value = '5';
growInput.dispatchEvent(new Event('input', { bubbles: true }));
}
// Check pixel again after tuner events: restored pixels must remain untouched
const pixelAfterTuner = [...restoredLayer.ctx.getImageData(0, 0, 1, 1).data];
return {
tunerAttached,
clearedAfterUndo,
restoredPixel,
originalPixel,
pixelAfterTuner,
rembgLiveLayerNull: state.rembgLiveLayer === null,
rembgLiveSnapNull: state.rembgLiveSnap === null,
};
}""")
assert result['tunerAttached'] is True
assert result['clearedAfterUndo'] is True
assert result['rembgLiveLayerNull'] is True
assert result['rembgLiveSnapNull'] is True
assert result['restoredPixel'] == result['originalPixel']
assert result['pixelAfterTuner'] == result['originalPixel']
+2 -2
View File
@@ -143,8 +143,8 @@ def test_crop_can_start_outside_the_canvas_without_panning():
# image (so it can be pulled out past the edge) instead of the canvas-area # image (so it can be pulled out past the edge) instead of the canvas-area
# pan grabbing the pointer and switching to hand mode. # pan grabbing the pointer and switching to hand mode.
CE = (ROOT / "static/js/editor/canvas-events.js").read_text(encoding="utf-8") CE = (ROOT / "static/js/editor/canvas-events.js").read_text(encoding="utf-8")
assert "if (state.tool !== 'lasso' && state.tool !== 'crop') return;" in CE assert "['marquee', 'lasso', 'crop'].includes(state.tool)" in CE
assert "(state.tool === 'lasso' || state.tool === 'crop') && !directPan" in CE assert "if (canStartOutside() && !directPan)" in CE
# Pressing editor chrome inside the area (e.g. the crop Apply panel) must # Pressing editor chrome inside the area (e.g. the crop Apply panel) must
# not start a fresh crop. # not start a fresh crop.
assert ".ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup" in CE assert ".ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup" in CE
+5 -6
View File
@@ -32,13 +32,12 @@ def test_preflight_module_has_the_four_setup_steps():
assert "required ready" in src assert "required ready" in src
def test_preflight_is_wired_into_the_editor(): def test_editor_uses_ai_settings_without_redundant_setup():
editor = _read("static/js/galleryEditor.js") editor = _read("static/js/galleryEditor.js")
assert "import { createPreflight } from './editor/preflight.js';" in editor assert "createPreflight" not in editor
assert "createPreflight({ canvasArea })" in editor assert "setupBtn.id = 'ge-setup-btn';" not in editor
assert "_preflight?.maybeOpen();" in editor assert "createAiSettings({ container })" in editor
assert "setupBtn.id = 'ge-setup-btn';" in editor assert "aiBtn.addEventListener('click', () => _aiSettings?.open());" in editor
assert "_preflight?.destroy();" in editor
def test_ai_models_exposes_status_for_the_checklist(): def test_ai_models_exposes_status_for_the_checklist():
+36 -1
View File
@@ -49,7 +49,42 @@ def test_generate_route_forwards_the_picked_model():
assert 'model_choice = str((body or {}).get("model") or "").strip()' in route assert 'model_choice = str((body or {}).get("model") or "").strip()' in route
assert 'payload["model"] = model_choice' in route assert 'payload["model"] = model_choice' in route
assert "content = _json.dumps(payload)" in route assert "content = _json.dumps(payload)" in route
assert "localStorage.getItem('ge-ai-command-model')" in JS operation = JS.split("'ai.generateImage':", 1)[1].split("\n", 1)[0]
generation = (ROOT / 'static/js/editor/image-generation.js').read_text()
assert '_generateImage(p || {})' in operation
assert "_use_image_settings: true" in generation
assert "document.getElementById('ge-ai-model')" not in operation
assert "_getSelectedQuickEditModel()" not in operation
assert "localStorage.getItem" not in operation
def test_ai_settings_separates_generation_from_editing_without_setup():
settings = (ROOT / "static/js/editor/ai-settings.js").read_text()
assert 'id="ge-ai-model"' in settings
assert 'id="ge-ai-command-model"' in settings
assert "Image generation model" in settings
assert "createPreflight" not in JS
assert "ge-setup-btn';" not in JS.split("const aiBtn", 1)[0]
def test_generate_uses_live_picker_and_auto_edit_fallback():
import subprocess
module = (ROOT / "static/js/editor/ai-models.js").as_uri()
script = """
import assert from 'node:assert/strict';
const {resolveQuickEditModel} = await import(MODULE);
const selected = 'https://openrouter.ai/api/v1::openai/gpt-5-image';
const option = {value: selected, textContent: 'GPT-5 Image'};
const picker = {value: selected, selectedIndex: 0, options: [option]};
const fallback = {value: '', options: [option]};
globalThis.document = {getElementById: id => id === 'ge-ai-command-model' ? picker : fallback};
globalThis.localStorage = {getItem: () => {throw new Error('must not read stale storage');}};
assert.equal(resolveQuickEditModel().model, 'openai/gpt-5-image');
picker.value = '';
assert.equal(resolveQuickEditModel().model, '');
""".replace("MODULE", repr(module))
subprocess.run(["node", "--input-type=module", "-e", script], check=True)
def test_cloud_image_model_is_resolved_before_the_local_fallback(): def test_cloud_image_model_is_resolved_before_the_local_fallback():
+7 -6
View File
@@ -52,7 +52,7 @@ def test_quick_edit_flip_and_rotate_accept_numeric_degrees():
def test_quick_edit_transforms_do_not_add_a_second_undo_snapshot(): def test_quick_edit_transforms_do_not_add_a_second_undo_snapshot():
start = EDITOR.index("if (/\\brotate\\b.*\\b180\\b") start = EDITOR.index("if (/\\brotate\\b.*\\b180\\b")
end = EDITOR.index("if (_looksBackgroundRemoval(p)) {", start) end = EDITOR.index("if (!/\\b(text|title|caption|font|heading|subtitle)", start)
transform_block = EDITOR[start:end] transform_block = EDITOR[start:end]
assert "_saveState('Rotate" not in transform_block assert "_saveState('Rotate" not in transform_block
assert "_saveState('Flip" not in transform_block assert "_saveState('Flip" not in transform_block
@@ -372,9 +372,10 @@ def test_quick_edit_has_a_model_picker():
assert "function _getSelectedQuickEditModel()" in EDITOR assert "function _getSelectedQuickEditModel()" in EDITOR
assert "getModel: () => _getSelectedQuickEditModel()," in EDITOR assert "getModel: () => _getSelectedQuickEditModel()," in EDITOR
# Populated from the provider list and persisted per user. # Populated from the provider list and persisted per user.
assert "aiCommandSelect.innerHTML = '<option value=\"\">Auto (default)</option>';" in models assert "aiCommandSelect.innerHTML = '<option value=\"\">Same as generation</option>';" in models
assert "localStorage.setItem('ge-ai-command-model', aiCommandSelect.value)" in models assert "localStorage.setItem('ge-ai-command-model'" not in models
assert "localStorage.getItem('ge-ai-command-model')" in models assert "localStorage.getItem('ge-ai-command-model')" not in models
assert 'saveImagePreferences({ [key]: model })' in AI_SETTINGS
# Styled. # Styled.
assert ".ge-ai-settings-card" in STYLE assert ".ge-ai-settings-card" in STYLE
assert ".ge-ai-command-model" in STYLE assert ".ge-ai-command-model" in STYLE
@@ -698,7 +699,7 @@ def test_quick_edit_shows_a_real_progress_bar():
assert "fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}`" in EDITOR assert "fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}`" in EDITOR
# The edit request carries the request_id the server keys progress on. # The edit request carries the request_id the server keys progress on.
assert "const requestId = 'qe-' + Date.now().toString(36)" in EDITOR assert "const requestId = 'qe-' + Date.now().toString(36)" in EDITOR
assert "const body = JSON.stringify({ image, request_id: requestId, ...payload });" in EDITOR assert "const body = JSON.stringify({ image, width: flat.width, height: flat.height, request_id: requestId, ...payload });" in EDITOR
assert "_startAiProgress(requestId);" in EDITOR assert "_startAiProgress(requestId);" in EDITOR
assert "_stopAiProgress();" in EDITOR assert "_stopAiProgress();" in EDITOR
# Styling exists. # Styling exists.
@@ -718,7 +719,7 @@ def test_generation_503_is_handled_gracefully():
# (returns 503). Surface a clear message and do not retry immediately. # (returns 503). Surface a clear message and do not retry immediately.
assert "if (status === 503) return _GEN_DISABLED_MSG;" in EDITOR assert "if (status === 503) return _GEN_DISABLED_MSG;" in EDITOR
assert "_GEN_DISABLED_MSG = 'Image generation is switched off on the server at the moment.'" in EDITOR assert "_GEN_DISABLED_MSG = 'Image generation is switched off on the server at the moment.'" in EDITOR
assert "window.__geGenerationDisabled" in EDITOR assert "formatError: _generationErrorMessage" in EDITOR
def test_sending_a_prompt_returns_focus_to_the_document(): def test_sending_a_prompt_returns_focus_to_the_document():
-49
View File
@@ -1,49 +0,0 @@
import asyncio
import pytest
from fastapi import HTTPException
import routes.gallery_routes as gallery
def route(path, method):
return next(r for r in gallery.setup_gallery_routes().routes if r.path == path and method in r.methods)
def call(path, method, monkeypatch, body):
monkeypatch.setattr(gallery, 'get_current_user', lambda request: 'alice')
class Request:
async def json(self):
return body
return asyncio.run(route(path, method).endpoint(Request(), 'album-1'))
@pytest.mark.parametrize('body', [
{'name': ''},
{'name': ' '},
{'name': 123},
{'name': ['x']},
{'description': {'a': 1}},
[1],
])
def test_album_update_rejects_bad_input(body, monkeypatch):
with pytest.raises(HTTPException) as exc:
call('/api/gallery/albums/{album_id}', 'PUT', monkeypatch, body)
assert exc.value.status_code == 400
@pytest.mark.parametrize('op', ['add', 'remove'])
@pytest.mark.parametrize('body', [
{'image_ids': 'abc'},
{'image_ids': 5},
{'image_ids': None},
{'image_ids': [{'a': 1}]},
{'image_ids': ['ok', 1]},
[1],
])
def test_album_image_ids_must_be_a_list_of_strings(op, body, monkeypatch):
with pytest.raises(HTTPException) as exc:
call(f'/api/gallery/albums/{{album_id}}/{op}', 'POST', monkeypatch, body)
assert exc.value.status_code == 400
+70 -5
View File
@@ -5,6 +5,7 @@ import json
from types import SimpleNamespace from types import SimpleNamespace
import httpx import httpx
import pytest
from PIL import Image from PIL import Image
import routes.gallery_routes as gallery import routes.gallery_routes as gallery
@@ -16,7 +17,10 @@ def png(image):
return base64.b64encode(output.getvalue()).decode() return base64.b64encode(output.getvalue()).decode()
def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog): @pytest.mark.parametrize('model', ['openai/gpt-5-image', 'openai/gpt-image-2'])
@pytest.mark.parametrize('saved_settings', [False, True])
@pytest.mark.parametrize('full_frame', [False, True])
def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog, model, saved_settings, full_frame):
import logging import logging
caplog.set_level(logging.DEBUG) caplog.set_level(logging.DEBUG)
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', api_key='test') endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', api_key='test')
@@ -29,36 +33,97 @@ def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog):
monkeypatch.setattr(gallery, 'require_privilege', lambda *args: 'alice') monkeypatch.setattr(gallery, 'require_privilege', lambda *args: 'alice')
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *args: endpoint) monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *args: endpoint)
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *args, **kwargs: (True, '')) monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *args, **kwargs: (True, ''))
monkeypatch.setattr('src.editor_image_settings.load_settings', lambda: {'image_model':'wrong-generation-model', 'image_edit_model':model})
def resolve_model(selected, **kwargs):
assert selected == model
assert kwargs['owner'] == 'alice'
return endpoint.base_url + '/chat/completions', model, {}
monkeypatch.setattr('src.ai_interaction._resolve_model', resolve_model)
requests = [] requests = []
def respond(request): def respond(request):
requests.append(request) requests.append(request)
assert str(request.url) == endpoint.base_url + '/images' assert str(request.url) == endpoint.base_url + ('/chat/completions' if model == 'openai/gpt-5-image' else '/images')
payload = json.loads(request.content) payload = json.loads(request.content)
assert payload['model'] == 'openai/gpt-5-image' assert payload['model'] == model
if model == 'openai/gpt-5-image':
assert len(payload['messages'][0]['content']) == 3
else:
assert len(payload['input_references']) == 2 assert len(payload['input_references']) == 2
assert request.headers['authorization'] == 'Bearer test' assert request.headers['authorization'] == 'Bearer test'
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (2, 1), 'blue'))}]}) image = png(Image.new('RGB', (4, 4) if full_frame else (2, 1), 'blue'))
if model == 'openai/gpt-5-image':
return httpx.Response(200, json={'choices':[{'message':{'images':[{'image_url':{'url':'data:image/png;base64,'+image}}]}}]})
return httpx.Response(200, json={'data': [{'b64_json': image}]})
client = httpx.AsyncClient client = httpx.AsyncClient
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kwargs: client(transport=httpx.MockTransport(respond), **kwargs)) monkeypatch.setattr(httpx, 'AsyncClient', lambda **kwargs: client(transport=httpx.MockTransport(respond), **kwargs))
mask = Image.new('L', (2, 1)) mask = Image.new('L', (2, 1))
if full_frame:
mask.paste(255, (0,0,2,1))
mask.putpixel((1, 0), 255) mask.putpixel((1, 0), 255)
class Request: class Request:
async def json(self): async def json(self):
return {'_endpoint': endpoint.base_url, '_model': 'openai/gpt-5-image', return {'_endpoint': endpoint.base_url, '_model': 'stale-model' if saved_settings else model,
'_use_image_settings':saved_settings,
'image': png(Image.new('RGB', (2, 1), 'red')), 'mask': png(mask), 'prompt': 'private-cloud-prompt'} 'image': png(Image.new('RGB', (2, 1), 'red')), 'mask': png(mask), 'prompt': 'private-cloud-prompt'}
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint') route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
result = asyncio.run(route.endpoint(Request())) result = asyncio.run(route.endpoint(Request()))
output = Image.open(io.BytesIO(base64.b64decode(result['image']))) output = Image.open(io.BytesIO(base64.b64decode(result['image'])))
if full_frame:
assert output.size == (4,4)
assert result['full_frame'] is True
else:
assert output.getpixel((0, 0)) == (255, 0, 0, 255) assert output.getpixel((0, 0)) == (255, 0, 0, 255)
assert output.getpixel((1, 0)) == (0, 0, 255, 255) assert output.getpixel((1, 0)) == (0, 0, 255, 255)
assert len(requests) == 1 assert len(requests) == 1
assert 'private-cloud-prompt' not in caplog.text assert 'private-cloud-prompt' not in caplog.text
def test_partial_image_edit_preserves_ratio_and_unmasked_pixels():
source = Image.new('RGBA',(4,4),'red')
generated = Image.new('RGBA',(4,2),'blue')
mask = Image.new('L',(4,4),255)
mask.putpixel((0,0),0)
result, full = gallery._compose_cloud_image_edit(source,generated,mask)
assert full is False
assert result.size == (4,4)
assert result.getpixel((2,0)) == source.getpixel((2,0))
assert result.getpixel((2,3)) == source.getpixel((2,3))
assert result.getpixel((2,1)) == (0,0,255,255)
def test_quick_edit_inpaint_requests_source_ratio_without_width_height_fields(monkeypatch):
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1',api_key='test')
monkeypatch.setattr(gallery,'SessionLocal',lambda:SimpleNamespace(close=lambda:None))
monkeypatch.setattr(gallery,'require_privilege',lambda *args:'alice')
monkeypatch.setattr(gallery,'_visible_image_endpoint_for_base',lambda *args:endpoint)
monkeypatch.setattr('src.url_safety.check_outbound_url',lambda *args,**kwargs:(True,''))
sent = []
original = httpx.AsyncClient
def respond(request):
payload = json.loads(request.content)
sent.append(payload)
return httpx.Response(200,json={'data':[{'b64_json':png(Image.new('RGB',(1536,864),'blue'))}]})
monkeypatch.setattr(httpx,'AsyncClient',lambda **kwargs:original(transport=httpx.MockTransport(respond),**kwargs))
class Request:
async def json(self):
return {'_endpoint':endpoint.base_url,'_model':'openai/gpt-image-2','prompt':'change the sky',
'image':png(Image.new('RGB',(1280,720),'red')),'mask':png(Image.new('L',(1280,720),255))}
route = next(route for route in gallery.setup_gallery_routes().routes if route.path == '/api/image/inpaint')
result = asyncio.run(route.endpoint(Request()))
assert sent[0]['aspect_ratio'] == '16:9'
assert 'size' not in sent[0]
image = Image.open(io.BytesIO(base64.b64decode(result['image'])))
assert image.size == (1536,864)
def test_mixed_endpoint_requires_configured_image_model(monkeypatch): def test_mixed_endpoint_requires_configured_image_model(monkeypatch):
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', owner='alice', endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', owner='alice',
model_type='llm', cached_models='["openai/gpt-5-image", "text-model"]', pinned_models=None) model_type='llm', cached_models='["openai/gpt-5-image", "text-model"]', pinned_models=None)
-135
View File
@@ -1,135 +0,0 @@
"""Regression for #2123 (gallery part): library search must match multi-word
queries in any order and treat % / _ literally.
Old code built a single ``%{search}%`` LIKE over prompt/tags/ai_tags, so
"red car" missed "a car that is red", and a literal "%" matched every row.
"""
import uuid
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
import core.database as cdb
from core.database import GalleryImage
import routes.gallery_routes as gallery_routes
def _client_with_gallery(monkeypatch, tmp_path):
engine = create_engine(
f"sqlite:///{tmp_path / 'gallery_search.db'}",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
cdb.Base.metadata.create_all(engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
monkeypatch.setattr(gallery_routes, "SessionLocal", session_factory)
db = session_factory()
try:
db.add_all(
[
GalleryImage(
id="img-red-car",
filename=f"{uuid.uuid4().hex}.png",
prompt="a car that is red",
model="model-a",
tags="",
ai_tags="",
is_active=True,
file_size=10,
),
GalleryImage(
id="img-boat",
filename=f"{uuid.uuid4().hex}.png",
prompt="blue boat on the lake",
model="model-a",
tags="",
ai_tags="",
is_active=True,
file_size=10,
),
GalleryImage(
id="img-percent",
filename=f"{uuid.uuid4().hex}.png",
prompt="100% cotton shirt",
model="model-a",
tags="sale_2024",
ai_tags="",
is_active=True,
file_size=10,
),
GalleryImage(
id="img-backslash",
filename=f"{uuid.uuid4().hex}.png",
prompt=r"C:\photos\sunset",
model="model-a",
tags="",
ai_tags="",
is_active=True,
file_size=10,
),
]
)
db.commit()
finally:
db.close()
app = FastAPI()
app.include_router(gallery_routes.setup_gallery_routes())
return TestClient(app)
def test_gallery_search_matches_words_in_any_order(monkeypatch, tmp_path):
monkeypatch.setenv("AUTH_ENABLED", "false")
client = _client_with_gallery(monkeypatch, tmp_path)
body = client.get("/api/gallery/library", params={"search": "red car"}).json()
ids = {item["id"] for item in body["items"]}
assert "img-red-car" in ids
assert "img-boat" not in ids
def test_gallery_search_escapes_percent_wildcard(monkeypatch, tmp_path):
monkeypatch.setenv("AUTH_ENABLED", "false")
client = _client_with_gallery(monkeypatch, tmp_path)
body = client.get("/api/gallery/library", params={"search": "%"}).json()
ids = {item["id"] for item in body["items"]}
assert ids == {"img-percent"}
def test_gallery_search_escapes_underscore_wildcard(monkeypatch, tmp_path):
monkeypatch.setenv("AUTH_ENABLED", "false")
client = _client_with_gallery(monkeypatch, tmp_path)
# Only img-percent has a literal underscore (in tags "sale_2024").
# Unescaped, "_" matches any single char so every row matches.
body = client.get("/api/gallery/library", params={"search": "_"}).json()
ids = {item["id"] for item in body["items"]}
assert ids == {"img-percent"}
def test_gallery_search_escapes_backslash(monkeypatch, tmp_path):
monkeypatch.setenv("AUTH_ENABLED", "false")
client = _client_with_gallery(monkeypatch, tmp_path)
# Only img-backslash has a literal backslash. The escape char itself
# must survive escaping instead of escaping the surrounding wildcards.
body = client.get("/api/gallery/library", params={"search": "\\"}).json()
ids = {item["id"] for item in body["items"]}
assert ids == {"img-backslash"}
def test_gallery_tag_filter_escapes_wildcards(monkeypatch, tmp_path):
monkeypatch.setenv("AUTH_ENABLED", "false")
client = _client_with_gallery(monkeypatch, tmp_path)
# Unescaped, "%" / "_" match every row; escaped, only literal matches.
assert client.get("/api/gallery/library", params={"tag": "%"}).json()["items"] == []
body = client.get("/api/gallery/library", params={"tag": "_"}).json()
assert {item["id"] for item in body["items"]} == {"img-percent"}
+67 -10
View File
@@ -23,7 +23,7 @@ def test_native_generation_uses_configured_model_and_multiline_prompt(image_tran
json.dumps({'prompt': prompt}), session_id='fixture', owner='alice')) json.dumps({'prompt': prompt}), session_id='fixture', owner='alice'))
assert 'error' not in result assert 'error' not in result
assert seen == [('openai/gpt-5-image', 'alice')] assert seen == [('openai/gpt-5-image', 'alice')]
assert json.loads(requests[0].content)['prompt'] == prompt assert json.loads(requests[0].content)['messages'][0]['content'] == prompt
assert result['image_id'] assert result['image_id']
@@ -87,13 +87,17 @@ def image_transport(monkeypatch, tmp_path):
original_client = httpx.AsyncClient original_client = httpx.AsyncClient
requests = [] requests = []
def configure(base, status=200, body=None): def configure(base, status=200, body=None, model='openai/gpt-5-image'):
monkeypatch.setattr(ai_interaction, '_resolve_model', lambda *args, **kwargs: ( monkeypatch.setattr(ai_interaction, '_resolve_model', lambda *args, **kwargs: (
base + '/chat/completions', 'openai/gpt-5-image', {'Authorization': 'Bearer test'}, base + '/chat/completions', model, {'Authorization': 'Bearer test'},
)) ))
def handle(request): def handle(request):
requests.append(request) requests.append(request)
if body is None and 'openrouter.ai' in base and request.url.path.endswith('/chat/completions'):
return httpx.Response(status, json={'choices': [{'message': {'images': [{
'image_url': {'url': 'data:image/png;base64,' + base64.b64encode(source.read_bytes()).decode()},
}]}}]})
return httpx.Response(status, json=body if body is not None else { return httpx.Response(status, json=body if body is not None else {
'data': [{'b64_json': base64.b64encode(source.read_bytes()).decode()}], 'data': [{'b64_json': base64.b64encode(source.read_bytes()).decode()}],
}) })
@@ -130,12 +134,13 @@ def test_image_provider_protocol(image_transport, editing, base):
assert request.headers['authorization'] == 'Bearer test' assert request.headers['authorization'] == 'Bearer test'
if 'openrouter.ai' in base: if 'openrouter.ai' in base:
assert len(requests) == 1 # No unsupported local progress/fallback probes. assert len(requests) == 1 # No unsupported local progress/fallback probes.
assert str(request.url) == base + '/images' assert str(request.url) == base + '/chat/completions'
payload = json.loads(request.content) payload = json.loads(request.content)
assert payload['n'] == 1 assert payload['model'] == 'openai/gpt-5-image'
assert payload['modalities'] == ['image', 'text']
assert 'prompt' not in payload
if editing: if editing:
assert payload['size'] == '1536x1024' reference = payload['messages'][0]['content'][1]['image_url']['url']
reference = payload['input_references'][0]['image_url']['url']
assert reference.startswith('data:image/png;base64,') assert reference.startswith('data:image/png;base64,')
assert base64.b64decode(reference.split(',', 1)[1]) == source.read_bytes() assert base64.b64decode(reference.split(',', 1)[1]) == source.read_bytes()
assert 'request_id' not in payload assert 'request_id' not in payload
@@ -181,13 +186,65 @@ def test_edit_dimensions_respect_exif_and_explicit_size(image_transport, explici
exif = Image.Exif() exif = Image.Exif()
exif[274] = 6 exif[274] = 6
Image.new('RGB', (120, 80)).save(source, exif=exif) Image.new('RGB', (120, 80)).save(source, exif=exif)
configure('https://openrouter.ai/api/v1') configure('https://openrouter.ai/api/v1', model='openai/gpt-image-1')
result = asyncio.run(ai_interaction.do_edit_image( result = asyncio.run(ai_interaction.do_edit_image(
'Edit', str(source), model_spec='openai/gpt-5-image', 'Edit', str(source), model_spec='openai/gpt-image-1',
size='1024x1024' if explicit else 'auto', size='1024x1024' if explicit else 'auto',
)) ))
assert 'error' not in result assert 'error' not in result
assert json.loads(requests[0].content)['size'] == ('1024x1024' if explicit else '1024x1536') assert json.loads(requests[0].content)['aspect_ratio'] == ('1:1' if explicit else '2:3')
def test_native_openrouter_image_model_keeps_dedicated_image_api(image_transport):
configure, source, requests, rows = image_transport
configure('https://openrouter.ai/api/v1', model='openai/gpt-image-1')
result = asyncio.run(ai_interaction.do_generate_image('A cow\nopenai/gpt-image-1', incognito=True))
assert 'error' not in result
assert str(requests[0].url).endswith('/api/v1/images')
assert json.loads(requests[0].content)['model'] == 'openai/gpt-image-1'
@pytest.mark.parametrize('editing', [False, True])
def test_http_200_provider_deprecation_is_not_reported_as_empty_image(image_transport, editing):
configure, source, requests, rows = image_transport
configure('https://openrouter.ai/api/v1', body={'choices': [{
'finish_reason': 'error', 'message': {'content': None},
'error': {'code': 502, 'message': 'The model gpt-image-1 has been deprecated'},
}]})
if editing:
result = asyncio.run(ai_interaction.do_edit_image('Edit', str(source), model_spec='openai/gpt-5-image'))
else:
result = asyncio.run(ai_interaction.do_generate_image('A cow\nopenai/gpt-5-image'))
assert 'deprecated' in result['error']
assert 'openai/gpt-5-image' in result['error']
assert 'AI settings' in result['error']
assert len(requests) == 1
assert not rows
def test_empty_image_response_identifies_the_selected_model(image_transport):
configure, source, requests, rows = image_transport
configure('https://openrouter.ai/api/v1', body={'choices': [{'message': {'content': 'No image.'}}]})
result = asyncio.run(ai_interaction.do_generate_image('A cow\nopenai/gpt-5-image'))
assert "'openai/gpt-5-image' returned no image" in result['error']
assert 'AI settings' in result['error']
assert len(requests) == 1
@pytest.mark.parametrize('editing', [False,True])
def test_openrouter_1280_by_720_requests_16_by_9_not_square(image_transport, editing):
configure, source, requests, rows = image_transport
Image.new('RGB',(1280,720),'red').save(source)
configure('https://openrouter.ai/api/v1',model='openai/gpt-image-2')
if editing:
result = asyncio.run(ai_interaction.do_edit_image('Change the sky',str(source),model_spec='openai/gpt-image-2',incognito=True))
else:
result = asyncio.run(ai_interaction.do_generate_image(json.dumps({
'prompt':'a landscape','model':'openai/gpt-image-2','size':'1280x720'}),incognito=True))
assert 'error' not in result
body = json.loads(requests[0].content)
assert body['aspect_ratio'] == '16:9'
assert 'size' not in body
def test_unreadable_image_does_not_call_provider(image_transport): def test_unreadable_image_does_not_call_provider(image_transport):
-98
View File
@@ -1,98 +0,0 @@
"""Actual embedded PDF images must reach vision as a decodable PNG."""
from pathlib import Path
from PIL import Image
from pypdf import PdfReader, PdfWriter
from pypdf.generic import DecodedStreamObject, DictionaryObject, NameObject, NumberObject
import pytest
from src import document_processor as dp
def make_pdf(tmp_path, mode):
colors = {"CMYK": (0, 255, 255, 0), "RGB": (255, 0, 0), "L": 127}
path = tmp_path / (mode + ".pdf")
image = Image.new(mode, (128, 96), colors[mode])
if mode == "CMYK":
# A raw DeviceCMYK XObject avoids JPEG/Adobe inversion differences in
# Pillow-generated PDFs; the extracted color samples are unambiguous.
writer = PdfWriter()
page = writer.add_blank_page(width=128, height=96)
stream = DecodedStreamObject()
stream.set_data(image.tobytes())
stream.update({NameObject(key): value for key, value in {
"/Type": NameObject("/XObject"), "/Subtype": NameObject("/Image"),
"/ColorSpace": NameObject("/DeviceCMYK"), "/BitsPerComponent": NumberObject(8),
"/Width": NumberObject(128), "/Height": NumberObject(96),
}.items()})
page[NameObject("/Resources")] = DictionaryObject({NameObject("/XObject"):
DictionaryObject({NameObject("/Im0"): writer._add_object(stream)})})
content = DecodedStreamObject()
content.set_data(b"q 128 0 0 96 0 0 cm /Im0 Do Q")
page[NameObject("/Contents")] = writer._add_object(content)
writer.write(path)
else:
image.save(path, "PDF")
assert PdfReader(path).pages[0].images[0].image.mode == mode
return path
@pytest.mark.parametrize("mode,expected", [("CMYK", "RGB"), ("RGB", "RGB"), ("L", "L")])
def test_pdf_image_reaches_vision_with_owner_and_png_pixels(tmp_path, monkeypatch, mode, expected):
pdf = make_pdf(tmp_path, mode)
calls = []
def analyze(path, owner=None):
with Image.open(path) as image:
image.load()
assert image.format == "PNG" and image.mode == expected
assert image.size == (128, 96)
if mode == "CMYK":
red, green, blue = image.getpixel((64, 48))
assert red > 250 and green < 5 and blue < 5
calls.append((Path(path), owner))
return "Synthetic OCR result"
monkeypatch.setattr(dp, "analyze_image_with_vl", analyze)
output = dp._process_pdf(str(pdf), owner="alice")
assert "[Page 1 image 1 text]: Synthetic OCR result" in output
assert len(calls) == 1 and calls[0][1] == "alice"
assert not calls[0][0].exists(), "Temporary PNG must be removed after analysis"
assert PdfReader(pdf).pages[0].images[0].image.mode == mode, "Source PDF is unchanged"
def test_mixed_pdf_keeps_images_on_both_pages(tmp_path, monkeypatch):
writer = PdfWriter()
for mode in ("CMYK", "RGB"):
writer.append(str(make_pdf(tmp_path, mode)))
pdf = tmp_path / "mixed.pdf"
writer.write(pdf)
calls = []
def analyze(path, owner=None):
with Image.open(path) as image:
image.load()
calls.append(image.mode)
return "Image content"
monkeypatch.setattr(dp, "analyze_image_with_vl", analyze)
output = dp._process_pdf(str(pdf), owner="alice")
assert calls == ["RGB", "RGB"]
assert "Page 1 image 1 text" in output and "Page 2 image 1 text" in output
def test_text_only_extraction_does_not_enable_vision(tmp_path, monkeypatch):
pdf = make_pdf(tmp_path, "CMYK")
def unexpected(*args, **kwargs):
pytest.fail("Text-only extraction must not call vision")
monkeypatch.setattr(dp, "analyze_image_with_vl", unexpected)
assert "no readable content" in dp.extract_local_document(str(pdf), owner="alice")
def test_vision_failure_still_removes_converted_png(tmp_path, monkeypatch):
pdf = make_pdf(tmp_path, "CMYK")
paths = []
def unavailable(path, owner=None):
paths.append(Path(path))
with Image.open(path) as image:
assert image.mode == "RGB" and image.format == "PNG"
raise RuntimeError("Synthetic unavailable vision endpoint")
monkeypatch.setattr(dp, "analyze_image_with_vl", unavailable)
assert "no readable content" in dp._process_pdf(str(pdf), owner="alice")
assert len(paths) == 1 and not paths[0].exists()
-108
View File
@@ -1,108 +0,0 @@
"""Route defaults must use manually pinned IDs without widening ownership."""
import json
from types import SimpleNamespace
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import core.database as cdb
import routes.chat_routes as chat
from routes.task.task_routes import _resolve_run_endpoint
from routes.research.research_routes import _resolve_endpoint_runtime
@pytest.fixture
def database(monkeypatch):
engine = create_engine("sqlite:///:memory:")
cdb.Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
monkeypatch.setattr(chat, "SessionLocal", factory)
with factory() as db:
yield db
engine.dispose()
def endpoint(db, eid="owned", *, owner="alice", cached=(), pinned=("pinned-chat",), hidden=()):
row = cdb.ModelEndpoint(
id=eid, name=eid, owner=owner, is_enabled=True,
base_url=f"https://{eid}.example.test/v1", api_key=None,
cached_models=json.dumps(cached), pinned_models=json.dumps(pinned),
hidden_models=json.dumps(hidden),
)
db.add(row)
db.commit()
return row
@pytest.mark.parametrize("cached", [(), ("hidden-chat",)])
def test_empty_session_recovers_a_visible_pinned_model(database, cached):
ep = endpoint(database, cached=cached, hidden=("hidden-chat",))
database.add(cdb.Session(id="chat", name="Chat", owner="alice", model="", endpoint_url=ep.base_url, endpoint_id=ep.id))
database.commit()
session = SimpleNamespace(model="", endpoint_url=ep.base_url, endpoint_id=ep.id)
assert chat._recover_empty_session_model(session, "chat", "alice") is True
assert session.model == "pinned-chat"
database.expire_all()
assert database.get(cdb.Session, "chat").model == "pinned-chat"
def test_empty_session_does_not_recover_a_hidden_pin(database):
ep = endpoint(database, hidden=("pinned-chat",))
session = SimpleNamespace(model="", endpoint_url=ep.base_url, endpoint_id=ep.id)
assert chat._recover_empty_session_model(session, "missing", "alice") is False
assert session.model == ""
def test_subscription_recovery_still_requires_its_live_catalog(database):
ep = endpoint(database)
ep.base_url = "https://chatgpt.com/backend-api/codex"
database.commit()
session = SimpleNamespace(model="", endpoint_url=ep.base_url, endpoint_id=ep.id)
assert chat._recover_empty_session_model(session, "missing", "alice") is False
assert session.model == ""
def test_task_run_reopens_against_its_owners_pinned_endpoint(database):
endpoint(database, "foreign", owner="bob", cached=("pinned-chat",))
own = endpoint(database)
task = SimpleNamespace(owner="alice", endpoint_url="", session_id=None, model="pinned-chat")
assert _resolve_run_endpoint(database, task, SimpleNamespace(model="pinned-chat")) == own.base_url
@pytest.mark.parametrize("owner", ["alice", None])
def test_task_run_can_find_visible_pins_on_owned_or_shared_endpoints(database, owner):
ep = endpoint(database, owner=owner)
task = SimpleNamespace(owner="alice", endpoint_url="", session_id=None, model="pinned-chat")
assert _resolve_run_endpoint(database, task, SimpleNamespace(model=None)) == ep.base_url
def test_task_run_does_not_autoselect_a_hidden_pin(database):
endpoint(database, hidden=("pinned-chat",))
task = SimpleNamespace(owner="alice", endpoint_url="", session_id=None, model="pinned-chat")
assert _resolve_run_endpoint(database, task, SimpleNamespace(model="pinned-chat")) == ""
@pytest.mark.parametrize("cached", [(), ("hidden-chat",)])
def test_research_default_uses_the_visible_pinned_id(database, cached):
ep = endpoint(database, cached=cached, hidden=("hidden-chat",))
assert _resolve_endpoint_runtime(ep, owner="alice") == (
ep.base_url + "/chat/completions", "pinned-chat", {},
)
def test_research_default_does_not_autoselect_a_hidden_pin(database):
ep = endpoint(database, hidden=("pinned-chat",))
assert _resolve_endpoint_runtime(ep, owner="alice") is None
def test_research_explicit_model_selection_is_preserved(database):
ep = endpoint(database, cached=("cached-chat",))
assert _resolve_endpoint_runtime(ep, owner="alice", model="explicit-chat")[1] == "explicit-chat"
def test_research_cached_default_keeps_priority_over_pins(database):
ep = endpoint(database, cached=("cached-chat",))
assert _resolve_endpoint_runtime(ep, owner="alice")[1] == "cached-chat"
-167
View File
@@ -137,170 +137,3 @@ def test_collect_aggregate_deadline_yields_controlled_result(monkeypatch):
net = [s for s in out["services"] if s["name"] != "chromadb"] net = [s for s in out["services"] if s["name"] != "chromadb"]
assert all(s["status"] == sh.DOWN and s["meta"].get("error") == "timeout" assert all(s["status"] == sh.DOWN and s["meta"].get("error") == "timeout"
for s in net) for s in net)
# ── Discovery failure is not "disabled" (#6154) ──
#
# `_gather_inputs()` fails soft to an empty inventory, and every probe reads an
# empty inventory as `disabled`. Without a record of *why* it is empty, a broken
# integrations file / email store / endpoint DB is reported as an intentionally
# unconfigured subsystem — and, with the rest disabled, `overall: "ok"`.
def _boom(*_a, **_k):
raise RuntimeError("secret-bearing detail: postgres://u:p@db/x")
def test_gather_inputs_records_each_failed_source(monkeypatch):
import core.database
import routes.email_helpers
import src.integrations
import src.settings
monkeypatch.setattr(src.settings, "load_settings", lambda: {"a": 1})
monkeypatch.setattr(src.integrations, "load_integrations", _boom)
monkeypatch.setattr(routes.email_helpers, "_list_email_accounts", _boom)
monkeypatch.setattr(core.database, "SessionLocal", _boom)
out = sh._gather_inputs()
assert out["settings"] == {"a": 1}
assert set(out["failed"]) == {"integrations", "accounts", "endpoints"}
# Controlled categories only — never the exception text.
assert set(out["failed"].values()) <= set(sh._ERROR_DETAIL)
assert "postgres" not in repr(out)
def test_gather_inputs_reports_no_failures_when_sources_load(monkeypatch):
import core.database
import routes.email_helpers
import src.integrations
import src.settings
class _DB:
def query(self, *_a):
return self
def filter(self, *_a):
return self
def all(self):
return []
def close(self):
pass
monkeypatch.setattr(src.settings, "load_settings", lambda: {})
monkeypatch.setattr(src.integrations, "load_integrations", lambda: [])
monkeypatch.setattr(routes.email_helpers, "_list_email_accounts", lambda: [])
monkeypatch.setattr(core.database, "SessionLocal", lambda: _DB())
assert sh._gather_inputs()["failed"] == {}
@pytest.mark.parametrize("source,service", [
("settings", "searxng"),
("integrations", "ntfy"),
("accounts", "email"),
("endpoints", "providers"),
])
def test_failed_source_is_not_reported_as_disabled(monkeypatch, source, service):
import asyncio
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
"settings": {"search_provider": "disabled"},
"integrations": [], "accounts": [], "endpoints": [],
"failed": {source: "network_error"},
})
out = asyncio.run(sh.collect_service_health(None, None))
entry = next(s for s in out["services"] if s["name"] == service)
assert entry["status"] != sh.DISABLED
assert entry["meta"]["error"] == sh.CONFIG_SOURCE_ERROR
assert entry["meta"]["source"] == source
assert entry["meta"]["source_error"] == "network_error"
# A failed source must reach the aggregate verdict.
assert out["overall"] != sh.OK
assert set(out) == {"overall", "services", "timestamp"}
def test_all_sources_failing_cannot_report_overall_ok(monkeypatch):
# The exact shape from the report: settings loads and says search is off,
# the other three sources raise. Before the fix this was overall "ok" with
# five "disabled" entries.
import asyncio
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
"settings": {"search_provider": "disabled"},
"integrations": [], "accounts": [], "endpoints": [],
"failed": {"integrations": "error", "accounts": "error",
"endpoints": "error"},
})
out = asyncio.run(sh.collect_service_health(None, None))
by_name = {s["name"]: s for s in out["services"]}
assert out["overall"] == sh.DEGRADED
# Loaded-and-genuinely-empty still reads as disabled.
assert by_name["searxng"]["status"] == sh.DISABLED
for name in ("ntfy", "email", "providers"):
assert by_name[name]["status"] == sh.DEGRADED
def test_empty_inventory_without_failure_stays_disabled(monkeypatch):
import asyncio
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
"settings": {"search_provider": "disabled"},
"integrations": [], "accounts": [], "endpoints": [], "failed": {},
})
out = asyncio.run(sh.collect_service_health(_Store(True), _Store(True)))
assert out["overall"] == sh.OK
assert all(s["status"] == sh.DISABLED
for s in out["services"] if s["name"] != "chromadb")
def test_one_failed_source_still_probes_the_others(monkeypatch):
# A broken endpoint DB must not stop ntfy/email from being probed.
import asyncio
probed = []
def _probe(name):
def _fn(*_a, **_k):
probed.append(name)
return sh._svc(name, sh.OK, "probed")
return _fn
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
"settings": {}, "integrations": [], "accounts": [], "endpoints": [],
"failed": {"endpoints": "timeout"},
})
monkeypatch.setattr(sh, "searxng_health", _probe("searxng"))
monkeypatch.setattr(sh, "ntfy_health", _probe("ntfy"))
monkeypatch.setattr(sh, "email_health", _probe("email"))
monkeypatch.setattr(sh, "providers_health", _probe("providers"))
out = asyncio.run(sh.collect_service_health(None, None))
assert sorted(probed) == ["email", "ntfy", "searxng"]
assert "providers" not in probed
by_name = {s["name"]: s for s in out["services"]}
assert by_name["providers"]["status"] == sh.DEGRADED
assert by_name["ntfy"]["status"] == sh.OK
def test_source_failure_entry_carries_no_raw_detail(monkeypatch):
import asyncio
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
"settings": {}, "integrations": [], "accounts": [], "endpoints": [],
"failed": {"accounts": "auth_or_protocol_error"},
})
out = asyncio.run(sh.collect_service_health(None, None))
entry = next(s for s in out["services"] if s["name"] == "email")
assert entry["detail"] == (
"Could not read configuration (configuration source could not be read).")
assert set(entry["meta"]) == {"error", "source", "source_error"}
def test_collect_tolerates_inputs_without_a_failed_key(monkeypatch):
# `_gather_inputs()` is monkeypatched in a number of existing tests (and by
# anything calling the collector with a stub); a payload predating `failed`
# must still probe normally rather than raise.
import asyncio
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
"settings": {"search_provider": "disabled"},
"integrations": [], "accounts": [], "endpoints": [],
})
out = asyncio.run(sh.collect_service_health(_Store(True), _Store(True)))
assert out["overall"] == sh.OK
+1 -42
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import sessionmaker
from core.database import Base from core.database import Base
from core.database import ChatMessage as DbChatMessage from core.database import ChatMessage as DbChatMessage
from core.database import Session as DbSession from core.database import Session as DbSession
from src.session_search import SessionSearchResult, _snippet, search_session_messages from src.session_search import SessionSearchResult, search_session_messages
def _db(with_fts=True): def _db(with_fts=True):
@@ -61,47 +61,6 @@ def _has_fts(db):
) )
def test_snippet_locates_quoted_phrase_instead_of_raw_query():
content = "A" * 100 + "The session search result is useful." + "Z" * 100
snippet = _snippet(content, '"session search"', radius=20)
assert "session search" in snippet
assert snippet.startswith("...")
assert snippet.endswith("...")
def test_snippet_locates_punctuation_bearing_term_case_insensitively():
content = "A" * 100 + "Use FOO_BAR.py for routing." + "Z" * 100
snippet = _snippet(content, "foo_bar.py!", radius=20)
assert "FOO_BAR.py" in snippet
def test_snippet_uses_first_meaningful_matching_term():
content = "A" * 100 + "second appears here; first appears later." + "Z" * 100
snippet = _snippet(content, "first second", radius=12)
assert "second appears" in snippet
assert "first appears" not in snippet
def test_snippet_falls_back_to_content_start_when_no_term_matches():
content = "Beginning of the transcript, followed by more detail."
assert _snippet(content, '"missing phrase"', radius=10) == content[:20]
def test_snippet_preserves_boundary_markers_and_truncation():
content = "A" * 50 + "target" + "Z" * 50
snippet = _snippet(content, "target", radius=5)
assert snippet == "...AAAAAtargetZZZZZ..."
def test_session_search_uses_fts_and_returns_context(): def test_session_search_uses_fts_and_returns_context():
db = _db(with_fts=True) db = _db(with_fts=True)
try: try:
@@ -1,54 +0,0 @@
"""Inline frontmatter lists must preserve quoted values across save/load cycles."""
import importlib.util
from pathlib import Path
import sys
import unittest
# Load the dependency-free formatter without starting the services package.
_PATH = Path(__file__).resolve().parents[1] / "services" / "memory" / "skill_format.py"
_SPEC = importlib.util.spec_from_file_location("_skill_list_format_under_test", _PATH)
_FORMAT = importlib.util.module_from_spec(_SPEC)
sys.modules[_SPEC.name] = _FORMAT
_SPEC.loader.exec_module(_FORMAT)
class FrontmatterListEscapesTests(unittest.TestCase):
def test_skill_tags_survive_repeated_save_load_cycles(self):
tags = ['review "release, notes', 'use "search", then summarize', 'publish']
skill = _FORMAT.Skill(name="release-review", tags=tags)
for _ in range(5):
skill = _FORMAT.Skill.from_markdown(skill.to_markdown())
self.assertEqual(skill.tags, tags)
def test_escaped_quotes_preserve_each_list_field(self):
for field in ("tags", "platforms", "requires_toolsets", "fallback_for_toolsets"):
with self.subTest(field=field):
values = ['review "release, notes', 'publish']
skill = _FORMAT.Skill(name="release-review", **{field: values})
restored = _FORMAT.Skill.from_markdown(skill.to_markdown())
self.assertEqual(getattr(restored, field), values)
def test_backslash_parity_does_not_hide_quotes_or_list_separators(self):
for count in range(1, 5):
with self.subTest(backslashes=count):
values = ['review ' + '\\' * count + '"release, notes', 'path, ' + '\\' * count, 'publish']
text = _FORMAT.emit_frontmatter({"tags": values})
parsed, _ = _FORMAT.parse_frontmatter(f"---\n{text}\n---\n")
self.assertEqual(parsed["tags"], values)
def test_single_quoted_backslashes_remain_literal(self):
text = "---\ntags: ['C:\\', next]\n---\n"
parsed, _ = _FORMAT.parse_frontmatter(text)
self.assertEqual(parsed["tags"], ["C:\\", "next"])
def test_nested_lists_preserve_quoted_brackets_and_commas(self):
values = [['review "], notes', 'publish'], 'archive']
text = _FORMAT.emit_frontmatter({"tags": values})
parsed, _ = _FORMAT.parse_frontmatter(f"---\n{text}\n---\n")
self.assertEqual(parsed["tags"], values)
if __name__ == "__main__":
unittest.main()
-83
View File
@@ -1,83 +0,0 @@
"""Renaming a skill onto a name the owner already uses must be refused.
``update_skill`` only checked whether the target directory existed. A
skill in another category with the wanted name lives in a different
directory, so the rename went through and the owner ended up with two
skills sharing one name. Update and delete look skills up by name, so the
next DELETE removed whichever of the two ``os.walk`` reached first, which
was often the one the user did not pick.
"""
import pytest
from routes.skills_routes import SkillUpdateRequest, setup_skills_routes
from services.memory.skills import SkillsManager
from tests.test_skills_routes_owner_update import _request, _route_handler, _write_skill_md
def _two_skills(tmp_path, owner="alice"):
mgr = SkillsManager(str(tmp_path))
mgr.add_skill(name="deploy-app", description="Deploy the web app", category="ops",
source="user", procedure=["ship it"], owner=owner)
mgr.add_skill(name="draft-notes", description="Draft meeting notes", category="writing",
source="user", procedure=["write"], owner=owner)
return mgr
def _names(mgr, owner="alice"):
return sorted((s["name"], s["category"]) for s in mgr.load(owner=owner))
def test_rename_onto_a_name_in_another_category_is_refused(tmp_path):
mgr = _two_skills(tmp_path)
assert mgr.update_skill("draft-notes", {"name": "deploy-app"}, owner="alice") is False
assert _names(mgr) == [("deploy-app", "ops"), ("draft-notes", "writing")]
def test_delete_after_refused_rename_removes_only_the_named_skill(tmp_path):
mgr = _two_skills(tmp_path)
mgr.update_skill("draft-notes", {"name": "deploy-app"}, owner="alice")
assert mgr.delete_skill("draft-notes", owner="alice") is True
assert _names(mgr) == [("deploy-app", "ops")]
def test_rename_to_a_free_name_still_moves_the_skill(tmp_path):
mgr = _two_skills(tmp_path)
assert mgr.update_skill("draft-notes", {"name": "meeting-notes"}, owner="alice") is True
assert _names(mgr) == [("deploy-app", "ops"), ("meeting-notes", "writing")]
def test_another_owners_skill_does_not_block_the_rename(tmp_path):
skills_root = tmp_path / "skills"
_write_skill_md(skills_root, "bob-cat", "caveman-mode", "bob")
_write_skill_md(skills_root, "alice-cat", "terse-mode", "alice")
mgr = SkillsManager(str(tmp_path))
assert mgr.update_skill("terse-mode", {"name": "caveman-mode"}, owner="alice") is True
assert [s["name"] for s in mgr.load(owner="alice")] == ["caveman-mode"]
assert [s["name"] for s in mgr.load(owner="bob")] == ["caveman-mode"]
def test_keeping_the_same_name_is_not_a_conflict(tmp_path):
mgr = _two_skills(tmp_path)
assert mgr.update_skill("draft-notes", {"name": "Draft Notes", "status": "published"},
owner="alice") is True
assert [s["status"] for s in mgr.load(owner="alice") if s["name"] == "draft-notes"] == ["published"]
@pytest.mark.asyncio
async def test_put_route_answers_409_for_a_taken_name(tmp_path):
from fastapi import HTTPException
mgr = _two_skills(tmp_path)
update_route = _route_handler(setup_skills_routes(mgr), "/api/skills/{skill_id}", "PUT")
with pytest.raises(HTTPException) as exc:
await update_route(_request("alice"), "draft-notes", SkillUpdateRequest(name="deploy-app"))
assert exc.value.status_code == 409
assert _names(mgr) == [("deploy-app", "ops"), ("draft-notes", "writing")]
-99
View File
@@ -27,102 +27,3 @@ def test_dsml_tool_markup_strips_to_empty_visible_text():
def test_chunk_split_tool_call_prefix_is_buffered_and_not_persisted(): def test_chunk_split_tool_call_prefix_is_buffered_and_not_persisted():
assert _streamed_tool_markup_starts('I will search<tool_cal') assert _streamed_tool_markup_starts('I will search<tool_cal')
assert _strip_incomplete_tool_markup_tail('I will search<tool_cal') == 'I will search' assert _strip_incomplete_tool_markup_tail('I will search<tool_cal') == 'I will search'
def test_bare_less_than_and_comparison_operators_false_alarm_recognition():
# A bare "<" starts buffering because it could be a tool-call tag prefix like "<tool_call>"
assert _streamed_tool_markup_starts(' <')
assert _streamed_tool_markup_starts('x <')
assert _streamed_tool_markup_starts('<tool_call')
assert _streamed_tool_markup_starts('<invoke')
# Once subsequent characters arrive and cannot become a tool-call tag, false alarm is detected
assert not _streamed_tool_markup_starts(' < 2')
assert not _streamed_tool_markup_starts('x < 2')
assert not _streamed_tool_markup_starts('len(items) < 2')
assert not _streamed_tool_markup_starts(' < 2:')
assert not _streamed_tool_markup_starts('x <= y')
assert not _streamed_tool_markup_starts('a < b')
async def test_stream_agent_loop_does_not_truncate_at_less_than_operator(monkeypatch):
import json
import src.agent_loop as al
async def fake_stream(*args, **kwargs):
for d in ["def is_short(items):\n", " return len(items)", " <", " 2\n"]:
yield "data: " + json.dumps({"delta": d}) + "\n\n"
yield "data: [DONE]\n\n"
monkeypatch.setattr(al, "get_setting", lambda k, d=None: d, raising=False)
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
monkeypatch.setattr(al, "stream_llm_with_fallback", fake_stream, raising=False)
deltas = []
async for chunk in al.stream_agent_loop(
"http://local.test/v1",
"qwen3.6-35b-a3b-heretic",
[{"role": "user", "content": "Write python function"}],
max_rounds=1,
owner="test_user",
):
for line in chunk.split("\n"):
line = line.strip()
if line.startswith("data: ") and line[6:] != "[DONE]":
try:
payload = json.loads(line[6:])
if "delta" in payload:
deltas.append(payload["delta"])
except Exception:
pass
full_text = "".join(deltas)
assert full_text == "def is_short(items):\n return len(items) < 2\n"
async def test_stream_agent_loop_still_hides_real_tool_markup(monkeypatch):
import json
import src.agent_loop as al
async def fake_stream(*args, **kwargs):
chunks = [
"Sure.",
" <",
"tool",
"_call",
'>{"name":"host_shell","arguments":{"command":"pwd"}}',
"</",
"tool",
"_call",
">",
" done",
]
for c in chunks:
yield "data: " + json.dumps({"delta": c}) + "\n\n"
yield "data: [DONE]\n\n"
monkeypatch.setattr(al, "get_setting", lambda k, d=None: d, raising=False)
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
monkeypatch.setattr(al, "stream_llm_with_fallback", fake_stream, raising=False)
deltas = []
async for chunk in al.stream_agent_loop(
"http://local.test/v1",
"qwen3.6-35b-a3b-heretic",
[{"role": "user", "content": "run command"}],
max_rounds=1,
owner="test_user",
):
for line in chunk.split("\n"):
line = line.strip()
if line.startswith("data: ") and line[6:] != "[DONE]":
try:
payload = json.loads(line[6:])
if "delta" in payload:
deltas.append(payload["delta"])
except Exception:
pass
assert "".join(deltas) == "Sure. done"
-132
View File
@@ -1,132 +0,0 @@
"""Issue #5573 — the vision model's description must not read as the user's own words.
For a text-only chat model the description comes from a separate vision call and
is appended to the user's message. Unlabelled, the chat model answers the prose
instead of the image — reviewing the user's "writing" back to them.
"""
import importlib
from types import SimpleNamespace
import pytest
DESCRIPTION = "A chocolate cake, plated with a mirror glaze and gold leaf."
QUESTION = "What am I looking at?"
class _UploadHandler:
def __init__(self, file_info):
self._file_info = file_info
def resolve_upload(self, att_id, owner=None):
return self._file_info if att_id == self._file_info["id"] else None
def is_image_file(self, *_args, **_kwargs):
return True
def _no_vision_call(*_args, **_kwargs):
raise AssertionError("the vision model must not be called on this path")
@pytest.fixture
def vision_env(monkeypatch, tmp_path):
"""Text-only chat model with one image attachment, no DB and no real vision call.
Other tests drop src.chat_handler from sys.modules, so resolve it here rather
than at import time — otherwise the patches land on a different module object
than the one under test.
"""
module = importlib.import_module("src.chat_handler")
monkeypatch.setattr(module, "UPLOAD_DIR", str(tmp_path))
monkeypatch.setattr(module, "model_supports_vision", lambda *a, **k: False)
monkeypatch.setattr(module, "_sync_upload_vision_to_gallery", lambda *a, **k: None)
monkeypatch.setattr(module, "build_user_content", lambda text, *a, **k: text)
monkeypatch.setattr(
importlib.import_module("src.settings"), "get_setting", lambda key, default=None: default
)
return SimpleNamespace(module=module, tmp_path=tmp_path, file_info={
"id": "img-1", "name": "cake.jpg", "path": str(tmp_path / "cake.jpg"),
"mime": "image/jpeg", "size": 1, "hash": "h",
})
async def _preprocess(env):
handler = env.module.ChatHandler(
session_manager=None, memory_manager=None, chat_processor=None,
research_handler=None, preset_manager=None,
upload_handler=_UploadHandler(env.file_info),
)
sess = SimpleNamespace(model="chat-model", endpoint_url="", owner="user", id="s1")
enhanced, *_ = await handler.preprocess_message(
QUESTION, ["img-1"], sess, auto_opened_docs=[],
)
return enhanced
def _describe(env, monkeypatch, text):
monkeypatch.setattr(env.module, "analyze_image_with_vl_result",
lambda *a, **k: {"text": text, "model": "vl-model"})
def _label_above(enhanced, description):
"""The line the description is introduced by."""
return enhanced.split(description)[0].rstrip("\n").rsplit("\n", 1)[-1]
@pytest.mark.asyncio
async def test_description_is_marked_as_not_the_users_message(vision_env, monkeypatch):
_describe(vision_env, monkeypatch, DESCRIPTION)
enhanced = await _preprocess(vision_env)
assert QUESTION in enhanced, "the user's own words must survive untouched"
label = _label_above(enhanced, DESCRIPTION)
assert label.startswith("[") and "user" in label.lower(), (
"the description is appended as bare prose, so the chat model reads it as "
f"the user's own writing. Got:\n{enhanced}"
)
@pytest.mark.asyncio
async def test_cached_description_is_labelled_too(vision_env, monkeypatch):
"""The cache holds an auto description or a hand-corrected one, and the label
has to be true either way — it is a description of the image regardless."""
cache = vision_env.tmp_path / ".vision"
cache.mkdir()
(cache / "img-1.txt").write_text(DESCRIPTION, encoding="utf-8")
monkeypatch.setattr(vision_env.module, "analyze_image_with_vl_result", _no_vision_call)
enhanced = await _preprocess(vision_env)
label = _label_above(enhanced, DESCRIPTION)
assert label.startswith("[") and "user" in label.lower(), (
f"a cached description is injected with no label. Got:\n{enhanced}"
)
@pytest.mark.asyncio
async def test_client_side_image_marker_is_preserved(vision_env, monkeypatch):
"""chatRenderer.js and sessions.js strip the block by matching `[Image: <name>]\\n`.
Renaming that marker leaves the raw description rendered in the user's bubble.
"""
_describe(vision_env, monkeypatch, DESCRIPTION)
assert "[Image: cake.jpg]\n" in await _preprocess(vision_env)
@pytest.mark.asyncio
async def test_no_dangling_label_when_the_vision_model_returns_nothing(vision_env, monkeypatch):
_describe(vision_env, monkeypatch, "")
enhanced = await _preprocess(vision_env)
assert enhanced.endswith("[Image: cake.jpg]\n"), (
"an empty description must leave the bare marker, trailing newline included "
f"— the client's strip regex needs it. Got:\n{enhanced!r}"
)
@pytest.mark.asyncio
async def test_vision_capable_model_path_is_unchanged(vision_env, monkeypatch):
monkeypatch.setattr(vision_env.module, "model_supports_vision", lambda *a, **k: True)
monkeypatch.setattr(vision_env.module, "analyze_image_with_vl_result", _no_vision_call)
assert await _preprocess(vision_env) == f"{QUESTION}\n\n[Image attached: cake.jpg]"