mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-30 17:52:26 +01:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61051d28f0 | ||
|
|
d87ae2a7f5 | ||
|
|
0e49c4ef40 | ||
|
|
1aff71836b | ||
|
|
b1c3125865 | ||
|
|
dbee1155e6 | ||
|
|
56c7cdf804 | ||
|
|
fe5d5caf34 | ||
|
|
bb654482f3 | ||
|
|
f45724faed | ||
|
|
6fc6095aec | ||
|
|
ef16398c5c | ||
|
|
6037707430 | ||
|
|
5c542d03ac | ||
|
|
3b8c60ac1e | ||
|
|
d237c3fbba | ||
|
|
fe81c491cd | ||
|
|
6f5c28b345 | ||
|
|
aa2db8d87c | ||
|
|
7fd4e0e9bb | ||
|
|
ad3e309074 | ||
|
|
50b05bf54f | ||
|
|
a7c03f85bf |
@@ -256,3 +256,10 @@ jobs:
|
||||
env:
|
||||
PYTEST_SHARD: ${{ matrix.shard }}
|
||||
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
|
||||
|
||||
- name: Playwright mobile layout checks (shard 1/4 only)
|
||||
if: steps.docs-check.outputs.docs_only != 'true' && matrix.shard == '1/4'
|
||||
run: |
|
||||
ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \
|
||||
--config tests/e2e/ios-layout.config.cjs \
|
||||
--reporter=line
|
||||
|
||||
+5
-1
@@ -9,7 +9,11 @@
|
||||
"test:photo-editor": "playwright test --config tests/e2e/playwright.config.js",
|
||||
"test:photo-editor:install": "playwright install chromium firefox webkit",
|
||||
"test:photo-editor:firefox": "PHOTO_EDITOR_E2E_BROWSER=firefox playwright test --config tests/e2e/playwright.config.js",
|
||||
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js"
|
||||
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js",
|
||||
"test:ios-layout": "playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||
"test:ios-layout:chromium": "ODYSSEUS_LAYOUT_BROWSER=chromium playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||
"test:ios-layout:webkit": "ODYSSEUS_LAYOUT_BROWSER=webkit playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||
"test:ios-layout:install": "playwright install chromium webkit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antithesishq/bombadil": "^0.7.0",
|
||||
|
||||
@@ -1030,6 +1030,166 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None:
|
||||
runner_lines.append(' fi # end _odysseus_have_prebuilt guard')
|
||||
|
||||
|
||||
ODYSSEUS_LLAMA_SHIM_MARKER = "# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike"
|
||||
|
||||
|
||||
def _append_llama_server_resolution_definition_lines(runner_lines: list[str]) -> None:
|
||||
"""Define the helper shell function that discovers, reconciles and prioritizes llama-server."""
|
||||
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
|
||||
runner_lines.append('_odysseus_resolve_llama_server() {')
|
||||
runner_lines.append(' _odysseus_llama_bin=""')
|
||||
runner_lines.append(f' local _marker={marker}')
|
||||
runner_lines.append(' local _saved_ifs="$IFS"')
|
||||
runner_lines.append(" IFS=':'")
|
||||
runner_lines.append(' local -a _parts')
|
||||
runner_lines.append(' read -r -a _parts <<< "$PATH"')
|
||||
runner_lines.append(' IFS="$_saved_ifs"')
|
||||
runner_lines.append(' local _dir _cand')
|
||||
runner_lines.append(' for _dir in "${_parts[@]}"; do')
|
||||
runner_lines.append(' [ -n "$_dir" ] || continue')
|
||||
runner_lines.append(' _cand="$_dir/llama-server"')
|
||||
runner_lines.append(' [ -x "$_cand" ] || continue')
|
||||
runner_lines.append(' if [ -f "$_cand" ] && [ ! -L "$_cand" ] && grep -Fxq "$_marker" "$_cand" 2>/dev/null; then')
|
||||
runner_lines.append(' continue')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' if "$_cand" --version >/dev/null 2>&1; then')
|
||||
runner_lines.append(' _odysseus_llama_bin="$_cand"')
|
||||
runner_lines.append(' break')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' done')
|
||||
runner_lines.append(' if [ -f "$HOME/bin/llama-server" ] && [ ! -L "$HOME/bin/llama-server" ] && grep -Fxq "$_marker" "$HOME/bin/llama-server" 2>/dev/null; then')
|
||||
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
|
||||
runner_lines.append(' echo "[odysseus] Native llama-server detected — retiring Odysseus compatibility shim in ~/bin"')
|
||||
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
|
||||
runner_lines.append(' hash -r')
|
||||
runner_lines.append(' elif python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||
runner_lines.append(' _odysseus_llama_bin="$HOME/bin/llama-server"')
|
||||
runner_lines.append(' else')
|
||||
runner_lines.append(' echo "[odysseus] Removing stale Odysseus llama-server shim (python3 -m llama_cpp.server unavailable)"')
|
||||
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
|
||||
runner_lines.append(' hash -r')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
|
||||
runner_lines.append(' local _cand_dir')
|
||||
runner_lines.append(' _cand_dir="$(dirname "$_odysseus_llama_bin")"')
|
||||
runner_lines.append(' if [ "${PATH%%:*}" != "$_cand_dir" ]; then')
|
||||
runner_lines.append(' export PATH="$_cand_dir:$PATH"')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' hash -r')
|
||||
runner_lines.append(' return 0')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' return 1')
|
||||
runner_lines.append('}')
|
||||
|
||||
|
||||
def _append_llama_server_shim_definition_lines(runner_lines: list[str]) -> None:
|
||||
"""Define the helper shell function that generates the Odysseus compatibility shim."""
|
||||
_append_llama_server_resolution_definition_lines(runner_lines)
|
||||
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
|
||||
runner_lines.append('_odysseus_create_llama_shim() {')
|
||||
runner_lines.append(' local _target="$HOME/bin/llama-server"')
|
||||
runner_lines.append(f' local _marker={marker}')
|
||||
runner_lines.append(' if [ -e "$_target" ] || [ -L "$_target" ]; then')
|
||||
runner_lines.append(' if [ -f "$_target" ] && [ ! -L "$_target" ] && grep -Fxq "$_marker" "$_target" 2>/dev/null; then')
|
||||
runner_lines.append(' : # existing Odysseus shim — safe to replace')
|
||||
runner_lines.append(' else')
|
||||
runner_lines.append(' echo "[odysseus] WARNING: $_target already exists and is not an Odysseus-managed shim; leaving untouched." >&2')
|
||||
runner_lines.append(' return 1')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' mkdir -p "$HOME/bin" || return 1')
|
||||
runner_lines.append(' local _tmp')
|
||||
runner_lines.append(' _tmp="$(mktemp "$HOME/bin/.llama-server.odysseus.tmp.XXXXXX")" || {')
|
||||
runner_lines.append(' echo "[odysseus] ERROR: unable to create secure temporary llama-server shim in $HOME/bin" >&2')
|
||||
runner_lines.append(' return 1')
|
||||
runner_lines.append(' }')
|
||||
runner_lines.append(" if ! cat > \"$_tmp\" <<'_ODY_LLAMA_SHIM_EOF'")
|
||||
runner_lines.append('#!/usr/bin/env bash')
|
||||
runner_lines.append(ODYSSEUS_LLAMA_SHIM_MARKER)
|
||||
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
|
||||
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
|
||||
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
|
||||
runner_lines.append('ARGS=()')
|
||||
runner_lines.append('while [ $# -gt 0 ]; do')
|
||||
runner_lines.append(' case "$1" in')
|
||||
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
|
||||
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
|
||||
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
|
||||
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
|
||||
runner_lines.append(' --no-warmup) shift ;; # native-only')
|
||||
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
|
||||
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
|
||||
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
|
||||
runner_lines.append(' esac')
|
||||
runner_lines.append('done')
|
||||
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
|
||||
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
|
||||
runner_lines.append(' then')
|
||||
runner_lines.append(' rm -f -- "$_tmp"')
|
||||
runner_lines.append(' return 1')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' chmod +x "$_tmp" || { rm -f -- "$_tmp"; return 1; }')
|
||||
runner_lines.append(' mv -f -- "$_tmp" "$_target" || { rm -f -- "$_tmp"; return 1; }')
|
||||
runner_lines.append(' hash -r')
|
||||
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
|
||||
runner_lines.append('}')
|
||||
|
||||
|
||||
def _append_llama_server_shim_reconciliation_lines(runner_lines: list[str]) -> None:
|
||||
"""Reconcile an existing ~/bin/llama-server if it is owned by Odysseus Cookbook.
|
||||
|
||||
Invariants:
|
||||
- Never touch an arbitrary user-owned executable or symlink.
|
||||
- If ~/bin/llama-server is an Odysseus generated shim:
|
||||
- If a native llama-server is present and healthy elsewhere on PATH, remove the shim
|
||||
so the native binary is used.
|
||||
- If python3 -m llama_cpp.server is unavailable, remove the stale shim
|
||||
and hash -r so the fallback/build path is triggered.
|
||||
- If no healthy native server exists and llama_cpp.server is working, preserve it.
|
||||
- If a healthy native candidate exists on PATH (even later in PATH), prioritize it and
|
||||
ensure the launched executable is the validated candidate.
|
||||
"""
|
||||
runner_lines.append('[ -r ~/.config/odysseus-llama-cpp-env ] && . ~/.config/odysseus-llama-cpp-env')
|
||||
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
|
||||
_append_llama_server_resolution_definition_lines(runner_lines)
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append('_odysseus_resolve_llama_server || true')
|
||||
|
||||
|
||||
def _append_llama_server_finalization_lines(runner_lines: list[str], platform_hint: str = "") -> None:
|
||||
"""Reconcile post-install launcher availability and enforce fail-closed invariant.
|
||||
|
||||
Guarantees that if python3 -m llama_cpp.server is usable but the `llama-server`
|
||||
command is unresolvable, the compatibility launcher is created. If no usable
|
||||
launcher exists after all build/install attempts, sets ODYSSEUS_PREFLIGHT_EXIT=127.
|
||||
"""
|
||||
err_msg = (
|
||||
f"ERROR: llama.cpp serving is not available on {platform_hint} after install attempts."
|
||||
if platform_hint
|
||||
else "ERROR: llama.cpp serving is not available after install/build attempts."
|
||||
)
|
||||
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
|
||||
_append_llama_server_resolution_definition_lines(runner_lines)
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append('if [ -z "$_odysseus_llama_bin" ] && python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||
runner_lines.append(' _odysseus_create_llama_shim')
|
||||
runner_lines.append(' _odysseus_resolve_llama_server || true')
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append('if [ -z "$_odysseus_llama_bin" ]; then')
|
||||
runner_lines.append(f' echo "{err_msg}"')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append('fi')
|
||||
|
||||
|
||||
def _llama_cpp_rebuild_cmd(update_source: bool = False) -> str:
|
||||
"""Shell command that clears the Cookbook-managed llama.cpp build.
|
||||
|
||||
|
||||
+14
-55
@@ -57,9 +57,11 @@ from routes.cookbook_helpers import (
|
||||
_pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
||||
_diagnose_serve_output, run_ssh_command_async,
|
||||
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
|
||||
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
||||
_append_pip_install_runner_lines, _pip_install_command_without_break_system_packages,
|
||||
_normalize_llama_cpp_python_cache_types,
|
||||
_append_llama_server_shim_definition_lines,
|
||||
_append_llama_server_shim_reconciliation_lines,
|
||||
_append_llama_server_finalization_lines,
|
||||
ModelDownloadRequest, ServeRequest,
|
||||
)
|
||||
|
||||
@@ -2161,7 +2163,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
ps_lines.append('}')
|
||||
elif "llama_cpp" in req.cmd or "llama-server" in req.cmd:
|
||||
ps_lines.append('# Auto-install llama-cpp-python if missing')
|
||||
ps_lines.append('try { python -c "import llama_cpp" 2>$null } catch {}')
|
||||
ps_lines.append('try { python -c "import llama_cpp.server" 2>$null } catch {}')
|
||||
ps_lines.append('if ($LASTEXITCODE -ne 0) {')
|
||||
ps_lines.append(' Write-Host "Installing llama-cpp-python..."')
|
||||
ps_lines.append(' python -m pip install llama-cpp-python[server]')
|
||||
@@ -2247,14 +2249,17 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# ollama is found (otherwise macOS falls back to a slow source build).
|
||||
# /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux.
|
||||
runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
|
||||
_append_llama_server_shim_definition_lines(runner_lines)
|
||||
_append_llama_server_shim_reconciliation_lines(runner_lines)
|
||||
runner_lines.append('if [ -d /data/data/com.termux ]; then')
|
||||
runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).')
|
||||
runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
||||
runner_lines.append(' if ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||
runner_lines.append(' pkg install -y cmake 2>/dev/null')
|
||||
runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null')
|
||||
runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append('elif ! command -v llama-server &>/dev/null; then')
|
||||
_append_llama_server_finalization_lines(runner_lines, platform_hint="Termux")
|
||||
runner_lines.append('elif [ -z "$_odysseus_llama_bin" ]; then')
|
||||
runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."')
|
||||
runner_lines.append(' mkdir -p ~/bin')
|
||||
runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp')
|
||||
@@ -2271,8 +2276,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is
|
||||
# explicit so the binary is optimized (Metal auto-enables on macOS).
|
||||
runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\')
|
||||
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server \\')
|
||||
runner_lines.append(' && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server')
|
||||
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server')
|
||||
runner_lines.append(' else')
|
||||
_append_llama_cpp_linux_accel_build_lines(runner_lines)
|
||||
runner_lines.append(' fi')
|
||||
@@ -2296,58 +2300,13 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' fi')
|
||||
# SHORT-CIRCUIT before the build/pip fallback: if the
|
||||
# native binary is missing but llama_cpp Python is already
|
||||
# installed, drop a wrapper at ~/bin/llama-server that
|
||||
# translates llama-server CLI args to llama_cpp.server's
|
||||
# underscore-style flags. The user's serve command stays
|
||||
# `llama-server ...` and "just works" — no build, no cmake,
|
||||
# no second install. This is the path that unblocks every
|
||||
# remote where pip-installed llama-cpp-python is already
|
||||
# working but Cookbook used to insist on a native binary.
|
||||
runner_lines.append(' if ! command -v llama-server >/dev/null 2>&1 && python3 -c "import llama_cpp" 2>/dev/null; then')
|
||||
runner_lines.append(' mkdir -p ~/bin')
|
||||
runner_lines.append(' cat > ~/bin/llama-server <<\'_ODY_LLAMA_SHIM_EOF\'')
|
||||
runner_lines.append('#!/usr/bin/env bash')
|
||||
runner_lines.append('# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike')
|
||||
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
|
||||
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
|
||||
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
|
||||
runner_lines.append('ARGS=()')
|
||||
runner_lines.append('while [ $# -gt 0 ]; do')
|
||||
runner_lines.append(' case "$1" in')
|
||||
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
|
||||
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
|
||||
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
|
||||
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
|
||||
runner_lines.append(' --no-warmup) shift ;; # native-only')
|
||||
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
|
||||
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
|
||||
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
|
||||
runner_lines.append(' esac')
|
||||
runner_lines.append('done')
|
||||
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
|
||||
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
|
||||
runner_lines.append(' chmod +x ~/bin/llama-server')
|
||||
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' # If the native build failed, fall back to the Python bindings.')
|
||||
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
||||
runner_lines.append(' _odysseus_resolve_llama_server || true')
|
||||
# If the native build failed, fall back to the Python bindings.
|
||||
runner_lines.append(' if [ -z "$_odysseus_llama_bin" ] && ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||
runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."')
|
||||
runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true")
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
||||
runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install/build attempts."')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append(' fi')
|
||||
_append_llama_server_finalization_lines(runner_lines)
|
||||
runner_lines.append('fi')
|
||||
elif re.search(r"\bollama\s+serve\b", req.cmd):
|
||||
handled_ollama_serve = True
|
||||
|
||||
@@ -855,11 +855,6 @@ def _plan_special(
|
||||
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.
|
||||
"""
|
||||
if re.fullmatch(
|
||||
r"\s*(?:please\s+)?(?:remove|erase|delete|clear|strip)\s+(?:the\s+)?(?:bg|background|backdrop)(?:\s+(?:from|of)\s+(?:the\s+)?(?:active|current|selected)\s+layer)?[.!\s]*",
|
||||
prompt, re.I,
|
||||
):
|
||||
return [{"op": "ai.removeBackground", "params": {}}], "Removing the active layer's background.", ""
|
||||
# Targeted recolour is fully deterministic (object mask + masked hue shift)
|
||||
# and must never be left to the model, which sometimes substitutes a
|
||||
# generative placeImage. Highest priority so it can never vary.
|
||||
@@ -1652,13 +1647,6 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
screenshot = body.get("screenshot")
|
||||
# Optional user-picked image model (from the editor model selector).
|
||||
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()
|
||||
model_choice = image_models["edit"]
|
||||
generation_model_choice = image_models["generation"]
|
||||
# Optional user-picked PLANNER model (the editor agent/planner model).
|
||||
agent_model = str(body.get("agent_model") or "").strip()
|
||||
tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
|
||||
@@ -1809,22 +1797,9 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
# Planner output is untrusted: it cannot redirect this turn to
|
||||
# another editor session or override an explicit model choice.
|
||||
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.
|
||||
selected_model = generation_model_choice if op == "generate_image" else 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)
|
||||
if model_choice:
|
||||
tool_payload["model"] = model_choice
|
||||
# Hand the AI step the canvas image the client already captured
|
||||
# for this request. Without it the tool falls back to a separate
|
||||
# bridge screenshot, which is not reliably answered right after a
|
||||
|
||||
@@ -37,24 +37,6 @@ _SAM_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:
|
||||
"""Current process RSS in MB (from /proc; -1.0 when unavailable)."""
|
||||
try:
|
||||
@@ -586,7 +568,6 @@ def _is_openai_api_base(url: str) -> bool:
|
||||
|
||||
|
||||
_GALLERY_ENDPOINT_PATHS = frozenset({
|
||||
"/chat/completions",
|
||||
"/images",
|
||||
"/images/edits",
|
||||
"/images/generations",
|
||||
@@ -1764,17 +1745,6 @@ def setup_gallery_routes() -> APIRouter:
|
||||
if not ok:
|
||||
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
|
||||
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()["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
|
||||
# provider's step progress for this op. Ignored by providers that do not
|
||||
# implement the progress contract.
|
||||
@@ -1892,30 +1862,14 @@ def setup_gallery_routes() -> APIRouter:
|
||||
mask_png.save(reference_mask, format="PNG")
|
||||
references = [src_buf.getvalue(), reference_mask.getvalue()]
|
||||
payload = {
|
||||
"model": oa_model, "n": 1,
|
||||
"model": oa_model, "size": size, "n": 1,
|
||||
"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", "")),
|
||||
"input_references": [{"type": "image_url", "image_url": {
|
||||
"url": "data:image/png;base64," + base64.b64encode(value).decode(),
|
||||
}} 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:
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files)
|
||||
if r.status_code != 200:
|
||||
@@ -1931,12 +1885,6 @@ def setup_gallery_routes() -> APIRouter:
|
||||
pass
|
||||
raise HTTPException(r.status_code, f"{_detail} (provider HTTP {r.status_code})")
|
||||
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
|
||||
if result.get("data"):
|
||||
item = result["data"][0]
|
||||
@@ -1956,13 +1904,17 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# the masked region actually changes.
|
||||
try:
|
||||
generated = Image.open(io.BytesIO(base64.b64decode(raw_b64))).convert("RGBA")
|
||||
blended, full_frame = _compose_cloud_image_edit(
|
||||
source_png, generated, mask_png,
|
||||
instruction=body.get("mode") == "instruction",
|
||||
)
|
||||
# Match the generated image to the source dims.
|
||||
if generated.size != source_png.size:
|
||||
generated = generated.resize(source_png.size, Image.LANCZOS)
|
||||
# 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()
|
||||
blended.save(out_buf, format="PNG")
|
||||
return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model, "full_frame": full_frame}
|
||||
return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model}
|
||||
except Exception as comp_err:
|
||||
# Never return a full-image edit when masking fails.
|
||||
logger.warning(f"Inpaint compose failed: {comp_err}")
|
||||
@@ -2622,9 +2574,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
height = int((body or {}).get("height") or 0)
|
||||
except (TypeError, ValueError):
|
||||
width = height = 0
|
||||
if width > 0 and height > 0:
|
||||
size = f"{width}x{height}"
|
||||
elif height > width:
|
||||
if height > width:
|
||||
size = "1024x1536"
|
||||
elif width > height:
|
||||
size = "1536x1024"
|
||||
@@ -2637,9 +2587,6 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# The editor's model picker may choose a specific (cloud) image model;
|
||||
# forward it so settings defaults only apply when none is selected.
|
||||
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()["generation"]
|
||||
payload = {"prompt": prompt, "size": size}
|
||||
if model_choice:
|
||||
payload["model"] = model_choice
|
||||
|
||||
@@ -1315,7 +1315,7 @@ def setup_session_routes(
|
||||
def auto_sort_sessions(request: Request, skip_llm: bool = False):
|
||||
"""Use AI to categorize all sessions into folders.
|
||||
|
||||
Phase 1 deletes empty/throwaway sessions and Phase 2 asks the LLM
|
||||
Phase 1 deletes empty/Incognito sessions and Phase 2 asks the LLM
|
||||
to assign folders. When `skip_llm=true` the endpoint returns
|
||||
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
|
||||
users can clean junk without spending tokens.
|
||||
@@ -1326,21 +1326,11 @@ def setup_session_routes(
|
||||
single_user_mode = not user and _auth_disabled()
|
||||
user_sessions = session_manager.get_sessions_for_user(user)
|
||||
|
||||
# Delete empty and throwaway sessions before sorting
|
||||
# Saved conversations are never disposable based on their title or prompt.
|
||||
from core.database import ChatMessage as DbMsg
|
||||
db = SessionLocal()
|
||||
deleted_empty = 0
|
||||
deleted_throwaway = 0
|
||||
# Names that indicate a throwaway/test session (case-insensitive exact or prefix match)
|
||||
_THROWAWAY_NAMES = {
|
||||
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
|
||||
"yo", "sup", "hola", "hii", "hiii", "heyo",
|
||||
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
|
||||
"new chat", "delete", "remove", "junk", "trash", "xxx",
|
||||
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
|
||||
"ok", "lol", "bruh", "hmm", "hm", "meh",
|
||||
}
|
||||
_THROWAWAY_MAX_MESSAGES = 4 # only delete if <= this many messages
|
||||
try:
|
||||
rows_q = db.query(DbSession).filter(DbSession.archived == False)
|
||||
if user:
|
||||
@@ -1349,15 +1339,11 @@ def setup_session_routes(
|
||||
rows_q = rows_q.filter(DbSession.owner == user)
|
||||
rows = rows_q.limit(2000).all()
|
||||
folder_map = {r.id: r.folder for r in rows}
|
||||
# Precompute per-session message counts in TWO aggregate queries
|
||||
# Precompute per-session message counts in one aggregate query
|
||||
# instead of 1–3 queries PER session — with many chats the per-row
|
||||
# loop was doing thousands of round-trips and blowing the timeout.
|
||||
from sqlalchemy import func as _sa_func
|
||||
_counts = dict(db.query(DbMsg.session_id, _sa_func.count(DbMsg.id)).group_by(DbMsg.session_id).all())
|
||||
_asst_counts = dict(
|
||||
db.query(DbMsg.session_id, _sa_func.count(DbMsg.id))
|
||||
.filter(DbMsg.role == "assistant").group_by(DbMsg.session_id).all()
|
||||
)
|
||||
cleanup_now = utcnow_naive()
|
||||
for row in rows:
|
||||
# Never delete important sessions
|
||||
@@ -1367,9 +1353,10 @@ def setup_session_routes(
|
||||
if (row.name or "").strip() == "Incognito":
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
db.delete(row)
|
||||
if hasattr(session_manager, 'delete_session'):
|
||||
session_manager.delete_session(row.id)
|
||||
else:
|
||||
db.delete(row)
|
||||
continue
|
||||
if is_session_recently_active(row, now=cleanup_now):
|
||||
continue
|
||||
@@ -1378,31 +1365,11 @@ def setup_session_routes(
|
||||
if msg_count == 0:
|
||||
should_delete = True
|
||||
deleted_empty += 1
|
||||
elif msg_count <= _THROWAWAY_MAX_MESSAGES:
|
||||
name = (row.name or "").strip().lower()
|
||||
# Check first user message content (AI renames sessions, so
|
||||
# "hi" becomes "Casual Greeting Exchange" — name alone won't match)
|
||||
first_msg = db.query(DbMsg.content).filter(
|
||||
DbMsg.session_id == row.id, DbMsg.role == "user"
|
||||
).order_by(DbMsg.timestamp).first()
|
||||
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
|
||||
# Count assistant messages — if user sent something but AI never replied, it's dead
|
||||
assistant_count = _asst_counts.get(row.id, 0)
|
||||
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
# Single user message with no AI response = dead session
|
||||
elif msg_count == 1 and assistant_count == 0:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
# Short phrase (1-3 words) with no real AI conversation (<=2 msgs)
|
||||
elif msg_count <= 2 and first_text and len(first_text.split()) <= 3 and len(first_text) <= 40:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
if should_delete:
|
||||
db.delete(row)
|
||||
if hasattr(session_manager, 'delete_session'):
|
||||
session_manager.delete_session(row.id)
|
||||
else:
|
||||
db.delete(row)
|
||||
if deleted_empty or deleted_throwaway:
|
||||
db.commit()
|
||||
logger.info(f"Auto-sort: deleted {deleted_empty} empty + {deleted_throwaway} throwaway sessions")
|
||||
|
||||
@@ -191,7 +191,7 @@ def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[
|
||||
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, native_size: bool = False) -> 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) -> bool:
|
||||
params: Dict[str, Any] = {
|
||||
"dataUrl": data_url,
|
||||
"name": name,
|
||||
@@ -199,8 +199,6 @@ async def _place_layer(data_url: str, name: str, owner: Optional[str], session:
|
||||
"blend": blend or "normal",
|
||||
"opacity": opacity,
|
||||
}
|
||||
if native_size:
|
||||
params["nativeSize"] = True
|
||||
# Forward the edit mode so the client can build a difference mask for
|
||||
# instruction-mode results: the whole image was regenerated under an
|
||||
# instruction prompt, so untouched pixels must stay pixel-identical (the
|
||||
@@ -508,8 +506,6 @@ class AiEditImageTool:
|
||||
}
|
||||
_t["image"] = time.perf_counter()
|
||||
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()
|
||||
mask_used = False
|
||||
place_mode: Optional[str] = None
|
||||
@@ -613,11 +609,10 @@ class AiEditImageTool:
|
||||
f"AI {op}",
|
||||
owner,
|
||||
session,
|
||||
masked=mask_used and data.get("full_frame") is not True,
|
||||
masked=mask_used,
|
||||
blend=str(args.get("blend") or "normal"),
|
||||
opacity=float(args.get("opacity") or 100),
|
||||
mode=place_mode,
|
||||
native_size=data.get("full_frame") is True,
|
||||
)
|
||||
info["placed"] = placed
|
||||
if placed:
|
||||
@@ -692,7 +687,6 @@ class GenerateImageLayerTool:
|
||||
str(args.get("name") or "Generated image"),
|
||||
owner,
|
||||
session,
|
||||
native_size=True,
|
||||
masked=bool(args.get("mask")),
|
||||
blend=str(args.get("blend") or "normal"),
|
||||
opacity=float(args.get("opacity") or 100),
|
||||
|
||||
+8
-113
@@ -179,13 +179,8 @@ def _resolve_model(spec: str, owner: Optional[str] = None, model_type: Optional[
|
||||
# served internal id. Include pinned/cached ids in the match set
|
||||
# so chat sessions using the HF repo id still resolve. Do not use
|
||||
# stale cached aliases when the endpoint itself is unreachable.
|
||||
if endpoint_reachable:
|
||||
# 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 model_type == "image" and endpoint_reachable:
|
||||
for extra in _json_list(getattr(ep, "pinned_models", None)) + _json_list(getattr(ep, "cached_models", None)):
|
||||
if extra not in model_ids:
|
||||
model_ids.append(extra)
|
||||
|
||||
@@ -1205,39 +1200,6 @@ def image_editing_provider_available(owner: Optional[str] = None) -> bool:
|
||||
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:
|
||||
"""Generate an image using an image-capable model (e.g. gpt-image-1).
|
||||
|
||||
@@ -1380,19 +1342,8 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
||||
images_url = base_url + "/images/generations"
|
||||
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":
|
||||
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)
|
||||
valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"}
|
||||
@@ -1408,9 +1359,6 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
"n": 1,
|
||||
"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
|
||||
if is_gpt_image or is_local_diffusion:
|
||||
@@ -1424,16 +1372,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
try:
|
||||
# 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:
|
||||
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)
|
||||
resp = await client.post(images_url, json=payload, headers=headers)
|
||||
|
||||
if resp.status_code != 200:
|
||||
error_text = resp.text[:500]
|
||||
@@ -1448,15 +1387,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
}
|
||||
|
||||
data = resp.json()
|
||||
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", [])
|
||||
images = data.get("data", [])
|
||||
if not images:
|
||||
return {"error": (
|
||||
f"Image model '{model_id}' returned no image. "
|
||||
"Choose another image generation model in AI settings."
|
||||
)}
|
||||
return {"error": "No images returned from API"}
|
||||
|
||||
img = images[0]
|
||||
image_url = None
|
||||
@@ -1575,7 +1508,7 @@ async def do_edit_image(
|
||||
_settings = {}
|
||||
|
||||
if not model_spec:
|
||||
model_spec = _settings.get("image_edit_model") or _settings.get("image_model", "")
|
||||
model_spec = _settings.get("image_model", "")
|
||||
if quality == "medium" and _settings.get("image_quality"):
|
||||
quality = _settings["image_quality"]
|
||||
if not model_spec:
|
||||
@@ -1584,10 +1517,6 @@ async def do_edit_image(
|
||||
try:
|
||||
try:
|
||||
def _call():
|
||||
try:
|
||||
return _resolve_model(model_spec, owner=owner)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return _resolve_model(model_spec, owner=owner, model_type="image")
|
||||
except TypeError as exc:
|
||||
@@ -1600,7 +1529,6 @@ async def do_edit_image(
|
||||
except ValueError:
|
||||
return {"error": f"No endpoint found with image model '{model_spec}'."}
|
||||
|
||||
requested_edit_size = size
|
||||
if not size or size == "auto":
|
||||
from PIL import Image
|
||||
from src.image_model_ids import image_edit_size
|
||||
@@ -1776,35 +1704,7 @@ async def do_edit_image(
|
||||
"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:
|
||||
with path.open("rb") as f:
|
||||
files = {"image": (path.name, f, mime)}
|
||||
@@ -1847,12 +1747,7 @@ async def do_edit_image(
|
||||
}
|
||||
|
||||
data = resp.json()
|
||||
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", []))
|
||||
images = data.get("data", [])
|
||||
if not images:
|
||||
return {"error": "No image returned from edit API"}
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
"""The shared model defaults used by Quick Edit's image operations."""
|
||||
from src.settings import load_settings
|
||||
|
||||
|
||||
def editor_image_models():
|
||||
settings = load_settings()
|
||||
generation = str(settings.get("image_model") or "").strip()
|
||||
return {
|
||||
"generation": generation,
|
||||
"edit": str(settings.get("image_edit_model") or generation).strip(),
|
||||
}
|
||||
@@ -40,15 +40,6 @@ def image_edit_size(model_id: str, width: int, height: int) -> str:
|
||||
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:
|
||||
"""Return True when a model id should use image generation routes.
|
||||
|
||||
|
||||
+15
-5
@@ -10,6 +10,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import contextlib
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
from src.database import McpServer, SessionLocal
|
||||
|
||||
@@ -383,12 +384,21 @@ class McpManager:
|
||||
|
||||
provider = build_provider(server_id, url, on_redirect=_on_redirect)
|
||||
stack = AsyncExitStack()
|
||||
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
|
||||
read_stream, write_stream, _get_session_id = transport
|
||||
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
|
||||
await session.initialize()
|
||||
try:
|
||||
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
|
||||
read_stream, write_stream, _get_session_id = transport
|
||||
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
|
||||
await session.initialize()
|
||||
|
||||
tools_result = await session.list_tools()
|
||||
tools_result = await session.list_tools()
|
||||
except BaseException:
|
||||
# Close the half-open transport here, in the task that entered
|
||||
# it. Left for the garbage collector, its AnyIO cancel scope is
|
||||
# exited from the wrong task and the event loop keeps spinning
|
||||
# (#5518). Its own teardown error would only mask the real one.
|
||||
with contextlib.suppress(Exception):
|
||||
await stack.aclose()
|
||||
raise
|
||||
tools = []
|
||||
for tool in tools_result.tools:
|
||||
tools.append({
|
||||
|
||||
+8
-51
@@ -12,16 +12,6 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Names that indicate a throwaway/test session
|
||||
_THROWAWAY_NAMES = {
|
||||
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
|
||||
"yo", "sup", "hola", "hii", "hiii", "heyo",
|
||||
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
|
||||
"new chat", "delete", "remove", "junk", "trash", "xxx",
|
||||
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
|
||||
"ok", "lol", "bruh", "hmm", "hm", "meh",
|
||||
}
|
||||
_THROWAWAY_MAX_MESSAGES = 4
|
||||
_FRESH_EMPTY_SESSION_GRACE = timedelta(minutes=10)
|
||||
_FRESH_SESSION_GRACE = _FRESH_EMPTY_SESSION_GRACE
|
||||
|
||||
@@ -54,14 +44,15 @@ def is_session_recently_active(row, now=None, grace=_FRESH_SESSION_GRACE) -> boo
|
||||
|
||||
|
||||
async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bool = True) -> str:
|
||||
"""Run session cleanup + (optional) AI folder sort for the given owner.
|
||||
"""Remove empty/Incognito sessions and optionally organize saved chats.
|
||||
|
||||
Args:
|
||||
owner: user whose sessions to process
|
||||
skip_llm: when True, do only Phase 1 (delete empty/throwaway sessions);
|
||||
skip_llm: when True, do only Phase 1 (delete empty/Incognito sessions);
|
||||
skip Phase 2 (AI folder assignment). Used by the built-in daily
|
||||
background sweep so it never burns LLM tokens.
|
||||
delete_throwaway: when False, only empty/incognito sessions are deleted.
|
||||
delete_throwaway: retained for caller compatibility. Ordinary sessions
|
||||
with saved messages are always preserved, regardless of this flag.
|
||||
|
||||
Returns a human-readable summary of what was done.
|
||||
"""
|
||||
@@ -71,7 +62,7 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# ── Phase 1: Delete empty/throwaway sessions ──
|
||||
# ── Phase 1: Delete only empty and explicitly Incognito sessions ──
|
||||
deleted_empty = 0
|
||||
deleted_throwaway = 0
|
||||
|
||||
@@ -93,47 +84,13 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
||||
if is_session_recently_active(row, now=cleanup_now):
|
||||
continue
|
||||
|
||||
msg_count = db.query(DbMsg.id).filter(
|
||||
has_messages = db.query(DbMsg.id).filter(
|
||||
DbMsg.session_id == row.id
|
||||
).limit(_THROWAWAY_MAX_MESSAGES + 1).count()
|
||||
should_delete = False
|
||||
|
||||
if msg_count == 0:
|
||||
).first() is not None
|
||||
if not has_messages:
|
||||
if is_fresh:
|
||||
continue
|
||||
should_delete = True
|
||||
deleted_empty += 1
|
||||
elif delete_throwaway and msg_count <= _THROWAWAY_MAX_MESSAGES:
|
||||
name = (row.name or "").strip().lower()
|
||||
first_msg = db.query(DbMsg.content).filter(
|
||||
DbMsg.session_id == row.id, DbMsg.role == "user"
|
||||
).order_by(DbMsg.timestamp).first()
|
||||
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
|
||||
assistant_count = db.query(DbMsg.id).filter(
|
||||
DbMsg.session_id == row.id, DbMsg.role == "assistant"
|
||||
).limit(1).count()
|
||||
|
||||
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
elif msg_count == 1 and assistant_count == 0:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
elif msg_count <= 4 and first_text and len(first_text.split()) <= 8 and len(first_text) <= 80:
|
||||
# Short trivial chats — e.g. "write hi to a friend" → "Hi!"
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
else:
|
||||
# Aggressive: total message text under 250 chars combined = trivial
|
||||
msg_rows = db.query(DbMsg.content).filter(
|
||||
DbMsg.session_id == row.id
|
||||
).all()
|
||||
total_chars = sum(len(m[0] or "") for m in msg_rows)
|
||||
if total_chars <= 250:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
|
||||
if should_delete:
|
||||
db.delete(row)
|
||||
|
||||
if deleted_empty or deleted_throwaway:
|
||||
|
||||
+1
-2
@@ -46,7 +46,6 @@ DEFAULT_SETTINGS = {
|
||||
"agent_email_confirm": True,
|
||||
"image_gen_enabled": False,
|
||||
"image_model": "",
|
||||
"image_edit_model": "",
|
||||
"image_quality": "medium",
|
||||
# Optional editor planner/caption endpoints. Empty uses configured chat
|
||||
# fallback without probing a developer-specific host.
|
||||
@@ -295,7 +294,7 @@ def is_setting_overridden(key: str) -> bool:
|
||||
# resolved by FastAPI deps; an empty/None owner falls through to the global.
|
||||
_PER_USER_KEYS = {
|
||||
"vision_model", "vision_enabled", "vision_model_fallbacks",
|
||||
"image_model", "image_edit_model", "image_gen_enabled", "image_quality",
|
||||
"image_model", "image_gen_enabled", "image_quality",
|
||||
# Default chat endpoint / model — without per-user resolution every new
|
||||
# account inherited whatever the most-recent admin picked, which then
|
||||
# got injected into the chat composer on first open.
|
||||
|
||||
@@ -264,42 +264,7 @@
|
||||
/* The brush controls (Color/Size/preset/caret) hug their content; only the
|
||||
trailing slider section grows, so the Opacity/Flow/Softness group sits
|
||||
right after the caret instead of leaving a wide gap. */
|
||||
.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-brush-controls { flex: 0 0 auto; }
|
||||
.ge-controls--in-options .ge-control-row {
|
||||
margin: 0 !important;
|
||||
flex: 0 0 auto;
|
||||
@@ -4642,8 +4607,6 @@ body.ge-editor-open textarea.ge-direct-text-editor {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.ge-filter-modal {
|
||||
max-height: calc(100dvh - 40px);
|
||||
overflow-y: auto;
|
||||
width: 240px;
|
||||
background: color-mix(in srgb, var(--panel) 95%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
@@ -4668,9 +4631,6 @@ body.ge-editor-open textarea.ge-direct-text-editor {
|
||||
gap: 2px;
|
||||
}
|
||||
.ge-filter-row label {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
@@ -9772,28 +9732,6 @@ body.ge-editor-open .tour-hint {
|
||||
margin-left: auto;
|
||||
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 {
|
||||
display: none;
|
||||
position: absolute;
|
||||
|
||||
@@ -2690,11 +2690,14 @@ body.theme-frosted .modal {
|
||||
max-height: 95dvh !important;
|
||||
}
|
||||
|
||||
/* Notes and Documents must respect the same viewport and safe-area
|
||||
top as other mobile full-height sheets. Keep these authoritative rules
|
||||
consistent with 05-documents.css and 14-email.css. */
|
||||
body.doc-view .doc-editor-pane,
|
||||
body.notes-view .notes-pane {
|
||||
inset: auto 0 0 !important;
|
||||
height: 95dvh !important;
|
||||
max-height: 95dvh !important;
|
||||
inset: var(--sheet-top) 0 0 !important;
|
||||
height: calc(var(--app-height) - var(--sheet-top)) !important;
|
||||
max-height: calc(var(--app-height) - var(--sheet-top)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,13 +16,6 @@
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
window._odysseusLoadTime = Date.now();
|
||||
// Stops iOS focus-zoom on every field; iOS still allows pinch-zoom, other platforms would not.
|
||||
(function(){
|
||||
var ua = navigator.userAgent || '';
|
||||
var isIOS = /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
|
||||
var vp = document.querySelector('meta[name="viewport"]');
|
||||
if (isIOS && vp) vp.setAttribute('content', vp.getAttribute('content') + ', maximum-scale=1');
|
||||
})();
|
||||
(function(){
|
||||
try {
|
||||
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
|
||||
@@ -1684,10 +1677,6 @@
|
||||
<label class="settings-label">Model</label>
|
||||
<select id="set-imgModelSelect" class="settings-select"><option value="">Auto-detect</option></select>
|
||||
</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;">
|
||||
<label class="settings-label">Quality</label>
|
||||
<select id="set-imgQualitySelect" class="settings-select">
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { imageResultGeometry } from './image-result-geometry.js';
|
||||
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
|
||||
|
||||
export function wireInpaintButtons({
|
||||
@@ -185,30 +184,17 @@ export function wireInpaintButtons({
|
||||
if (!state.editorOpen) return; // user closed mid-decode
|
||||
try {
|
||||
saveState('Inpaint result');
|
||||
// Whole-image edits keep the provider's native frame; only masked
|
||||
// edits need registration with the source document's coordinates.
|
||||
// OpenAI returns at one of its allowed sizes (1024²,
|
||||
// 1024×1536, 1536×1024) which often differs from our
|
||||
// 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 layerName = shortPrompt ? `Inpaint: ${shortPrompt}` : 'Inpaint Result';
|
||||
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);
|
||||
const resultLayer = createLayer(layerName, state.imgWidth, state.imgHeight);
|
||||
resultLayer.ctx.imageSmoothingEnabled = true;
|
||||
resultLayer.ctx.imageSmoothingQuality = 'high';
|
||||
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);
|
||||
}
|
||||
resultLayer.ctx.drawImage(resultImg, 0, 0, state.imgWidth, state.imgHeight);
|
||||
// Snapshot the AI result + hard mask used for this run.
|
||||
if (!fullFrame) {
|
||||
const aiSnap = document.createElement('canvas');
|
||||
aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight;
|
||||
aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0);
|
||||
@@ -219,7 +205,6 @@ export function wireInpaintButtons({
|
||||
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
|
||||
// Apply initial alpha = hard mask (no feather, no edge shift).
|
||||
applyInpaintFeather(resultLayer, 0, 0);
|
||||
}
|
||||
state.layers.push(resultLayer);
|
||||
state.activeLayerId = resultLayer.id;
|
||||
state.lastInpaintLayerId = resultLayer.id;
|
||||
@@ -235,10 +220,6 @@ export function wireInpaintButtons({
|
||||
}
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
if (fullFrame) {
|
||||
if (uiModule) uiModule.showToast('Image edit complete', 4500);
|
||||
return;
|
||||
}
|
||||
// Reveal post-generation Feather + Edge Stroke sliders.
|
||||
// Cap Edge Stroke at ±padPx so the slider can't ask for
|
||||
// more AI buffer than we generated.
|
||||
|
||||
@@ -54,16 +54,16 @@ export function resolveQuickEditModel() {
|
||||
label: ((opt && opt.textContent) || '').trim(),
|
||||
};
|
||||
}
|
||||
const generation = document.getElementById('ge-ai-model');
|
||||
if (generation?.value && generation.value !== '__serve_cookbook__') {
|
||||
const index = generation.value.indexOf('::');
|
||||
const model = index < 0 ? '' : generation.value.slice(index + 2);
|
||||
if (model && modelCaps(model, '', '').inpaint) {
|
||||
return { endpoint: generation.value.slice(0, index), model,
|
||||
label: generation.options[generation.selectedIndex]?.textContent?.trim() || model };
|
||||
}
|
||||
}
|
||||
return { endpoint: '', model: '', label: 'Auto (saved settings)' };
|
||||
const inp = document.getElementById('ge-ai-inpaint');
|
||||
const opt = resolveInpaintModel(inp);
|
||||
if (!opt) return { endpoint: '', model: '', label: '' };
|
||||
const raw = opt.value || '';
|
||||
const idx = raw.indexOf('::');
|
||||
return {
|
||||
endpoint: idx < 0 ? raw : raw.slice(0, idx),
|
||||
model: idx < 0 ? '' : raw.slice(idx + 2),
|
||||
label: ((opt.textContent || '').trim().replace(/^Auto\s*\((.*)\)$/, '$1')),
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve the Quick Edit AGENT (planner) model — the vision-language model
|
||||
@@ -140,7 +140,25 @@ export function modelCaps(modelId, endpointName, endpointType) {
|
||||
return { gen: false, inpaint: false };
|
||||
}
|
||||
|
||||
globalThis.window?.addEventListener('ge:model-endpoints-updated', e => {
|
||||
// Global listeners resolve live controls instead of retaining editor instances.
|
||||
function syncCommandModel(value) {
|
||||
const select = document.getElementById('ge-ai-command-model');
|
||||
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) {
|
||||
select.value = value;
|
||||
if (select.value !== value) select.value = '';
|
||||
updateFooterEditModel();
|
||||
}
|
||||
}
|
||||
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 || '');
|
||||
});
|
||||
window.addEventListener('ge:model-endpoints-updated', e => {
|
||||
document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || '');
|
||||
});
|
||||
|
||||
@@ -238,9 +256,9 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
const data = await res.json();
|
||||
if (listeners.signal.aborted || !root.isConnected) return;
|
||||
const modelsList = data.models || [];
|
||||
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">Auto-detect</option>';
|
||||
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
|
||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
|
||||
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Same as generation</option>';
|
||||
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>';
|
||||
const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model'));
|
||||
for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>';
|
||||
let firstGen = null;
|
||||
@@ -328,11 +346,45 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
// Sentinel changes are handled by the component's delegated listener.
|
||||
if (aiCommandSelect) {
|
||||
aiCommandSelect._modelsReady = true;
|
||||
try {
|
||||
const rawSaved = localStorage.getItem('ge-ai-command-model') || '';
|
||||
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) {
|
||||
aiCommandSelect._commandWired = listeners;
|
||||
listen(aiCommandSelect, 'change', () => {
|
||||
if (aiCommandSelect.value === '__serve_cookbook__') return;
|
||||
const val = aiCommandSelect.value || '';
|
||||
try {
|
||||
if (val) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
|
||||
else localStorage.removeItem('ge-ai-command-model');
|
||||
} catch {}
|
||||
updateFooterEditModel();
|
||||
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -357,7 +409,6 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
});
|
||||
}
|
||||
_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 {}
|
||||
} catch (e) {
|
||||
if (listeners.signal.aborted || !root.isConnected) return;
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* Background Remove (rembg) + Sharpen wiring + the live edge-cleanup
|
||||
* tuner that runs on the most-recent rembg cutout.
|
||||
*
|
||||
* rembg-run button: active layer + POST to /api/image/remove-bg with an
|
||||
* rembg-run button: flatten + POST to /api/image/remove-bg with an
|
||||
* optional hint_mask if the user has a wand/lasso selection
|
||||
* active. Updates only that layer and binds its live edge tuner;
|
||||
* other layers and their visibility remain unchanged.
|
||||
* active. After the new layer lands, hides every previously-
|
||||
* visible layer so the cutout reads cleanly, and binds the
|
||||
* live-tuner to the new layer.
|
||||
*
|
||||
* Live edge-cleanup tuner: snapshots the pristine cutout the
|
||||
* moment it lands; subsequent feather/grow slider tweaks
|
||||
@@ -64,13 +65,37 @@ export function wireRembgAndSharpen({
|
||||
// NB: edge_feather / edge_grow are applied CLIENT-side so the
|
||||
// sliders can re-tune the cutout without re-running the model.
|
||||
const btn = document.getElementById('ge-rembg-run');
|
||||
const editedLayer = await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
|
||||
const before = state.layers.length;
|
||||
// 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.
|
||||
const f = document.getElementById('ge-rembg-feather');
|
||||
const g = document.getElementById('ge-rembg-grow');
|
||||
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 (editedLayer) bindRembgLiveTuner(editedLayer);
|
||||
});
|
||||
|
||||
// ── Live edge-cleanup tuner ──
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
* "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.
|
||||
*/
|
||||
import { readImagePreferences, saveImagePreferences } from '../settings/imagePreferences.js';
|
||||
import { updateFooterEditModel } from './ai-models.js';
|
||||
|
||||
export function createAiSettings({ container }) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'ge-ai-settings';
|
||||
@@ -22,14 +19,11 @@ export function createAiSettings({ container }) {
|
||||
<button type="button" class="ge-ai-settings-close" data-ai-settings-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<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" id="ge-ai-settings-save-status" role="status"></p>
|
||||
<label class="ge-ai-settings-label" for="ge-look-strength">Look strength</label>
|
||||
<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" />
|
||||
@@ -47,7 +41,6 @@ export function createAiSettings({ container }) {
|
||||
|
||||
function open(focusId) {
|
||||
el.hidden = false;
|
||||
syncModels();
|
||||
if (focusId) {
|
||||
const target = document.getElementById(focusId);
|
||||
if (target) {
|
||||
@@ -63,59 +56,10 @@ export function createAiSettings({ container }) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
const onKey = (e) => {
|
||||
if (e.key !== 'Escape' || el.hidden) return;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
close();
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
const onKey = (e) => { if (e.key === 'Escape' && !el.hidden) close(); };
|
||||
document.addEventListener('keydown', onKey);
|
||||
|
||||
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 strengthVal = el.querySelector('#ge-look-strength-val');
|
||||
@@ -133,9 +77,7 @@ export function createAiSettings({ container }) {
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
window.removeEventListener('image-preferences-changed', onPreferencesChanged);
|
||||
container.removeEventListener('ge-image-models-ready', syncModels);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
el.remove();
|
||||
}
|
||||
|
||||
|
||||
@@ -76,12 +76,7 @@ export function createApplyImageTool({
|
||||
if (sel.endpoint) extraPayload._endpoint = sel.endpoint;
|
||||
if (sel.model && !extraPayload._model) extraPayload._model = sel.model;
|
||||
}
|
||||
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 flatCanvas = flatten();
|
||||
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
|
||||
const body = { image: imageB64, ...extraPayload };
|
||||
const res = await fetch(endpoint, {
|
||||
@@ -101,23 +96,6 @@ export function createApplyImageTool({
|
||||
const img = await decodeAIImage(data.image, operation.signal);
|
||||
operation.signal.throwIfAborted();
|
||||
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();
|
||||
const layer = createLayer(layerName, state.imgWidth, state.imgHeight);
|
||||
|
||||
Vendored
+5
-5
@@ -55,11 +55,6 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<label>Color</label>
|
||||
<input type="color" class="ge-color-picker" value="${color}" />
|
||||
</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">
|
||||
<label><span class="ge-size-label">${brushSize}px</span></label>
|
||||
<input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" />
|
||||
@@ -99,6 +94,11 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<label>Flow <span id="ge-brush-flow-label">100%</span></label>
|
||||
<input type="range" id="ge-brush-flow" min="5" max="100" value="100" />
|
||||
</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 class="ge-brush-stroke-group" data-stroke-tool="eraser">
|
||||
<div class="ge-section-title">Eraser</div>
|
||||
|
||||
@@ -78,7 +78,6 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
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';
|
||||
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
|
||||
// guard. A committed cropRect/selection is a resting state — clicking
|
||||
// its Apply button must not be treated as "strayed onto chrome".
|
||||
@@ -144,16 +143,15 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
};
|
||||
state.mainCanvas.addEventListener('pointerup', endPen);
|
||||
state.mainCanvas.addEventListener('pointercancel', endPen);
|
||||
// Selection and Crop can start OUTSIDE the canvas — fallback mousedown on the
|
||||
// Lasso 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
|
||||
// space around the image. Other tools stay canvas-only.
|
||||
canvasArea.addEventListener('mousedown', (e) => {
|
||||
if (!canStartOutside() || Date.now() < suppressMouseUntil) return;
|
||||
if (e.button !== 0 || isDirectPanIntent(state.tool, state.spacePanActive, e.button)) return;
|
||||
if (state.tool !== 'lasso' && state.tool !== 'crop') return;
|
||||
if (e.target === state.mainCanvas) return; // already handled
|
||||
// Ignore presses on editor chrome inside the area (the crop Apply panel,
|
||||
// popups, buttons) so clicking Apply does not start a fresh crop.
|
||||
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;
|
||||
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;
|
||||
beginDraw(e);
|
||||
});
|
||||
state.mainCanvas.addEventListener('mouseenter', (e) => {
|
||||
@@ -307,7 +305,6 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
let panning = false;
|
||||
let pid = null;
|
||||
let transformPointerId = null;
|
||||
let outsideDrawPointerId = null;
|
||||
let startX = 0, startY = 0;
|
||||
let startPanX = 0, startPanY = 0;
|
||||
const getOffset = () => ({ x: state.panX || 0, y: state.panY || 0 });
|
||||
@@ -318,23 +315,10 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
};
|
||||
canvasArea.addEventListener('pointerdown', (e) => {
|
||||
const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button);
|
||||
// With a selection or crop tool, an empty-area press draws a rect/path
|
||||
// With the crop (or lasso) tool, an empty-area press draws a rect/path
|
||||
// rather than panning — so the crop can be pulled out past the image.
|
||||
// Space-held / middle-mouse (directPan) still pans.
|
||||
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 ((state.tool === 'lasso' || state.tool === 'crop') && !directPan) 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;
|
||||
if (onImage && !directPan) return;
|
||||
@@ -373,12 +357,6 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
e.preventDefault();
|
||||
});
|
||||
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) {
|
||||
continueDraw(e);
|
||||
return;
|
||||
@@ -392,14 +370,6 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
applyOffset(next.x, next.y);
|
||||
});
|
||||
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)) {
|
||||
const capturedId = transformPointerId;
|
||||
transformPointerId = null;
|
||||
|
||||
@@ -21,7 +21,6 @@ export const EDITOR_AGENT_VERSION = '1.0';
|
||||
export const DEFAULT_AGENT_ENDPOINT = '/api/editor/agent';
|
||||
|
||||
import { formatCropRect, isGenericIntent } from './nl-edit.js';
|
||||
import { waitForImagePreferences } from '../settings/imagePreferences.js';
|
||||
|
||||
export function createEditorAgent({
|
||||
control,
|
||||
@@ -29,7 +28,6 @@ export function createEditorAgent({
|
||||
fetchImpl = null,
|
||||
getSession = null,
|
||||
getModel = null,
|
||||
getGenerationModel = null,
|
||||
getAgentModel = null,
|
||||
onStep = null,
|
||||
} = {}) {
|
||||
@@ -147,7 +145,6 @@ export function createEditorAgent({
|
||||
activeStep = report;
|
||||
executed = [];
|
||||
running = true;
|
||||
await waitForImagePreferences();
|
||||
|
||||
// Honest before-image (captured before any client pre-step) for the
|
||||
// imperceptible-edit check, then the history mark scoping the undo group.
|
||||
@@ -197,8 +194,6 @@ export function createEditorAgent({
|
||||
signal: signal || undefined,
|
||||
body: JSON.stringify({
|
||||
prompt, session, editor_session: session, state, screenshot,
|
||||
_use_image_settings: true,
|
||||
generation_model: getGenerationModel ? getGenerationModel() : '',
|
||||
model: (modelInfo && modelInfo.model) || '',
|
||||
endpoint: (modelInfo && modelInfo.endpoint) || '',
|
||||
model_label: (modelInfo && modelInfo.label) || '',
|
||||
|
||||
@@ -132,7 +132,7 @@ function renderEffects(source, effects, masks) {
|
||||
const contribution = maskedContribution(next, masks[index]);
|
||||
const blended = copyCanvas(current);
|
||||
const blendCtx = blended.getContext('2d');
|
||||
if (effect.type === 'drop-shadow' && !effect.params.inside) blendCtx.globalCompositeOperation = 'destination-over';
|
||||
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over';
|
||||
blendCtx.globalAlpha = effect.opacity;
|
||||
blendCtx.drawImage(contribution, 0, 0);
|
||||
blendCtx.globalAlpha = 1;
|
||||
|
||||
Vendored
+1
-2
@@ -81,7 +81,6 @@ export function normalizeEffect(effect) {
|
||||
params.blendMode = typeof params.blendMode === 'string' ? params.blendMode : defaults.blendMode;
|
||||
}
|
||||
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;
|
||||
for (const key of ['opacity', 'blur', 'x', 'y']) params[key] = Number(params[key]) || 0;
|
||||
params.opacity = Math.max(0, Math.min(1, params.opacity));
|
||||
@@ -292,7 +291,7 @@ export function renderEffects(source, effects = [], shouldContinue = () => true)
|
||||
: next;
|
||||
const blended = copyCanvas(current);
|
||||
const blendCtx = blended.getContext('2d');
|
||||
if (effect.type === 'drop-shadow' && !effect.params.inside) blendCtx.globalCompositeOperation = 'destination-over';
|
||||
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over';
|
||||
blendCtx.globalAlpha = effect.opacity;
|
||||
blendCtx.drawImage(contribution, 0, 0);
|
||||
blendCtx.globalAlpha = 1;
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export function wireKeyboardShortcuts(deps) {
|
||||
toolbar, toolKeyMap,
|
||||
composite, saveState, undo, redo,
|
||||
toggleShortcuts, confirmTransform, cancelTransform, startTransform, nudgeTransform,
|
||||
resizeCustomPrompt, addEmptyLayer, brushSizeSync, openLayerAdjustment,
|
||||
resizeCustomPrompt, addEmptyLayer, brushSizeSync,
|
||||
invertSelection,
|
||||
wandDeleteSelection, wandCopyToNewLayer, copyPixelsToClipboard,
|
||||
lassoDeleteSelection, lassoCopyToLayer,
|
||||
@@ -181,12 +181,6 @@ export function wireKeyboardShortcuts(deps) {
|
||||
return;
|
||||
}
|
||||
// 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) {
|
||||
e.preventDefault();
|
||||
document.getElementById(e.shiftKey ? 'ge-export-gallery' : 'ge-save')?.click();
|
||||
@@ -227,12 +221,11 @@ export function wireKeyboardShortcuts(deps) {
|
||||
addEmptyLayer();
|
||||
return;
|
||||
}
|
||||
// Ctrl+Alt+L edits the current layer, leaving Levels controls open.
|
||||
// Ctrl+Alt+L — Levels adjustment. The Image menu owns the row and its
|
||||
// dialog, so click it instead of duplicating the adjustment here.
|
||||
if (e.altKey && e.code === 'KeyL') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openLayerAdjustment?.('levels');
|
||||
return;
|
||||
const row = document.querySelector('[data-image-action="adj-levels"]');
|
||||
if (row) { e.preventDefault(); e.stopPropagation(); row.click(); return; }
|
||||
}
|
||||
// Ctrl/Cmd+J duplicates the active layer through the layer panel's
|
||||
// existing implementation, which preserves masks and effects.
|
||||
|
||||
@@ -2233,7 +2233,6 @@ export function createLayerPanelRenderer(deps) {
|
||||
if (dragSortModule) {
|
||||
dragSortModule.enable('ge-layers-list', '.ge-layer-item', {
|
||||
instanceKey: 'ge-layers',
|
||||
excludeSelector: '.ge-adj-sub-item',
|
||||
threshold: 4,
|
||||
ignoreSelector: 'button, input, select, textarea, a, [contenteditable="true"], .ge-adj-sub-item',
|
||||
onReorder: (orderedItems) => {
|
||||
@@ -2259,9 +2258,6 @@ export function createLayerPanelRenderer(deps) {
|
||||
state.layers = newLayers;
|
||||
normalizeLayerClipping(state);
|
||||
composite();
|
||||
// Effects, adjustments and masks are sibling rows, not children
|
||||
// of the dragged row. Rebuild them under their reordered owner.
|
||||
render();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ const size = () => number('size', 'Size', 8, 0, 100);
|
||||
const angle = () => number('angle', 'Angle', 120, -180, 180, 'deg');
|
||||
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()] },
|
||||
'inner-shadow': { label: 'Inner Shadow', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 6, 0, 300)] },
|
||||
'inner-shadow': { label: 'Inner Shadow', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 6, 0, 100)] },
|
||||
'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)] },
|
||||
'gradient-overlay': { label: 'Gradient Overlay', controls: [color('color', 'Start', '#e06c75'), color('endColor', 'End', '#ffffff'), opacity(), angle(), number('scale', 'Scale', 100, 10, 300, '%')] },
|
||||
@@ -32,13 +32,6 @@ function surface(width, height) {
|
||||
}
|
||||
|
||||
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
|
||||
// this silhouette; compositing the source again thickens translucent edges.
|
||||
const silhouette = surface(source.width, source.height);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -14,26 +14,19 @@ function toolSettings(tool) {
|
||||
|
||||
let _lastColor = null;
|
||||
let _lastTransparentColor = null;
|
||||
function transparentColor(color, alpha = 0) {
|
||||
const key = `${color}|${alpha}`;
|
||||
if (key === _lastColor && _lastTransparentColor) return _lastTransparentColor;
|
||||
_lastColor = key;
|
||||
_lastTransparentColor = colorWithAlpha(color, alpha);
|
||||
return _lastTransparentColor;
|
||||
}
|
||||
|
||||
function colorWithAlpha(color, alpha) {
|
||||
function transparentColor(color) {
|
||||
if (color === _lastColor && _lastTransparentColor) return _lastTransparentColor;
|
||||
_lastColor = color;
|
||||
if (color.startsWith('#')) {
|
||||
const raw = color.slice(1);
|
||||
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));
|
||||
return `rgba(${rgb.join(',')},${alpha})`;
|
||||
_lastTransparentColor = `rgba(${rgb.join(',')},0)`;
|
||||
return _lastTransparentColor;
|
||||
}
|
||||
const match = color.match(/rgba?\(([^)]+)\)/);
|
||||
if (!match) return `rgba(0,0,0,${alpha})`;
|
||||
const components = match[1].split(',');
|
||||
const originalAlpha = components.length > 3 ? Number(components[3]) : 1;
|
||||
return `rgba(${components.slice(0, 3).join(',')},${alpha * originalAlpha})`;
|
||||
_lastTransparentColor = match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
|
||||
return _lastTransparentColor;
|
||||
}
|
||||
|
||||
function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
|
||||
@@ -45,19 +38,8 @@ function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
|
||||
ctx.fillStyle = color;
|
||||
} else {
|
||||
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(1, transparentColor(color));
|
||||
}
|
||||
gradient.addColorStop(0, color);
|
||||
gradient.addColorStop(1, transparentColor(color));
|
||||
ctx.fillStyle = gradient;
|
||||
}
|
||||
ctx.beginPath();
|
||||
@@ -292,10 +274,6 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
|
||||
if (!PAINT_TOOLS.has(tool)) return;
|
||||
const layer = activeLayer();
|
||||
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 === 'heal') {
|
||||
// A healing stroke without a picked source uses the convenient local
|
||||
@@ -316,7 +294,7 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
|
||||
let operation = 'source-over';
|
||||
let color = state.color;
|
||||
let alpha = opacity * flow;
|
||||
let softness = Math.max(0, Math.min(4, settings.softness / 300));
|
||||
let softness = Math.max(0, Math.min(1, settings.softness / 300));
|
||||
if (tool === 'eraser') { operation = 'destination-out'; color = 'rgba(0,0,0,1)'; }
|
||||
else if (tool === 'inpaint') {
|
||||
operation = state.inpaintEraseStroke ? 'destination-out' : 'source-over';
|
||||
|
||||
@@ -57,11 +57,8 @@ function wireToolSliders(prefix, fields) {
|
||||
// softness rises (the CSS already sets the radial gradient — we
|
||||
// just tween the inner solid radius to communicate the falloff).
|
||||
if (softPrev) {
|
||||
const amount = state[fields.softness];
|
||||
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%)`;
|
||||
const innerStop = Math.max(0, 60 - state[fields.softness] * 0.55);
|
||||
softPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,23 +20,6 @@ export function wireSubmenuToggles(menu) {
|
||||
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) => {
|
||||
const parent = e.target.closest('.ge-menu-parent');
|
||||
if (!parent) return;
|
||||
@@ -51,7 +34,10 @@ export function wireSubmenuToggles(menu) {
|
||||
parent.setAttribute('aria-expanded', String(willOpen));
|
||||
if (willOpen) {
|
||||
// Align the flyout with the clicked row rather than the menu top.
|
||||
positionPanel(parent);
|
||||
const panel = parent.nextElementSibling;
|
||||
if (panel && panel.classList.contains('ge-submenu')) {
|
||||
panel.style.top = parent.offsetTop + 'px';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { topPortalZ } from '../toolWindowZOrder.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'];
|
||||
@@ -102,7 +101,6 @@ export function wireTopbar(deps) {
|
||||
};
|
||||
const positionSaveMenu = () => {
|
||||
const r = saveBtn.getBoundingClientRect();
|
||||
saveMenu.style.zIndex = String(topPortalZ());
|
||||
saveMenu.style.top = `${r.bottom + 2}px`;
|
||||
saveMenu.style.right = `${Math.max(8, window.innerWidth - r.right)}px`;
|
||||
saveMenu.style.left = 'auto';
|
||||
|
||||
@@ -1321,12 +1321,7 @@ function _renderEditorLanding() {
|
||||
// openEditor() now returns a Promise — it's async because the size
|
||||
// prompt is a styled modal. Await it before checking whether the
|
||||
// editor actually opened (the user may have cancelled).
|
||||
try {
|
||||
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);
|
||||
}
|
||||
await openEditor(null, null, null, 'New canvas');
|
||||
if (!document.querySelector('#gallery-editor-container .gallery-editor')) {
|
||||
_renderEditorLanding();
|
||||
}
|
||||
|
||||
+57
-121
@@ -277,11 +277,8 @@ 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 { wireClipboardAndDrop } from './editor/clipboard-and-drop.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 { 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 { wireInpaintButtons } from './editor/ai-inpaint.js?v=20260708match1';
|
||||
import { wireAIToolsMisc } from './editor/ai-tools-misc.js';
|
||||
@@ -359,6 +356,7 @@ function _computeTransformSnap(frame, center) {
|
||||
const _INPAINT_DEFAULT_BRUSH = 100;
|
||||
let _samAbortController = null;
|
||||
let _precisionGuides = null;
|
||||
let _preflight = null;
|
||||
let _aiSettings = null;
|
||||
let _selectionAnimationFrame = null;
|
||||
let _selectionAnimationLast = 0;
|
||||
@@ -396,12 +394,6 @@ if (!window.__galleryEditEscHardGuardInstalled) {
|
||||
}
|
||||
if (e.target?.closest?.('#styled-confirm-overlay')) return;
|
||||
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
|
||||
// Escape reliably (their document-capture handlers can miss when focus is
|
||||
// outside them). Close the top-most one and stop here.
|
||||
@@ -846,7 +838,6 @@ function _setCanvasAiBusy(busy) {
|
||||
}
|
||||
|
||||
let _aiProgressTimer = null;
|
||||
let _aiProgressEpoch = 0;
|
||||
function _setAiProgress(frac, label) {
|
||||
const wrap = document.getElementById('ge-ai-command-progress');
|
||||
if (!wrap) return;
|
||||
@@ -866,17 +857,12 @@ function _startAiProgress(requestId) {
|
||||
const endpointId = arguments[1] || '';
|
||||
_stopAiProgress();
|
||||
if (!requestId) return;
|
||||
const epoch = _aiProgressEpoch;
|
||||
let pending = false;
|
||||
const tick = async () => {
|
||||
if (pending || epoch !== _aiProgressEpoch) return;
|
||||
pending = true;
|
||||
try {
|
||||
const epQuery = endpointId ? `&endpoint_id=${encodeURIComponent(endpointId)}` : '';
|
||||
const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}` + epQuery, { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
const p = await r.json();
|
||||
if (epoch !== _aiProgressEpoch) return;
|
||||
const st = p.stage || '';
|
||||
if (st === 'done' || st === 'error') { _setAiProgress(1, st === 'done' ? 'Done' : 'Failed'); return; }
|
||||
const frac = typeof p.progress === 'number' ? p.progress : 0;
|
||||
@@ -885,13 +871,12 @@ function _startAiProgress(requestId) {
|
||||
const verb = st === 'editing' ? 'Generating' : (st ? st[0].toUpperCase() + st.slice(1) : 'Working');
|
||||
_setAiProgress(frac, `${verb}…${stepTxt}${eta}`);
|
||||
_setAiCommandStatus(`${verb}…${stepTxt}${eta}`, 'running');
|
||||
} catch (_) {} finally { pending = false; }
|
||||
} catch (_) {}
|
||||
};
|
||||
_aiProgressTimer = setInterval(tick, 1000);
|
||||
tick();
|
||||
}
|
||||
function _stopAiProgress() {
|
||||
_aiProgressEpoch++;
|
||||
if (_aiProgressTimer) { clearInterval(_aiProgressTimer); _aiProgressTimer = null; }
|
||||
_setAiProgress(null);
|
||||
}
|
||||
@@ -1089,7 +1074,6 @@ const _QUICK_EDIT_TOOL_SUGGESTIONS = Object.entries(_TOOLS).map(([id, t]) => ({
|
||||
kind: 'Tool',
|
||||
icon: t.icon || '',
|
||||
toolId: id,
|
||||
runPrompt: id === 'rembg' ? 'remove background' : null,
|
||||
aliases: [id],
|
||||
}));
|
||||
const _QUICK_EDIT_CATALOG = [..._QUICK_EDIT_TOOL_SUGGESTIONS, ..._AI_COMMAND_SUGGESTIONS];
|
||||
@@ -1297,13 +1281,8 @@ function _wireAiCommandBox() {
|
||||
if (item.toolId) {
|
||||
_clickToolButton(item.toolId);
|
||||
_setAiCommandStatus(`${item.label} tool`, 'done');
|
||||
if (item.runPrompt) {
|
||||
input.value = item.runPrompt;
|
||||
syncClearButton();
|
||||
}
|
||||
hideSuggestions();
|
||||
input.focus();
|
||||
if (run && item.runPrompt) form.requestSubmit();
|
||||
return true;
|
||||
}
|
||||
input.value = item.insert;
|
||||
@@ -1492,24 +1471,6 @@ function _wireAiCommandBox() {
|
||||
// model (window.geEditorAI) just like the server would route it.
|
||||
const _executeQuickEditLocal = async (prompt) => {
|
||||
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
|
||||
// teal") needs the model to repaint a specific object — never fake it with a
|
||||
// global colour tweak from the deterministic fast path.
|
||||
@@ -1972,6 +1933,24 @@ function _wireAiCommandBox() {
|
||||
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)
|
||||
&& /\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
|
||||
@@ -2835,8 +2814,8 @@ function _wireAiCommandBox() {
|
||||
if (_looksBackgroundRemoval(prompt)) {
|
||||
_setAiCommandStatus('Removing the background…', 'running');
|
||||
try {
|
||||
await _executeQuickEditLocal(prompt);
|
||||
return { ok: true };
|
||||
const done = await _executeQuickEditLocal(prompt);
|
||||
if (done !== false) return { ok: true };
|
||||
} catch (err) {
|
||||
if (signal && signal.aborted) { _setAiCommandStatus('', ''); return { ok: false, cancelled: true }; }
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
@@ -2862,7 +2841,6 @@ function _wireAiCommandBox() {
|
||||
_setAiCommandStatus('Generated an image.', 'done');
|
||||
return { ok: true, image: res };
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return { ok: false, cancelled: true };
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
_setAiCommandStatus(_isGenOfflineMsg(msg) ? msg : `Could not generate an image: ${msg}`, 'error');
|
||||
return { ok: false, error: msg };
|
||||
@@ -3105,22 +3083,19 @@ function _wireAiCommandBox() {
|
||||
job.controller = controller;
|
||||
renderJobStack();
|
||||
_quickEditActiveJob = job;
|
||||
let result;
|
||||
try {
|
||||
result = await executeQuickEdit(job.prompt, { forceAi: !!job.forceAi, signal: controller ? controller.signal : null });
|
||||
await executeQuickEdit(job.prompt, { forceAi: !!job.forceAi, signal: controller ? controller.signal : null });
|
||||
} catch (err) {
|
||||
if (!job.cancelled) {
|
||||
job.status = 'error';
|
||||
job.detail = err?.message || 'AI edit failed';
|
||||
}
|
||||
} finally {
|
||||
_stopAiProgress();
|
||||
finishQuickEditJob(job, result);
|
||||
_setAiCommandStatus(job.cancelled ? '' : job.detail, job.cancelled ? '' : job.status);
|
||||
if (_quickEditActiveJob === job) _quickEditActiveJob = null;
|
||||
job.onChange = null;
|
||||
job.controller = null;
|
||||
if (job.cancelled) continue;
|
||||
if (job.status === 'running') job.status = 'done';
|
||||
renderJobStack();
|
||||
setTimeout(() => dismissJob(job), job.status === 'error' ? 3200 : 1600);
|
||||
}
|
||||
@@ -5332,9 +5307,6 @@ function _restoreState(snap) {
|
||||
}
|
||||
|
||||
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;
|
||||
const target = state.undoStack.pop();
|
||||
const cur = _snapshotState();
|
||||
@@ -6550,11 +6522,6 @@ const _nudgeTransform = _transformSession.nudgeTransform;
|
||||
const _confirmTransform = _transformSession.confirmTransform;
|
||||
const _cancelTransform = _transformSession.cancelTransform;
|
||||
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');
|
||||
if (sizePrompt && getComputedStyle(sizePrompt).display !== 'none') {
|
||||
sizePrompt._cancelCanvasSize?.();
|
||||
@@ -8084,7 +8051,6 @@ async function _canMutateLayerPixels(layer, action = 'editing pixels') {
|
||||
return false;
|
||||
}
|
||||
if (!['placed', 'text', 'shape'].includes(layer.kind)) return true;
|
||||
if (action === 'cutting pixels') return _rasterizeLayerNow(layer);
|
||||
return _confirmRasterizeLayer(layer);
|
||||
}
|
||||
|
||||
@@ -8283,13 +8249,10 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
|
||||
// standard slim red-thumb styling instead of the bare browser
|
||||
// default. Value chip on the same line as the label.
|
||||
const isColor = p.type === 'color';
|
||||
const isSelect = p.type === 'select';
|
||||
rows += `
|
||||
<div class="ge-filter-row ge-eraser-row">
|
||||
<label>${p.label}${isColor || isSelect ? '' : ` <span class="ge-filter-row-value" data-val-for="${p.key}">${p.value}${p.suffix || ''}</span>`}</label>
|
||||
${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
|
||||
<label>${p.label}${isColor ? '' : ` <span class="ge-filter-row-value" data-val-for="${p.key}">${p.value}${p.suffix || ''}</span>`}</label>
|
||||
${isColor
|
||||
? `<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}" />`}
|
||||
</div>
|
||||
@@ -8310,7 +8273,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
|
||||
for (const p of params) values[p.key] = p.value;
|
||||
// Initial preview render.
|
||||
try { onPreview(values); } catch {}
|
||||
overlay.querySelectorAll('input[data-key], select[data-key]').forEach(inp => {
|
||||
overlay.querySelectorAll('input[data-key]').forEach(inp => {
|
||||
inp.addEventListener('input', (e) => {
|
||||
if (!isCurrent()) { cleanup(null); return; }
|
||||
const k = e.target.dataset.key;
|
||||
@@ -8341,7 +8304,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
|
||||
e.preventDefault();
|
||||
cleanup(e.target?.dataset.action === 'cancel' ? null : values);
|
||||
} else if (e.key === 'Tab') {
|
||||
const fields = [...overlay.querySelectorAll('input, select, button')];
|
||||
const fields = [...overlay.querySelectorAll('input, button')];
|
||||
const index = fields.indexOf(document.activeElement);
|
||||
e.preventDefault();
|
||||
fields[(index + (e.shiftKey ? -1 : 1) + fields.length) % fields.length]?.focus();
|
||||
@@ -8353,7 +8316,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
|
||||
if (!overlay.isConnected || !isCurrent()) cleanup(null);
|
||||
});
|
||||
observer.observe(state.container, { childList: true, subtree: true });
|
||||
overlay.querySelector('input, select, button')?.focus({ preventScroll: true });
|
||||
overlay.querySelector('input, button')?.focus({ preventScroll: true });
|
||||
overlay.querySelector('[data-action="apply"]').addEventListener('click', () => cleanup(values));
|
||||
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => cleanup(null));
|
||||
// Click outside the modal (on the dim backdrop) = cancel.
|
||||
@@ -8519,7 +8482,6 @@ function _shadowOffsets(values = {}) {
|
||||
return {
|
||||
x: Math.round(Math.cos(radians) * distance),
|
||||
y: Math.round(-Math.sin(radians) * distance),
|
||||
inside: Number(values.inside) === 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8581,7 +8543,6 @@ async function _addRetainedEffect(type, presetName = null) {
|
||||
{ key: 'color', label: 'Color', type: 'color', value: '#000000' },
|
||||
{ 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: '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: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).angle, suffix: '\u00b0' },
|
||||
];
|
||||
@@ -8795,7 +8756,6 @@ async function _editRetainedEffect(layer, effect) {
|
||||
{ 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: '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: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance(p).angle, suffix: '\u00b0' },
|
||||
];
|
||||
@@ -10020,8 +9980,9 @@ function _buildEditor(container) {
|
||||
if (colorRow) colorRow.style.display = ['eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'liquify'].includes(toolId) ? 'none' : '';
|
||||
const colorLabel = colorRow?.querySelector('label');
|
||||
if (colorLabel) colorLabel.textContent = 'Color';
|
||||
// Secondary modifiers stay in More; brush softness remains in the main
|
||||
// strip. Only the active tool's secondary group is shown.
|
||||
// Per-tool stroke modifiers all live in the shared brush "More" menu so
|
||||
// every brush-family tool has the same compact strip as the Brush. Only
|
||||
// the group for the active tool is shown; the rest stay hidden.
|
||||
const strokeTool = (toolId === 'brush' || toolId === 'pixel' || toolId === 'dodge' || toolId === 'burn') ? 'brush'
|
||||
: toolId === 'eraser' ? 'eraser'
|
||||
: (toolId === 'clone' || toolId === 'heal') ? 'clone'
|
||||
@@ -10031,8 +9992,6 @@ function _buildEditor(container) {
|
||||
row.style.display = row.dataset.strokeTool === strokeTool ? '' : 'none';
|
||||
});
|
||||
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
|
||||
// Burn only (and never for the other brush-family tools).
|
||||
if (dodgeBurnRow) dodgeBurnRow.style.display = (toolId === 'dodge' || toolId === 'burn') ? '' : 'none';
|
||||
@@ -10485,6 +10444,7 @@ function _buildEditor(container) {
|
||||
canvasArea.appendChild(state.transformOverlay);
|
||||
canvasArea.appendChild(_buildQuickMaskBar());
|
||||
canvasArea.appendChild(_buildAiCommandBox());
|
||||
_preflight = createPreflight({ canvasArea });
|
||||
_aiSettings = createAiSettings({ container });
|
||||
try { window.geEditorAiSettings = _aiSettings; } catch (_) {}
|
||||
// On phones the rulers eat scarce canvas width; default them off.
|
||||
@@ -10677,10 +10637,19 @@ function _buildEditor(container) {
|
||||
const initialStatusTool = document.getElementById('ge-status-tool');
|
||||
if (initialStatusTool) initialStatusTool.textContent = _TOOLS[state.tool]?.label || state.tool || '';
|
||||
}
|
||||
// Keep model configuration in the AI settings dialog.
|
||||
// Preflight checklist: greet the user on open (unless dismissed) and
|
||||
// expose a footer button to re-open it.
|
||||
try {
|
||||
const footer = container.querySelector('.ge-editor-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');
|
||||
aiBtn.type = 'button';
|
||||
aiBtn.id = 'ge-ai-settings-btn';
|
||||
@@ -10690,6 +10659,7 @@ function _buildEditor(container) {
|
||||
aiBtn.addEventListener('click', () => _aiSettings?.open());
|
||||
footer.appendChild(aiBtn);
|
||||
}
|
||||
_preflight?.maybeOpen();
|
||||
} catch (_) {}
|
||||
try { _initControlKit(container); } catch (_) {}
|
||||
let _lastLayoutDesktop = null;
|
||||
@@ -11809,14 +11779,6 @@ function _buildEditor(container) {
|
||||
// Keyboard shortcuts — full implementation in
|
||||
// editor/keyboard-shortcuts.js.
|
||||
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,
|
||||
copyPixelsToClipboard: _copyPixelsToClipboard,
|
||||
toolbar, toolKeyMap: _toolKeyMap,
|
||||
@@ -12181,9 +12143,7 @@ function _buildEditor(container) {
|
||||
out.getContext('2d').drawImage(layer.canvas, off.x, off.y);
|
||||
return out;
|
||||
};
|
||||
const _aiEditCanvas = async (endpoint, payload = {}, { keepNaturalSize = true } = {}) => {
|
||||
await waitForImagePreferences();
|
||||
if (endpoint === '/api/image/inpaint') payload = { ...payload, _use_image_settings: true };
|
||||
const _aiEditCanvas = async (endpoint, payload = {}, { keepNaturalSize = false } = {}) => {
|
||||
const flat = _aiEditSourceCanvas();
|
||||
const image = flat.toDataURL('image/png').split(',')[1];
|
||||
const requestId = 'qe-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
||||
@@ -12194,7 +12154,7 @@ function _buildEditor(container) {
|
||||
} else {
|
||||
_startAiProgress(requestId);
|
||||
}
|
||||
const body = JSON.stringify({ image, width: flat.width, height: flat.height, request_id: requestId, ...payload });
|
||||
const body = JSON.stringify({ image, request_id: requestId, ...payload });
|
||||
// The image servers occasionally reject a request transiently (503 while a
|
||||
// model swaps, 502/504 gateways, dropped connections). Retry those a couple
|
||||
// of times with backoff so a hiccup doesn't silently leave the canvas
|
||||
@@ -12252,7 +12212,7 @@ function _buildEditor(container) {
|
||||
c.height = state.imgHeight;
|
||||
}
|
||||
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
|
||||
return { canvas: c, dataUrl: c.toDataURL('image/png'), fullFrame: data.full_frame === true };
|
||||
return { canvas: c, dataUrl: c.toDataURL('image/png') };
|
||||
} finally {
|
||||
if (_timer) clearTimeout(_timer);
|
||||
if (_aiEditAbort === _ctl) _aiEditAbort = null;
|
||||
@@ -12261,17 +12221,12 @@ function _buildEditor(container) {
|
||||
};
|
||||
const _placeResultLayer = (canvas, name, maskCanvas) => {
|
||||
_saveState(`AI: ${name}`);
|
||||
const geometry = imageResultGeometry(canvas.width, canvas.height, state.imgWidth, state.imgHeight);
|
||||
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);
|
||||
const layer = createLayer(name, state.imgWidth, state.imgHeight);
|
||||
layer.ctx.drawImage(canvas, 0, 0);
|
||||
if (maskCanvas) {
|
||||
layer.ctx.save();
|
||||
layer.ctx.globalCompositeOperation = 'destination-in';
|
||||
layer.ctx.drawImage(maskCanvas, -offset.x, -offset.y);
|
||||
layer.ctx.drawImage(maskCanvas, 0, 0);
|
||||
layer.ctx.restore();
|
||||
}
|
||||
state.layers.push(layer);
|
||||
@@ -12281,24 +12236,13 @@ function _buildEditor(container) {
|
||||
_renderLayerPanel();
|
||||
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) => {
|
||||
if (!state.wandMask) throw new Error('make a selection first');
|
||||
const layer = activeLayer() || _activeParentLayer();
|
||||
if (!layer) throw new Error('no layer');
|
||||
const mask = state.wandMask.toDataURL('image/png').split(',')[1];
|
||||
const selection = state.wandMask;
|
||||
const { canvas, fullFrame } = await _aiEditCanvas('/api/image/inpaint', { mask, prompt: prompt || '', steps: 4 });
|
||||
return _placeResultLayer(canvas, 'Generative fill', fullFrame ? null : selection);
|
||||
const { canvas } = await _aiEditCanvas('/api/image/inpaint', { mask, prompt: prompt || '', steps: 4 });
|
||||
return _placeResultLayer(canvas, 'Generative fill', state.wandMask);
|
||||
};
|
||||
// Replace the background/scene while keeping the subject: the server derives
|
||||
// the subject mask, repaints the inverse (new background), harmonises colour
|
||||
@@ -12591,7 +12535,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.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.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.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.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.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; } },
|
||||
@@ -12607,7 +12551,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.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.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.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.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.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; } },
|
||||
@@ -12852,10 +12796,6 @@ function _buildEditor(container) {
|
||||
endpoint: window.geEditorAgentEndpoint || '/api/editor/agent',
|
||||
getSession: () => agentBridgeSession,
|
||||
getModel: () => _getSelectedQuickEditModel(),
|
||||
getGenerationModel: () => {
|
||||
const value = document.getElementById('ge-ai-model')?.value || '';
|
||||
return value.includes('::') ? value.slice(value.indexOf('::') + 2) : '';
|
||||
},
|
||||
getAgentModel: () => _getSelectedQuickEditAgentModel(),
|
||||
});
|
||||
} catch (e) { console.error('[ge-editor] agent init failed', e); }
|
||||
@@ -13955,12 +13895,6 @@ export function openEditor(imageUrl, imageId, presetSize, displayName, draftId)
|
||||
return;
|
||||
}
|
||||
_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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14115,6 +14049,8 @@ export function closeEditor(options = {}) {
|
||||
_selectionNudgeTimer = null;
|
||||
_selectionNudgeHistorySaved = false;
|
||||
_precisionGuides = null;
|
||||
try { _preflight?.destroy(); } catch {}
|
||||
_preflight = null;
|
||||
try { _aiSettings?.destroy(); } catch {}
|
||||
_aiSettings = null;
|
||||
try { delete window.geEditorAiSettings; } catch (_) { window.geEditorAiSettings = null; }
|
||||
|
||||
@@ -379,7 +379,6 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
|
||||
root.classList.toggle('keyboard-open', keyboardOpen);
|
||||
if (keyboardOpen) root.style.setProperty('--keyboard-viewport-height', vv.height + 'px');
|
||||
else root.style.removeProperty('--keyboard-viewport-height');
|
||||
if (window.scrollY) window.scrollTo(0, 0);
|
||||
};
|
||||
// iOS fires visualViewport resize only once, mid-animation, and no focusin when
|
||||
// the tapped field already had focus (e.g. the composer after a new chat)
|
||||
@@ -392,7 +391,6 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
|
||||
window.visualViewport.addEventListener('scroll', _syncKeyboardViewport);
|
||||
document.addEventListener('focusin', _syncThroughKeyboardAnimation);
|
||||
document.addEventListener('focusout', _syncThroughKeyboardAnimation);
|
||||
window.addEventListener('scroll', () => { if (window.scrollY) window.scrollTo(0, 0); }, { passive: true });
|
||||
|
||||
let _lastVVHeight = window.visualViewport.height;
|
||||
window.visualViewport.addEventListener('resize', function() {
|
||||
|
||||
@@ -4,14 +4,12 @@
|
||||
// them from the same places, so load order and init sequence are untouched.
|
||||
|
||||
import { byId as el } from './dom.js';
|
||||
import { saveImagePreferences as _postSettings } from './imagePreferences.js';
|
||||
import { postSettings as _postSettings } from './api.js';
|
||||
import { modelCaps } from '../editor/ai-models.js';
|
||||
import { sortModelIds } from '../modelSort.js';
|
||||
let preferencesListener = null;
|
||||
|
||||
export async function initImageSettings() {
|
||||
const modelSel = el('set-imgModelSelect');
|
||||
const editModelSel = el('set-imgEditModelSelect');
|
||||
const qualSel = el('set-imgQualitySelect');
|
||||
const msg = el('set-imgSettingsMsg');
|
||||
const enabledToggle = el('set-imgEnabledToggle');
|
||||
@@ -20,36 +18,23 @@ export async function initImageSettings() {
|
||||
const endpointsRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
const endpoints = await endpointsRes.json();
|
||||
const imageModels = new Set();
|
||||
const editModels = new Set();
|
||||
(Array.isArray(endpoints) ? endpoints : []).forEach(endpoint => {
|
||||
if (!endpoint.is_enabled || !endpoint.online) return;
|
||||
(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).inpaint) editModels.add(String(modelId));
|
||||
});
|
||||
});
|
||||
modelSel.length = 1;
|
||||
sortModelIds(Array.from(imageModels)).forEach(mid => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = mid;
|
||||
opt.textContent = mid;
|
||||
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); }
|
||||
try {
|
||||
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
const settings = await settingsRes.json();
|
||||
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 (enabledToggle) enabledToggle.checked = settings.image_gen_enabled === true;
|
||||
} catch (e) { console.warn('Failed to load settings', e); }
|
||||
@@ -64,19 +49,12 @@ export async function initImageSettings() {
|
||||
|
||||
async function saveSettings() {
|
||||
try {
|
||||
const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_edit_model: editModelSel?.value || '', image_quality: qualSel.value });
|
||||
const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value });
|
||||
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);
|
||||
} 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);
|
||||
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);
|
||||
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncImgDisabled(); saveSettings(); });
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { invalidateSettings } from '../appConfig.js';
|
||||
|
||||
export async function readImagePreferences() {
|
||||
const response = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
if (!response.ok) throw new Error('Could not load image settings');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
let saveQueue = Promise.resolve();
|
||||
export function waitForImagePreferences() { return saveQueue; }
|
||||
export function saveImagePreferences(patch) {
|
||||
const save = async () => {
|
||||
const response = await fetch('/api/auth/settings', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch),
|
||||
});
|
||||
if (!response.ok) throw new Error(response.status === 403
|
||||
? 'Admin access is required to change image settings.' : '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;
|
||||
}
|
||||
@@ -267,8 +267,9 @@ export function makeWindowResizable(content, options = {}) {
|
||||
content.style.maxWidth = 'none';
|
||||
content.style.maxHeight = 'none';
|
||||
const left = parseFloat(content.style.left);
|
||||
if (Number.isFinite(left) && left + w > window.innerWidth - padRight) {
|
||||
content.style.left = Math.max(padLeft, window.innerWidth - padRight - w) + 'px';
|
||||
if (Number.isFinite(left)) {
|
||||
const rightmostLeft = Math.max(padLeft, window.innerWidth - padRight - w);
|
||||
content.style.left = Math.max(padLeft, Math.min(left, rightmostLeft)) + 'px';
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
+1
-5
@@ -7,7 +7,7 @@
|
||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||
// - API / non-GET: never cached.
|
||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
||||
const CACHE_NAME = 'odysseus-v1133-pr40-editor-precache';
|
||||
const CACHE_NAME = 'odysseus-v1114-pr40-editor-precache';
|
||||
|
||||
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
||||
// gives offline math fallback glyphs instead of proper typesetting.
|
||||
@@ -236,10 +236,6 @@ const PANEL_PRECACHE = [
|
||||
'/static/js/editor/aspect-fit.js',
|
||||
'/static/js/editor/preflight.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/editor-control.js',
|
||||
'/static/js/editor/editor-bridge.js',
|
||||
|
||||
@@ -99,8 +99,10 @@ cleanup. Specifically:
|
||||
`os.environ[...] = ...` that outlives the test.
|
||||
- **Current working directory** - never `chdir` without restoring; never assert
|
||||
against cwd-relative paths like `./data`. Use a temp workspace helper instead.
|
||||
- **Database** - the root `conftest.py` defaults `DATABASE_URL` to an in-memory
|
||||
SQLite for collection safety. A test that needs a real file-backed DB must opt
|
||||
- **Database** - the root `conftest.py` defaults `DATABASE_URL` to a private,
|
||||
worker-owned SQLite file for collection safety and consistent schema across
|
||||
TestClient threads. This fallback is not a per-test data fixture. A test that
|
||||
writes or depends on particular rows must opt
|
||||
in explicitly via `tests.helpers.sqlite_db.make_temp_sqlite` and bind its
|
||||
`SessionLocal` onto the module under test. Do not rely on a persistent
|
||||
on-disk DB existing.
|
||||
|
||||
+14
-1
@@ -12,9 +12,17 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
# File-backed databases remain fixture-owned. Cleanup restores the caller.
|
||||
# Keep the existing generated environment-reference locations stable.
|
||||
_database_environment = pytest.MonkeyPatch()
|
||||
_database_environment.setenv("DATABASE_URL", "sqlite:///:memory:")
|
||||
from tests.helpers.worker_runtime import bootstrap_runtime, configure_runtime
|
||||
_runtime_environment = bootstrap_runtime()
|
||||
# A file keeps the collection schema consistent across TestClient threads;
|
||||
# SingletonThreadPool can evict the populated in-memory main-thread database.
|
||||
# One patcher owns this variable, including restoration of an inherited URL.
|
||||
_database_environment.setenv(
|
||||
"DATABASE_URL",
|
||||
f"sqlite:///{_runtime_environment.root / 'collection.db'}"
|
||||
if _runtime_environment is not None else "sqlite:///:memory:",
|
||||
)
|
||||
_collection_database_engine = None
|
||||
|
||||
# Pre-import real heavy modules BEFORE any test file's module-level stubs can
|
||||
# replace them with MagicMock. Some test files (e.g. test_llm_core_sanitize_*)
|
||||
@@ -26,6 +34,7 @@ try:
|
||||
import sqlalchemy.orm # noqa: F401
|
||||
import core.database # noqa: F401
|
||||
import src.database
|
||||
_collection_database_engine = core.database.engine
|
||||
except ImportError:
|
||||
pass # not installed - the stubs below will handle it
|
||||
|
||||
@@ -100,6 +109,10 @@ def pytest_configure(config):
|
||||
only registers marker names; it imports no production module.
|
||||
"""
|
||||
config.add_cleanup(_database_environment.undo)
|
||||
if _collection_database_engine is not None:
|
||||
# Registered after runtime cleanup, so connections close before the
|
||||
# owned file is removed (also required on Windows).
|
||||
config.add_cleanup(_collection_database_engine.dispose)
|
||||
|
||||
import pathlib
|
||||
from tests._taxonomy import discover_markers
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Browser End-to-End Tests
|
||||
|
||||
## Photo Editor Release Gate
|
||||
|
||||
The photo editor release gate launches an isolated Odysseus server on port
|
||||
`7013` with a temporary SQLite database and disposable authenticated account.
|
||||
|
||||
@@ -18,3 +20,53 @@ The disposable account is created in the temporary data directory and exercises
|
||||
owner-scoped draft and notification routes without using an existing account.
|
||||
`PHOTO_EDITOR_E2E_AUTH=0` selects the legacy unauthenticated configuration for
|
||||
investigating it; offline-upgrade coverage requires the authenticated mode.
|
||||
|
||||
## Mobile and iOS Layout Suite
|
||||
|
||||
The dedicated mobile and iOS layout suite exercises viewport behavior,
|
||||
accessibility zoom, scrolling, mobile safe-area geometry (Notes and Documents
|
||||
panes), and floating window restoration against an isolated local Odysseus server
|
||||
running on port `17739`.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run test:ios-layout:chromium
|
||||
```
|
||||
|
||||
### Profiles and Browser Prerequisites
|
||||
|
||||
- **Chromium profiles** (`desktop-chromium`, `phone-chromium-portrait`, `phone-chromium-landscape`, `tablet-chromium`):
|
||||
Provide automated baseline coverage in CI and local development without requiring
|
||||
WebKit dependencies.
|
||||
- **WebKit profiles** (`phone-webkit-portrait`, `phone-webkit-landscape`, `tablet-webkit`):
|
||||
Used for platform-oriented WebKit rendering validation. WebKit requires browser binaries:
|
||||
|
||||
```bash
|
||||
npm run test:ios-layout:install
|
||||
# or: npx playwright install webkit
|
||||
npm run test:ios-layout:webkit
|
||||
```
|
||||
|
||||
To run all installed profiles:
|
||||
|
||||
```bash
|
||||
npm run test:ios-layout
|
||||
```
|
||||
|
||||
### Evidence and Cleanup
|
||||
|
||||
- **Evidence artifacts**: Screenshots are written to `test-results/ios-layout/` by
|
||||
default and traces are written to `test-results/ios-layout-traces/`. Both paths
|
||||
are git-ignored. Set `ODYSSEUS_LAYOUT_EVIDENCE_DIR` to override the screenshot
|
||||
output path.
|
||||
- **Teardown**: Disposable application state and database files created in the
|
||||
OS temporary directory are automatically cleaned up on both normal and failure
|
||||
teardown, preserving diagnostic screenshots and failure traces.
|
||||
|
||||
### Platform Considerations
|
||||
|
||||
Emulated Chromium and WebKit viewports validate deterministic CSS cascade,
|
||||
computed layout geometry, viewport meta tags, and JavaScript restoration. However,
|
||||
browser emulation is not a physical device substitute: physical iOS Safari and
|
||||
standalone installed PWAs should still be verified separately for native gesture
|
||||
pinch-zoom, software keyboard dismissal transitions, and dynamic hardware notch/island insets.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
/**
|
||||
* Global teardown for the iOS/mobile layout Playwright test suite.
|
||||
* Cleans up only disposable temporary application data directories,
|
||||
* preserving diagnostic artifacts (screenshots and failure traces) in test-results/.
|
||||
*/
|
||||
module.exports = async () => {
|
||||
const dataDirectory = process.env.ODYSSEUS_LAYOUT_DATA_DIR;
|
||||
if (!dataDirectory || typeof dataDirectory !== 'string') return;
|
||||
|
||||
try {
|
||||
const resolvedData = path.resolve(dataDirectory);
|
||||
const allowedTmpBases = [os.tmpdir(), '/var/tmp', '/tmp'].map(b => path.resolve(b));
|
||||
const isTmp = allowedTmpBases.some(base => resolvedData.startsWith(base + path.sep));
|
||||
const baseName = path.basename(resolvedData);
|
||||
|
||||
if (isTmp && (baseName.startsWith('odysseus-ios-layout-') || baseName.startsWith('odysseus-pr6396-'))) {
|
||||
if (fs.existsSync(resolvedData)) {
|
||||
fs.rmSync(resolvedData, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[ios-layout-teardown] Warning: failed to clean temporary runtime dir: ${err.message}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
// Tests against the running Odysseus app, not a static mock page.
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
const repositoryRoot = path.resolve(__dirname, '../..');
|
||||
const port = Number(process.env.ODYSSEUS_LAYOUT_PORT || '17739');
|
||||
const baseURL = `http://127.0.0.1:${port}`;
|
||||
const defaultPython = process.platform === 'win32'
|
||||
? path.join(repositoryRoot, '.venv', 'Scripts', 'python.exe')
|
||||
: path.join(repositoryRoot, '.venv', 'bin', 'python');
|
||||
const python = process.env.ODYSSEUS_TEST_PYTHON
|
||||
|| (fs.existsSync(defaultPython) ? defaultPython : 'python3');
|
||||
|
||||
const dataDirectory = process.env.ODYSSEUS_LAYOUT_DATA_DIR
|
||||
|| fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-ios-layout-'));
|
||||
process.env.ODYSSEUS_LAYOUT_DATA_DIR = dataDirectory;
|
||||
|
||||
// Default evidence directory to repository-relative test-results/ios-layout
|
||||
// while preserving the environment-variable override if provided.
|
||||
const evidenceDir = process.env.ODYSSEUS_LAYOUT_EVIDENCE_DIR
|
||||
|| path.join(repositoryRoot, 'test-results', 'ios-layout');
|
||||
const outputDir = path.join(repositoryRoot, 'test-results', 'ios-layout-traces');
|
||||
|
||||
// Emergency process-exit cleanup for disposable application runtime state
|
||||
const cleanDataDirSync = () => {
|
||||
const dir = process.env.ODYSSEUS_LAYOUT_DATA_DIR;
|
||||
if (!dir || typeof dir !== 'string') return;
|
||||
try {
|
||||
const resolved = path.resolve(dir);
|
||||
const allowed = [os.tmpdir(), '/var/tmp', '/tmp'].map(b => path.resolve(b));
|
||||
if (allowed.some(base => resolved.startsWith(base + path.sep))) {
|
||||
const baseName = path.basename(resolved);
|
||||
if (baseName.startsWith('odysseus-ios-layout-') || baseName.startsWith('odysseus-pr6396-')) {
|
||||
if (fs.existsSync(resolved)) {
|
||||
fs.rmSync(resolved, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
process.once('SIGINT', () => { cleanDataDirSync(); process.exit(130); });
|
||||
process.once('SIGTERM', () => { cleanDataDirSync(); process.exit(143); });
|
||||
|
||||
function isWebKitInstalled() {
|
||||
try {
|
||||
const { webkit } = require('@playwright/test');
|
||||
return fs.existsSync(webkit.executablePath());
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const chromiumProjects = [
|
||||
{
|
||||
name: 'desktop-chromium',
|
||||
use: { browserName: 'chromium', viewport: { width: 1440, height: 900 } },
|
||||
},
|
||||
{
|
||||
name: 'phone-chromium-portrait',
|
||||
use: { browserName: 'chromium', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true },
|
||||
},
|
||||
{
|
||||
name: 'phone-chromium-landscape',
|
||||
use: { browserName: 'chromium', viewport: { width: 844, height: 390 }, hasTouch: true, isMobile: true },
|
||||
},
|
||||
{
|
||||
name: 'tablet-chromium',
|
||||
use: { browserName: 'chromium', viewport: { width: 820, height: 1000 }, hasTouch: true, isMobile: true },
|
||||
},
|
||||
];
|
||||
|
||||
const webkitProjects = [
|
||||
{
|
||||
name: 'phone-webkit-portrait',
|
||||
use: { browserName: 'webkit', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true },
|
||||
},
|
||||
{
|
||||
name: 'phone-webkit-landscape',
|
||||
use: { browserName: 'webkit', viewport: { width: 844, height: 390 }, hasTouch: true, isMobile: true },
|
||||
},
|
||||
{
|
||||
name: 'tablet-webkit',
|
||||
use: { browserName: 'webkit', viewport: { width: 820, height: 1000 }, hasTouch: true, isMobile: true },
|
||||
},
|
||||
];
|
||||
|
||||
// Determine active projects based on environment and local browser availability
|
||||
const requestedBrowser = (process.env.ODYSSEUS_LAYOUT_BROWSER || 'all').toLowerCase();
|
||||
let selectedProjects = [];
|
||||
|
||||
if (requestedBrowser === 'chromium') {
|
||||
selectedProjects = chromiumProjects;
|
||||
} else if (requestedBrowser === 'webkit') {
|
||||
selectedProjects = webkitProjects;
|
||||
} else {
|
||||
// 'all': Include chromium always; include webkit if installed or if explicitly targeted via cli args
|
||||
const hasCliWebKitTarget = process.argv.some(arg => arg.toLowerCase().includes('webkit'));
|
||||
if (isWebKitInstalled() || hasCliWebKitTarget) {
|
||||
selectedProjects = [...chromiumProjects, ...webkitProjects];
|
||||
} else {
|
||||
// Only Chromium is available locally (e.g. standard CI / default runner)
|
||||
selectedProjects = chromiumProjects;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: path.join(__dirname, 'ios-layout'),
|
||||
testMatch: ['**/*.spec.cjs'],
|
||||
outputDir,
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 15_000 },
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [['line']],
|
||||
globalTeardown: require.resolve('./ios-layout-teardown.cjs'),
|
||||
use: {
|
||||
baseURL,
|
||||
headless: true,
|
||||
serviceWorkers: 'block',
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
projects: selectedProjects,
|
||||
metadata: { evidenceDir },
|
||||
webServer: {
|
||||
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
|
||||
cwd: repositoryRoot,
|
||||
url: baseURL,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: false,
|
||||
env: {
|
||||
...process.env,
|
||||
AUTH_ENABLED: 'false',
|
||||
// Never connect the disposable app to the host's real ChromaDB.
|
||||
CHROMADB_HOST: '127.0.0.1',
|
||||
CHROMADB_PORT: String(port + 1),
|
||||
LLM_HOST: '127.0.0.1',
|
||||
EMBEDDING_URL: `http://127.0.0.1:${port + 1}/v1/embeddings`,
|
||||
DATABASE_URL: `sqlite:///${path.join(dataDirectory, 'app.db')}`,
|
||||
ODYSSEUS_DATA_DIR: dataDirectory,
|
||||
ODYSSEUS_STARTUP_WARMUPS: '0',
|
||||
RESPONSE_COMPRESSION_ENABLED: 'false',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
async function openApp(page) {
|
||||
const response = await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
expect(response?.ok()).toBeTruthy();
|
||||
// The runtime currently produces two #message nodes. Select the
|
||||
// initialized composer while diagnosing whether both are visible.
|
||||
const composers = await page.locator('textarea#message').evaluateAll(nodes =>
|
||||
nodes.map((el, index) => ({
|
||||
index,
|
||||
initialized: el.dataset.startupPreserveBound === '1',
|
||||
visible: !!el.getClientRects().length &&
|
||||
getComputedStyle(el).visibility !== 'hidden',
|
||||
parent: el.parentElement?.className || '',
|
||||
}))
|
||||
);
|
||||
if (composers.length !== 1) {
|
||||
console.log('Composer DOM diagnostic:', JSON.stringify(composers));
|
||||
}
|
||||
const activeComposer = page.locator(
|
||||
'textarea#message[data-startup-preserve-bound="1"]'
|
||||
);
|
||||
await expect(activeComposer).toHaveCount(1);
|
||||
await expect(activeComposer).toBeVisible();
|
||||
expect(
|
||||
composers.filter(composer => composer.visible).length,
|
||||
'Exactly one composer should be visible'
|
||||
).toBe(1);
|
||||
}
|
||||
|
||||
function isMobileViewport(testInfo) {
|
||||
const width = testInfo.project.use?.viewport?.width || 0;
|
||||
return width > 0 && width <= 768;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reach Settings through genuine UI controls on either desktop or mobile viewports.
|
||||
* On mobile (<=768px), the rail is hidden and the sidebar must be opened via hamburger.
|
||||
*/
|
||||
async function openSettings(page, isMobile) {
|
||||
let settingsButton;
|
||||
if (isMobile) {
|
||||
const hamburger = page.locator('#hamburger-btn:visible').first();
|
||||
await expect(hamburger, 'Hamburger button must be visible on mobile').toBeVisible({ timeout: 5000 });
|
||||
await hamburger.click();
|
||||
const sidebar = page.locator('#sidebar:not(.hidden)');
|
||||
await expect(sidebar, 'Sidebar must open upon hamburger tap').toBeVisible({ timeout: 5000 });
|
||||
await page.waitForTimeout(300);
|
||||
settingsButton = sidebar.locator('#user-bar-settings');
|
||||
} else {
|
||||
settingsButton = page.locator('#rail-settings:visible, #user-bar-settings:visible').first();
|
||||
}
|
||||
|
||||
await expect(settingsButton, 'Settings button must be reachable and visible').toBeVisible({ timeout: 5000 });
|
||||
await settingsButton.click();
|
||||
const dialog = page.locator('#settings-modal');
|
||||
await expect(dialog, 'Settings modal must become visible').toBeVisible({ timeout: 5000 });
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reach Notes through genuine UI controls on either desktop or mobile viewports.
|
||||
*/
|
||||
async function openNotes(page, isMobile) {
|
||||
let notesButton;
|
||||
if (isMobile) {
|
||||
const hamburger = page.locator('#hamburger-btn:visible').first();
|
||||
await expect(hamburger, 'Hamburger button must be visible on mobile').toBeVisible({ timeout: 5000 });
|
||||
await hamburger.click();
|
||||
const sidebar = page.locator('#sidebar:not(.hidden)');
|
||||
await expect(sidebar, 'Sidebar must open upon hamburger tap').toBeVisible({ timeout: 5000 });
|
||||
await page.waitForTimeout(300);
|
||||
notesButton = sidebar.locator('#tool-notes-btn');
|
||||
} else {
|
||||
notesButton = page.locator('#rail-notes:visible, #tool-notes-btn:visible').first();
|
||||
}
|
||||
|
||||
await expect(notesButton, 'Notes button must be reachable and visible').toBeVisible({ timeout: 5000 });
|
||||
await notesButton.click();
|
||||
const realNotes = page.locator('.notes-pane').first();
|
||||
await expect(realNotes, 'Notes pane must become visible').toBeVisible({ timeout: 5000 });
|
||||
return realNotes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reach Document editor through genuine UI controls on either desktop or mobile viewports.
|
||||
*/
|
||||
async function openDocument(page, isMobile) {
|
||||
let docButton;
|
||||
if (isMobile) {
|
||||
const hamburger = page.locator('#hamburger-btn:visible').first();
|
||||
await expect(hamburger, 'Hamburger button must be visible on mobile').toBeVisible({ timeout: 5000 });
|
||||
await hamburger.click();
|
||||
const sidebar = page.locator('#sidebar:not(.hidden)');
|
||||
await expect(sidebar, 'Sidebar must open upon hamburger tap').toBeVisible({ timeout: 5000 });
|
||||
await page.waitForTimeout(300);
|
||||
docButton = sidebar.locator('#library-new-doc-btn');
|
||||
} else {
|
||||
docButton = page.locator('#library-new-doc-btn:visible').first();
|
||||
if (!(await docButton.count())) {
|
||||
const hamburger = page.locator('#hamburger-btn:visible').first();
|
||||
if (await hamburger.count()) {
|
||||
await hamburger.click();
|
||||
await page.waitForTimeout(300);
|
||||
docButton = page.locator('#sidebar:not(.hidden) #library-new-doc-btn');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await expect(docButton, 'Document creation button must be reachable and visible').toBeVisible({ timeout: 5000 });
|
||||
await docButton.click();
|
||||
const docPane = page.locator('.doc-editor-pane').first();
|
||||
await expect(docPane, 'Document editor pane must become visible').toBeVisible({ timeout: 10000 });
|
||||
return docPane;
|
||||
}
|
||||
|
||||
test('renders actual Odysseus UI and captures review screenshots', async ({ page }, testInfo) => {
|
||||
await openApp(page);
|
||||
const out = testInfo.config.metadata.evidenceDir;
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const name = testInfo.project.name;
|
||||
const isMobile = isMobileViewport(testInfo);
|
||||
|
||||
// 1. Chat screenshot (default view)
|
||||
await page.screenshot({ path: path.join(out, `${name}-chat.png`), fullPage: false, animations: 'disabled' });
|
||||
|
||||
// 2. Settings screenshot via actual user-visible navigation
|
||||
await openApp(page);
|
||||
await openSettings(page, isMobile);
|
||||
await page.screenshot({ path: path.join(out, `${name}-settings.png`), fullPage: false, animations: 'disabled' });
|
||||
|
||||
// 3. Notes screenshot via actual user-visible navigation
|
||||
await openApp(page);
|
||||
await openNotes(page, isMobile);
|
||||
await page.screenshot({ path: path.join(out, `${name}-notes.png`), fullPage: false, animations: 'disabled' });
|
||||
|
||||
testInfo.annotations.push({ type: 'evidence', description: out });
|
||||
});
|
||||
|
||||
test('viewport meta does not restrict user zoom', async ({ page }) => {
|
||||
await openApp(page);
|
||||
const meta = await page.locator('meta[name="viewport"]').getAttribute('content');
|
||||
expect(meta || '').not.toMatch(/maximum-scale\s*=\s*1(?:\b|\.)/i);
|
||||
expect(meta || '').not.toMatch(/user-scalable\s*=\s*no/i);
|
||||
});
|
||||
|
||||
test('browser-owned window scrolling is not globally reset', async ({ page }) => {
|
||||
await openApp(page);
|
||||
const value = await page.evaluate(async () => {
|
||||
document.documentElement.style.overflowY = 'auto';
|
||||
document.body.style.overflowY = 'auto';
|
||||
document.body.style.height = 'auto';
|
||||
document.body.style.minHeight = '3200px';
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'position:absolute;top:3100px;width:1px;height:10px;pointer-events:none;';
|
||||
document.body.appendChild(spacer);
|
||||
window.scrollTo(0, 250);
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
return window.scrollY;
|
||||
});
|
||||
expect(value).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test('CSS cascade rules for mobile Notes and Documents respect safe areas', async ({ page }, testInfo) => {
|
||||
test.skip(!isMobileViewport(testInfo), 'Only mobile viewports <=768px');
|
||||
await openApp(page);
|
||||
const results = await page.evaluate(() => {
|
||||
document.documentElement.style.setProperty('--safe-top', '44px');
|
||||
document.documentElement.style.setProperty('--sheet-top', '68px');
|
||||
document.documentElement.style.setProperty('--app-height', '844px');
|
||||
const values = {};
|
||||
for (const [key, bodyClass, paneClass] of [
|
||||
['notes', 'notes-view', 'notes-pane'],
|
||||
['document', 'doc-view', 'doc-editor-pane'],
|
||||
]) {
|
||||
const pane = document.createElement('section');
|
||||
pane.className = paneClass;
|
||||
pane.style.opacity = '0'; // CSS cascade regression check
|
||||
document.body.appendChild(pane);
|
||||
document.body.classList.add(bodyClass);
|
||||
const style = window.getComputedStyle(pane);
|
||||
values[key] = {
|
||||
top: style.top,
|
||||
height: style.height,
|
||||
insetTop: pane.getBoundingClientRect().top,
|
||||
};
|
||||
pane.remove();
|
||||
document.body.classList.remove(bodyClass);
|
||||
}
|
||||
return values;
|
||||
});
|
||||
for (const key of ['notes', 'document']) {
|
||||
expect(results[key].top, key).toBe('68px');
|
||||
expect(parseFloat(results[key].height), key).toBeGreaterThanOrEqual(770);
|
||||
}
|
||||
});
|
||||
|
||||
test('real Notes and Document panes honor safe-area geometry in the live application', async ({ page }, testInfo) => {
|
||||
const isMobile = isMobileViewport(testInfo);
|
||||
test.skip(!isMobile, 'Only mobile viewports <=768px');
|
||||
await openApp(page);
|
||||
|
||||
// Configure simulated safe-area insets (e.g. iPhone notch / Dynamic Island)
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.setProperty('--safe-top', '44px');
|
||||
document.documentElement.style.setProperty('--sheet-top', '68px');
|
||||
document.documentElement.style.setProperty('--app-height', '844px');
|
||||
});
|
||||
|
||||
// 1. Verify actual Notes UI
|
||||
const realNotes = await openNotes(page, isMobile);
|
||||
const notesMetrics = await realNotes.evaluate(el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
top: style.top,
|
||||
height: style.height,
|
||||
maxHeight: style.maxHeight,
|
||||
rectTop: rect.top,
|
||||
rectHeight: rect.height,
|
||||
};
|
||||
});
|
||||
|
||||
expect(notesMetrics.top, 'Real Notes pane top must match --sheet-top').toBe('68px');
|
||||
expect(parseFloat(notesMetrics.height), 'Real Notes pane height must respect safe area').toBeGreaterThanOrEqual(770);
|
||||
expect(notesMetrics.rectTop, 'Real Notes pane must not overlap the 44px safe-area top notch').toBeGreaterThanOrEqual(44);
|
||||
|
||||
// 2. Verify actual Document UI
|
||||
await openApp(page);
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.setProperty('--safe-top', '44px');
|
||||
document.documentElement.style.setProperty('--sheet-top', '68px');
|
||||
document.documentElement.style.setProperty('--app-height', '844px');
|
||||
});
|
||||
|
||||
const realDoc = await openDocument(page, isMobile);
|
||||
const docMetrics = await realDoc.evaluate(el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
top: style.top,
|
||||
height: style.height,
|
||||
maxHeight: style.maxHeight,
|
||||
rectTop: rect.top,
|
||||
rectHeight: rect.height,
|
||||
};
|
||||
});
|
||||
|
||||
expect(docMetrics.top, 'Real Document pane top must match --sheet-top').toBe('68px');
|
||||
expect(parseFloat(docMetrics.height), 'Real Document pane height must respect safe area').toBeGreaterThanOrEqual(770);
|
||||
expect(docMetrics.rectTop, 'Real Document pane must not overlap the 44px safe-area top notch').toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
|
||||
test('restored floating windows clamp away from a left safe area', async ({ page }, testInfo) => {
|
||||
test.skip(isMobileViewport(testInfo), 'Geometry test uses desktop resizable windows');
|
||||
await openApp(page);
|
||||
const value = await page.evaluate(async () => {
|
||||
document.documentElement.style.setProperty('--safe-left', '58px');
|
||||
document.body.style.setProperty('padding-left', '58px', 'important');
|
||||
const probe = document.createElement('div');
|
||||
probe.style.cssText = 'position:fixed;left:10px;top:80px;width:420px;height:250px;z-index:999999;';
|
||||
document.body.appendChild(probe);
|
||||
const key = 'odysseus-layout-restore-test';
|
||||
localStorage.setItem(key, JSON.stringify({ w: 480, h: 280 }));
|
||||
try {
|
||||
// Body horizontal padding transitions over 160ms. Wait for the
|
||||
// simulated safe-area change to settle before restoring geometry.
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
const padAtRestore = parseFloat(
|
||||
getComputedStyle(document.body).paddingLeft
|
||||
);
|
||||
|
||||
if (Math.abs(padAtRestore - 58) > 0.5) {
|
||||
throw new Error(
|
||||
`Safe-area fixture did not settle: ${padAtRestore}px`
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the running server actually serves the patched module.
|
||||
const response = await fetch(
|
||||
'/static/js/windowResize.js',
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('Cannot fetch windowResize.js');
|
||||
}
|
||||
const moduleSource = await response.text();
|
||||
if (!moduleSource.includes('const rightmostLeft = Math.max(padLeft')) {
|
||||
throw new Error('Running server is serving unpatched windowResize.js');
|
||||
}
|
||||
|
||||
const { makeWindowResizable } = await import('/static/js/windowResize.js');
|
||||
makeWindowResizable(probe, { storageKey: key, mobileSkip: 768 });
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
return {
|
||||
left: parseFloat(probe.style.left),
|
||||
width: parseFloat(probe.style.width),
|
||||
paddingLeft: parseFloat(getComputedStyle(document.body).paddingLeft),
|
||||
viewportWidth: window.innerWidth,
|
||||
};
|
||||
} finally {
|
||||
localStorage.removeItem(key);
|
||||
probe.remove();
|
||||
}
|
||||
});
|
||||
|
||||
expect(value.paddingLeft).toBeGreaterThanOrEqual(58);
|
||||
expect(value.width).toBe(480);
|
||||
expect(value.left).toBeGreaterThanOrEqual(value.paddingLeft);
|
||||
});
|
||||
@@ -14,12 +14,22 @@ from routes.auth_routes import SESSION_COOKIE
|
||||
from tests.helpers.database import disposable_database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_app(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
auth = AuthManager(str(tmp_path / "auth.json"))
|
||||
@pytest.fixture(scope="module")
|
||||
def auth_configuration(tmp_path_factory):
|
||||
"""Hash real fixture passwords once; only immutable bytes are reused."""
|
||||
path = tmp_path_factory.mktemp("admin-auth-seed") / "auth.json"
|
||||
auth = AuthManager(str(path))
|
||||
for user, admin in (("member", False), ("admin", True)):
|
||||
assert auth.create_user(user, "disposable-test-password", is_admin=admin)
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_app(tmp_path, monkeypatch, auth_configuration):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
path = tmp_path / "auth.json"
|
||||
path.write_bytes(auth_configuration)
|
||||
auth = AuthManager(str(path))
|
||||
app = FastAPI()
|
||||
app.state.auth_manager = auth
|
||||
|
||||
@@ -133,3 +143,17 @@ def test_diagnostics_require_real_admin_before_sensitive_work(
|
||||
for operation in (stats, rag.get_stats, research.call_research_service,
|
||||
extract_id, transcript, collect, read_log):
|
||||
operation.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("instance", [0, 1])
|
||||
def test_authenticated_fixture_has_private_users_sessions_and_file(authenticated_app, instance):
|
||||
app, client_for = authenticated_app
|
||||
auth = app.state.auth_manager
|
||||
assert set(auth.users) == {"member", "admin"}
|
||||
assert not auth._sessions
|
||||
with client_for("member") as client:
|
||||
assert auth.get_username_for_token(client.cookies.get(SESSION_COOKIE)) == "member"
|
||||
assert auth.create_user("fixture-local", "disposable-test-password")
|
||||
# Both parameter cases mutate their own file. Any shared manager, mutable
|
||||
# users/sessions or written-back seed makes the next case fail, in any order.
|
||||
assert "fixture-local" in auth.users
|
||||
|
||||
@@ -1929,23 +1929,60 @@ def test_place_layer_reports_false_on_queued_response(monkeypatch):
|
||||
assert placed is False
|
||||
|
||||
|
||||
def test_bridge_await_cancellation_handled_cleanly():
|
||||
def test_bridge_await_cancellation_handled_cleanly(monkeypatch):
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
app = _bridge_app()
|
||||
|
||||
async def scenario():
|
||||
entered_wait = asyncio.Event()
|
||||
real_wait_for = asyncio.wait_for
|
||||
waits = []
|
||||
|
||||
async def observe_wait(awaitable, timeout):
|
||||
# Observe the real wait; do not replace its result or cancellation.
|
||||
waits.append(awaitable)
|
||||
entered_wait.set()
|
||||
return await real_wait_for(awaitable, timeout)
|
||||
|
||||
monkeypatch.setattr(eaar.asyncio, "wait_for", observe_wait)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
cmd_resp = await c.post("/api/editor/bridge/command", json={"session": "cancel-sess", "op": "document.info", "wait": False})
|
||||
assert cmd_resp.status_code == 200
|
||||
assert cmd_resp.json()["completed"] is False
|
||||
cid = cmd_resp.json()["queued"][0]
|
||||
|
||||
await_task = asyncio.create_task(c.post("/api/editor/bridge/await", json={"id": cid, "timeout": 30}))
|
||||
await asyncio.sleep(0.05)
|
||||
await_task.cancel()
|
||||
try:
|
||||
await await_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await real_wait_for(entered_wait.wait(), 5)
|
||||
assert len(waits) == 1
|
||||
assert not waits[0].done()
|
||||
assert not await_task.done(), "await returned without a queued result"
|
||||
await_task.cancel()
|
||||
response = await real_wait_for(await_task, 5)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"ok": False, "error": "cancelled", "status": "cancelled",
|
||||
"completed": False, "id": cid,
|
||||
}
|
||||
finally:
|
||||
if not await_task.done():
|
||||
await_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await await_task
|
||||
|
||||
# Cancelling the waiter must not cancel the queued command itself.
|
||||
payload = {"width": 137, "height": 251}
|
||||
delivered = await c.post("/api/editor/bridge/result", json={"id": cid, "ok": True, "result": payload})
|
||||
assert delivered.status_code == 200
|
||||
completed = await c.post("/api/editor/bridge/await", json={"id": cid, "timeout": 1})
|
||||
assert completed.status_code == 200
|
||||
assert completed.json()["status"] == "completed"
|
||||
assert completed.json()["completed"] is True
|
||||
assert completed.json()["ok"] is True
|
||||
assert completed.json()["result"] == payload
|
||||
assert len(waits) == 2
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@@ -206,35 +206,50 @@ class TestDiskConsistency:
|
||||
@pytest.mark.slow
|
||||
def test_file_always_valid_json_during_concurrent_ops(self, tmp_path):
|
||||
mgr = _fresh_auth_manager(tmp_path)
|
||||
mgr.create_user("admin", "adminpw", is_admin=True)
|
||||
assert mgr.create_user("admin", "adminpw", is_admin=True)
|
||||
|
||||
stop_event = threading.Event()
|
||||
reader_ready = threading.Event()
|
||||
corruption_found = []
|
||||
observations = {"reads": 0, "with_writes": 0}
|
||||
|
||||
def reader():
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
with open(mgr.auth_path, "r") as f:
|
||||
content = f.read()
|
||||
json.loads(content)
|
||||
data = json.loads(content)
|
||||
observations["reads"] += 1
|
||||
if any(name.startswith("stress") for name in data["users"]):
|
||||
observations["with_writes"] += 1
|
||||
reader_ready.set()
|
||||
except json.JSONDecodeError as e:
|
||||
corruption_found.append(str(e))
|
||||
break
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
time.sleep(0.001)
|
||||
|
||||
def writer():
|
||||
assert reader_ready.wait(5), "reader never observed the initial file"
|
||||
results = []
|
||||
for i in range(50):
|
||||
mgr.create_user(f"stress{i}", f"pw{i}")
|
||||
results.append(mgr.create_user(f"stress{i}", f"pw{i}"))
|
||||
return results
|
||||
|
||||
reader_thread = threading.Thread(target=reader)
|
||||
writer_thread = threading.Thread(target=writer)
|
||||
|
||||
reader_thread.start()
|
||||
writer_thread.start()
|
||||
writer_thread.join()
|
||||
stop_event.set()
|
||||
reader_thread.join()
|
||||
# Futures propagate reader/writer exceptions to the test thread.
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
reading = pool.submit(reader)
|
||||
writing = pool.submit(writer)
|
||||
try:
|
||||
results = writing.result(timeout=120)
|
||||
finally:
|
||||
stop_event.set()
|
||||
reading.result(timeout=5)
|
||||
|
||||
assert not corruption_found, f"Corrupt JSON detected: {corruption_found[0]}"
|
||||
assert len(results) == 50 and all(results), "writer operations did not succeed"
|
||||
assert observations["reads"] > 0
|
||||
assert observations["with_writes"] > 0, "reader saw no persisted writer activity"
|
||||
expected = {"admin"} | {f"stress{i}" for i in range(50)}
|
||||
with open(mgr.auth_path) as persisted:
|
||||
assert set(json.load(persisted)["users"]) == expected
|
||||
assert set(_fresh_auth_manager(tmp_path).users) == expected
|
||||
|
||||
@@ -13,6 +13,7 @@ import socketserver
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from src import caldav_sync, caldav_writeback
|
||||
|
||||
@@ -26,12 +27,13 @@ def test_build_dav_client_disables_redirects():
|
||||
|
||||
def test_dav_client_does_not_follow_redirect_to_internal_host():
|
||||
"""End-to-end through the real DAVClient: a 302 toward an internal host
|
||||
must NOT be followed. Without the fix the sink is contacted (SSRF); with it
|
||||
the redirect is refused and the sink is never reached."""
|
||||
must NOT be followed. Exercise the session limit separately because some
|
||||
caldav versions independently refuse cross-origin PROPFIND redirects."""
|
||||
pytest.importorskip("caldav")
|
||||
|
||||
sink_hits: list[str] = []
|
||||
public_methods: list[str] = []
|
||||
same_origin_hits: list[str] = []
|
||||
|
||||
class _Internal(http.server.BaseHTTPRequestHandler):
|
||||
# Stand-in for an internal service the attacker redirects toward.
|
||||
@@ -48,9 +50,15 @@ def test_dav_client_does_not_follow_redirect_to_internal_host():
|
||||
class _Public(http.server.BaseHTTPRequestHandler):
|
||||
# The "validated" public CalDAV server that redirects everything inward.
|
||||
def do_GET(self): # noqa: N802
|
||||
if self.path == "/landing":
|
||||
same_origin_hits.append(self.path)
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
return
|
||||
public_methods.append(self.command)
|
||||
self.send_response(302)
|
||||
self.send_header("Location", f"http://127.0.0.1:{internal_port}/leak")
|
||||
location = "/landing" if self.path == "/same" else f"http://127.0.0.1:{internal_port}/leak"
|
||||
self.send_header("Location", location)
|
||||
self.end_headers()
|
||||
|
||||
do_PROPFIND = do_GET
|
||||
@@ -62,8 +70,10 @@ def test_dav_client_does_not_follow_redirect_to_internal_host():
|
||||
internal_port = internal.server_address[1]
|
||||
public = socketserver.TCPServer(("127.0.0.1", 0), _Public)
|
||||
public_port = public.server_address[1]
|
||||
threading.Thread(target=internal.serve_forever, daemon=True).start()
|
||||
threading.Thread(target=public.serve_forever, daemon=True).start()
|
||||
internal_thread = threading.Thread(target=internal.serve_forever, daemon=True)
|
||||
public_thread = threading.Thread(target=public.serve_forever, daemon=True)
|
||||
internal_thread.start()
|
||||
public_thread.start()
|
||||
try:
|
||||
public_url = f"http://127.0.0.1:{public_port}/dav"
|
||||
client = caldav_sync._build_dav_client(public_url, "u", "p")
|
||||
@@ -82,17 +92,35 @@ def test_dav_client_does_not_follow_redirect_to_internal_host():
|
||||
assert public_methods in (["PROPFIND"], ["PROPFIND", "GET"]), public_methods
|
||||
assert sink_hits == [], "redirect toward an internal host must not be followed"
|
||||
|
||||
# Positive control: demonstrate that the sink would see the redirect
|
||||
# if the session's redirect protection were removed.
|
||||
# GET redirects are handled by the HTTP session even in caldav 3.7.
|
||||
# This makes the cross-port SSRF assertion sensitive to our own limit.
|
||||
with pytest.raises(Exception, match="Exceeded 0 redirects"):
|
||||
client.request(public_url, "GET", "")
|
||||
assert sink_hits == [], "GET redirect toward the internal sink must be blocked"
|
||||
|
||||
# requests can reach the cross-port sink when redirects are enabled,
|
||||
# even if DAVClient independently rejects cross-origin PROPFIND redirects.
|
||||
with requests.Session() as session:
|
||||
response = session.get(public_url, timeout=5)
|
||||
assert response.status_code == 207
|
||||
assert sink_hits == ["/leak"], "the cross-port redirect must reach the sink"
|
||||
|
||||
# GET follows redirects at the HTTP session layer across these caldav
|
||||
# versions. Check the application's limit on a same-origin redirect too.
|
||||
same_url = f"http://127.0.0.1:{public_port}/same"
|
||||
with pytest.raises(Exception, match="Exceeded 0 redirects"):
|
||||
client.request(same_url, "GET", "")
|
||||
assert same_origin_hits == []
|
||||
client.session.max_redirects = 30
|
||||
try:
|
||||
client.request(public_url, "PROPFIND", "")
|
||||
except Exception:
|
||||
pass # The sink's response is intentionally not a valid DAV body.
|
||||
assert sink_hits == ["/leak"], "the test must detect a followed redirect"
|
||||
client.request(same_url, "GET", "")
|
||||
assert same_origin_hits == ["/landing"]
|
||||
finally:
|
||||
internal.shutdown()
|
||||
public.shutdown()
|
||||
internal_thread.join()
|
||||
public_thread.join()
|
||||
internal.server_close()
|
||||
public.server_close()
|
||||
|
||||
|
||||
def test_sync_and_writeback_construct_clients_through_the_helper():
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Both chat tidy entry points retain saved conversations and still remove empties."""
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
import core.database as database
|
||||
import core.session_manager as manager_module
|
||||
import routes.session_routes as routes
|
||||
import src.session_actions as actions
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tidy_api(tmp_path, monkeypatch):
|
||||
engine = create_engine(
|
||||
f"sqlite:///{(tmp_path / 'tidy.db').as_posix()}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
database.Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, autoflush=False)
|
||||
monkeypatch.setattr(database, "SessionLocal", factory)
|
||||
monkeypatch.setattr(manager_module, "SessionLocal", factory)
|
||||
monkeypatch.setattr(routes, "SessionLocal", factory)
|
||||
monkeypatch.setattr(routes, "router", APIRouter(prefix="/api"))
|
||||
monkeypatch.setattr(routes, "effective_user", lambda request: "alice")
|
||||
manager = manager_module.SessionManager.__new__(manager_module.SessionManager)
|
||||
manager.sessions = {}
|
||||
manager.upload_handler = None
|
||||
monkeypatch.setattr(manager, "get_sessions_for_user", lambda owner: {})
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_session_routes(manager, {}))
|
||||
with TestClient(app) as client:
|
||||
yield client, factory, manager
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def run_tidy(client, entrypoint):
|
||||
if entrypoint == "action":
|
||||
return asyncio.run(actions.run_auto_sort("alice", skip_llm=True))
|
||||
response = client.post("/api/sessions/auto-sort?skip_llm=true")
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entrypoint", ["route", "action"])
|
||||
@pytest.mark.parametrize("title,prompt", [
|
||||
("New chat", "Please analyze the following contract and retain all details. " * 20),
|
||||
("Contract analysis", "Review contract"),
|
||||
("Chat: analysis", "Explain these results and retain the full answer"),
|
||||
("Test", "Here is a large input that should be analyzed and retained"),
|
||||
], ids=["generic-title", "short-prompt", "title-prefix", "test-title"])
|
||||
def test_tidy_preserves_nontrivial_saved_answer(tidy_api, title, prompt, entrypoint):
|
||||
client, factory, manager = tidy_api
|
||||
old = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=2)
|
||||
answer = "Clause analysis and recommended changes.\n" * 2000
|
||||
with factory() as db:
|
||||
db.add(database.Session(
|
||||
id="valuable-chat", name=title, owner="alice", endpoint_url="", model="",
|
||||
archived=False, is_important=False, message_count=2,
|
||||
created_at=old, updated_at=old, last_accessed=old, last_message_at=old,
|
||||
))
|
||||
db.commit()
|
||||
db.add_all([
|
||||
database.ChatMessage(id="prompt", session_id="valuable-chat", role="user",
|
||||
content=prompt, timestamp=old),
|
||||
database.ChatMessage(id="answer", session_id="valuable-chat", role="assistant",
|
||||
content=answer, timestamp=old + timedelta(seconds=1)),
|
||||
])
|
||||
db.commit()
|
||||
run_tidy(client, entrypoint)
|
||||
with factory() as db:
|
||||
remaining = db.query(database.ChatMessage).filter_by(session_id="valuable-chat").count()
|
||||
survives = db.get(database.Session, "valuable-chat") is not None
|
||||
assert survives and remaining == 2, "Title/prompt heuristics must not erase saved answers"
|
||||
with factory() as db:
|
||||
assert db.get(database.ChatMessage, "answer").content == answer
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entrypoint", ["route", "action"])
|
||||
@pytest.mark.parametrize("kind", ["unanswered", "greeting", "empty", "fresh-empty", "starred-empty", "archived", "other-owner", "incognito"])
|
||||
def test_tidy_cleanup_boundaries(tidy_api, entrypoint, kind):
|
||||
client, factory, manager = tidy_api
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
timestamp = now if kind == "fresh-empty" else now - timedelta(hours=2)
|
||||
message = kind in {"unanswered", "greeting", "archived", "other-owner", "incognito"}
|
||||
with factory() as db:
|
||||
db.add(database.Session(
|
||||
id="boundary-chat", name="Incognito" if kind == "incognito" else "New chat",
|
||||
owner="bob" if kind == "other-owner" else "alice", endpoint_url="", model="",
|
||||
archived=kind == "archived", is_important=kind == "starred-empty",
|
||||
# Deliberately inaccurate metadata: cleanup must use message rows.
|
||||
message_count=0 if message else 40,
|
||||
created_at=timestamp, updated_at=timestamp, last_accessed=timestamp, last_message_at=timestamp,
|
||||
))
|
||||
db.commit()
|
||||
if message:
|
||||
db.add(database.ChatMessage(id="boundary-message", session_id="boundary-chat", role="user",
|
||||
content="hi" if kind == "greeting" else "Pending question " * 100,
|
||||
timestamp=timestamp))
|
||||
db.commit()
|
||||
run_tidy(client, entrypoint)
|
||||
deleted = kind in {"empty", "incognito"}
|
||||
with factory() as db:
|
||||
assert (db.get(database.Session, "boundary-chat") is None) == deleted
|
||||
assert db.query(database.ChatMessage).filter_by(session_id="boundary-chat").count() == int(message and not deleted)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entrypoint", ["route", "action"])
|
||||
def test_tidy_keeps_folder_assignment_working(tidy_api, monkeypatch, entrypoint):
|
||||
from types import SimpleNamespace
|
||||
import src.llm_core as llm
|
||||
import src.task_endpoint as endpoints
|
||||
client, factory, manager = tidy_api
|
||||
old = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=2)
|
||||
for sid in ["chat-one-long-id", "chat-two-long-id"]:
|
||||
with factory() as db:
|
||||
db.add(database.Session(id=sid, name="New chat", owner="alice", endpoint_url="", model="",
|
||||
archived=False, created_at=old, updated_at=old,
|
||||
last_accessed=old, last_message_at=old))
|
||||
db.commit()
|
||||
db.add(database.ChatMessage(id=f"{sid}-message", session_id=sid, role="user",
|
||||
content="Saved question", timestamp=old))
|
||||
db.commit()
|
||||
manager.sessions[sid] = SimpleNamespace(id=sid, name="New chat", archived=False,
|
||||
updated_at=old.isoformat(), created_at=old.isoformat())
|
||||
monkeypatch.setattr(manager, "get_sessions_for_user", lambda owner: manager.sessions)
|
||||
monkeypatch.setattr(endpoints, "resolve_task_endpoint", lambda *args, **kwargs: ("http://model.invalid", "test", {}))
|
||||
response_text = '{"folders":{"Projects":["chat-one","chat-two"]}}'
|
||||
monkeypatch.setattr(llm, "llm_call", lambda *args, **kwargs: response_text)
|
||||
|
||||
async def async_response(*args, **kwargs):
|
||||
return response_text
|
||||
|
||||
monkeypatch.setattr(llm, "llm_call_async", async_response)
|
||||
if entrypoint == "action":
|
||||
result = asyncio.run(actions.run_auto_sort("alice"))
|
||||
assert "Sorted 2 sessions" in result
|
||||
else:
|
||||
response = client.post("/api/sessions/auto-sort")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["updated"] == 2
|
||||
with factory() as db:
|
||||
assert db.query(database.ChatMessage).count() == 2
|
||||
assert {row.folder for row in db.query(database.Session).all()} == {"Projects"}
|
||||
@@ -1,4 +1,14 @@
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from core.platform_compat import find_bash, git_bash_path
|
||||
from routes.cookbook_helpers import (
|
||||
ODYSSEUS_LLAMA_SHIM_MARKER,
|
||||
_append_llama_server_shim_definition_lines,
|
||||
_append_llama_server_shim_reconciliation_lines,
|
||||
_append_llama_server_finalization_lines,
|
||||
)
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
@@ -615,3 +625,730 @@ def test_local_dependency_probe_refreshes_user_site_visibility():
|
||||
# deps (realesrgan) probe as not-installed until a restart. See #4810.
|
||||
assert "if user_site and os.path.isdir(user_site):" in source
|
||||
assert "site.addsitedir(user_site)" in source
|
||||
|
||||
|
||||
def test_llama_runner_path_preserves_standard_user_bin_order():
|
||||
"""The runner PATH must preserve standard user $HOME/bin precedence while
|
||||
relying on ownership-aware reconciliation to retire Odysseus shims when
|
||||
native builds exist."""
|
||||
source = _read("routes/cookbook_routes.py")
|
||||
|
||||
assert (
|
||||
'export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"'
|
||||
in source
|
||||
)
|
||||
|
||||
|
||||
def _run_shell(tmp_path, lines, cwd=None, path=None, extra_env=None):
|
||||
from core.platform_compat import find_bash, git_bash_path
|
||||
bash = find_bash() or "bash"
|
||||
env = dict(os.environ, HOME=git_bash_path(tmp_path))
|
||||
if path is not None:
|
||||
env["PATH"] = path
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
return subprocess.run(
|
||||
[bash, "-c", "\n".join(lines)],
|
||||
cwd=str(cwd) if cwd else None,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _write_odysseus_shim(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
f"#!/usr/bin/env bash\n{ODYSSEUS_LLAMA_SHIM_MARKER}\nexec python3 -m llama_cpp.server \"$@\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def _write_mock_native(path: Path, succeeds: bool = True) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if succeeds:
|
||||
content = '#!/usr/bin/env bash\nif [ "$1" = "--version" ]; then echo "version: 0.1.0-dev"; exit 0; fi\necho "RUN_NATIVE: $@"\nexit 0\n'
|
||||
else:
|
||||
content = '#!/usr/bin/env bash\necho "error: missing libllama.so" >&2\nexit 127\n'
|
||||
path.write_text(content, encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def _write_mock_python3(path: Path, succeeds: bool = True) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(f"#!/usr/bin/env bash\nexit {0 if succeeds else 1}\n", encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def test_odysseus_create_llama_shim_never_overwrites_user_regular_file(tmp_path):
|
||||
"""Creation boundary: never overwrite a non-executable user-owned regular file."""
|
||||
user_file = tmp_path / "bin/llama-server"
|
||||
user_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
original_content = "user configuration or binary data without marker"
|
||||
user_file.write_text(original_content, encoding="utf-8")
|
||||
|
||||
lines = []
|
||||
_append_llama_server_shim_definition_lines(lines)
|
||||
lines.append('_odysseus_create_llama_shim')
|
||||
|
||||
res = _run_shell(tmp_path, lines)
|
||||
assert res.returncode != 0, "Creation should return non-zero when target is a user file"
|
||||
assert user_file.read_text(encoding="utf-8") == original_content, "User file must remain byte-for-byte unchanged"
|
||||
|
||||
|
||||
def test_odysseus_create_llama_shim_never_overwrites_user_symlink(tmp_path):
|
||||
"""Creation boundary: never follow or overwrite a user-owned symlink."""
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
target_file = tmp_path / "user_target.txt"
|
||||
target_file.write_text("precious user target content", encoding="utf-8")
|
||||
user_symlink = bin_dir / "llama-server"
|
||||
user_symlink.symlink_to(target_file)
|
||||
|
||||
lines = []
|
||||
_append_llama_server_shim_definition_lines(lines)
|
||||
lines.append('_odysseus_create_llama_shim')
|
||||
|
||||
res = _run_shell(tmp_path, lines)
|
||||
assert res.returncode != 0, "Creation should return non-zero when target is a user symlink"
|
||||
assert user_symlink.is_symlink(), "Symlink must remain intact"
|
||||
assert target_file.read_text(encoding="utf-8") == "precious user target content", "Target file must not be modified"
|
||||
|
||||
|
||||
def test_odysseus_create_llama_shim_never_overwrites_dangling_symlink(tmp_path):
|
||||
"""Creation boundary: never overwrite a dangling user-owned symlink."""
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
dangling_symlink = bin_dir / "llama-server"
|
||||
dangling_symlink.symlink_to(tmp_path / "nonexistent_target")
|
||||
|
||||
lines = []
|
||||
_append_llama_server_shim_definition_lines(lines)
|
||||
lines.append('_odysseus_create_llama_shim')
|
||||
|
||||
res = _run_shell(tmp_path, lines)
|
||||
assert res.returncode != 0, "Creation should return non-zero when target is a dangling symlink"
|
||||
assert dangling_symlink.is_symlink(), "Dangling symlink must remain intact"
|
||||
|
||||
|
||||
def test_odysseus_create_llama_shim_safely_replaces_existing_odysseus_shim(tmp_path):
|
||||
"""Creation boundary: safely replace an existing Odysseus-owned compatibility shim."""
|
||||
shim_file = tmp_path / "bin/llama-server"
|
||||
shim_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
shim_file.write_text(f"#!/bin/bash\n{ODYSSEUS_LLAMA_SHIM_MARKER}\n# old shim body\n", encoding="utf-8")
|
||||
|
||||
lines = []
|
||||
_append_llama_server_shim_definition_lines(lines)
|
||||
lines.append('_odysseus_create_llama_shim')
|
||||
|
||||
res = _run_shell(tmp_path, lines)
|
||||
assert res.returncode == 0
|
||||
new_content = shim_file.read_text(encoding="utf-8")
|
||||
assert ODYSSEUS_LLAMA_SHIM_MARKER in new_content
|
||||
assert "exec python3 -m llama_cpp.server" in new_content
|
||||
|
||||
|
||||
def test_odysseus_create_llama_shim_secure_temp_prevents_symlink_attack(tmp_path):
|
||||
"""Creation boundary: secure mktemp prevents predictable $$ temp symlink attacks.
|
||||
|
||||
Given:
|
||||
- A victim file at ~/victim containing DO_NOT_TOUCH
|
||||
- A pre-created symlink at ~/bin/.llama-server.odysseus.tmp.$$ pointing to ~/victim
|
||||
- _odysseus_create_llama_shim executed within that exact same bash PID ($$)
|
||||
|
||||
Prove:
|
||||
- ~/victim remains unmodified
|
||||
- Pre-existing crafted symlink is NOT followed or overwritten
|
||||
- Legitimate shim is successfully created at ~/bin/llama-server via secure temp path
|
||||
"""
|
||||
lines = [
|
||||
'victim="$HOME/victim"',
|
||||
'printf "DO_NOT_TOUCH\\n" > "$victim"',
|
||||
'mkdir -p "$HOME/bin"',
|
||||
'old_tmp="$HOME/bin/.llama-server.odysseus.tmp.$$";',
|
||||
'ln -s "$victim" "$old_tmp"',
|
||||
]
|
||||
_append_llama_server_shim_definition_lines(lines)
|
||||
lines.append('_odysseus_create_llama_shim')
|
||||
lines.append('res=$?')
|
||||
lines.append('echo "CREATION_RES=$res"')
|
||||
|
||||
res = _run_shell(tmp_path, lines)
|
||||
assert res.returncode == 0
|
||||
assert "CREATION_RES=0" in res.stdout
|
||||
|
||||
victim_file = tmp_path / "victim"
|
||||
assert victim_file.exists()
|
||||
assert victim_file.read_text(encoding="utf-8") == "DO_NOT_TOUCH\n", "Victim file must remain untouched"
|
||||
|
||||
target_shim = tmp_path / "bin/llama-server"
|
||||
assert target_shim.exists()
|
||||
assert ODYSSEUS_LLAMA_SHIM_MARKER in target_shim.read_text(encoding="utf-8")
|
||||
assert target_shim.stat().st_mode & 0o111 != 0, "Shim must be executable"
|
||||
|
||||
|
||||
def test_stale_odysseus_shim_removed_when_python_server_unusable(tmp_path):
|
||||
"""Scenario 1: Stale Odysseus shim + missing Python server -> owned shim removed."""
|
||||
shim_file = tmp_path / "bin/llama-server"
|
||||
_write_odysseus_shim(shim_file)
|
||||
|
||||
lines = []
|
||||
_append_llama_server_shim_reconciliation_lines(lines)
|
||||
|
||||
res = _run_shell(tmp_path, lines)
|
||||
assert res.returncode == 0
|
||||
assert not shim_file.exists(), "Stale Odysseus shim must be removed when python server is unavailable"
|
||||
|
||||
|
||||
def test_healthy_odysseus_shim_retained_when_no_native_server(tmp_path):
|
||||
"""Scenario 2: Healthy Odysseus shim + no native server -> shim preserved."""
|
||||
shim_file = tmp_path / "bin/llama-server"
|
||||
_write_odysseus_shim(shim_file)
|
||||
mock_py_dir = tmp_path / "mock_bin"
|
||||
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
|
||||
|
||||
lines = []
|
||||
_append_llama_server_shim_reconciliation_lines(lines)
|
||||
|
||||
res = _run_shell(tmp_path, lines, path=f"{git_bash_path(mock_py_dir)}:{os.environ.get('PATH', '')}")
|
||||
assert res.returncode == 0
|
||||
assert shim_file.exists(), "Healthy Odysseus shim must be preserved when no native server is present"
|
||||
|
||||
|
||||
def test_reconciliation_does_not_retire_healthy_shim_for_broken_native_binary(tmp_path):
|
||||
"""Reconciliation health check: a broken native candidate executable must NOT retire
|
||||
a healthy Odysseus Python shim."""
|
||||
shim_file = tmp_path / "bin/llama-server"
|
||||
_write_odysseus_shim(shim_file)
|
||||
_write_mock_native(tmp_path / "llama.cpp/build/bin/llama-server", succeeds=False)
|
||||
mock_py_dir = tmp_path / "mock_bin"
|
||||
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
|
||||
|
||||
lines = [f'export PATH="$HOME/llama.cpp/build/bin:{git_bash_path(mock_py_dir)}:$PATH"']
|
||||
_append_llama_server_shim_reconciliation_lines(lines)
|
||||
|
||||
res = _run_shell(tmp_path, lines)
|
||||
assert res.returncode == 0
|
||||
assert shim_file.exists(), "Healthy Odysseus shim must NOT be retired when native binary is broken"
|
||||
|
||||
|
||||
def test_odysseus_shim_retired_when_native_server_healthy(tmp_path):
|
||||
"""Scenario 3: Odysseus shim + healthy native server available -> native server wins."""
|
||||
shim_file = tmp_path / "bin/llama-server"
|
||||
_write_odysseus_shim(shim_file)
|
||||
_write_mock_native(tmp_path / "llama.cpp/build/bin/llama-server", succeeds=True)
|
||||
|
||||
lines = ['export PATH="$HOME/bin:$HOME/llama.cpp/build/bin:$PATH"']
|
||||
_append_llama_server_shim_reconciliation_lines(lines)
|
||||
|
||||
res = _run_shell(tmp_path, lines)
|
||||
assert res.returncode == 0
|
||||
assert not shim_file.exists(), "Odysseus shim must be retired when healthy native server is available"
|
||||
|
||||
|
||||
def test_user_owned_bin_llama_server_never_touched(tmp_path):
|
||||
"""Scenario 4: Arbitrary user ~/bin/llama-server -> untouched and not deprioritized."""
|
||||
user_bin = tmp_path / "bin/llama-server"
|
||||
user_bin.parent.mkdir(parents=True, exist_ok=True)
|
||||
user_bin.write_text("#!/bin/bash\n# Custom user build script\nexec /opt/custom/llama-server\n", encoding="utf-8")
|
||||
user_bin.chmod(0o755)
|
||||
|
||||
_write_mock_native(tmp_path / "llama.cpp/build/bin/llama-server", succeeds=True)
|
||||
|
||||
lines = ['export PATH="$HOME/bin:$HOME/llama.cpp/build/bin:$PATH"']
|
||||
_append_llama_server_shim_reconciliation_lines(lines)
|
||||
|
||||
res = _run_shell(tmp_path, lines)
|
||||
assert res.returncode == 0
|
||||
assert user_bin.exists(), "User-owned ~/bin/llama-server must never be deleted"
|
||||
assert "Custom user build script" in user_bin.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_reconciliation_ignores_native_binary_outside_effective_path(tmp_path):
|
||||
"""Native candidate outside effective PATH must NOT be considered."""
|
||||
shim_file = tmp_path / "bin/llama-server"
|
||||
_write_odysseus_shim(shim_file)
|
||||
# Native binary placed in /usr_custom/bin which is NOT in PATH
|
||||
_write_mock_native(tmp_path / "usr_custom/bin/llama-server", succeeds=True)
|
||||
mock_py_dir = tmp_path / "mock_bin"
|
||||
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
|
||||
|
||||
lines = []
|
||||
_append_llama_server_shim_reconciliation_lines(lines)
|
||||
|
||||
# PATH only contains ~/bin and mock_py_dir — usr_custom is omitted
|
||||
path = f"{git_bash_path(tmp_path / 'bin')}:{git_bash_path(mock_py_dir)}"
|
||||
res = _run_shell(tmp_path, lines, path=path)
|
||||
assert res.returncode == 0
|
||||
assert shim_file.exists(), "Shim must NOT be retired by native binary outside effective PATH"
|
||||
|
||||
|
||||
def test_reconciliation_skips_home_bin_with_or_without_trailing_slash(tmp_path):
|
||||
"""Reconciliation must exclude $HOME/bin and $HOME/bin/ from native candidate discovery."""
|
||||
shim_file = tmp_path / "bin/llama-server"
|
||||
_write_odysseus_shim(shim_file)
|
||||
mock_py_dir = tmp_path / "mock_bin"
|
||||
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
|
||||
|
||||
lines = []
|
||||
_append_llama_server_shim_reconciliation_lines(lines)
|
||||
|
||||
# PATH has ~/bin/ with trailing slash
|
||||
path = f"{git_bash_path(tmp_path / 'bin')}/:{git_bash_path(mock_py_dir)}"
|
||||
res = _run_shell(tmp_path, lines, path=path)
|
||||
assert res.returncode == 0
|
||||
assert shim_file.exists(), "Shim must NOT rediscover itself when PATH has trailing slash in $HOME/bin/"
|
||||
|
||||
|
||||
def test_reconciliation_does_not_discover_cwd_executable_when_path_omits_dot(tmp_path):
|
||||
"""Reconciliation must NOT synthesize an empty/trailing PATH element that searches cwd.
|
||||
|
||||
Given:
|
||||
- Current working directory contains an executable `llama-server`
|
||||
- Healthy Odysseus Python shim in ~/bin/llama-server
|
||||
- PATH does NOT contain '.' or cwd
|
||||
- No other healthy native binary on PATH
|
||||
|
||||
Prove:
|
||||
- Reconciliation does not discover ./llama-server
|
||||
- The healthy Odysseus shim in ~/bin/llama-server is preserved
|
||||
"""
|
||||
shim_file = tmp_path / "bin/llama-server"
|
||||
_write_odysseus_shim(shim_file)
|
||||
mock_py_dir = tmp_path / "mock_bin"
|
||||
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
|
||||
|
||||
cwd_dir = tmp_path / "workdir"
|
||||
_write_mock_native(cwd_dir / "llama-server", succeeds=True)
|
||||
|
||||
lines = []
|
||||
_append_llama_server_shim_reconciliation_lines(lines)
|
||||
|
||||
path = f"{git_bash_path(tmp_path / 'bin')}:{git_bash_path(mock_py_dir)}:/usr/bin:/bin"
|
||||
res = _run_shell(tmp_path, lines, cwd=cwd_dir, path=path)
|
||||
assert res.returncode == 0
|
||||
assert shim_file.exists(), "Healthy Odysseus shim must NOT be retired by finding ./llama-server in cwd"
|
||||
|
||||
|
||||
def test_reconciliation_discovers_healthy_native_on_explicit_other_path_entry(tmp_path):
|
||||
"""Reconciliation must discover healthy native binaries placed on explicit PATH entries."""
|
||||
shim_file = tmp_path / "bin/llama-server"
|
||||
_write_odysseus_shim(shim_file)
|
||||
custom_opt_bin = tmp_path / "custom_opt/bin"
|
||||
_write_mock_native(custom_opt_bin / "llama-server", succeeds=True)
|
||||
|
||||
lines = []
|
||||
_append_llama_server_shim_reconciliation_lines(lines)
|
||||
|
||||
path = f"{git_bash_path(tmp_path / 'bin')}:{git_bash_path(custom_opt_bin)}:/usr/bin:/bin"
|
||||
res = _run_shell(tmp_path, lines, path=path)
|
||||
assert res.returncode == 0
|
||||
assert not shim_file.exists(), "Odysseus shim must be retired when healthy native server is on explicit PATH entry"
|
||||
|
||||
|
||||
def test_reconciliation_safely_iterates_path_with_glob_metacharacters(tmp_path):
|
||||
"""Reconciliation must not perform pathname expansion / globbing on PATH components.
|
||||
|
||||
Given:
|
||||
- A directory path containing shell glob characters: ~/custom[dir]/bin
|
||||
- A native binary ~/custom[dir]/bin/llama-server
|
||||
- A working directory containing matching glob entries
|
||||
- An existing Odysseus shim in ~/bin/llama-server
|
||||
|
||||
Prove:
|
||||
- Reconciliation correctly parses the literal path without expanding [dir]
|
||||
- Discovers the healthy native server and retires the Odysseus shim
|
||||
"""
|
||||
shim_file = tmp_path / "bin/llama-server"
|
||||
_write_odysseus_shim(shim_file)
|
||||
|
||||
glob_dir = tmp_path / "custom[dir]/bin"
|
||||
_write_mock_native(glob_dir / "llama-server", succeeds=True)
|
||||
|
||||
cwd_dir = tmp_path / "cwd"
|
||||
cwd_dir.mkdir(parents=True, exist_ok=True)
|
||||
(cwd_dir / "customd").mkdir(parents=True, exist_ok=True)
|
||||
(cwd_dir / "customi").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
lines = []
|
||||
_append_llama_server_shim_reconciliation_lines(lines)
|
||||
|
||||
path = f"{git_bash_path(tmp_path / 'bin')}:{git_bash_path(glob_dir)}:/usr/bin:/bin"
|
||||
res = _run_shell(tmp_path, lines, cwd=cwd_dir, path=path)
|
||||
assert res.returncode == 0
|
||||
assert not shim_file.exists(), "Reconciliation must discover literal bracketed PATH without globbing"
|
||||
|
||||
|
||||
def test_fallback_install_success_creates_usable_launcher(tmp_path):
|
||||
"""Scenario 5: No native + no Python server -> fallback install succeeds
|
||||
-> usable compatibility launcher exists and resolves command -v llama-server."""
|
||||
mock_py_dir = tmp_path / "mock_bin"
|
||||
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
|
||||
|
||||
lines = [f'export PATH="$HOME/bin:{git_bash_path(mock_py_dir)}:$PATH"']
|
||||
_append_llama_server_shim_definition_lines(lines)
|
||||
_append_llama_server_finalization_lines(lines)
|
||||
lines.append('command -v llama-server')
|
||||
|
||||
res = _run_shell(tmp_path, lines)
|
||||
assert res.returncode == 0
|
||||
created_shim = tmp_path / "bin/llama-server"
|
||||
assert created_shim.exists()
|
||||
assert ODYSSEUS_LLAMA_SHIM_MARKER in created_shim.read_text(encoding="utf-8")
|
||||
assert str(tmp_path / "bin/llama-server") in res.stdout
|
||||
|
||||
|
||||
def test_failed_fallback_sets_preflight_exit_127(tmp_path):
|
||||
"""Scenario 6: No native + failed fallback installation -> ODYSSEUS_PREFLIGHT_EXIT=127."""
|
||||
lines = [
|
||||
'ODYSSEUS_PREFLIGHT_EXIT=""',
|
||||
'export PATH="$HOME/bin:$PATH"',
|
||||
]
|
||||
_append_llama_server_shim_definition_lines(lines)
|
||||
_append_llama_server_finalization_lines(lines)
|
||||
lines.append('echo "EXIT=$ODYSSEUS_PREFLIGHT_EXIT"')
|
||||
|
||||
res = _run_shell(tmp_path, lines)
|
||||
assert res.returncode == 0
|
||||
assert "EXIT=127" in res.stdout
|
||||
|
||||
|
||||
def test_python_server_readiness_requires_llama_cpp_server_module():
|
||||
"""Scenario 7 & 8: Python server readiness requires `import llama_cpp.server`,
|
||||
while GPU offload capability checks still use base `import llama_cpp`."""
|
||||
routes_source = _read("routes/cookbook_routes.py")
|
||||
helpers_source = _read("routes/cookbook_helpers.py")
|
||||
|
||||
# Windows fallback readiness check requires .server
|
||||
assert 'try { python -c "import llama_cpp.server" 2>$null } catch {}' in routes_source
|
||||
# Termux fallback readiness check requires .server
|
||||
assert 'if ! python3 -c "import llama_cpp.server" 2>/dev/null; then' in routes_source
|
||||
# Build-failure fallback pip check requires .server
|
||||
assert 'if [ -z "$_odysseus_llama_bin" ] && ! python3 -c "import llama_cpp.server" 2>/dev/null; then' in routes_source
|
||||
# Shim reconciliation and post-install finalization require .server
|
||||
assert 'elif python3 -c "import llama_cpp.server" 2>/dev/null; then' in helpers_source
|
||||
assert 'if [ -z "$_odysseus_llama_bin" ] && python3 -c "import llama_cpp.server" 2>/dev/null; then' in helpers_source
|
||||
|
||||
# GPU offload support checks still intentionally inspect base llama_cpp
|
||||
assert 'llama_cpp.llama_supports_gpu_offload()' in routes_source
|
||||
|
||||
|
||||
def test_darwin_native_build_does_not_force_write_bin_symlink():
|
||||
"""Darwin source build must not overwrite ~/bin/llama-server with a forced symlink."""
|
||||
source = _read("routes/cookbook_routes.py")
|
||||
|
||||
assert "&& ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server" not in source
|
||||
assert 'cmake --build build -j"$NPROC" --target llama-server' in source
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generated_runner_preflight_retires_stale_shim_before_backend_selection(monkeypatch, tmp_path):
|
||||
"""Integration regression: The actual generated Cookbook runner must execute
|
||||
ownership reconciliation BEFORE evaluating the backend selection (`if Termux ... elif ! command -v llama-server`).
|
||||
|
||||
Given:
|
||||
- Executable stale Odysseus shim in ~/bin/llama-server (with canonical marker)
|
||||
- Healthy native llama-server in ~/llama.cpp/build/bin/llama-server
|
||||
- ~/bin before ~/llama.cpp/build/bin on PATH
|
||||
|
||||
Prove:
|
||||
1. Generated runner contains reconciliation shell lines strictly before the backend branching
|
||||
2. Executing the generated runner script retires the stale ~/bin/llama-server shim
|
||||
3. Resolves to the healthy native binary without attempting source build or pip fallback.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from routes.cookbook_helpers import ServeRequest
|
||||
import routes.cookbook_routes as cookbook_routes
|
||||
from starlette.requests import Request
|
||||
|
||||
fake_home = tmp_path / "fake_home"
|
||||
stale_shim = fake_home / "bin/llama-server"
|
||||
_write_odysseus_shim(stale_shim)
|
||||
native_bin = fake_home / "llama.cpp/build/bin/llama-server"
|
||||
_write_mock_native(native_bin, succeeds=True)
|
||||
|
||||
# Mock environment to generate the real runner file with isolated DB
|
||||
tmux_log_dir = tmp_path / "tmux_logs"
|
||||
tmux_log_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(cookbook_routes, "TMUX_LOG_DIR", tmux_log_dir)
|
||||
monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None)
|
||||
monkeypatch.setattr(cookbook_routes, "_binary_available", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(cookbook_routes, "load_stored_hf_token", lambda **kwargs: "")
|
||||
monkeypatch.setattr("core.database.SessionLocal", MagicMock())
|
||||
|
||||
class _Proc:
|
||||
returncode = 0
|
||||
stderr = None
|
||||
async def wait(self):
|
||||
return 0
|
||||
monkeypatch.setattr(cookbook_routes.asyncio, "create_subprocess_shell", AsyncMock(return_value=_Proc()))
|
||||
|
||||
router = cookbook_routes.setup_cookbook_routes()
|
||||
endpoint = None
|
||||
for route in router.routes:
|
||||
if route.path == "/api/model/serve" and "POST" in route.methods:
|
||||
endpoint = route.endpoint
|
||||
break
|
||||
assert endpoint is not None
|
||||
|
||||
from types import SimpleNamespace
|
||||
http_req = Request({
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/api/model/serve",
|
||||
"headers": [],
|
||||
"app": SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(
|
||||
is_configured=True, is_admin=lambda user: user == "admin"))),
|
||||
})
|
||||
http_req.state.current_user = "admin"
|
||||
http_req.state.local_model_authority = True
|
||||
|
||||
req = ServeRequest(
|
||||
repo_id="test/repo",
|
||||
cmd="llama-server -m /dummy/model.gguf --port 8085",
|
||||
remote_host=None,
|
||||
)
|
||||
|
||||
resp = await endpoint(http_req, req)
|
||||
assert resp["ok"] is True
|
||||
session_id = resp["session_id"]
|
||||
runner_file = tmux_log_dir / f"{session_id}_run.sh"
|
||||
assert runner_file.exists()
|
||||
runner_script = runner_file.read_text(encoding="utf-8")
|
||||
|
||||
# Assert structural ordering in the generated script:
|
||||
# Reconciliation must appear strictly BEFORE the Termux/backend check
|
||||
recon_idx = runner_script.find("Native llama-server detected — retiring Odysseus compatibility shim in ~/bin")
|
||||
termux_idx = runner_script.find("if [ -d /data/data/com.termux ]; then")
|
||||
backend_idx = runner_script.find('elif [ -z "$_odysseus_llama_bin" ]; then')
|
||||
assert recon_idx != -1, "Reconciliation block missing from generated runner"
|
||||
assert termux_idx != -1, "Termux check missing from generated runner"
|
||||
assert backend_idx != -1, "Backend selection missing from generated runner"
|
||||
assert recon_idx < termux_idx < backend_idx, "Reconciliation must execute strictly before backend selection"
|
||||
|
||||
# Execute the generated runner preflight in a real subshell
|
||||
res = _run_shell(fake_home, [str(runner_file)], path=f"{git_bash_path(fake_home / 'bin')}:{git_bash_path(fake_home / 'llama.cpp/build/bin')}:/usr/bin:/bin")
|
||||
assert res.returncode == 0
|
||||
assert not stale_shim.exists(), "Stale ~/bin/llama-server must have been retired by the generated preflight"
|
||||
assert "RUN_NATIVE: -m /dummy/model.gguf --port 8085" in res.stdout
|
||||
assert "Native llama-server not found — building from source" not in res.stdout
|
||||
|
||||
|
||||
def test_broken_first_native_candidate_healthy_second_candidate_executes_healthy(tmp_path):
|
||||
"""Regression finding P2: When PATH contains an earlier broken candidate followed by a
|
||||
healthy candidate, reconciliation and launch must select and execute the healthy candidate."""
|
||||
from core.platform_compat import git_bash_path
|
||||
broken_dir = tmp_path / "broken_bin"
|
||||
_write_mock_native(broken_dir / "llama-server", succeeds=False)
|
||||
|
||||
healthy_dir = tmp_path / "healthy_bin"
|
||||
_write_mock_native(healthy_dir / "llama-server", succeeds=True)
|
||||
|
||||
shim_file = tmp_path / "bin/llama-server"
|
||||
_write_odysseus_shim(shim_file)
|
||||
|
||||
lines = [
|
||||
f'export PATH="$HOME/bin:{git_bash_path(broken_dir)}:{git_bash_path(healthy_dir)}:/usr/bin:/bin"',
|
||||
]
|
||||
_append_llama_server_shim_reconciliation_lines(lines)
|
||||
lines.append('echo "RESOLVED=$_odysseus_llama_bin"')
|
||||
lines.append('command -v llama-server')
|
||||
lines.append('llama-server -m /dummy/model.gguf --port 8080')
|
||||
|
||||
res = _run_shell(tmp_path, lines)
|
||||
assert res.returncode == 0
|
||||
assert not shim_file.exists(), "Stale Odysseus shim must be retired"
|
||||
assert f"RESOLVED={git_bash_path(healthy_dir / 'llama-server')}" in res.stdout
|
||||
assert str(healthy_dir / "llama-server") in res.stdout
|
||||
assert "RUN_NATIVE: -m /dummy/model.gguf --port 8080" in res.stdout
|
||||
assert "error: missing libllama.so" not in res.stdout
|
||||
|
||||
|
||||
def test_paths_with_spaces_broken_first_healthy_second_launch(tmp_path):
|
||||
"""Regression: Paths containing spaces must be safely resolved, prioritized and launched."""
|
||||
from core.platform_compat import git_bash_path
|
||||
broken_dir = tmp_path / "broken path with spaces/bin"
|
||||
_write_mock_native(broken_dir / "llama-server", succeeds=False)
|
||||
|
||||
healthy_dir = tmp_path / "healthy path with spaces/bin"
|
||||
_write_mock_native(healthy_dir / "llama-server", succeeds=True)
|
||||
|
||||
shim_file = tmp_path / "bin/llama-server"
|
||||
_write_odysseus_shim(shim_file)
|
||||
|
||||
model_dir = tmp_path / "models with spaces"
|
||||
model_dir.mkdir(parents=True, exist_ok=True)
|
||||
model_file = model_dir / "my model.gguf"
|
||||
model_file.write_text("dummy", encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
f'export PATH="$HOME/bin:{git_bash_path(broken_dir)}:{git_bash_path(healthy_dir)}:/usr/bin:/bin"',
|
||||
]
|
||||
_append_llama_server_shim_reconciliation_lines(lines)
|
||||
lines.append(f'llama-server -m "{git_bash_path(model_file)}" --port 8080')
|
||||
|
||||
res = _run_shell(tmp_path, lines)
|
||||
assert res.returncode == 0
|
||||
assert not shim_file.exists(), "Stale shim must be retired"
|
||||
assert f"RUN_NATIVE: -m {git_bash_path(model_file)} --port 8080" in res.stdout
|
||||
assert "error: missing libllama.so" not in res.stdout
|
||||
|
||||
|
||||
def test_command_hash_invalidation_switches_from_cached_broken_to_healthy(tmp_path):
|
||||
"""Regression: Command cache invalidation (`hash -r`) must prevent bash from reusing a
|
||||
previously cached lookup to an earlier broken executable."""
|
||||
from core.platform_compat import git_bash_path
|
||||
broken_dir = tmp_path / "broken_bin"
|
||||
_write_mock_native(broken_dir / "llama-server", succeeds=False)
|
||||
|
||||
healthy_dir = tmp_path / "healthy_bin"
|
||||
_write_mock_native(healthy_dir / "llama-server", succeeds=True)
|
||||
|
||||
lines = [
|
||||
# Populate bash hash cache with broken binary
|
||||
f'export PATH="{git_bash_path(broken_dir)}:/usr/bin:/bin"',
|
||||
'llama-server >/dev/null 2>&1 || true',
|
||||
'hash',
|
||||
# Now add healthy candidate later in PATH and run reconciliation
|
||||
f'export PATH="$HOME/bin:{git_bash_path(broken_dir)}:{git_bash_path(healthy_dir)}:/usr/bin:/bin"',
|
||||
]
|
||||
_append_llama_server_shim_reconciliation_lines(lines)
|
||||
lines.append('llama-server -m /dummy/test.gguf')
|
||||
lines.append('hash')
|
||||
|
||||
res = _run_shell(tmp_path, lines)
|
||||
assert res.returncode == 0
|
||||
assert "RUN_NATIVE: -m /dummy/test.gguf" in res.stdout
|
||||
# Verify hash table ended with healthy binary, not broken
|
||||
assert str(healthy_dir / "llama-server") in res.stdout
|
||||
|
||||
|
||||
def test_broken_native_candidate_falls_back_to_healthy_python_compatibility_shim(tmp_path):
|
||||
"""Regression: A broken native candidate must not shadow or retire a healthy Python shim."""
|
||||
from core.platform_compat import git_bash_path
|
||||
broken_dir = tmp_path / "broken_bin"
|
||||
_write_mock_native(broken_dir / "llama-server", succeeds=False)
|
||||
|
||||
shim_file = tmp_path / "bin/llama-server"
|
||||
_write_odysseus_shim(shim_file)
|
||||
|
||||
mock_py_dir = tmp_path / "mock_bin"
|
||||
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
|
||||
|
||||
lines = [
|
||||
f'export PATH="$HOME/bin:{git_bash_path(broken_dir)}:{git_bash_path(mock_py_dir)}:/usr/bin:/bin"',
|
||||
]
|
||||
_append_llama_server_shim_reconciliation_lines(lines)
|
||||
lines.append('echo "RESOLVED=$_odysseus_llama_bin"')
|
||||
lines.append('command -v llama-server')
|
||||
|
||||
res = _run_shell(tmp_path, lines)
|
||||
assert res.returncode == 0
|
||||
assert shim_file.exists(), "Healthy Python compatibility shim must be preserved"
|
||||
assert f"RESOLVED={git_bash_path(shim_file)}" in res.stdout
|
||||
assert str(shim_file) in res.stdout
|
||||
|
||||
|
||||
def test_broken_native_and_missing_python_backend_sets_preflight_127(tmp_path):
|
||||
"""Regression: When the only native candidates are broken and Python server is unavailable,
|
||||
finalization must fail closed with ODYSSEUS_PREFLIGHT_EXIT=127 rather than claiming readiness."""
|
||||
from core.platform_compat import git_bash_path
|
||||
broken_dir = tmp_path / "broken_bin"
|
||||
_write_mock_native(broken_dir / "llama-server", succeeds=False)
|
||||
|
||||
mock_py_dir = tmp_path / "mock_bin"
|
||||
_write_mock_python3(mock_py_dir / "python3", succeeds=False)
|
||||
|
||||
lines = [
|
||||
'ODYSSEUS_PREFLIGHT_EXIT=""',
|
||||
f'export PATH="$HOME/bin:{git_bash_path(broken_dir)}:{git_bash_path(mock_py_dir)}:/usr/bin:/bin"',
|
||||
]
|
||||
_append_llama_server_shim_definition_lines(lines)
|
||||
_append_llama_server_shim_reconciliation_lines(lines)
|
||||
_append_llama_server_finalization_lines(lines)
|
||||
lines.append('echo "EXIT=$ODYSSEUS_PREFLIGHT_EXIT"')
|
||||
|
||||
res = _run_shell(tmp_path, lines)
|
||||
assert res.returncode == 0
|
||||
assert "EXIT=127" in res.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generated_runner_broken_first_healthy_second_e2e(monkeypatch, tmp_path):
|
||||
"""Integration regression: The full generated Cookbook runner script must launch the healthy
|
||||
native candidate even when an earlier broken candidate exists on PATH."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from types import SimpleNamespace
|
||||
from routes.cookbook_helpers import ServeRequest
|
||||
import routes.cookbook_routes as cookbook_routes
|
||||
from starlette.requests import Request
|
||||
from core.platform_compat import git_bash_path
|
||||
|
||||
fake_home = tmp_path / "fake_home"
|
||||
stale_shim = fake_home / "bin/llama-server"
|
||||
_write_odysseus_shim(stale_shim)
|
||||
|
||||
broken_bin = fake_home / "broken/bin/llama-server"
|
||||
_write_mock_native(broken_bin, succeeds=False)
|
||||
|
||||
healthy_bin = fake_home / "healthy/bin/llama-server"
|
||||
_write_mock_native(healthy_bin, succeeds=True)
|
||||
|
||||
tmux_log_dir = tmp_path / "tmux_logs"
|
||||
tmux_log_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(cookbook_routes, "TMUX_LOG_DIR", tmux_log_dir)
|
||||
monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None)
|
||||
monkeypatch.setattr(cookbook_routes, "_binary_available", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(cookbook_routes, "load_stored_hf_token", lambda **kwargs: "")
|
||||
monkeypatch.setattr("core.database.SessionLocal", MagicMock())
|
||||
|
||||
class _Proc:
|
||||
returncode = 0
|
||||
stderr = None
|
||||
async def wait(self):
|
||||
return 0
|
||||
monkeypatch.setattr(cookbook_routes.asyncio, "create_subprocess_shell", AsyncMock(return_value=_Proc()))
|
||||
|
||||
router = cookbook_routes.setup_cookbook_routes()
|
||||
endpoint = None
|
||||
for route in router.routes:
|
||||
if route.path == "/api/model/serve" and "POST" in route.methods:
|
||||
endpoint = route.endpoint
|
||||
break
|
||||
assert endpoint is not None
|
||||
|
||||
http_req = Request({
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/api/model/serve",
|
||||
"headers": [],
|
||||
"app": SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(
|
||||
is_configured=True, is_admin=lambda user: user == "admin"))),
|
||||
})
|
||||
http_req.state.current_user = "admin"
|
||||
http_req.state.local_model_authority = True
|
||||
|
||||
req = ServeRequest(
|
||||
repo_id="test/repo",
|
||||
cmd="llama-server -m /dummy/model.gguf --port 8090",
|
||||
remote_host=None,
|
||||
)
|
||||
|
||||
resp = await endpoint(http_req, req)
|
||||
assert resp["ok"] is True
|
||||
session_id = resp["session_id"]
|
||||
runner_file = tmux_log_dir / f"{session_id}_run.sh"
|
||||
assert runner_file.exists()
|
||||
|
||||
# Broken is earlier on PATH than healthy
|
||||
res = _run_shell(
|
||||
fake_home,
|
||||
[str(runner_file)],
|
||||
path=f"{git_bash_path(fake_home / 'bin')}:{git_bash_path(broken_bin.parent)}:{git_bash_path(healthy_bin.parent)}:/usr/bin:/bin",
|
||||
)
|
||||
assert res.returncode == 0
|
||||
assert not stale_shim.exists(), "Stale shim must have been retired"
|
||||
assert "RUN_NATIVE: -m /dummy/model.gguf --port 8090" in res.stdout
|
||||
assert "error: missing libllama.so" not in res.stdout
|
||||
assert "Native llama-server not found — building from source" not in res.stdout
|
||||
|
||||
@@ -85,6 +85,9 @@ def test_actual_tests_restore_process_state_and_ignore_inherited_database(tmp_pa
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
def snapshot():
|
||||
import core
|
||||
@@ -107,7 +110,11 @@ def test_actual_tests_restore_process_state_and_ignore_inherited_database(tmp_pa
|
||||
class StateGuard:
|
||||
def pytest_sessionstart(self):
|
||||
self.initial = snapshot()
|
||||
assert self.initial[0] == "sqlite:///:memory:"
|
||||
assert self.initial[0] != inherited_url
|
||||
self.default_path = Path(make_url(self.initial[0]).database)
|
||||
assert self.default_path.is_file()
|
||||
assert self.default_path.name == "collection.db"
|
||||
assert self.default_path.parent == Path(tempfile.gettempdir()).parent
|
||||
|
||||
def pytest_collection_finish(self):
|
||||
assert snapshot() == self.initial, "collection changed database bindings"
|
||||
@@ -118,9 +125,11 @@ def test_actual_tests_restore_process_state_and_ignore_inherited_database(tmp_pa
|
||||
assert snapshot() == self.initial, "test leaked database or module state"
|
||||
|
||||
inherited_url = os.environ["DATABASE_URL"]
|
||||
guard = StateGuard()
|
||||
result = pytest.main(["-q", "-p", "no:cacheprovider", *sys.argv[1:]],
|
||||
plugins=[StateGuard()])
|
||||
plugins=[guard])
|
||||
assert os.environ["DATABASE_URL"] == inherited_url
|
||||
assert not guard.default_path.exists(), "collection database outlived its runtime"
|
||||
raise SystemExit(result)
|
||||
''')
|
||||
result = subprocess.run(
|
||||
|
||||
@@ -11,6 +11,8 @@ cache from the DB, where the rows are already gone, and the token is rejected.
|
||||
"""
|
||||
import asyncio
|
||||
import types
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from routes.auth_routes import setup_auth_routes, DeleteUserRequest
|
||||
|
||||
@@ -60,12 +62,9 @@ def test_refused_delete_does_not_invalidate_cache():
|
||||
invalidations = []
|
||||
router = setup_auth_routes(_auth_manager(delete_result=False))
|
||||
handler = _handler(router)
|
||||
try:
|
||||
with pytest.raises(HTTPException) as refused:
|
||||
asyncio.run(handler(DeleteUserRequest(username="admin"), _fake_request(invalidations)))
|
||||
raised = False
|
||||
except Exception:
|
||||
raised = True
|
||||
assert raised, "a refused delete should raise (HTTP 400)"
|
||||
assert refused.value.status_code == 400
|
||||
assert invalidations == [], "a refused delete must not touch the token cache"
|
||||
|
||||
|
||||
|
||||
@@ -29,10 +29,8 @@ def test_secondary_brush_controls_live_after_the_menu_open():
|
||||
block = CONTROLS[i:j]
|
||||
for marker in ('id="ge-brush-spacing"', 'id="ge-brush-smoothing"',
|
||||
'id="ge-brush-blend"', 'ge-brush-pressure-row',
|
||||
'id="ge-brush-flow-row"'):
|
||||
'id="ge-brush-flow-row"', 'id="ge-brush-softness-row"'):
|
||||
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():
|
||||
@@ -100,7 +98,7 @@ def test_brush_strip_fits_and_smudge_row_respects_inline_hide():
|
||||
# The Opacity/Flow/Softness sliders are wound tighter so the strip fits.
|
||||
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.
|
||||
assert ".ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; flex-direction: row;" in CSS
|
||||
assert ".ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }" in CSS
|
||||
# The smudge Strength row is hidden with an inline display:none on other
|
||||
# tools; the `.ge-eraser-row { display:flex !important }` rule must not
|
||||
# override that.
|
||||
|
||||
@@ -44,11 +44,9 @@ def test_quick_edit_shows_the_generation_message_verbatim():
|
||||
assert "_isGenOfflineMsg" 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
|
||||
# The extracted generation action keeps the shared failure classifier.
|
||||
generation = (ROOT / 'static/js/editor/image-generation.js').read_text()
|
||||
assert "formatError: _generationErrorMessage" in EDITOR
|
||||
assert "formatError({ network: true" in generation
|
||||
assert "formatError({ status: response.status" in generation
|
||||
# the generateImage op maps its own fetch/HTTP failures through the classifier
|
||||
assert "_generationErrorMessage({ network: true" in EDITOR
|
||||
assert "_generationErrorMessage({ status: res.status, detail: _gd })" in EDITOR
|
||||
|
||||
|
||||
def test_sw_cache_is_bumped():
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
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'}
|
||||
@@ -49,9 +49,10 @@ def test_ctrl_zoom_shortcuts_drive_the_toolbar_and_override_the_browser():
|
||||
|
||||
def test_ctrl_alt_l_opens_the_levels_adjustment():
|
||||
keyboard = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8")
|
||||
# Open layer-local controls without creating/committing a new adjustment layer.
|
||||
# Photoshop-style chord: reuse the Image menu row so the dialog and the
|
||||
# adjustment-layer plumbing stay in one place.
|
||||
assert "e.altKey && e.code === 'KeyL'" in keyboard
|
||||
assert "openLayerAdjustment?.('levels');" in keyboard
|
||||
assert "document.querySelector('[data-image-action=\"adj-levels\"]')" in keyboard
|
||||
|
||||
|
||||
def test_ctrl_alt_shift_e_stamps_the_visible_layers():
|
||||
|
||||
@@ -1,770 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page():
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1000, "height": 700})
|
||||
|
||||
def serve(route):
|
||||
relative = route.request.url.split('http://editor.test/', 1)[1].split('?', 1)[0]
|
||||
if not relative:
|
||||
route.fulfill(body='<html><body></body></html>', content_type='text/html')
|
||||
else:
|
||||
route.fulfill(path=str(ROOT / relative))
|
||||
|
||||
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 ></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']}
|
||||
@@ -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
|
||||
# pan grabbing the pointer and switching to hand mode.
|
||||
CE = (ROOT / "static/js/editor/canvas-events.js").read_text(encoding="utf-8")
|
||||
assert "['marquee', 'lasso', 'crop'].includes(state.tool)" in CE
|
||||
assert "if (canStartOutside() && !directPan)" in CE
|
||||
assert "if (state.tool !== 'lasso' && state.tool !== 'crop') return;" in CE
|
||||
assert "(state.tool === 'lasso' || state.tool === 'crop') && !directPan" in CE
|
||||
# Pressing editor chrome inside the area (e.g. the crop Apply panel) must
|
||||
# not start a fresh crop.
|
||||
assert ".ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup" in CE
|
||||
|
||||
@@ -32,12 +32,13 @@ def test_preflight_module_has_the_four_setup_steps():
|
||||
assert "required ready" in src
|
||||
|
||||
|
||||
def test_editor_uses_ai_settings_without_redundant_setup():
|
||||
def test_preflight_is_wired_into_the_editor():
|
||||
editor = _read("static/js/galleryEditor.js")
|
||||
assert "createPreflight" not in editor
|
||||
assert "setupBtn.id = 'ge-setup-btn';" not in editor
|
||||
assert "createAiSettings({ container })" in editor
|
||||
assert "aiBtn.addEventListener('click', () => _aiSettings?.open());" in editor
|
||||
assert "import { createPreflight } from './editor/preflight.js';" in editor
|
||||
assert "createPreflight({ canvasArea })" in editor
|
||||
assert "_preflight?.maybeOpen();" in editor
|
||||
assert "setupBtn.id = 'ge-setup-btn';" in editor
|
||||
assert "_preflight?.destroy();" in editor
|
||||
|
||||
|
||||
def test_ai_models_exposes_status_for_the_checklist():
|
||||
|
||||
@@ -49,42 +49,7 @@ def test_generate_route_forwards_the_picked_model():
|
||||
assert 'model_choice = str((body or {}).get("model") or "").strip()' in route
|
||||
assert 'payload["model"] = model_choice' in route
|
||||
assert "content = _json.dumps(payload)" in route
|
||||
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)
|
||||
assert "localStorage.getItem('ge-ai-command-model')" in JS
|
||||
|
||||
|
||||
def test_cloud_image_model_is_resolved_before_the_local_fallback():
|
||||
|
||||
@@ -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():
|
||||
start = EDITOR.index("if (/\\brotate\\b.*\\b180\\b")
|
||||
end = EDITOR.index("if (!/\\b(text|title|caption|font|heading|subtitle)", start)
|
||||
end = EDITOR.index("if (_looksBackgroundRemoval(p)) {", start)
|
||||
transform_block = EDITOR[start:end]
|
||||
assert "_saveState('Rotate" not in transform_block
|
||||
assert "_saveState('Flip" not in transform_block
|
||||
@@ -372,10 +372,9 @@ def test_quick_edit_has_a_model_picker():
|
||||
assert "function _getSelectedQuickEditModel()" in EDITOR
|
||||
assert "getModel: () => _getSelectedQuickEditModel()," in EDITOR
|
||||
# Populated from the provider list and persisted per user.
|
||||
assert "aiCommandSelect.innerHTML = '<option value=\"\">Same as generation</option>';" in models
|
||||
assert "localStorage.setItem('ge-ai-command-model'" not in models
|
||||
assert "localStorage.getItem('ge-ai-command-model')" not in models
|
||||
assert 'saveImagePreferences({ [key]: model })' in AI_SETTINGS
|
||||
assert "aiCommandSelect.innerHTML = '<option value=\"\">Auto (default)</option>';" in models
|
||||
assert "localStorage.setItem('ge-ai-command-model', aiCommandSelect.value)" in models
|
||||
assert "localStorage.getItem('ge-ai-command-model')" in models
|
||||
# Styled.
|
||||
assert ".ge-ai-settings-card" in STYLE
|
||||
assert ".ge-ai-command-model" in STYLE
|
||||
@@ -699,7 +698,7 @@ def test_quick_edit_shows_a_real_progress_bar():
|
||||
assert "fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}`" in EDITOR
|
||||
# The edit request carries the request_id the server keys progress on.
|
||||
assert "const requestId = 'qe-' + Date.now().toString(36)" in EDITOR
|
||||
assert "const body = JSON.stringify({ image, width: flat.width, height: flat.height, request_id: requestId, ...payload });" in EDITOR
|
||||
assert "const body = JSON.stringify({ image, request_id: requestId, ...payload });" in EDITOR
|
||||
assert "_startAiProgress(requestId);" in EDITOR
|
||||
assert "_stopAiProgress();" in EDITOR
|
||||
# Styling exists.
|
||||
@@ -719,7 +718,7 @@ def test_generation_503_is_handled_gracefully():
|
||||
# (returns 503). Surface a clear message and do not retry immediately.
|
||||
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 "formatError: _generationErrorMessage" in EDITOR
|
||||
assert "window.__geGenerationDisabled" in EDITOR
|
||||
|
||||
|
||||
def test_sending_a_prompt_returns_focus_to_the_document():
|
||||
|
||||
@@ -56,14 +56,35 @@ def test_cold_start_with_no_snapshot_and_no_index_is_not_marked_stale(monkeypatc
|
||||
# falls through to the real (blocking) path, so the response is not a
|
||||
# stale-marked snapshot.
|
||||
endpoint = _list_endpoint()
|
||||
calls = []
|
||||
|
||||
class EmptyMailbox:
|
||||
def select(self, folder, readonly=False):
|
||||
calls.append(("select", folder, readonly))
|
||||
return "OK", [b"0"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
assert command == "SEARCH"
|
||||
calls.append(("search", args))
|
||||
return "OK", [b""]
|
||||
|
||||
def logout(self):
|
||||
pass
|
||||
|
||||
def connect(*args, **kwargs):
|
||||
calls.append(("connect", args, kwargs))
|
||||
return EmptyMailbox()
|
||||
|
||||
monkeypatch.setattr(er, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(er, "_email_index_list", lambda *a, **k: ([], 0, None))
|
||||
monkeypatch.setattr(er, "_imap_connect", lambda *a, **k: (_ for _ in ()).throw(
|
||||
RuntimeError("offline test")))
|
||||
try:
|
||||
result = _call(endpoint)
|
||||
except Exception:
|
||||
return # the cold path legitimately attempted real IMAP
|
||||
monkeypatch.setattr(er, "_imap_connect", connect)
|
||||
result = _call(endpoint)
|
||||
assert [call[0] for call in calls] == ["connect", "select", "search"]
|
||||
assert calls[1][2] is True
|
||||
assert isinstance(result, dict)
|
||||
assert result["emails"] == []
|
||||
assert result["total"] == 0
|
||||
assert not result.get("error")
|
||||
assert not (result.get("sync") or {}).get("stale")
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
import routes.gallery_routes as gallery
|
||||
@@ -17,10 +16,7 @@ def png(image):
|
||||
return base64.b64encode(output.getvalue()).decode()
|
||||
|
||||
|
||||
@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):
|
||||
def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog):
|
||||
import logging
|
||||
caplog.set_level(logging.DEBUG)
|
||||
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', api_key='test')
|
||||
@@ -33,97 +29,36 @@ def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog, model, sa
|
||||
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, ''))
|
||||
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 = []
|
||||
|
||||
def respond(request):
|
||||
requests.append(request)
|
||||
assert str(request.url) == endpoint.base_url + ('/chat/completions' if model == 'openai/gpt-5-image' else '/images')
|
||||
assert str(request.url) == endpoint.base_url + '/images'
|
||||
payload = json.loads(request.content)
|
||||
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 payload['model'] == 'openai/gpt-5-image'
|
||||
assert len(payload['input_references']) == 2
|
||||
assert request.headers['authorization'] == 'Bearer test'
|
||||
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}]})
|
||||
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (2, 1), 'blue'))}]})
|
||||
|
||||
client = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kwargs: client(transport=httpx.MockTransport(respond), **kwargs))
|
||||
mask = Image.new('L', (2, 1))
|
||||
if full_frame:
|
||||
mask.paste(255, (0,0,2,1))
|
||||
mask.putpixel((1, 0), 255)
|
||||
|
||||
class Request:
|
||||
async def json(self):
|
||||
return {'_endpoint': endpoint.base_url, '_model': 'stale-model' if saved_settings else model,
|
||||
'_use_image_settings':saved_settings,
|
||||
return {'_endpoint': endpoint.base_url, '_model': 'openai/gpt-5-image',
|
||||
'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')
|
||||
result = asyncio.run(route.endpoint(Request()))
|
||||
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((1, 0)) == (0, 0, 255, 255)
|
||||
assert output.getpixel((0, 0)) == (255, 0, 0, 255)
|
||||
assert output.getpixel((1, 0)) == (0, 0, 255, 255)
|
||||
assert len(requests) == 1
|
||||
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):
|
||||
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)
|
||||
|
||||
@@ -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'))
|
||||
assert 'error' not in result
|
||||
assert seen == [('openai/gpt-5-image', 'alice')]
|
||||
assert json.loads(requests[0].content)['messages'][0]['content'] == prompt
|
||||
assert json.loads(requests[0].content)['prompt'] == prompt
|
||||
assert result['image_id']
|
||||
|
||||
|
||||
@@ -87,17 +87,13 @@ def image_transport(monkeypatch, tmp_path):
|
||||
original_client = httpx.AsyncClient
|
||||
requests = []
|
||||
|
||||
def configure(base, status=200, body=None, model='openai/gpt-5-image'):
|
||||
def configure(base, status=200, body=None):
|
||||
monkeypatch.setattr(ai_interaction, '_resolve_model', lambda *args, **kwargs: (
|
||||
base + '/chat/completions', model, {'Authorization': 'Bearer test'},
|
||||
base + '/chat/completions', 'openai/gpt-5-image', {'Authorization': 'Bearer test'},
|
||||
))
|
||||
|
||||
def handle(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 {
|
||||
'data': [{'b64_json': base64.b64encode(source.read_bytes()).decode()}],
|
||||
})
|
||||
@@ -134,13 +130,12 @@ def test_image_provider_protocol(image_transport, editing, base):
|
||||
assert request.headers['authorization'] == 'Bearer test'
|
||||
if 'openrouter.ai' in base:
|
||||
assert len(requests) == 1 # No unsupported local progress/fallback probes.
|
||||
assert str(request.url) == base + '/chat/completions'
|
||||
assert str(request.url) == base + '/images'
|
||||
payload = json.loads(request.content)
|
||||
assert payload['model'] == 'openai/gpt-5-image'
|
||||
assert payload['modalities'] == ['image', 'text']
|
||||
assert 'prompt' not in payload
|
||||
assert payload['n'] == 1
|
||||
if editing:
|
||||
reference = payload['messages'][0]['content'][1]['image_url']['url']
|
||||
assert payload['size'] == '1536x1024'
|
||||
reference = payload['input_references'][0]['image_url']['url']
|
||||
assert reference.startswith('data:image/png;base64,')
|
||||
assert base64.b64decode(reference.split(',', 1)[1]) == source.read_bytes()
|
||||
assert 'request_id' not in payload
|
||||
@@ -186,65 +181,13 @@ def test_edit_dimensions_respect_exif_and_explicit_size(image_transport, explici
|
||||
exif = Image.Exif()
|
||||
exif[274] = 6
|
||||
Image.new('RGB', (120, 80)).save(source, exif=exif)
|
||||
configure('https://openrouter.ai/api/v1', model='openai/gpt-image-1')
|
||||
configure('https://openrouter.ai/api/v1')
|
||||
result = asyncio.run(ai_interaction.do_edit_image(
|
||||
'Edit', str(source), model_spec='openai/gpt-image-1',
|
||||
'Edit', str(source), model_spec='openai/gpt-5-image',
|
||||
size='1024x1024' if explicit else 'auto',
|
||||
))
|
||||
assert 'error' not in result
|
||||
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
|
||||
assert json.loads(requests[0].content)['size'] == ('1024x1024' if explicit else '1024x1536')
|
||||
|
||||
|
||||
def test_unreadable_image_does_not_call_provider(image_transport):
|
||||
|
||||
@@ -39,3 +39,74 @@ def test_http_transport_routes_to_start_http_connect():
|
||||
result = asyncio.run(mgr.connect_server("id1", "n", "http", url="https://x/mcp"))
|
||||
assert result == "ROUTED"
|
||||
m.assert_called_once()
|
||||
|
||||
|
||||
def _patch_http_transport(monkeypatch, initialize):
|
||||
"""Fake the Streamable HTTP transport and record, for each close, whether
|
||||
it ran in the task that opened it (AnyIO requires that)."""
|
||||
import contextlib
|
||||
import mcp
|
||||
import mcp.client.streamable_http as streamable_http
|
||||
import src.mcp_oauth as mcp_oauth
|
||||
|
||||
closes = []
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def fake_client(url, auth=None):
|
||||
opener = asyncio.current_task()
|
||||
try:
|
||||
yield (object(), object(), lambda: None)
|
||||
finally:
|
||||
closes.append(asyncio.current_task() is opener)
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, *a):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
FakeSession.initialize = initialize
|
||||
monkeypatch.setattr(streamable_http, "streamablehttp_client", fake_client)
|
||||
monkeypatch.setattr(mcp, "ClientSession", FakeSession)
|
||||
monkeypatch.setattr(mcp_oauth, "build_provider", lambda *a, **k: None)
|
||||
return closes
|
||||
|
||||
|
||||
def test_failed_http_connect_closes_transport_in_its_own_task(monkeypatch):
|
||||
"""#5518: a failed connect left its transport for the GC, which exited the
|
||||
AnyIO cancel scope from the wrong task and kept the event loop spinning."""
|
||||
async def initialize(self):
|
||||
raise RuntimeError("Session terminated")
|
||||
|
||||
closes = _patch_http_transport(monkeypatch, initialize)
|
||||
mgr = McpManager()
|
||||
|
||||
ok = asyncio.run(mgr._start_http_connect("s1", "remote", "http://x/mcp/", wait=2))
|
||||
|
||||
assert ok is False
|
||||
assert mgr._connections["s1"]["status"] == "error"
|
||||
assert "Session terminated" in mgr._connections["s1"]["error"]
|
||||
assert closes == [True]
|
||||
assert "s1" not in mgr._stacks
|
||||
|
||||
|
||||
def test_cancelled_http_connect_closes_transport(monkeypatch):
|
||||
"""Deleting a server mid-connect cancels the connect task; the transport
|
||||
must still be closed rather than leaked."""
|
||||
async def initialize(self):
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
closes = _patch_http_transport(monkeypatch, initialize)
|
||||
mgr = McpManager()
|
||||
|
||||
async def run():
|
||||
await mgr._start_http_connect("s1", "remote", "http://x/mcp/", wait=0.05)
|
||||
await mgr.disconnect_server("s1")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(run())
|
||||
assert closes == [True]
|
||||
|
||||
@@ -128,7 +128,7 @@ def test_auto_sort_keeps_fresh_session_while_first_response_is_pending(monkeypat
|
||||
db.close()
|
||||
|
||||
|
||||
def test_auto_sort_still_deletes_old_throwaway_sessions(monkeypatch):
|
||||
def test_auto_sort_keeps_old_nonempty_sessions_even_with_throwaway_title(monkeypatch):
|
||||
session_factory = _make_session_factory()
|
||||
_install_session_factory(monkeypatch, session_factory)
|
||||
|
||||
@@ -160,7 +160,8 @@ def test_auto_sort_still_deletes_old_throwaway_sessions(monkeypatch):
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
assert db.query(DbSession).filter(DbSession.id == sid).first() is None
|
||||
assert "Cleaned 1 sessions" in result
|
||||
assert db.query(DbSession).filter(DbSession.id == sid).first() is not None
|
||||
assert db.query(DbMessage).filter(DbMessage.session_id == sid).count() == 1
|
||||
assert "Cleaned 0 sessions" in result
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -19,6 +19,7 @@ from core.database import ChatMessage as DbMessage
|
||||
from core.database import Session as DbSession
|
||||
|
||||
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_TMPDB.close() # SQLite owns its connections; release the Windows file handle.
|
||||
_ENGINE = create_engine(
|
||||
f"sqlite:///{_TMPDB.name}",
|
||||
connect_args={"check_same_thread": False},
|
||||
@@ -230,12 +231,14 @@ def test_patch_session_updates_persisted_cwd(monkeypatch):
|
||||
|
||||
def test_auto_sort_skip_llm_cleans_owner_stamped_sessions_when_auth_disabled(monkeypatch):
|
||||
import routes.session_routes as sr
|
||||
from fastapi import APIRouter
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
_stub_multipart_if_missing(monkeypatch)
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
monkeypatch.setattr(sr, "SessionLocal", _TS)
|
||||
monkeypatch.setattr(sr, "effective_user", lambda request: None)
|
||||
monkeypatch.setattr(sr, "router", APIRouter(prefix="/api"))
|
||||
|
||||
sid = str(uuid.uuid4())
|
||||
old_time = cdb.utcnow_naive() - timedelta(hours=2)
|
||||
@@ -250,19 +253,12 @@ def test_auto_sort_skip_llm_cleans_owner_stamped_sessions_when_auth_disabled(mon
|
||||
endpoint_url="http://localhost",
|
||||
model="gpt-4",
|
||||
archived=False,
|
||||
message_count=1,
|
||||
message_count=0,
|
||||
created_at=old_time,
|
||||
updated_at=old_time,
|
||||
last_message_at=old_time,
|
||||
last_accessed=old_time,
|
||||
))
|
||||
db.add(DbMessage(
|
||||
id="m-" + uuid.uuid4().hex,
|
||||
session_id=sid,
|
||||
role="user",
|
||||
content="hi",
|
||||
timestamp=old_time,
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
@@ -270,6 +266,13 @@ def test_auto_sort_skip_llm_cleans_owner_stamped_sessions_when_auth_disabled(mon
|
||||
session = MagicMock(id=sid, name="New chat", model="gpt-4", endpoint_url="http://localhost", rag=False, archived=False)
|
||||
sm = MagicMock()
|
||||
sm.get_sessions_for_user.return_value = {sid: session}
|
||||
|
||||
def delete_session(session_id):
|
||||
with _TS() as db:
|
||||
db.query(DbSession).filter(DbSession.id == session_id).delete()
|
||||
db.commit()
|
||||
|
||||
sm.delete_session.side_effect = delete_session
|
||||
router = sr.setup_session_routes(sm, {})
|
||||
endpoint = next(r.endpoint for r in router.routes
|
||||
if getattr(r, "path", "") == "/api/sessions/auto-sort"
|
||||
@@ -277,7 +280,8 @@ def test_auto_sort_skip_llm_cleans_owner_stamped_sessions_when_auth_disabled(mon
|
||||
|
||||
result = endpoint(request=MagicMock(), skip_llm=True)
|
||||
|
||||
assert result["deleted_throwaway"] == 1
|
||||
assert result["deleted_empty"] == 1
|
||||
assert result["deleted_throwaway"] == 0
|
||||
db = _TS()
|
||||
try:
|
||||
assert db.query(DbSession).filter(DbSession.id == sid).first() is None
|
||||
|
||||
@@ -79,3 +79,32 @@ def test_tmp_path_under_private_runtime_fits_unix_socket(tmp_path):
|
||||
# witness. A nested pytest-of-<user> basetemp made it 110 bytes under xdist.
|
||||
with socket.socket(socket.AF_UNIX) as sock:
|
||||
sock.bind(str(tmp_path / "tmux.sock"))
|
||||
|
||||
|
||||
def test_collection_database_survives_thread_connection_churn():
|
||||
"""TestClient threads must not lose the collection database or its schema.
|
||||
|
||||
SingletonThreadPool's default five-connection limit used to close the main
|
||||
thread's in-memory database after repeated route clients. Each new thread
|
||||
also saw an empty database. Exercise more threads than that limit and then
|
||||
verify both compatibility factories still see all registered tables.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from sqlalchemy import inspect
|
||||
import core.database as database
|
||||
import src.database as compatibility
|
||||
|
||||
expected = set(database.Base.metadata.tables)
|
||||
|
||||
def table_names(factory):
|
||||
with factory() as session:
|
||||
return set(inspect(session.connection()).get_table_names())
|
||||
|
||||
assert {"documents", "model_endpoints", "mcp_servers", "calendars"} <= expected
|
||||
assert expected <= table_names(database.SessionLocal)
|
||||
for _ in range(8):
|
||||
# A fresh executor creates a fresh thread rather than reusing one.
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
assert expected <= executor.submit(table_names, database.SessionLocal).result()
|
||||
assert expected <= table_names(database.SessionLocal)
|
||||
assert expected <= table_names(compatibility.SessionLocal)
|
||||
|
||||
Reference in New Issue
Block a user