Compare commits

..
Author SHA1 Message Date
Alexandre Teixeira 61051d28f0 Merge pull request #6996 from odysseus-dev/test/ci-python-baseline-repair
test(caldav): restore redirect positive-control coverage
2026-10-30 15:53:51 +00:00
PR #6029 Reviewer d87ae2a7f5 test(caldav): restore redirect positive-control coverage 2026-10-30 15:35:37 +00:00
Alexandre Teixeira 0e49c4ef40 Merge pull request #6978 from odysseus-dev/test/remediation-false-green-oracles
test: reject vacuous success in email, bridge, auth and deletion tests
2026-10-30 15:25:10 +00:00
Alexandre Teixeira 1aff71836b Merge pull request #6611 from robyroro/fix/chat-tidy-preserve-content
fix(chats): preserve saved conversations during tidy
2026-10-30 15:09:09 +00:00
Alexandre Teixeira b1c3125865 test: accept positional arguments in task endpoint mock for auto-sort 2026-10-30 15:00:08 +00:00
Alexandre Teixeira dbee1155e6 Merge pull request #6080 from alteixeira20/fix/cookbook-stale-llama-server-shim
fix(cookbook): recover from stale llama-server shims
2026-10-30 14:13:15 +00:00
Alexandre Teixeira 56c7cdf804 Merge pull request #6544 from harshit-ojha0324/fix/mcp-http-connect-leak
fix(mcp): close the HTTP transport when a remote connect fails
2026-10-30 14:09:43 +00:00
Alexandre Teixeira fe5d5caf34 Merge pull request #6977 from odysseus-dev/test/remediation-execution-isolation
test: fix pytest schema loss across route-client threads
2026-10-30 13:59:20 +00:00
Alexandre Teixeira bb654482f3 Merge pull request #6774 from odysseus-dev/fix/ios-safe-area-postmerge-hardening
fix(ui): harden iOS viewport and safe-area behavior after #6396
2026-10-30 13:56:25 +00:00
Alexandre Teixeira f45724faed Merge pull request #6983 from odysseus-dev/test/remediation-auth-fixture-seed
perf(tests): reuse immutable auth setup with fresh per-case state
2026-10-30 13:42:10 +00:00
Alexandre Teixeira 6fc6095aec test(e2e): harden mobile layout coverage, artifact isolation, and live pane assertions
- Store Playwright evidence in ignored test-results/ paths by default while preserving env override
- Clean up disposable temporary application data directories in /tmp on global teardown and exit signals
- Add Chromium mobile and tablet viewport profiles (phone portrait/landscape, tablet) for automated coverage
- Add a scoped Chromium mobile layout CI check on shard 1/4 to prevent redundant matrix execution
- Reach mobile Settings and Notes views via genuine sidebar navigation controls opened through the hamburger button
- Add assertions against live rendered Notes and Documents panes honoring safe-area top and height constraints
- Document local WebKit prerequisites and physical iOS/PWA test limitations in tests/e2e/README.md and package.json
2026-10-30 13:41:15 +00:00
Alexandre Teixeira ef16398c5c merge(dev): integrate latest dev into fix/ios-safe-area-postmerge-hardening 2026-10-30 13:25:33 +00:00
Alexandre Teixeira 6037707430 fix(cookbook): launch validated llama-server candidate and handle PATH precedence
Ensure the native candidate that passed the health check is prioritized and executed when an earlier broken candidate exists on PATH.

Reconcile Odysseus-owned shims safely, invalidate command hash cache, and verify launchability rather than command existence before serve dispatch.

Fixes #6079
2026-10-30 13:15:40 +00:00
Alexandre Teixeira 5c542d03ac Merge remote-tracking branch 'origin/dev' into fix/pr-6080-llama-recovery 2026-10-30 13:02:47 +00:00
Alexandre Teixeira 3b8c60ac1e test: check database ownership against the private temp namespace 2026-10-29 17:49:45 +00:00
Alexandre Teixeira d237c3fbba perf(tests): reuse immutable admin auth configuration 2026-10-29 17:35:59 +00:00
Alexandre Teixeira fe81c491cd test: give collection database URL one cleanup owner 2026-10-29 17:30:10 +00:00
Alexandre Teixeira 6f5c28b345 test: require real cold reads, cancellation and auth writes 2026-10-29 17:12:20 +00:00
Alexandre Teixeira aa2db8d87c test: keep worker collection databases consistent across threads 2026-10-29 17:12:14 +00:00
PR #6029 Reviewer 7fd4e0e9bb fix(ui): harden iOS safe-area and viewport regressions 2026-10-20 11:39:51 +01:00
robyroro ad3e309074 fix(chats): preserve saved conversations during tidy 2026-10-08 06:48:41 +03:00
harshit-ojha0324 50b05bf54f fix(mcp): close the HTTP transport when a remote connect fails
_connect_http entered streamablehttp_client and ClientSession on an
AsyncExitStack but never closed it when initialize or list_tools
failed. The half-open transport was left to the garbage collector,
which exited its AnyIO cancel scope from the wrong task, and the event
loop kept spinning: about 18% of a core per failed connect, growing
with each one, until restart.

Close the stack in the connecting task on any failure or cancellation,
the same way _connect_sse already does.

Fixes #5518
2026-10-06 19:01:10 -04:00
Alexandre Teixeira a7c03f85bf fix(cookbook): recover from stale llama-server shims
Reconcile Odysseus-owned Python compatibility shims when llama_cpp.server is no longer available, allowing healthy native llama-server binaries in PATH to be discovered and executed without shadowing.

Fixes #6079
2026-08-17 00:59:13 +01:00
74 changed files with 2234 additions and 2157 deletions
+7
View File
@@ -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
View File
@@ -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",
+160
View File
@@ -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
View File
@@ -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
+2 -27
View File
@@ -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
+12 -65
View File
@@ -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
+7 -40
View File
@@ -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")
+2 -8
View File
@@ -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
View File
@@ -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"}
-11
View File
@@ -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(),
}
-9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+1 -63
View File
@@ -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;
+6 -3
View File
@@ -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;
}
}
-11
View File
@@ -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">
+6 -25
View File
@@ -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.
+65 -14
View File
@@ -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;
+30 -5
View File
@@ -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 ──
+3 -61
View File
@@ -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();
}
+1 -23
View File
@@ -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);
+5 -5
View File
@@ -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>
+5 -35
View File
@@ -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;
-5
View File
@@ -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) || '',
+1 -1
View File
@@ -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;
+1 -2
View File
@@ -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;
-71
View File
@@ -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);
}
};
}
-11
View File
@@ -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 };
}
+5 -12
View File
@@ -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.
-4
View File
@@ -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();
}
},
});
+1 -8
View File
@@ -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);
-13
View File
@@ -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';
}
}
+10 -32
View File
@@ -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';
+2 -5
View File
@@ -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%)`;
}
});
}
+4 -18
View File
@@ -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';
}
}
});
}
-2
View File
@@ -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';
+1 -6
View File
@@ -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
View File
@@ -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; }
-2
View File
@@ -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() {
+2 -24
View File
@@ -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(); });
}
-26
View File
@@ -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;
}
+3 -2
View File
@@ -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
View File
@@ -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',
+4 -2
View File
@@ -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
View File
@@ -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
+52
View File
@@ -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.
+28
View File
@@ -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}`);
}
};
+148
View File
@@ -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);
});
+28 -4
View File
@@ -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
+43 -6
View File
@@ -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())
+28 -13
View File
@@ -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
+40 -12
View File
@@ -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
+11 -2
View File
@@ -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"
+2 -4
View File
@@ -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'}
+3 -2
View File
@@ -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 &gt;</button><div class="ge-submenu">Gaussian</div></div>';
const {wireSubmenuToggles} = await import('/static/js/editor/submenu-toggles.js');
wireSubmenuToggles(document.getElementById('menu'));
}""")
page.locator('.ge-menu-parent').hover()
row = page.locator('.ge-menu-parent').bounding_box()
panel = page.locator('.ge-submenu').bounding_box()
assert abs(panel['y'] - row['y']) <= 1
assert panel['x'] + panel['width'] <= row['x'] + 1
page.screenshot(path='/tmp/editor-submenu-alignment.png')
@pytest.mark.parametrize('prompt', ['remove bg', 'remove background', 'remove the background from the selected layer'])
def test_quick_edit_planner_uses_background_tool(prompt):
from routes.editor_agent_routes import _plan_special
steps, _, _ = _plan_special(prompt, {}, True)
assert steps == [{'op': 'ai.removeBackground', 'params': {}}]
def test_quick_edit_background_removal_precedes_creative_routing():
source = (ROOT / 'static/js/galleryEditor.js').read_text()
local = source.split('const _executeQuickEditLocal = async (prompt) => {', 1)[1]
assert local.index('if (_looksBackgroundRemoval(p))') < local.index('if (_looksRecolor(p))')
@pytest.mark.parametrize('run', [False, True])
def test_background_tool_suggestion_prepares_command_and_enter_submits(page, run):
source = (ROOT / 'static/js/galleryEditor.js').read_text()
picker = 'const pickSuggestion = ' + source.split('const pickSuggestion = ', 1)[1].split(" wrap.addEventListener('pointerdown'", 1)[0]
assert "runPrompt: id === 'rembg' ? 'remove background' : null" in source
result = page.evaluate("""({picker, run}) => {
const suggestionItems = [{toolId:'rembg', label:'Bg Remove', runPrompt:'remove background'}];
const input = document.createElement('input'); input.value = 'bg';
let submitted = 0, selected = null, clearSynced = false;
const form = {requestSubmit: () => submitted++};
const _clickToolButton = id => selected = id;
const _setAiCommandStatus = () => {};
const hideSuggestions = () => {};
const syncClearButton = () => clearSynced = true;
eval(picker + '; pickSuggestion(0, run);');
return {prompt:input.value, submitted, selected, clearSynced};
}""", {'picker':picker, 'run':run})
assert result == dict(prompt='remove background', submitted=int(run), selected='rembg', clearSynced=True)
@pytest.mark.parametrize('tool', ['dodge', 'burn'])
def test_retouch_invalidates_adjustment_preview_and_stops_before_undo(page, tool):
result = page.evaluate("""async tool => {
const {state} = await import('/static/js/editor/state.js');
const {createStrokePipeline} = await import('/static/js/editor/stroke-pipeline.js');
const {renderLayerWithAdjLayers} = await import('/static/js/editor/fx/pixel-pass.js');
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 30;
const ctx = canvas.getContext('2d'); ctx.fillStyle = 'rgb(100,100,100)'; ctx.fillRect(0,0,30,30);
const layer = {id:'target', canvas, ctx, adjLayers:[{id:'adj', type:'brightness-contrast',
params:{brightness:1.1,contrast:1}, visible:true, opacity:1}]};
state.layers = [layer]; state.layerOffsets = new Map(); state.tool = tool;
Object.assign(state,{brushSize:12, brushOpacity:100, brushFlow:100, brushSoftness:0,
brushSpacing:20, brushSmoothing:0, dodgeBurnIntensity:50});
const original = ctx.getImageData(0,0,30,30);
const pixel = () => renderLayerWithAdjLayers(layer).getContext('2d').getImageData(15,15,1,1).data[0];
const before = pixel();
const pipeline = createStrokePipeline({activeLayer:()=>layer, getActiveMaskLayer:()=>null, composite:()=>{}});
pipeline.beginStroke({x:15,y:15,pressure:1},tool);
const during = pixel();
pipeline.endStroke(null); // Finish before Undo restores the saved pixels.
ctx.putImageData(original,0,0); layer._adjFinalKey = null; layer._adjCacheKey = null;
pipeline.endStroke({x:28,y:28,pressure:1}); // A late pointer-up cannot replay this stroke.
return {before,during,after:pixel(),corner:ctx.getImageData(28,28,1,1).data[0]};
}""", tool)
assert result['during'] > result['before'] if tool == 'dodge' else result['during'] < result['before']
assert result['after'] == result['before']
assert result['corner'] == 100
def test_undo_finishes_active_stroke_before_restoring_history():
source = (ROOT / 'static/js/galleryEditor.js').read_text()
undo = source.split('function undo() {', 1)[1].split('function redo()', 1)[0]
assert undo.index('if (state.drawing) _strokeTool.tryEnd();') < undo.index('state.undoStack.pop()')
@pytest.mark.parametrize('width,height', [(1000,700), (390,844)])
def test_filter_submenu_rows_have_equal_heights_with_and_without_icons(page, width, height):
page.set_viewport_size({'width':width,'height':height})
page.add_style_tag(content=(ROOT / 'static/css/13-image-editor.css').read_text())
page.evaluate("""() => {
document.body.innerHTML = '<div class="ge-submenu" style="display:block;left:8px;top:8px;">'
+ '<button class="dropdown-item-compact"><span>Plain</span></button>'
+ '<button class="dropdown-item-compact"><svg width="24" height="24"><circle cx="12" cy="12" r="8"/></svg><span>Icon</span></button>'
+ '<button class="dropdown-item-compact"><span>Longer label</span><svg width="10" height="10"></svg></button></div>';
}""")
heights = page.locator('.dropdown-item-compact').evaluate_all('(rows)=>rows.map(row=>row.getBoundingClientRect().height)')
assert heights == ([28]*3 if width > 820 else [32]*3)
page.screenshot(path=f'/tmp/editor-filter-row-heights-{width}.png')
def test_ai_settings_escape_closes_only_the_dialog(page):
page.route('**/api/auth/settings', lambda route: route.fulfill(json={'image_model':'', 'image_edit_model':''}))
result = page.evaluate("""async () => {
const {createAiSettings} = await import('/static/js/editor/ai-settings.js');
const container = document.createElement('div'); document.body.append(container);
const settings = createAiSettings({container}); settings.open();
let reachedEditor = false;
document.addEventListener('keydown', () => reachedEditor = true, {capture:true, once:true});
document.dispatchEvent(new KeyboardEvent('keydown', {key:'Escape', bubbles:true, cancelable:true}));
const result = {closed:settings.element.hidden, reachedEditor}; settings.destroy();
return result;
}""")
assert result == dict(closed=True, reachedEditor=False)
def test_image_model_settings_sync_both_directions_and_survive_reopening(page):
saved = {'image_model':'openai/gpt-5-image', 'image_edit_model':'', 'image_quality':'medium', 'image_gen_enabled':True}
writes = []
def settings_api(route):
if route.request.method == 'POST':
patch = route.request.post_data_json
writes.append(patch)
saved.update(patch)
route.fulfill(json=dict(saved))
page.route('**/api/auth/settings', settings_api)
page.route('**/api/model-endpoints', lambda route: route.fulfill(json=[{
'is_enabled':True, 'online':True, 'model_type':'llm',
'models':['openai/gpt-5-image','openai/gpt-image-2'],
}]))
page.evaluate("""async () => {
document.body.innerHTML = '<select id="set-imgModelSelect"><option value="">Auto</option></select>'
+ '<select id="set-imgEditModelSelect"><option value="">Same</option></select>'
+ '<select id="set-imgQualitySelect"><option value="medium">Medium</option></select>'
+ '<input type="checkbox" id="set-imgEnabledToggle"><span id="set-imgSettingsMsg"></span>';
const {initImageSettings} = await import('/static/js/settings/imageModels.js');
await initImageSettings();
const {createAiSettings} = await import('/static/js/editor/ai-settings.js');
window.dialog = createAiSettings({container:document.body});
for (const id of ['ge-ai-model','ge-ai-command-model']) {
const select = document.getElementById(id);
for (const model of ['', 'openai/gpt-5-image','openai/gpt-image-2']) {
const option = document.createElement('option');
option.value = model ? 'https://openrouter.ai/api/v1::'+model : '';
option.textContent = model || 'Auto'; select.append(option);
}
}
dialog.open();
}""")
page.wait_for_function("document.getElementById('ge-ai-model').value.endsWith('::openai/gpt-5-image')")
page.select_option('#ge-ai-model', 'https://openrouter.ai/api/v1::openai/gpt-image-2')
page.wait_for_function("document.getElementById('set-imgModelSelect').value === 'openai/gpt-image-2'")
assert saved['image_model'] == 'openai/gpt-image-2'
page.select_option('#set-imgEditModelSelect', 'openai/gpt-image-2')
page.wait_for_function("document.getElementById('ge-ai-command-model').value.endsWith('::openai/gpt-image-2')")
page.evaluate('dialog.close(); dialog.open();')
page.wait_for_function("document.getElementById('ge-ai-command-model').value.endsWith('::openai/gpt-image-2')")
assert saved['image_edit_model'] == 'openai/gpt-image-2'
assert len(writes) == 2
def test_generation_places_result_without_stretching_or_refresh(page):
result = page.evaluate("""async () => {
const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js');
const image = document.createElement('canvas'); image.width = 80; image.height = 20;
const ctx = image.getContext('2d'); ctx.fillStyle = 'red'; ctx.fillRect(0,0,80,20);
const selection = document.createElement('canvas'); selection.width = selection.height = 40;
selection.getContext('2d').fillRect(0,0,40,40);
let placed = null, body = null;
const run = createImageGenerationAction({
getDocument:()=>({key:'one',width:40,height:40,mask:selection}),
fetchImpl:async (url,opts) => {
body = JSON.parse(opts.body);
selection.getContext('2d').clearRect(0,0,40,40);
return {ok:true,json:async()=>({image:image.toDataURL()})};
},
placeResult:(canvas,name,mask)=>{
const data = canvas.getContext('2d');
placed = {width:canvas.width,height:canvas.height,left:data.getImageData(0,10,1,1).data[0],
right:data.getImageData(79,10,1,1).data[0], mask:mask.getContext('2d').getImageData(20,20,1,1).data[3]};
return 'new-layer';
},
});
const id = await run({prompt:'a cow',masked:true});
return {id,placed,body};
}""")
assert result['id'] == 'new-layer'
assert result['placed'] == dict(width=80,height=20,left=255,right=255,mask=255)
assert result['body'] == dict(_use_image_settings=True,prompt='a cow',width=40,height=40)
@pytest.mark.parametrize('action', ['cancel','timeout','invalid-image','document-switch'])
def test_generation_finishes_cleanly_on_failure_without_late_placement(page, action):
result = page.evaluate("""async action => {
const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js');
let key = 'original', placements = 0;
const controller = new AbortController();
const run = createImageGenerationAction({
getDocument:()=>({key,width:40,height:40}), getSignal:()=>controller.signal,
timeoutMs:action === 'timeout' ? 20 : 2000,
fetchImpl:async (url,opts)=>{
if (action === 'timeout' || action === 'cancel') {
if (action === 'cancel') setTimeout(()=>controller.abort(),10);
return new Promise((resolve,reject)=>opts.signal.addEventListener('abort',
()=>reject(new DOMException('Aborted','AbortError')), {once:true}));
}
if (action === 'invalid-image') return {ok:true,json:async()=>({image:'bad-image-data'})};
const image = document.createElement('canvas'); image.width = image.height = 4;
key = 'different';
return {ok:true,json:async()=>({image:image.toDataURL()})};
},
placeResult:()=>placements++,
});
let error = '';
try {await run({prompt:'a cow'});} catch (e) {error = e.message;}
return {error,placements};
}""", action)
assert result['placements'] == 0
assert result['error']
if action == 'timeout':
assert 'timed out' in result['error']
if action == 'document-switch':
assert 'document changed' in result['error']
def test_generation_provider_error_is_not_retried_and_next_request_can_succeed(page):
result = page.evaluate("""async () => {
const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js');
let calls = 0, placements = 0;
const image = document.createElement('canvas'); image.width = image.height = 4;
const run = createImageGenerationAction({
getDocument:()=>({key:'one',width:40,height:40}),
formatError:({status})=>status === 503 ? 'Generation is disabled' : 'error',
fetchImpl:async ()=> ++calls === 1
? {ok:false,status:503,json:async()=>({detail:'Disabled'})}
: {ok:true,json:async()=>({image:image.toDataURL()})},
placeResult:()=>++placements,
});
let error;
try {await run({prompt:'one'});} catch (e) {error = e.message;}
const callsAfterFailure = calls;
await run({prompt:'two'});
return {error,callsAfterFailure,calls,placements};
}""")
assert result == dict(error='Generation is disabled',callsAfterFailure=1,calls=2,placements=1)
def test_late_progress_response_cannot_restore_working_after_completion(page):
source = (ROOT / 'static/js/galleryEditor.js').read_text()
functions = source.split('function _startAiProgress(requestId) {',1)[1].split('function _setAiCommandStatus(',1)[0]
functions = 'function _startAiProgress(requestId) {' + functions
result = page.evaluate("""async functions => {
let _aiProgressTimer = null, _aiProgressEpoch = 0, status = 'Working';
let resolveRequest;
const fetch = () => new Promise(resolve => resolveRequest = resolve);
const _setAiProgress = () => {};
const _setAiCommandStatus = text => status = text;
const api = eval(functions + '; ({start:_startAiProgress,stop:_stopAiProgress});');
api.start('old-request');
api.stop(); status = 'Done';
resolveRequest({ok:true,json:async()=>({stage:'editing',progress:0.5})});
await new Promise(resolve=>setTimeout(resolve,20));
return status;
}""", functions)
assert result == 'Done'
@pytest.mark.parametrize('success', [True,False])
def test_quick_edit_queue_clears_working_on_completion(page, success):
source = (ROOT / 'static/js/galleryEditor.js').read_text()
pump = 'const pumpQuickEdit = ' + source.split('const pumpQuickEdit = ',1)[1].split(' const enqueueQuickEditJob',1)[0]
result = page.evaluate("""async ({pump,success}) => {
const {finishQuickEditJob} = await import('/static/js/editor/quick-edit-job.js');
const jobs = [{id:'one',prompt:'fill',status:'queued'}];
let pumping = false, _quickEditActiveJob = null, stopped = 0, terminal;
const renderJobStack = () => {};
const dismissJob = () => {};
const _stopAiProgress = () => stopped++;
const _setAiCommandStatus = (text,kind) => terminal = {text,kind};
const executeQuickEdit = async () => success ? {ok:true} : {ok:false,error:'Provider failed'};
await eval(pump + '; pumpQuickEdit();');
return {status:jobs[0].status,detail:jobs[0].detail,terminal,stopped,pumping};
}""", {'pump':pump,'success':success})
expected = 'done' if success else 'error'
message = 'Done' if success else 'Provider failed'
assert result == dict(status=expected,detail=message,terminal=dict(text=message,kind=expected),stopped=1,pumping=False)
@pytest.mark.parametrize('bridge', [False,True])
@pytest.mark.parametrize('instruction', [False,True])
@pytest.mark.parametrize('matching_ratio', [False,True])
def test_generated_layer_can_keep_pixels_outside_document_bounds(page, bridge, instruction, matching_ratio):
source = (ROOT / 'static/js/galleryEditor.js').read_text()
placement = 'const _placeResultLayer = ' + source.split('const _placeResultLayer = ',1)[1].split(' const _generateImage',1)[0]
bridge_line = next(line for line in source.splitlines() if "'layers.placeImage':" in line)
result = page.evaluate("""async ({placement,bridgeLine,bridge,instruction,matchingRatio}) => {
const {imageResultGeometry} = await import('/static/js/editor/image-result-geometry.js');
const state = {imgWidth:matchingRatio ? 1280 : 40,imgHeight:matchingRatio ? 720 : 40,layers:[],layerOffsets:new Map()};
let saved = 0;
const _saveState = ()=>saved++;
const composite = ()=>{};
const _renderLayerPanel = ()=>{};
const createLayer = (name,w,h)=>{
const canvas = document.createElement('canvas'); canvas.width=w;canvas.height=h;
return {id:'new',name,canvas,ctx:canvas.getContext('2d')};
};
const image = document.createElement('canvas'); image.width=matchingRatio ? 1536 : 80;image.height=matchingRatio ? 864 : 20;
image.getContext('2d').fillRect(0,0,image.width,image.height);
if (bridge) {
const run = eval('({' + bridgeLine + '})')["layers.placeImage"].run;
await run({dataUrl:image.toDataURL(),nativeSize:true,masked:false,
mode:instruction ? 'instruction' : undefined,differenceMask:instruction});
} else {
eval(placement + '; _placeResultLayer(image,"Generated",null);');
}
const layer = state.layers[0];
return {width:layer.canvas.width,height:layer.canvas.height,offset:state.layerOffsets.get(layer.id),
outsideAlpha:layer.ctx.getImageData(79,10,1,1).data[3],saved};
}""", {'placement':placement,'bridgeLine':bridge_line,'bridge':bridge,'instruction':instruction,'matchingRatio':matching_ratio})
expected = dict(width=1280,height=720,offset=dict(x=0,y=0),outsideAlpha=255,saved=1) if matching_ratio else dict(width=80,height=20,offset=dict(x=-20,y=10),outsideAlpha=255,saved=1)
assert result == expected
def test_whole_image_edit_does_not_clip_native_result_to_select_all(page):
source = (ROOT / 'static/js/galleryEditor.js').read_text()
operation = 'const _aiInpaintSelection = ' + source.split('const _aiInpaintSelection = ',1)[1].split(' // Replace the background/scene',1)[0]
result = page.evaluate("""async operation => {
const mask = document.createElement('canvas'); mask.width=40;mask.height=40;
const canvas = document.createElement('canvas'); canvas.width=80;canvas.height=20;
const state = {wandMask:mask};
const activeLayer = ()=>({id:'one'});
const _aiEditCanvas = async ()=>({canvas,fullFrame:true});
const _placeResultLayer = (result,name,resultMask)=>({width:result.width,height:result.height,masked:!!resultMask});
return await eval(operation + '; _aiInpaintSelection("change the sky");');
}""", operation)
assert result == dict(width=80,height=20,masked=False)
@pytest.mark.parametrize('matching_ratio', [False,True])
def test_inpaint_panel_preserves_native_whole_edit_dimensions(page, matching_ratio):
source = (ROOT / 'static/js/editor/ai-inpaint.js').read_text()
body = 'const fullFrame = ' + source.split('const fullFrame = ',1)[1].split(' // Snapshot the AI result',1)[0]
result = page.evaluate("""async ({body,matchingRatio}) => {
const {imageResultGeometry} = await import('/static/js/editor/image-result-geometry.js');
const state = {imgWidth:matchingRatio ? 1280 : 40,imgHeight:matchingRatio ? 720 : 40,layerOffsets:new Map()};
const data = {full_frame:true}, layerName = 'Edit';
const createLayer = (name,w,h)=>{
const canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
return {id:'result',canvas,ctx:canvas.getContext('2d')};
};
const source = document.createElement('canvas'); source.width=matchingRatio ? 1536 : 80;source.height=matchingRatio ? 864 : 20;
source.getContext('2d').fillRect(0,0,source.width,source.height);
const resultImg = new Image(); resultImg.src=source.toDataURL(); await resultImg.decode();
return eval(body + '; ({width:resultLayer.canvas.width,height:resultLayer.canvas.height,offset:state.layerOffsets.get(resultLayer.id)});');
}""", {'body':body,'matchingRatio':matching_ratio})
assert result == (dict(width=1280,height=720,offset=dict(x=0,y=0)) if matching_ratio else dict(width=80,height=20,offset=dict(x=-20,y=10)))
def test_brush_softness_is_in_main_bar_and_saves_extended_value(page):
page.add_style_tag(content=(ROOT / 'static/css/13-image-editor.css').read_text())
result = page.evaluate("""async () => {
const {controlsHTML} = await import('/static/js/editor/build/controls.js');
const {wireStrokeToolSliders} = await import('/static/js/editor/stroke-tool-sliders.js');
const {state} = await import('/static/js/editor/state.js');
const bar = document.createElement('div');bar.className='ge-options-bar';
const controls = document.createElement('div');controls.className='ge-controls ge-controls--in-options';
controls.innerHTML=controlsHTML({color:'#000000',brushSize:40,wandTolerance:20});
for(const child of controls.children) if(child.id !== 'ge-brush-controls') child.style.display='none';
bar.append(controls);document.body.append(bar);
wireStrokeToolSliders();
const slider=document.getElementById('ge-brush-softness');
slider.value='1200';slider.dispatchEvent(new Event('input',{bubbles:true}));
return {inMore:!!slider.closest('#ge-brush-more-menu'),max:slider.max,value:state.brushSoftness,
visible:slider.getBoundingClientRect().height>0,label:document.getElementById('ge-brush-softness-label').textContent};
}""")
assert result == dict(inMore=False,max='1200',value=1200,visible=True,label='1200%')
page.screenshot(path='/tmp/editor-brush-softness-main-bar.png')
def test_extended_softness_produces_softer_pixels_not_just_larger_slider_values(page):
result = page.evaluate("""async () => {
const {state}=await import('/static/js/editor/state.js');
const {createStrokePipeline}=await import('/static/js/editor/stroke-pipeline.js');
const results=[];
for(const softness of [300,1200]) {
const canvas=document.createElement('canvas');canvas.width=canvas.height=100;
const layer={id:'brush',canvas,ctx:canvas.getContext('2d')};
Object.assign(state,{tool:'brush',brushSize:60,brushSoftness:softness,brushOpacity:100,brushFlow:100,
brushBlendMode:'source-over',color:'#000000',brushSpacing:15,brushSmoothing:0,
brushPixel:false,pressureSize:false,pressureOpacity:false,pressureFlow:false,layerOffsets:new Map()});
const pipeline=createStrokePipeline({activeLayer:()=>layer,getActiveMaskLayer:()=>null,composite:()=>{}});
pipeline.beginStroke({x:50,y:50,pressure:1},'brush');pipeline.endStroke(null);
const alpha=x=>layer.ctx.getImageData(x,50,1,1).data[3];
results.push({middle:alpha(65),center:alpha(50),outside:alpha(85)});
document.body.append(canvas);
}
return results;
}""")
assert result[1]['middle'] < result[0]['middle'] / 3
assert result[1]['center'] > 150
assert result[0]['outside'] == result[1]['outside'] == 0
page.screenshot(path='/tmp/editor-brush-softness-falloff.png')
@pytest.mark.parametrize('width', [1000,840])
def test_brush_numbers_do_not_overlap_after_real_panel_normalization(page,width):
page.set_viewport_size({'width':width,'height':700})
for stylesheet in ['static/css/13-image-editor.css','static/css/documents-gallery-editor.css']:
page.add_style_tag(content=(ROOT / stylesheet).read_text())
page.add_style_tag(content=':root{--bg:#171719;--panel:#242426;--fg:#eee;--red:#d47965;--border:#454549;}body{background:var(--bg);}')
result = page.evaluate("""async () => {
const {state}=await import('/static/js/editor/state.js');
const {controlsHTML}=await import('/static/js/editor/build/controls.js');
const {buildRightPanel}=await import('/static/js/editor/build/right-panel.js');
const {wireStrokeToolSliders}=await import('/static/js/editor/stroke-tool-sliders.js');
state.container=document.createElement('div');document.body.append(state.container);
state.brushSize=800;
const {controls}=buildRightPanel({controlsHTML,layerPanelHTML:()=>'<div></div>'});
controls.classList.add('ge-controls--in-options');
const bar=document.createElement('div');bar.className='ge-options-bar';bar.append(controls);state.container.append(bar);
for(const child of controls.children) if(child.id !== 'ge-brush-controls') child.style.display='none';
wireStrokeToolSliders();
const slider=document.getElementById('ge-brush-softness');
const elements=[document.querySelector('#ge-brush-softness-row label'),slider,
document.getElementById('ge-brush-softness-label'),document.querySelector('.ge-brush-size-row label'),
document.querySelector('.ge-brush-size-row input')];
const bounds=()=>elements.map(element=>{
const r=element.getBoundingClientRect();return {x:r.x,right:r.right,width:r.width};
});
slider.value='1';slider.dispatchEvent(new Event('input'));const small=bounds();
slider.value='1200';slider.dispatchEvent(new Event('input'));const large=bounds();
return {small,large,value:document.getElementById('ge-brush-softness-label').textContent};
}""")
assert result['value'] == '1200%'
assert all(bounds['width'] > 0 for bounds in result['large']), result
for left,right in zip(result['large'],result['large'][1:]):
assert left['right'] <= right['x'] + 1, result
for small,large in zip(result['small'],result['large']):
assert abs(small['x']-large['x']) <= 1, result
page.screenshot(path=f'/tmp/editor-brush-number-layout-{width}.png')
@pytest.mark.parametrize('fail_once', [False, True])
def test_new_project_create_initializes_canvas(page, fail_once):
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
page.route('http://editor.test/api/**', lambda route: route.fulfill(json={}))
page.add_style_tag(content='''
.modal {position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:250;pointer-events:none;}
.modal-content {pointer-events:auto;background:#333;padding:20px;}
#gallery-modal {z-index:700;}
#gallery-modal > .modal-content {width:90vw;height:90vh;}
''')
page.evaluate("""async () => {
document.body.innerHTML = '<div id="toast"></div><div class="modal" id="gallery-modal"><div class="modal-content"><div id="gallery-editor-container"></div></div></div>';
const editor = await import('/static/js/galleryEditor.js');
window.newProject = editor.openEditor(null, null, null, 'New canvas');
window.newProject.catch(error => { window.createError = error.stack; });
}""")
if fail_once:
page.evaluate("""() => {
const fill = CanvasRenderingContext2D.prototype.fillRect;
CanvasRenderingContext2D.prototype.fillRect = function(...args) {
if (this.canvas.width === 1024 && this.canvas.height === 1024) {
CanvasRenderingContext2D.prototype.fillRect = fill;
throw Error('Canvas allocation test failure');
}
return fill.apply(this, args);
};
}""")
page.locator('#ge-canvas-prompt-ok').click(timeout=3000)
if fail_once:
page.wait_for_function("window.createError")
assert 'Canvas allocation test failure' in page.evaluate('window.createError')
assert 'Could not create project' in page.locator('body').inner_text()
page.evaluate("""async () => {
window.createError = null;
const editor = await import('/static/js/galleryEditor.js');
window.newProject = editor.openEditor(null, null, null, 'New canvas');
window.newProject.catch(error => { window.createError = error.stack; });
}""")
page.locator('#ge-canvas-prompt-ok').click(timeout=3000)
page.wait_for_timeout(500)
result = page.evaluate("""async () => {
const {state} = await import('/static/js/editor/state.js');
return {error:window.createError, width:state.mainCanvas?.width,
height:state.mainCanvas?.height, layers:state.layers.map(layer=>layer.name)};
}""")
assert not errors, errors
assert not result.get('error'), result
assert result == {'error':None,'width':1024,'height':1024,'layers':['Background','Edit']}
+2 -2
View File
@@ -143,8 +143,8 @@ def test_crop_can_start_outside_the_canvas_without_panning():
# image (so it can be pulled out past the edge) instead of the canvas-area
# 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
+6 -5
View File
@@ -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():
+1 -36
View File
@@ -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():
+6 -7
View File
@@ -52,7 +52,7 @@ def test_quick_edit_flip_and_rotate_accept_numeric_degrees():
def test_quick_edit_transforms_do_not_add_a_second_undo_snapshot():
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")
+8 -73
View File
@@ -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)
+10 -67
View File
@@ -23,7 +23,7 @@ def test_native_generation_uses_configured_model_and_multiline_prompt(image_tran
json.dumps({'prompt': prompt}), session_id='fixture', owner='alice'))
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):
+71
View File
@@ -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]
+4 -3
View File
@@ -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()
+13 -9
View File
@@ -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
+29
View File
@@ -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)