mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-01 10:42:23 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a19d75481 | ||
|
|
39303f19cd | ||
|
|
5353241743 | ||
|
|
285d05e72e | ||
|
|
dc0fbbf7aa | ||
|
|
12b9c5f2ad | ||
|
|
cb26ccd9a7 | ||
|
|
23a480571a | ||
|
|
6a43cf23ea | ||
|
|
64b457dc0a | ||
|
|
ef3467e8d4 | ||
|
|
72e8520fa3 | ||
|
|
9276db9c11 | ||
|
|
a797cee0b3 | ||
|
|
76061498c8 | ||
|
|
04f8546d9d | ||
|
|
9835c1a7f9 | ||
|
|
239f09cbc4 | ||
|
|
a1a7f8fc9a | ||
|
|
87193d23f5 | ||
|
|
516f046fef | ||
|
|
48c3a7fc36 | ||
|
|
70fc58225c | ||
|
|
9efedc8b07 | ||
|
|
1d251ef867 | ||
|
|
bf5bf57bc1 | ||
|
|
2019a97339 | ||
|
|
9a1fdb192e |
@@ -76,7 +76,7 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
# Byte-compile sources — catches syntax errors without installing deps.
|
# Byte-compile sources — catches syntax errors without installing deps.
|
||||||
@@ -91,7 +91,7 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
with:
|
with:
|
||||||
node-version: "22.23.4"
|
node-version: "22.23.4"
|
||||||
- run: npm ci --ignore-scripts --no-audit --no-fund
|
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||||
@@ -151,7 +151,7 @@ jobs:
|
|||||||
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
@@ -168,7 +168,7 @@ jobs:
|
|||||||
|
|
||||||
# uv resolves the same versions as pip but installs ~5x faster, and its
|
# uv resolves the same versions as pip but installs ~5x faster, and its
|
||||||
# cache keeps each shard from re-downloading the whole requirement set.
|
# cache keeps each shard from re-downloading the whole requirement set.
|
||||||
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
with:
|
with:
|
||||||
version: "0.13.5"
|
version: "0.13.5"
|
||||||
@@ -179,8 +179,9 @@ jobs:
|
|||||||
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
||||||
python scripts/check_ca_trust.py --python
|
python scripts/check_ca_trust.py --python
|
||||||
uv pip install --system -r requirements.txt
|
uv pip install --system -r requirements.txt
|
||||||
|
uv pip install --system "playwright==1.62.0"
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
with:
|
with:
|
||||||
node-version: "22.23.4"
|
node-version: "22.23.4"
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
build-mode: none
|
build-mode: none
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||||
with:
|
with:
|
||||||
category: "/language:${{ matrix.language }}"
|
category: "/language:${{ matrix.language }}"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Lint Dockerfile
|
- name: Lint Dockerfile
|
||||||
uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
|
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
|
||||||
with:
|
with:
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
# DL3008: pinning apt package versions is impractical on a -slim base
|
# DL3008: pinning apt package versions is impractical on a -slim base
|
||||||
|
|||||||
@@ -61,14 +61,14 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Buildx
|
- name: Set up Buildx
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
with:
|
with:
|
||||||
driver: docker
|
driver: docker
|
||||||
|
|
||||||
# Build without pushing so a broken Dockerfile is caught here, and the
|
# Build without pushing so a broken Dockerfile is caught here, and the
|
||||||
# exact image we ship is what gets scanned.
|
# exact image we ship is what gets scanned.
|
||||||
- name: Build image
|
- name: Build image
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
@@ -107,12 +107,12 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Buildx
|
- name: Set up Buildx
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
with:
|
with:
|
||||||
driver: docker
|
driver: docker
|
||||||
|
|
||||||
- name: Build image
|
- name: Build image
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
@@ -133,7 +133,7 @@ jobs:
|
|||||||
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
|
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
|
||||||
|
|
||||||
- name: Upload Trivy results
|
- name: Upload Trivy results
|
||||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||||
with:
|
with:
|
||||||
sarif_file: trivy-results.sarif
|
sarif_file: trivy-results.sarif
|
||||||
category: trivy-image
|
category: trivy-image
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
|
|||||||
@@ -47,4 +47,4 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Deploy to GitHub Pages
|
- name: Deploy to GitHub Pages
|
||||||
id: deployment
|
id: deployment
|
||||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
|
||||||
|
|||||||
@@ -55,16 +55,16 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||||
- name: Set up Buildx
|
- name: Set up Buildx
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push by digest
|
- name: Build and push by digest
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
pull: true
|
pull: true
|
||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
digest="${{ steps.build.outputs.digest }}"
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
with:
|
with:
|
||||||
name: digest-${{ matrix.arch }}
|
name: digest-${{ matrix.arch }}
|
||||||
path: /tmp/digests/*
|
path: /tmp/digests/*
|
||||||
@@ -107,15 +107,15 @@ jobs:
|
|||||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||||
echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digest-*
|
pattern: digest-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
- name: Set up Buildx
|
- name: Set up Buildx
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -62,9 +62,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
SEARXNG_TEST_IMAGE: ${{ steps.image.outputs.image }}
|
SEARXNG_TEST_IMAGE: ${{ steps.image.outputs.image }}
|
||||||
run: docker pull "$SEARXNG_TEST_IMAGE"
|
run: docker pull "$SEARXNG_TEST_IMAGE"
|
||||||
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
- name: Check unmodified upstream trust and actual entrypoint offline
|
- name: Check unmodified upstream trust and actual entrypoint offline
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/searxng/Dockerfile
|
file: docker/searxng/Dockerfile
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
|
|||||||
@@ -82,13 +82,15 @@ services:
|
|||||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
||||||
# Host workspace translation is opt-in. Keep the public compose file
|
# Host workspace translation is opt-in. Keep the public compose file
|
||||||
# user-neutral; configure these in a local .env or use the host-workspace
|
# user-neutral; configure these in a local .env or use the host-workspace
|
||||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||||
|
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
||||||
|
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
@@ -165,7 +167,7 @@ services:
|
|||||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- searxng-data:/etc/searxng
|
- searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||||
|
|||||||
@@ -81,13 +81,15 @@ services:
|
|||||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
||||||
# Host workspace translation is opt-in. Keep the public compose file
|
# Host workspace translation is opt-in. Keep the public compose file
|
||||||
# user-neutral; configure these in a local .env or use the host-workspace
|
# user-neutral; configure these in a local .env or use the host-workspace
|
||||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||||
|
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
||||||
|
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
@@ -168,7 +170,7 @@ services:
|
|||||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- searxng-data:/etc/searxng
|
- searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||||
|
|||||||
+4
-2
@@ -70,13 +70,15 @@ services:
|
|||||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
||||||
# Host workspace translation is opt-in. Keep the public compose file
|
# Host workspace translation is opt-in. Keep the public compose file
|
||||||
# user-neutral; configure these in a local .env or use the host-workspace
|
# user-neutral; configure these in a local .env or use the host-workspace
|
||||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||||
|
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
||||||
|
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
@@ -146,7 +148,7 @@ services:
|
|||||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- searxng-data:/etc/searxng
|
- searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||||
|
|||||||
@@ -31,21 +31,43 @@ for spec in $SPECS; do
|
|||||||
name="${spec%%==*}"
|
name="${spec%%==*}"
|
||||||
ver="${spec##*==}"
|
ver="${spec##*==}"
|
||||||
# pip download builds metadata (and trips the same bug), so fetch the raw
|
# pip download builds metadata (and trips the same bug), so fetch the raw
|
||||||
# sdist URL from the PyPI JSON API instead.
|
# sdist URL from the PyPI JSON API instead. Use curl with retries to
|
||||||
url="$(python - "$name" "$ver" <<'PY'
|
# survive transient TLS / network failures that urllib cannot recover from.
|
||||||
import json, sys, urllib.request
|
metadata="${name}-${ver}.json"
|
||||||
name, ver = sys.argv[1], sys.argv[2]
|
curl --fail --silent --show-error --location \
|
||||||
data = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
|
--retry 8 \
|
||||||
for f in data["urls"]:
|
--retry-delay 3 \
|
||||||
if f["packagetype"] == "sdist":
|
--retry-max-time 120 \
|
||||||
print(f["url"]); break
|
--retry-all-errors \
|
||||||
|
"https://pypi.org/pypi/${name}/${ver}/json" \
|
||||||
|
-o "$metadata"
|
||||||
|
|
||||||
|
url="$(python - "$metadata" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
for item in data["urls"]:
|
||||||
|
if item["packagetype"] == "sdist":
|
||||||
|
print(item["url"])
|
||||||
|
break
|
||||||
else:
|
else:
|
||||||
sys.exit(f"no sdist found for {name}=={ver}")
|
raise SystemExit("no sdist found")
|
||||||
PY
|
PY
|
||||||
)"
|
)"
|
||||||
|
rm -f "$metadata"
|
||||||
|
|
||||||
echo ">> fetching ${name} ${ver}: ${url}"
|
echo ">> fetching ${name} ${ver}: ${url}"
|
||||||
curl -fsSL "$url" -o "${name}.tar.gz"
|
curl --fail --silent --show-error --location \
|
||||||
|
--retry 8 \
|
||||||
|
--retry-delay 3 \
|
||||||
|
--retry-max-time 120 \
|
||||||
|
--retry-all-errors \
|
||||||
|
"$url" \
|
||||||
|
-o "${name}.tar.gz"
|
||||||
tar xzf "${name}.tar.gz"
|
tar xzf "${name}.tar.gz"
|
||||||
|
rm -f "${name}.tar.gz"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo ">> patching get_version()"
|
echo ">> patching get_version()"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -2,3 +2,4 @@
|
|||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
# psutil lets `-n auto` use physical cores instead of logical CPU threads.
|
# psutil lets `-n auto` use physical cores instead of logical CPU threads.
|
||||||
pytest-xdist[psutil]>=3.8,<4
|
pytest-xdist[psutil]>=3.8,<4
|
||||||
|
playwright==1.62.0
|
||||||
|
|||||||
+17
-1
@@ -1901,7 +1901,7 @@ def _ts_or_zero(value) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
|
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
|
||||||
"""Re-populate sess.model from the matching endpoint's cached models.
|
"""Recover sess.model from the endpoint's visible cached and pinned models.
|
||||||
|
|
||||||
Covers the window between endpoint setup and the first chat send: the
|
Covers the window between endpoint setup and the first chat send: the
|
||||||
picker showed a model in the dropdown but the session record never got
|
picker showed a model in the dropdown but the session record never got
|
||||||
@@ -1965,6 +1965,9 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
|
|||||||
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
|
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
|
||||||
except Exception:
|
except Exception:
|
||||||
visible = cached
|
visible = cached
|
||||||
|
if not is_chatgpt_subscription:
|
||||||
|
from src.endpoint_resolver import _endpoint_enabled_models
|
||||||
|
visible = _endpoint_enabled_models(ep)
|
||||||
if current_model and current_model in {str(item).strip() for item in visible}:
|
if current_model and current_model in {str(item).strip() for item in visible}:
|
||||||
return False
|
return False
|
||||||
if is_chatgpt_subscription:
|
if is_chatgpt_subscription:
|
||||||
@@ -3923,6 +3926,19 @@ def setup_chat_routes(
|
|||||||
if ctx.preprocessed.attachment_meta:
|
if ctx.preprocessed.attachment_meta:
|
||||||
yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n"
|
yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n"
|
||||||
|
|
||||||
|
# Give the page the saved user message's id. Only the reply's id
|
||||||
|
# was sent back, so a message sent in the open page had none and
|
||||||
|
# a later regenerate/edit of it could not cut the chat by id.
|
||||||
|
try:
|
||||||
|
_last_user_msg = next((m for m in reversed(getattr(sess, "history", None) or [])
|
||||||
|
if getattr(m, "role", None) == "user"), None)
|
||||||
|
_user_db_id = ((getattr(_last_user_msg, "metadata", None) or {}).get("_db_id")
|
||||||
|
if _last_user_msg is not None else None)
|
||||||
|
if _user_db_id:
|
||||||
|
yield f"data: {json.dumps({'type': 'user_message_saved', 'id': _user_db_id})}\n\n"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Announce any docs auto-created during preprocess (e.g. fillable
|
# Announce any docs auto-created during preprocess (e.g. fillable
|
||||||
# PDF → editable markdown) so the editor pane switches to them
|
# PDF → editable markdown) so the editor pane switches to them
|
||||||
# before the model starts streaming.
|
# before the model starts streaming.
|
||||||
|
|||||||
@@ -1491,6 +1491,20 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
|||||||
"Port is already in use.",
|
"Port is already in use.",
|
||||||
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
|
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
# PyPI torch/vLLM wheels now target CUDA 13, which needs driver 580+.
|
||||||
|
# torch: "The NVIDIA driver on your system is too old (found version 12080)";
|
||||||
|
# vLLM kernels: "CUDA driver version is insufficient for CUDA runtime version".
|
||||||
|
r"NVIDIA driver on your system is too old|CUDA driver version is insufficient",
|
||||||
|
"The installed PyTorch/vLLM build needs a newer NVIDIA driver than this server has.",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"label": "install builds for the driver's CUDA version, e.g. vLLM's +cu129 wheel from its GitHub release with uv pip install … --torch-backend cu129",
|
||||||
|
"op": "manual",
|
||||||
|
},
|
||||||
|
{"label": "or update the NVIDIA driver (CUDA 13 builds need driver 580 or newer)", "op": "manual"},
|
||||||
|
],
|
||||||
|
),
|
||||||
(
|
(
|
||||||
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
|
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
|
||||||
"No GPUs are visible to the serve process.",
|
"No GPUs are visible to the serve process.",
|
||||||
|
|||||||
+5
-172
@@ -468,175 +468,6 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
task["payload"].pop("hf_token", None)
|
task["payload"].pop("hf_token", None)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def _diagnose_serve_output(text: str) -> dict | None:
|
|
||||||
"""Server-side mirror of the Cookbook UI's common serve diagnoses.
|
|
||||||
|
|
||||||
The browser uses cookbook-diagnosis.js for clickable fixes. This gives
|
|
||||||
the agent/tool path the same structured signal so it can retry with an
|
|
||||||
adjusted command instead of guessing from raw tmux output.
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
tail = text[-6000:]
|
|
||||||
patterns = [
|
|
||||||
(
|
|
||||||
r"No available memory for the cache blocks|Available KV cache memory:.*-",
|
|
||||||
"No GPU memory left for KV cache after loading model.",
|
|
||||||
[
|
|
||||||
{"label": "retry with GPU memory utilization 0.95", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.95"},
|
|
||||||
{"label": "retry with context 2048", "op": "replace", "flag": "--max-model-len", "value": "2048"},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory|warming up sampler|max_num_seqs.*gpu_memory_utilization",
|
|
||||||
"GPU ran out of memory during startup or warmup.",
|
|
||||||
[
|
|
||||||
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
|
|
||||||
{"label": "retry with GPU memory utilization 0.80", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.80"},
|
|
||||||
{"label": "retry with --enforce-eager", "op": "append", "arg": "--enforce-eager"},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"not divisib|must be divisible|attention heads.*divisible",
|
|
||||||
"Tensor parallel size is incompatible with the model.",
|
|
||||||
[
|
|
||||||
{"label": "retry with tensor parallel size 1", "op": "replace", "flag": "--tensor-parallel-size", "value": "1"},
|
|
||||||
{"label": "retry with tensor parallel size 2", "op": "replace", "flag": "--tensor-parallel-size", "value": "2"},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context",
|
|
||||||
"Context length is too large for available GPU memory.",
|
|
||||||
[
|
|
||||||
{"label": "retry with context 8192", "op": "replace", "flag": "--max-model-len", "value": "8192"},
|
|
||||||
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"enable-auto-tool-choice requires --tool-call-parser",
|
|
||||||
"Auto tool choice requires an explicit tool call parser.",
|
|
||||||
[{"label": "retry with Hermes tool parser", "op": "append", "arg": "--tool-call-parser hermes"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"Please pass.*trust.remote.code=True|contains custom code which must be executed to correctly load|does not recognize this architecture|model type.*but Transformers does not",
|
|
||||||
"Model requires custom code or newer model support.",
|
|
||||||
[{"label": "retry with --trust-remote-code", "op": "append", "arg": "--trust-remote-code"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"Either a revision or a version must be specified|transformers\.integrations\.hub_kernels|kernels/layer",
|
|
||||||
"vLLM/Transformers kernel package mismatch.",
|
|
||||||
[{"label": "update vLLM, Transformers, and kernels on this server", "op": "dependency", "package": "vllm transformers kernels"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"Address already in use|bind.*address.*in use",
|
|
||||||
"Port is already in use.",
|
|
||||||
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
|
|
||||||
"No GPUs are visible to the serve process.",
|
|
||||||
[{"label": "clear Cookbook GPU selection or choose available GPUs", "op": "settings", "field": "gpus", "value": ""}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"Failed to infer device type|NVML Shared Library Not Found|No module named 'amdsmi'|platform is not available",
|
|
||||||
"vLLM could not find a supported GPU (CUDA or ROCm). "
|
|
||||||
"This machine may have integrated or unsupported graphics only.",
|
|
||||||
[
|
|
||||||
{"label": "switch to llama.cpp (CPU/Metal, works without a discrete GPU)", "op": "manual"},
|
|
||||||
{"label": "switch to Ollama (CPU/Metal, works without a discrete GPU)", "op": "manual"},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"vllm.*command not found|No module named vllm|ERROR: vLLM is not installed",
|
|
||||||
"vLLM is not installed or not in PATH on this server.",
|
|
||||||
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|"
|
|
||||||
r"(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)[\s\S]*sgl_kernel|"
|
|
||||||
r"Could not load any common_ops library|"
|
|
||||||
r"Please ensure sgl_kernel is properly installed",
|
|
||||||
"SGLang native kernel/runtime is missing or mismatched on this server.",
|
|
||||||
[
|
|
||||||
{"label": "repair sglang-kernel in this Python environment", "op": "dependency", "package": "sglang-kernel"},
|
|
||||||
{"label": "if libnvrtc is still missing, install the matching CUDA/NVRTC runtime on this host", "op": "manual"},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"sglang.*command not found|No module named sglang|SGLang is not installed",
|
|
||||||
"SGLang is not installed or not in PATH on this server.",
|
|
||||||
[{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"No module named ['\"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed",
|
|
||||||
"MLX LM is not installed on this server.",
|
|
||||||
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed",
|
|
||||||
"This image model uses a custom Diffusers pipeline that the launch environment does not know yet.",
|
|
||||||
[{"label": "update Diffusers image dependencies", "op": "dependency", "package": "diffusers transformers accelerate"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
|
|
||||||
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
|
|
||||||
[
|
|
||||||
{"label": "relaunch from the cached local Hugging Face snapshot path on this Mac", "op": "manual"},
|
|
||||||
{"label": "Odysseus now rewrites MLX repo-id launches to a cached snapshot when one exists", "op": "manual"},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
# System build deps come BEFORE the generic llama.cpp catch-all
|
|
||||||
# so cmake / build-essential / git missing → a specific OS-package
|
|
||||||
# remediation instead of "install llama-cpp-python[server]" (which
|
|
||||||
# itself fails to compile when cmake is absent).
|
|
||||||
(
|
|
||||||
r"cmake: command not found|cmake.*not found.*[Cc]ould not",
|
|
||||||
"cmake is required to build llama.cpp from source but isn't installed on this server.",
|
|
||||||
[{"label": "install build deps for llama.cpp (apt: cmake build-essential git / pacman: cmake base-devel git / dnf: cmake gcc-c++ make git / brew: cmake git)", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"^(make|g\+\+|gcc): command not found|Could not find C\+\+ compiler",
|
|
||||||
"A C/C++ compiler (build-essential) is required to build llama.cpp from source.",
|
|
||||||
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"^git: command not found",
|
|
||||||
"git is required to clone the llama.cpp source tree.",
|
|
||||||
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'",
|
|
||||||
"llama.cpp / llama-cpp-python dependencies are missing.",
|
|
||||||
[{"label": "install llama.cpp dependencies or llama-cpp-python[server]", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"No GGUF found on this host|no \.gguf file|No GGUF file found",
|
|
||||||
"No GGUF file found for this model on this host. The llama.cpp backend needs a .gguf file.",
|
|
||||||
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"No module named 'torch'|No module named torch|No module named 'torchvision'|No module named torchvision|No module named 'diffusers'|No module named diffusers|No module named 'scipy'|No module named scipy|install scipy if you want to use beta sigmas|requires the Torchvision library",
|
|
||||||
"Diffusion serving requires PyTorch, Torchvision, Diffusers, Accelerate, and SciPy.",
|
|
||||||
[{"label": "install Diffusers image deps in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch] torchvision accelerate scipy python-multipart"}],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
|
|
||||||
"Model access is gated or unauthorized.",
|
|
||||||
[{"label": "set HF token and request model access on HuggingFace", "op": "manual"}],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
for pattern, message, suggestions in patterns:
|
|
||||||
if re.search(pattern, tail, re.I):
|
|
||||||
return {"message": message, "suggestions": suggestions}
|
|
||||||
if re.search(r"Traceback \(most recent call last\)", tail, re.I) and not re.search(
|
|
||||||
r"Application startup complete|GET /v1/|Uvicorn running on", tail, re.I
|
|
||||||
):
|
|
||||||
return {
|
|
||||||
"message": "Python traceback detected during serve startup.",
|
|
||||||
"suggestions": [{"label": "inspect traceback and retry with adjusted backend/settings", "op": "manual"}],
|
|
||||||
}
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _state_for_client(state):
|
def _state_for_client(state):
|
||||||
"""Return cookbook state without raw secrets for browser clients."""
|
"""Return cookbook state without raw secrets for browser clients."""
|
||||||
_strip_task_secrets(state)
|
_strip_task_secrets(state)
|
||||||
@@ -3930,7 +3761,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
return {"ok": False, "files": [], "error": f"HF API HTTP {resp.status_code}"}
|
return {"ok": False, "files": [], "error": f"HF API HTTP {resp.status_code}"}
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("HF GGUF file scan failed for %s", repo)
|
logger.exception("HF GGUF file scan failed for %s", repo_id)
|
||||||
return {"ok": False, "files": [], "error": "HF API request failed"}
|
return {"ok": False, "files": [], "error": "HF API request failed"}
|
||||||
files = [
|
files = [
|
||||||
str(s.get("rfilename") or "")
|
str(s.get("rfilename") or "")
|
||||||
@@ -4446,7 +4277,9 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
# fills the visible tail. Without this, output_tail ends up
|
# fills the visible tail. Without this, output_tail ends up
|
||||||
# as just "Locale: C / Ubuntu_Odysseus ❯" and the agent
|
# as just "Locale: C / Ubuntu_Odysseus ❯" and the agent
|
||||||
# can't diagnose the actual error.
|
# can't diagnose the actual error.
|
||||||
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-S", "-500")]
|
# -J joins lines tmux wrapped at the pane width, so an error
|
||||||
|
# split mid-word still matches the serve diagnosis patterns.
|
||||||
|
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-J", "-S", "-500")]
|
||||||
elif IS_WINDOWS:
|
elif IS_WINDOWS:
|
||||||
# LOCAL Windows task: launched as a detached process (no tmux).
|
# LOCAL Windows task: launched as a detached process (no tmux).
|
||||||
# Liveness comes from the <session>.pid file, output from the
|
# Liveness comes from the <session>.pid file, output from the
|
||||||
@@ -4455,7 +4288,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
capture_cmd = None
|
capture_cmd = None
|
||||||
else:
|
else:
|
||||||
check_cmd = ["tmux", "has-session", "-t", session_id]
|
check_cmd = ["tmux", "has-session", "-t", session_id]
|
||||||
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-500"]
|
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-J", "-S", "-500"]
|
||||||
|
|
||||||
local_win_task = (not remote) and IS_WINDOWS
|
local_win_task = (not remote) and IS_WINDOWS
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from core.database import SessionLocal, Document, DocumentVersion
|
|||||||
from core.database import Session as DbSession
|
from core.database import Session as DbSession
|
||||||
from src.auth_helpers import get_current_user, _auth_disabled
|
from src.auth_helpers import get_current_user, _auth_disabled
|
||||||
from src.constants import MAIL_ATTACHMENTS_DIR
|
from src.constants import MAIL_ATTACHMENTS_DIR
|
||||||
|
from src.session_search import _escape_like
|
||||||
from src.upload_handler import reserve_upload_references
|
from src.upload_handler import reserve_upload_references
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -571,9 +572,10 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
# match docs containing both words regardless of position/order.
|
# match docs containing both words regardless of position/order.
|
||||||
if search:
|
if search:
|
||||||
for tok in search.split():
|
for tok in search.split():
|
||||||
term = f"%{tok}%"
|
term = f"%{_escape_like(tok)}%"
|
||||||
q = q.filter(
|
q = q.filter(
|
||||||
Document.title.ilike(term) | Document.current_content.ilike(term)
|
Document.title.ilike(term, escape="\\")
|
||||||
|
| Document.current_content.ilike(term, escape="\\")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Language filter. "pdf" is a display language derived from the
|
# Language filter. "pdf" is a display language derived from the
|
||||||
@@ -812,10 +814,13 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
ext = _ext.get(doc.language or "text", ".txt")
|
ext = _ext.get(doc.language or "text", ".txt")
|
||||||
base = (doc.title or "document").strip() or "document"
|
base = (doc.title or "document").strip() or "document"
|
||||||
base = re.sub(r"[^\w\-. ]+", "", base)[:60].strip() or doc.id
|
base = re.sub(r"[^\w\-. ]+", "", base)[:60].strip() or doc.id
|
||||||
name = base if "." in base else base + ext
|
has_ext = base.lower().endswith(ext)
|
||||||
|
stem = base[:-len(ext)] if has_ext else base
|
||||||
|
suffix = base[-len(ext):] if has_ext else ext
|
||||||
|
name = stem + suffix
|
||||||
i = 1
|
i = 1
|
||||||
while name in used:
|
while name in used:
|
||||||
name = f"{base}-{i}" + ("" if "." in base else ext)
|
name = f"{stem}-{i}{suffix}"
|
||||||
i += 1
|
i += 1
|
||||||
used.add(name)
|
used.add(name)
|
||||||
content = doc.current_content or ""
|
content = doc.current_content or ""
|
||||||
|
|||||||
@@ -846,6 +846,64 @@ def _plan_psd_steps(prompt: str) -> Optional[List[Dict[str, Any]]]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_background_removal_intent(prompt: str) -> bool:
|
||||||
|
"""Linear-time, bounded intent parser for background removal commands.
|
||||||
|
|
||||||
|
Replaces the previous backtracking regular expression flagged by CodeQL
|
||||||
|
(#1212) while preserving the exact command grammar.
|
||||||
|
"""
|
||||||
|
if not prompt or not isinstance(prompt, str):
|
||||||
|
return False
|
||||||
|
i = len(prompt)
|
||||||
|
while i > 0 and prompt[i - 1] in ".! \t\r\n":
|
||||||
|
i -= 1
|
||||||
|
cleaned = prompt[:i].strip()
|
||||||
|
if not cleaned:
|
||||||
|
return False
|
||||||
|
|
||||||
|
tokens = cleaned.split()
|
||||||
|
if not tokens:
|
||||||
|
return False
|
||||||
|
|
||||||
|
idx = 0
|
||||||
|
if tokens[idx].lower() == "please":
|
||||||
|
idx += 1
|
||||||
|
if idx >= len(tokens):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if tokens[idx].lower() not in {"remove", "erase", "delete", "clear", "strip"}:
|
||||||
|
return False
|
||||||
|
idx += 1
|
||||||
|
if idx >= len(tokens):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if tokens[idx].lower() == "the":
|
||||||
|
idx += 1
|
||||||
|
if idx >= len(tokens):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if tokens[idx].lower() not in {"bg", "background", "backdrop"}:
|
||||||
|
return False
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if idx == len(tokens):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if tokens[idx].lower() in {"from", "of"}:
|
||||||
|
idx += 1
|
||||||
|
if idx < len(tokens) and tokens[idx].lower() == "the":
|
||||||
|
idx += 1
|
||||||
|
if (
|
||||||
|
idx + 1 < len(tokens)
|
||||||
|
and tokens[idx].lower() in {"active", "current", "selected"}
|
||||||
|
and tokens[idx + 1].lower() == "layer"
|
||||||
|
and idx + 2 == len(tokens)
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _plan_special(
|
def _plan_special(
|
||||||
prompt: str, state: Any, provider_available: bool,
|
prompt: str, state: Any, provider_available: bool,
|
||||||
) -> Optional[Tuple[List[Dict[str, Any]], str, str]]:
|
) -> Optional[Tuple[List[Dict[str, Any]], str, str]]:
|
||||||
@@ -855,6 +913,8 @@ def _plan_special(
|
|||||||
left to the model. Returns (steps, reply, note) or None. These never need an
|
left to the model. Returns (steps, reply, note) or None. These never need an
|
||||||
image provider except generative border fill, used only when one exists.
|
image provider except generative border fill, used only when one exists.
|
||||||
"""
|
"""
|
||||||
|
if _is_background_removal_intent(prompt):
|
||||||
|
return [{"op": "ai.removeBackground", "params": {}}], "Removing the active layer's background.", ""
|
||||||
# Targeted recolour is fully deterministic (object mask + masked hue shift)
|
# Targeted recolour is fully deterministic (object mask + masked hue shift)
|
||||||
# and must never be left to the model, which sometimes substitutes a
|
# and must never be left to the model, which sometimes substitutes a
|
||||||
# generative placeImage. Highest priority so it can never vary.
|
# generative placeImage. Highest priority so it can never vary.
|
||||||
@@ -1647,6 +1707,13 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
screenshot = body.get("screenshot")
|
screenshot = body.get("screenshot")
|
||||||
# Optional user-picked image model (from the editor model selector).
|
# Optional user-picked image model (from the editor model selector).
|
||||||
model_choice = str(body.get("model") or body.get("image_model") or "").strip()
|
model_choice = str(body.get("model") or body.get("image_model") or "").strip()
|
||||||
|
generation_model_choice = str(body.get("generation_model") or "").strip()
|
||||||
|
use_image_settings = body.get("_use_image_settings") is True
|
||||||
|
if use_image_settings:
|
||||||
|
from src.editor_image_settings import editor_image_models
|
||||||
|
image_models = editor_image_models(owner=owner)
|
||||||
|
model_choice = image_models["edit"]
|
||||||
|
generation_model_choice = image_models["generation"]
|
||||||
# Optional user-picked PLANNER model (the editor agent/planner model).
|
# Optional user-picked PLANNER model (the editor agent/planner model).
|
||||||
agent_model = str(body.get("agent_model") or "").strip()
|
agent_model = str(body.get("agent_model") or "").strip()
|
||||||
tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
|
tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
|
||||||
@@ -1797,9 +1864,22 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
# Planner output is untrusted: it cannot redirect this turn to
|
# Planner output is untrusted: it cannot redirect this turn to
|
||||||
# another editor session or override an explicit model choice.
|
# another editor session or override an explicit model choice.
|
||||||
tool_payload["session"] = session
|
tool_payload["session"] = session
|
||||||
|
if use_image_settings:
|
||||||
|
tool_payload["_use_image_settings"] = True
|
||||||
|
if op == "generate_image" and state.get("width") and state.get("height"):
|
||||||
|
try:
|
||||||
|
width, height = int(state["width"]), int(state["height"])
|
||||||
|
if width > 0 and height > 0:
|
||||||
|
tool_payload["size"] = f"{width}x{height}"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
# Route to the user-picked model when one is selected.
|
# Route to the user-picked model when one is selected.
|
||||||
if model_choice:
|
selected_model = generation_model_choice if op == "generate_image" else model_choice
|
||||||
tool_payload["model"] = model_choice
|
if selected_model:
|
||||||
|
tool_payload["model"] = selected_model
|
||||||
|
elif op == "generate_image" or use_image_settings:
|
||||||
|
# Let server settings resolve Auto, not a planner-invented model.
|
||||||
|
tool_payload.pop("model", None)
|
||||||
# Hand the AI step the canvas image the client already captured
|
# Hand the AI step the canvas image the client already captured
|
||||||
# for this request. Without it the tool falls back to a separate
|
# for this request. Without it the tool falls back to a separate
|
||||||
# bridge screenshot, which is not reliably answered right after a
|
# bridge screenshot, which is not reliably answered right after a
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
|||||||
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
|
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
|
||||||
from core.database import Session as DbSession
|
from core.database import Session as DbSession
|
||||||
from src.auth_helpers import get_current_user, owner_filter, require_privilege
|
from src.auth_helpers import get_current_user, owner_filter, require_privilege
|
||||||
|
from src.session_search import escape_like_pattern
|
||||||
from src.upload_limits import (
|
from src.upload_limits import (
|
||||||
read_upload_limited,
|
read_upload_limited,
|
||||||
GALLERY_UPLOAD_MAX_BYTES,
|
GALLERY_UPLOAD_MAX_BYTES,
|
||||||
@@ -37,6 +38,24 @@ _SAM_STATE: Dict[str, Any] = {}
|
|||||||
_GROUNDING_STATE: Dict[str, Any] = {}
|
_GROUNDING_STATE: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_cloud_image_edit(source, generated, mask, *, instruction=False):
|
||||||
|
from PIL import Image, ImageChops, ImageOps
|
||||||
|
|
||||||
|
full_frame = instruction or mask.getextrema() == (255, 255)
|
||||||
|
if full_frame:
|
||||||
|
return generated, True
|
||||||
|
# Partial edits must share the source coordinate system, but resizing
|
||||||
|
# width/height independently distorts the subject when the provider changes ratio.
|
||||||
|
fitted = ImageOps.contain(generated, source.size, Image.Resampling.LANCZOS)
|
||||||
|
offset = ((source.width - fitted.width) // 2, (source.height - fitted.height) // 2)
|
||||||
|
aligned = Image.new("RGBA", source.size)
|
||||||
|
aligned.paste(fitted, offset)
|
||||||
|
coverage = Image.new("L", source.size)
|
||||||
|
coverage.paste(255, (offset[0], offset[1], offset[0] + fitted.width, offset[1] + fitted.height))
|
||||||
|
effective_mask = ImageChops.multiply(mask, coverage)
|
||||||
|
return Image.composite(aligned, source, effective_mask), False
|
||||||
|
|
||||||
|
|
||||||
def _rss_mb() -> float:
|
def _rss_mb() -> float:
|
||||||
"""Current process RSS in MB (from /proc; -1.0 when unavailable)."""
|
"""Current process RSS in MB (from /proc; -1.0 when unavailable)."""
|
||||||
try:
|
try:
|
||||||
@@ -305,6 +324,23 @@ def _b64_bytes(value: str, *, label: str = "image") -> bytes:
|
|||||||
raise HTTPException(400, f"Invalid {label}") from exc
|
raise HTTPException(400, f"Invalid {label}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def _json_object(request: Request) -> dict:
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "Invalid JSON body")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise HTTPException(400, "JSON object body required")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def _image_ids(request: Request) -> list:
|
||||||
|
ids = (await _json_object(request)).get("image_ids", [])
|
||||||
|
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
|
||||||
|
raise HTTPException(400, "image_ids must be a list of image ids")
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
def _pil_image_to_b64(img, *, fmt: str = "PNG") -> str:
|
def _pil_image_to_b64(img, *, fmt: str = "PNG") -> str:
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
img.save(buf, format=fmt)
|
img.save(buf, format=fmt)
|
||||||
@@ -568,6 +604,7 @@ def _is_openai_api_base(url: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
_GALLERY_ENDPOINT_PATHS = frozenset({
|
_GALLERY_ENDPOINT_PATHS = frozenset({
|
||||||
|
"/chat/completions",
|
||||||
"/images",
|
"/images",
|
||||||
"/images/edits",
|
"/images/edits",
|
||||||
"/images/generations",
|
"/images/generations",
|
||||||
@@ -1184,15 +1221,18 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
)
|
)
|
||||||
q = _owner_filter(q, user)
|
q = _owner_filter(q, user)
|
||||||
|
|
||||||
# Search filter (prompt + tags + ai_tags)
|
# Per-term AND search: a single `%foo bar%` LIKE only matched the
|
||||||
|
# exact phrase, so "red car" missed "a car that is red".
|
||||||
|
# LIKE wildcards are escaped per token.
|
||||||
if search:
|
if search:
|
||||||
term = f"%{search}%"
|
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
q = q.filter(or_(
|
for tok in search.split():
|
||||||
GalleryImage.prompt.ilike(term),
|
term = f"%{escape_like_pattern(tok)}%"
|
||||||
GalleryImage.tags.ilike(term),
|
q = q.filter(or_(
|
||||||
GalleryImage.ai_tags.ilike(term),
|
GalleryImage.prompt.ilike(term, escape="\\"),
|
||||||
))
|
GalleryImage.tags.ilike(term, escape="\\"),
|
||||||
|
GalleryImage.ai_tags.ilike(term, escape="\\"),
|
||||||
|
))
|
||||||
|
|
||||||
# Tag filter. The UI stacks multiple tag pills by passing them
|
# Tag filter. The UI stacks multiple tag pills by passing them
|
||||||
# comma-separated — each tag adds a separate AND-filter so the
|
# comma-separated — each tag adds a separate AND-filter so the
|
||||||
@@ -1203,9 +1243,10 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
for one in (t.strip() for t in tag.split(",")):
|
for one in (t.strip() for t in tag.split(",")):
|
||||||
if not one:
|
if not one:
|
||||||
continue
|
continue
|
||||||
|
safe_tag = f"%{escape_like_pattern(one)}%"
|
||||||
q = q.filter(_or(
|
q = q.filter(_or(
|
||||||
GalleryImage.tags.ilike(f"%{one}%"),
|
GalleryImage.tags.ilike(safe_tag, escape="\\"),
|
||||||
GalleryImage.ai_tags.ilike(f"%{one}%"),
|
GalleryImage.ai_tags.ilike(safe_tag, escape="\\"),
|
||||||
))
|
))
|
||||||
|
|
||||||
# Model filter
|
# Model filter
|
||||||
@@ -1745,6 +1786,17 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
|
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
|
||||||
chosen_model = (body.pop("_model", "") or "").strip()
|
chosen_model = (body.pop("_model", "") or "").strip()
|
||||||
|
if body.pop("_use_image_settings", False) is True:
|
||||||
|
from src.editor_image_settings import editor_image_models
|
||||||
|
from src.ai_interaction import _resolve_model
|
||||||
|
chosen_model = editor_image_models(owner=user)["edit"]
|
||||||
|
if not chosen_model:
|
||||||
|
raise HTTPException(400, "Choose an image edit model in AI settings")
|
||||||
|
try:
|
||||||
|
edit_url, chosen_model, _ = await asyncio.to_thread(_resolve_model, chosen_model, owner=user)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
requested_base = edit_url.removesuffix("/chat/completions").rstrip("/")
|
||||||
# Optional client-supplied id so GET /api/image/edit-progress can poll the
|
# Optional client-supplied id so GET /api/image/edit-progress can poll the
|
||||||
# provider's step progress for this op. Ignored by providers that do not
|
# provider's step progress for this op. Ignored by providers that do not
|
||||||
# implement the progress contract.
|
# implement the progress contract.
|
||||||
@@ -1862,14 +1914,30 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
mask_png.save(reference_mask, format="PNG")
|
mask_png.save(reference_mask, format="PNG")
|
||||||
references = [src_buf.getvalue(), reference_mask.getvalue()]
|
references = [src_buf.getvalue(), reference_mask.getvalue()]
|
||||||
payload = {
|
payload = {
|
||||||
"model": oa_model, "size": size, "n": 1,
|
"model": oa_model, "n": 1,
|
||||||
"output_format": "png",
|
"output_format": "png",
|
||||||
"prompt": "Edit the first image. The second image is a mask: white marks the region to edit, black marks the region to preserve. Keep composition and framing unchanged. Requested edit: " + str(body.get("prompt", "")),
|
"prompt": "Edit the first image. The second image is a mask: white marks the region to edit, black marks the region to preserve. Keep composition and framing unchanged. Requested edit: " + str(body.get("prompt", "")),
|
||||||
"input_references": [{"type": "image_url", "image_url": {
|
"input_references": [{"type": "image_url", "image_url": {
|
||||||
"url": "data:image/png;base64," + base64.b64encode(value).decode(),
|
"url": "data:image/png;base64," + base64.b64encode(value).decode(),
|
||||||
}} for value in references],
|
}} for value in references],
|
||||||
}
|
}
|
||||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images"), headers=headers, json=payload)
|
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)
|
||||||
else:
|
else:
|
||||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files)
|
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files)
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
@@ -1885,6 +1953,12 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
pass
|
pass
|
||||||
raise HTTPException(r.status_code, f"{_detail} (provider HTTP {r.status_code})")
|
raise HTTPException(r.status_code, f"{_detail} (provider HTTP {r.status_code})")
|
||||||
result = r.json()
|
result = r.json()
|
||||||
|
from src.ai_interaction import _chat_image_results, _image_response_error
|
||||||
|
provider_error = _image_response_error(result, oa_model)
|
||||||
|
if provider_error:
|
||||||
|
raise HTTPException(400 if "deprecated" in provider_error.lower() else 502, provider_error)
|
||||||
|
if is_openrouter and _uses_openrouter_chat_images(oa_model):
|
||||||
|
result = {"data": _chat_image_results(result)}
|
||||||
raw_b64 = None
|
raw_b64 = None
|
||||||
if result.get("data"):
|
if result.get("data"):
|
||||||
item = result["data"][0]
|
item = result["data"][0]
|
||||||
@@ -1904,17 +1978,13 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
# the masked region actually changes.
|
# the masked region actually changes.
|
||||||
try:
|
try:
|
||||||
generated = Image.open(io.BytesIO(base64.b64decode(raw_b64))).convert("RGBA")
|
generated = Image.open(io.BytesIO(base64.b64decode(raw_b64))).convert("RGBA")
|
||||||
# Match the generated image to the source dims.
|
blended, full_frame = _compose_cloud_image_edit(
|
||||||
if generated.size != source_png.size:
|
source_png, generated, mask_png,
|
||||||
generated = generated.resize(source_png.size, Image.LANCZOS)
|
instruction=body.get("mode") == "instruction",
|
||||||
# mask_png: white = regenerate (use generated),
|
)
|
||||||
# black = keep (use source).
|
|
||||||
# Composite: result = source * (1 - mask_norm) + generated * mask_norm
|
|
||||||
# Image.composite does exactly that with `mask`.
|
|
||||||
blended = Image.composite(generated, source_png, mask_png)
|
|
||||||
out_buf = io.BytesIO()
|
out_buf = io.BytesIO()
|
||||||
blended.save(out_buf, format="PNG")
|
blended.save(out_buf, format="PNG")
|
||||||
return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model}
|
return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model, "full_frame": full_frame}
|
||||||
except Exception as comp_err:
|
except Exception as comp_err:
|
||||||
# Never return a full-image edit when masking fails.
|
# Never return a full-image edit when masking fails.
|
||||||
logger.warning(f"Inpaint compose failed: {comp_err}")
|
logger.warning(f"Inpaint compose failed: {comp_err}")
|
||||||
@@ -2571,7 +2641,9 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
height = int((body or {}).get("height") or 0)
|
height = int((body or {}).get("height") or 0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
width = height = 0
|
width = height = 0
|
||||||
if height > width:
|
if width > 0 and height > 0:
|
||||||
|
size = f"{width}x{height}"
|
||||||
|
elif height > width:
|
||||||
size = "1024x1536"
|
size = "1024x1536"
|
||||||
elif width > height:
|
elif width > height:
|
||||||
size = "1536x1024"
|
size = "1536x1024"
|
||||||
@@ -2584,6 +2656,9 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
# The editor's model picker may choose a specific (cloud) image model;
|
# The editor's model picker may choose a specific (cloud) image model;
|
||||||
# forward it so settings defaults only apply when none is selected.
|
# forward it so settings defaults only apply when none is selected.
|
||||||
model_choice = str((body or {}).get("model") or "").strip()
|
model_choice = str((body or {}).get("model") or "").strip()
|
||||||
|
if body.get("_use_image_settings") is True:
|
||||||
|
from src.editor_image_settings import editor_image_models
|
||||||
|
model_choice = editor_image_models(owner=user)["generation"]
|
||||||
payload = {"prompt": prompt, "size": size}
|
payload = {"prompt": prompt, "size": size}
|
||||||
if model_choice:
|
if model_choice:
|
||||||
payload["model"] = model_choice
|
payload["model"] = model_choice
|
||||||
@@ -2939,14 +3014,22 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
@router.put("/api/gallery/albums/{album_id}")
|
@router.put("/api/gallery/albums/{album_id}")
|
||||||
async def update_album(request: Request, album_id: str):
|
async def update_album(request: Request, album_id: str):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
data = await request.json()
|
data = await _json_object(request)
|
||||||
|
name = data.get("name")
|
||||||
|
if name is not None:
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
raise HTTPException(400, "Album name required")
|
||||||
|
name = name.strip()
|
||||||
|
description = data.get("description")
|
||||||
|
if description is not None and not isinstance(description, str):
|
||||||
|
raise HTTPException(400, "Album description must be a string")
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
album = _get_or_404_album(db, album_id, user)
|
album = _get_or_404_album(db, album_id, user)
|
||||||
if data.get("name") is not None:
|
if name is not None:
|
||||||
album.name = data["name"]
|
album.name = name
|
||||||
if data.get("description") is not None:
|
if description is not None:
|
||||||
album.description = data["description"]
|
album.description = description
|
||||||
if data.get("cover_id") is not None:
|
if data.get("cover_id") is not None:
|
||||||
cover_id = data["cover_id"] or None
|
cover_id = data["cover_id"] or None
|
||||||
if cover_id:
|
if cover_id:
|
||||||
@@ -2976,8 +3059,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
@router.post("/api/gallery/albums/{album_id}/add")
|
@router.post("/api/gallery/albums/{album_id}/add")
|
||||||
async def add_to_album(request: Request, album_id: str):
|
async def add_to_album(request: Request, album_id: str):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
data = await request.json()
|
ids = await _image_ids(request)
|
||||||
ids = data.get("image_ids", [])
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
_get_or_404_album(db, album_id, user)
|
_get_or_404_album(db, album_id, user)
|
||||||
@@ -2994,8 +3076,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
@router.post("/api/gallery/albums/{album_id}/remove")
|
@router.post("/api/gallery/albums/{album_id}/remove")
|
||||||
async def remove_from_album(request: Request, album_id: str):
|
async def remove_from_album(request: Request, album_id: str):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
data = await request.json()
|
ids = await _image_ids(request)
|
||||||
ids = data.get("image_ids", [])
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
_get_or_404_album(db, album_id, user)
|
_get_or_404_album(db, album_id, user)
|
||||||
|
|||||||
@@ -825,6 +825,23 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
keep_count = body.get("keep_count", 0)
|
keep_count = body.get("keep_count", 0)
|
||||||
|
# A message id is exact; keep_count from the client counts only the
|
||||||
|
# messages loaded in the page. before_msg_id forks before that
|
||||||
|
# message, through_msg_id includes it.
|
||||||
|
before_msg_id = str(body.get("before_msg_id") or "").strip()
|
||||||
|
through_msg_id = str(body.get("through_msg_id") or "").strip()
|
||||||
|
if before_msg_id or through_msg_id:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
all_db_messages = db.query(DbChatMessage).filter(
|
||||||
|
DbChatMessage.session_id == session_id
|
||||||
|
).order_by(DbChatMessage.timestamp).all()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
resolved = _keep_count_before_message(all_db_messages, before_msg_id or through_msg_id)
|
||||||
|
if resolved is None:
|
||||||
|
raise HTTPException(404, "Message not found")
|
||||||
|
keep_count = resolved + (0 if before_msg_id else 1)
|
||||||
|
|
||||||
# Get the source session. keep_count indexes into source.history,
|
# Get the source session. keep_count indexes into source.history,
|
||||||
# so this must go through get_session — reading the cache directly
|
# so this must go through get_session — reading the cache directly
|
||||||
|
|||||||
@@ -659,7 +659,7 @@ _NON_CHAT_CONTAINS = (
|
|||||||
"gliner", "riva", "-parse", "-embedqa", "-nemoretriever",
|
"gliner", "riva", "-parse", "-embedqa", "-nemoretriever",
|
||||||
"topic-control", "calibration",
|
"topic-control", "calibration",
|
||||||
"ai-synthetic-video", "cosmos-reason2",
|
"ai-synthetic-video", "cosmos-reason2",
|
||||||
"bge", "llama-guard",
|
"bge", "llama-guard", "-embed", "rerank",
|
||||||
)
|
)
|
||||||
_NON_CHAT_EXACT_PREFIXES = (
|
_NON_CHAT_EXACT_PREFIXES = (
|
||||||
"gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat)
|
"gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat)
|
||||||
@@ -2846,11 +2846,12 @@ def setup_model_routes(model_discovery):
|
|||||||
return {"endpoint_id": "", "endpoint_url": "", "model": ""}
|
return {"endpoint_id": "", "endpoint_url": "", "model": ""}
|
||||||
base = _normalize_base(ep.base_url)
|
base = _normalize_base(ep.base_url)
|
||||||
chat_url = build_chat_url(base)
|
chat_url = build_chat_url(base)
|
||||||
|
if model and not _is_chat_model(model):
|
||||||
|
model = ""
|
||||||
if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
|
if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
|
||||||
try:
|
try:
|
||||||
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
|
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
|
||||||
if visible:
|
model = next((mid for mid in visible if _is_chat_model(mid)), "")
|
||||||
model = visible[0]
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model}
|
return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model}
|
||||||
|
|||||||
@@ -118,8 +118,12 @@ def setup_prefs_routes():
|
|||||||
async def set_pref(request: Request, key: str, body: dict):
|
async def set_pref(request: Request, key: str, body: dict):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
prefs = _load_for_user(user)
|
prefs = _load_for_user(user)
|
||||||
prefs[key] = body.get("value")
|
val = body.get("value")
|
||||||
|
if val is None:
|
||||||
|
prefs.pop(key, None)
|
||||||
|
else:
|
||||||
|
prefs[key] = val
|
||||||
_save_for_user(user, prefs)
|
_save_for_user(user, prefs)
|
||||||
return {"key": key, "value": prefs[key]}
|
return {"key": key, "value": prefs.get(key)}
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
|||||||
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
|
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
|
||||||
"""
|
"""
|
||||||
from src.endpoint_resolver import (
|
from src.endpoint_resolver import (
|
||||||
|
_endpoint_enabled_models,
|
||||||
build_chat_url,
|
build_chat_url,
|
||||||
build_headers,
|
build_headers,
|
||||||
resolve_endpoint_runtime as resolve_model_endpoint_runtime,
|
resolve_endpoint_runtime as resolve_model_endpoint_runtime,
|
||||||
@@ -197,7 +198,7 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
|||||||
ep_model = (model or "").strip()
|
ep_model = (model or "").strip()
|
||||||
if not ep_model:
|
if not ep_model:
|
||||||
try:
|
try:
|
||||||
models = json.loads(ep.cached_models) if ep.cached_models else []
|
models = _endpoint_enabled_models(ep)
|
||||||
if models:
|
if models:
|
||||||
ep_model = _first_chat_model(models)
|
ep_model = _first_chat_model(models)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -824,23 +825,13 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
if not ep_url or not ep_model:
|
if not ep_url or not ep_model:
|
||||||
# Last resort: this user's enabled endpoint, plus legacy shared rows.
|
# Last resort: this user's enabled endpoint, plus legacy shared rows.
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ep = _owned_enabled_endpoint(db, user)
|
ep = _owned_enabled_endpoint(db, user)
|
||||||
if ep:
|
if ep:
|
||||||
base = normalize_base(ep.base_url)
|
resolved = _resolve_endpoint_runtime(ep, owner=user)
|
||||||
fallback_url = build_chat_url(base)
|
if resolved:
|
||||||
fallback_headers = build_headers(ep.api_key, base)
|
_merge(*resolved)
|
||||||
fallback_model = ""
|
|
||||||
if ep.cached_models:
|
|
||||||
try:
|
|
||||||
models = json.loads(ep.cached_models)
|
|
||||||
if models:
|
|
||||||
fallback_model = _first_chat_model(models)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_merge(fallback_url, fallback_model, fallback_headers)
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import httpx
|
|||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from services.memory.skill_format import slugify
|
||||||
from services.memory.skills import SkillsManager
|
from services.memory.skills import SkillsManager
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from services.skills import evaluation as _evaluation
|
from services.skills import evaluation as _evaluation
|
||||||
@@ -836,6 +837,10 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
updates = body.dict(exclude_none=True)
|
updates = body.dict(exclude_none=True)
|
||||||
if not updates:
|
if not updates:
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
if "name" in updates:
|
||||||
|
new_name = slugify(updates["name"] or match.get("name"))
|
||||||
|
if new_name != match.get("name") and skills_manager.has_skill(new_name, owner=user):
|
||||||
|
raise HTTPException(409, f"A skill named '{new_name}' already exists")
|
||||||
ok = skills_manager.update_skill(match.get("name"), updates, owner=user)
|
ok = skills_manager.update_skill(match.get("name"), updates, owner=user)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(404, "Skill not found")
|
raise HTTPException(404, "Skill not found")
|
||||||
|
|||||||
@@ -289,15 +289,15 @@ def _resolve_run_endpoint(db, task: ScheduledTask, run: TaskRun) -> str:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from core.database import ModelEndpoint
|
from core.database import ModelEndpoint
|
||||||
eps = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
from src.auth_helpers import owner_filter
|
||||||
|
from src.endpoint_resolver import _endpoint_enabled_models
|
||||||
|
|
||||||
|
eps = owner_filter(
|
||||||
|
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
|
||||||
|
ModelEndpoint, getattr(task, "owner", None),
|
||||||
|
).all()
|
||||||
for ep in eps:
|
for ep in eps:
|
||||||
cached = []
|
if model in _endpoint_enabled_models(ep):
|
||||||
if ep.cached_models:
|
|
||||||
try:
|
|
||||||
cached = json.loads(ep.cached_models) or []
|
|
||||||
except Exception:
|
|
||||||
cached = []
|
|
||||||
if model in cached:
|
|
||||||
return ep.base_url or ""
|
return ep.base_url or ""
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -130,10 +130,15 @@ def _parse_scalar(raw: str, *, string_only: bool = False) -> Any:
|
|||||||
def _split_top_level(s: str, sep: str) -> List[str]:
|
def _split_top_level(s: str, sep: str) -> List[str]:
|
||||||
"""Split `s` on `sep` ignoring separators inside [] or quotes."""
|
"""Split `s` on `sep` ignoring separators inside [] or quotes."""
|
||||||
out, buf, depth, quote = [], [], 0, None
|
out, buf, depth, quote = [], [], 0, None
|
||||||
|
escaped = False
|
||||||
for ch in s:
|
for ch in s:
|
||||||
if quote:
|
if quote:
|
||||||
buf.append(ch)
|
buf.append(ch)
|
||||||
if ch == quote:
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif ch == "\\" and quote == '"':
|
||||||
|
escaped = True
|
||||||
|
elif ch == quote:
|
||||||
quote = None
|
quote = None
|
||||||
continue
|
continue
|
||||||
if ch in ("'", '"'):
|
if ch in ("'", '"'):
|
||||||
|
|||||||
@@ -500,6 +500,19 @@ class SkillsManager:
|
|||||||
sk.path = self._skill_file(cat, nm)
|
sk.path = self._skill_file(cat, nm)
|
||||||
return sk.to_dict()
|
return sk.to_dict()
|
||||||
|
|
||||||
|
def has_skill(self, name: str, owner: Optional[str] = None) -> bool:
|
||||||
|
"""Whether `owner` already has a skill called `name`, in any category.
|
||||||
|
|
||||||
|
Update and delete find a skill by name within its owner, so two of
|
||||||
|
one owner's skills sharing a name would make them act on whichever
|
||||||
|
file is walked first.
|
||||||
|
"""
|
||||||
|
for path in self._iter_skill_files():
|
||||||
|
sk = self._read_skill(path)
|
||||||
|
if sk and sk.name == name and (sk.owner or "") == (owner or ""):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool:
|
def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool:
|
||||||
"""`skill_id` is the slug name. Allows updating any field plus
|
"""`skill_id` is the slug name. Allows updating any field plus
|
||||||
renames if `name` changes (file is moved on disk).
|
renames if `name` changes (file is moved on disk).
|
||||||
@@ -551,6 +564,9 @@ class SkillsManager:
|
|||||||
# Rename
|
# Rename
|
||||||
new_name = slugify(updates.get("name") or sk.name)
|
new_name = slugify(updates.get("name") or sk.name)
|
||||||
if new_name != sk.name:
|
if new_name != sk.name:
|
||||||
|
if self.has_skill(new_name, owner=owner):
|
||||||
|
logger.warning(f"Skill rename target name in use: {new_name}")
|
||||||
|
return False
|
||||||
sk.name = new_name
|
sk.name = new_name
|
||||||
|
|
||||||
# Write to potentially new path
|
# Write to potentially new path
|
||||||
|
|||||||
+23
-11
@@ -24193,11 +24193,14 @@ async def stream_agent_loop(
|
|||||||
budget_is_explicit,
|
budget_is_explicit,
|
||||||
hard_max=hard_max,
|
hard_max=hard_max,
|
||||||
)
|
)
|
||||||
if candidate_context <= 8192:
|
if 0 < candidate_context <= 8192:
|
||||||
# Small local servers often tokenize chat wrappers and tool
|
# Small local servers often tokenize chat wrappers and tool
|
||||||
# results much more generously than our rough estimator. Keep
|
# results much more generously than our rough estimator. Keep
|
||||||
# substantial headroom for those wrappers and generation so a
|
# substantial headroom for those wrappers and generation so a
|
||||||
# follow-up tool round cannot exceed the server's n_ctx.
|
# follow-up tool round cannot exceed the server's n_ctx.
|
||||||
|
# 0 means the window is unknown, which already gets the
|
||||||
|
# conservative default budget; clamping it as well cut every
|
||||||
|
# request to 1200 tokens and dropped open documents (#6678).
|
||||||
effective_budget = min(
|
effective_budget = min(
|
||||||
effective_budget,
|
effective_budget,
|
||||||
max(1200, int(candidate_context * 0.40)),
|
max(1200, int(candidate_context * 0.40)),
|
||||||
@@ -27833,16 +27836,25 @@ async def stream_agent_loop(
|
|||||||
if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text):
|
if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text):
|
||||||
_streamed_tool_markup += _delta_text
|
_streamed_tool_markup += _delta_text
|
||||||
if not _streamed_tool_markup_complete(_streamed_tool_markup):
|
if not _streamed_tool_markup_complete(_streamed_tool_markup):
|
||||||
continue
|
if _streamed_tool_markup_starts(_streamed_tool_markup):
|
||||||
_visible_markup_tail = strip_tool_blocks(
|
continue
|
||||||
_streamed_tool_markup,
|
# False alarm: a bare "<" (e.g. ``x < 2``) looked like
|
||||||
skip_fenced=True,
|
# a tag prefix, but the buffer can no longer become a
|
||||||
).strip()
|
# tool tag. Release it as prose instead of holding the
|
||||||
_streamed_tool_markup = ""
|
# rest of the answer forever.
|
||||||
if not _visible_markup_tail:
|
_delta_text = _streamed_tool_markup
|
||||||
continue
|
_streamed_tool_markup = ""
|
||||||
_delta_text = _visible_markup_tail
|
data["delta"] = _delta_text
|
||||||
data["delta"] = _delta_text
|
else:
|
||||||
|
_visible_markup_tail = strip_tool_blocks(
|
||||||
|
_streamed_tool_markup,
|
||||||
|
skip_fenced=True,
|
||||||
|
).strip()
|
||||||
|
_streamed_tool_markup = ""
|
||||||
|
if not _visible_markup_tail:
|
||||||
|
continue
|
||||||
|
_delta_text = _visible_markup_tail
|
||||||
|
data["delta"] = _delta_text
|
||||||
full_response += _delta_text
|
full_response += _delta_text
|
||||||
if not _suppress_unavailable_web_delta:
|
if not _suppress_unavailable_web_delta:
|
||||||
_qwen_buffered_route = bool(
|
_qwen_buffered_route = bool(
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None) -> bool:
|
async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None, native_size: bool = False) -> bool:
|
||||||
params: Dict[str, Any] = {
|
params: Dict[str, Any] = {
|
||||||
"dataUrl": data_url,
|
"dataUrl": data_url,
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -199,6 +199,8 @@ async def _place_layer(data_url: str, name: str, owner: Optional[str], session:
|
|||||||
"blend": blend or "normal",
|
"blend": blend or "normal",
|
||||||
"opacity": opacity,
|
"opacity": opacity,
|
||||||
}
|
}
|
||||||
|
if native_size:
|
||||||
|
params["nativeSize"] = True
|
||||||
# Forward the edit mode so the client can build a difference mask for
|
# Forward the edit mode so the client can build a difference mask for
|
||||||
# instruction-mode results: the whole image was regenerated under an
|
# instruction-mode results: the whole image was regenerated under an
|
||||||
# instruction prompt, so untouched pixels must stay pixel-identical (the
|
# instruction prompt, so untouched pixels must stay pixel-identical (the
|
||||||
@@ -506,6 +508,8 @@ class AiEditImageTool:
|
|||||||
}
|
}
|
||||||
_t["image"] = time.perf_counter()
|
_t["image"] = time.perf_counter()
|
||||||
payload: Dict[str, Any] = {"image": image}
|
payload: Dict[str, Any] = {"image": image}
|
||||||
|
if args.get("_use_image_settings") is True:
|
||||||
|
payload["_use_image_settings"] = True
|
||||||
model_choice = str(args.get("model") or "").strip()
|
model_choice = str(args.get("model") or "").strip()
|
||||||
mask_used = False
|
mask_used = False
|
||||||
place_mode: Optional[str] = None
|
place_mode: Optional[str] = None
|
||||||
@@ -609,10 +613,11 @@ class AiEditImageTool:
|
|||||||
f"AI {op}",
|
f"AI {op}",
|
||||||
owner,
|
owner,
|
||||||
session,
|
session,
|
||||||
masked=mask_used,
|
masked=mask_used and data.get("full_frame") is not True,
|
||||||
blend=str(args.get("blend") or "normal"),
|
blend=str(args.get("blend") or "normal"),
|
||||||
opacity=float(args.get("opacity") or 100),
|
opacity=float(args.get("opacity") or 100),
|
||||||
mode=place_mode,
|
mode=place_mode,
|
||||||
|
native_size=data.get("full_frame") is True,
|
||||||
)
|
)
|
||||||
info["placed"] = placed
|
info["placed"] = placed
|
||||||
if placed:
|
if placed:
|
||||||
@@ -687,6 +692,7 @@ class GenerateImageLayerTool:
|
|||||||
str(args.get("name") or "Generated image"),
|
str(args.get("name") or "Generated image"),
|
||||||
owner,
|
owner,
|
||||||
session,
|
session,
|
||||||
|
native_size=True,
|
||||||
masked=bool(args.get("mask")),
|
masked=bool(args.get("mask")),
|
||||||
blend=str(args.get("blend") or "normal"),
|
blend=str(args.get("blend") or "normal"),
|
||||||
opacity=float(args.get("opacity") or 100),
|
opacity=float(args.get("opacity") or 100),
|
||||||
|
|||||||
+130
-17
@@ -179,8 +179,13 @@ def _resolve_model(spec: str, owner: Optional[str] = None, model_type: Optional[
|
|||||||
# served internal id. Include pinned/cached ids in the match set
|
# served internal id. Include pinned/cached ids in the match set
|
||||||
# so chat sessions using the HF repo id still resolve. Do not use
|
# so chat sessions using the HF repo id still resolve. Do not use
|
||||||
# stale cached aliases when the endpoint itself is unreachable.
|
# stale cached aliases when the endpoint itself is unreachable.
|
||||||
if model_type == "image" and endpoint_reachable:
|
if endpoint_reachable:
|
||||||
for extra in _json_list(getattr(ep, "pinned_models", None)) + _json_list(getattr(ep, "cached_models", None)):
|
# Gateway image models may live in a dedicated catalogue,
|
||||||
|
# so explicitly pinned IDs are valid on mixed endpoints too.
|
||||||
|
extras = _json_list(getattr(ep, "pinned_models", None))
|
||||||
|
if model_type == "image":
|
||||||
|
extras += _json_list(getattr(ep, "cached_models", None))
|
||||||
|
for extra in extras:
|
||||||
if extra not in model_ids:
|
if extra not in model_ids:
|
||||||
model_ids.append(extra)
|
model_ids.append(extra)
|
||||||
|
|
||||||
@@ -1176,9 +1181,9 @@ def image_editing_provider_available(owner: Optional[str] = None) -> bool:
|
|||||||
request on this so it never falls back to deterministic approximations.
|
request on this so it never falls back to deterministic approximations.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from src.settings import load_settings
|
from src.settings import get_user_setting, load_settings
|
||||||
|
|
||||||
if not load_settings().get("image_gen_enabled", True):
|
if not load_settings().get("image_gen_enabled", True) or not get_user_setting("image_gen_enabled", owner=owner or "", default=True):
|
||||||
return False
|
return False
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -1200,6 +1205,39 @@ def image_editing_provider_available(owner: Optional[str] = None) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _uses_openrouter_chat_images(model_id: str) -> bool:
|
||||||
|
# GPT-5 Image is an LLM wrapper, unlike native gpt-image-* image models.
|
||||||
|
leaf = model_id_leaf(model_id).lower()
|
||||||
|
return leaf.startswith("gpt-") and "-image" in leaf and not leaf.startswith("gpt-image-")
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_image_results(data: dict) -> list:
|
||||||
|
images = []
|
||||||
|
for choice in data.get("choices") or []:
|
||||||
|
message = choice.get("message") or {}
|
||||||
|
for item in message.get("images") or []:
|
||||||
|
url = (item.get("image_url") or {}).get("url")
|
||||||
|
if not isinstance(url, str):
|
||||||
|
continue
|
||||||
|
if url.startswith("data:image/") and ";base64," in url:
|
||||||
|
images.append({"b64_json": url.split(",", 1)[1]})
|
||||||
|
elif url.startswith(("https://", "http://")):
|
||||||
|
images.append({"url": url})
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
def _image_response_error(data: dict, model_id: str) -> Optional[str]:
|
||||||
|
errors = [data.get("error")]
|
||||||
|
errors.extend(choice.get("error") for choice in data.get("choices") or [])
|
||||||
|
for error in errors:
|
||||||
|
if not error:
|
||||||
|
continue
|
||||||
|
message = str(error.get("message") or "Image provider failed") if isinstance(error, dict) else str(error)
|
||||||
|
hint = " Choose a currently supported image model in AI settings." if "deprecated" in message.lower() else ""
|
||||||
|
return f"Image provider failed for '{model_id}': {message}{hint}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict:
|
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict:
|
||||||
"""Generate an image using an image-capable model (e.g. gpt-image-1).
|
"""Generate an image using an image-capable model (e.g. gpt-image-1).
|
||||||
|
|
||||||
@@ -1236,21 +1274,27 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
if not prompt:
|
if not prompt:
|
||||||
return {"error": "Image prompt is required (line 1)"}
|
return {"error": "Image prompt is required (line 1)"}
|
||||||
|
|
||||||
# Load admin settings for defaults
|
# Load settings for defaults
|
||||||
try:
|
try:
|
||||||
from src.settings import load_settings
|
from src.settings import get_user_setting, load_settings
|
||||||
_settings = load_settings()
|
_settings = load_settings()
|
||||||
except Exception:
|
except Exception:
|
||||||
_settings = {}
|
_settings = {}
|
||||||
|
def get_user_setting(key, owner="", default=None):
|
||||||
|
return default
|
||||||
|
|
||||||
|
# The administrator's global image-generation disable switch is authoritative.
|
||||||
|
# Non-admin preferences cannot re-enable generation when disabled globally.
|
||||||
if not _settings.get("image_gen_enabled", True):
|
if not _settings.get("image_gen_enabled", True):
|
||||||
return {"error": "Image generation is disabled by the administrator."}
|
return {"error": "Image generation is disabled by the administrator."}
|
||||||
|
if not get_user_setting("image_gen_enabled", owner=owner or "", default=True):
|
||||||
|
return {"error": "Image generation is disabled by user preference."}
|
||||||
|
|
||||||
# Use admin-configured model/quality if not specified by the tool call
|
# Use user- or admin-configured model/quality if not specified by the tool call
|
||||||
if not model_spec:
|
if not model_spec:
|
||||||
model_spec = _settings.get("image_model", "")
|
model_spec = get_user_setting("image_model", owner=owner, default="") or ""
|
||||||
if not quality:
|
if not quality:
|
||||||
quality = _settings.get("image_quality") or "medium"
|
quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
|
||||||
|
|
||||||
# Auto-detect best available image model if still not set
|
# Auto-detect best available image model if still not set
|
||||||
if not model_spec:
|
if not model_spec:
|
||||||
@@ -1342,8 +1386,19 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
||||||
images_url = base_url + "/images/generations"
|
images_url = base_url + "/images/generations"
|
||||||
from src.model_capability_readers.base import detect_vendor
|
from src.model_capability_readers.base import detect_vendor
|
||||||
|
chat_images = detect_vendor(url) == "openrouter" and _uses_openrouter_chat_images(model_id)
|
||||||
if detect_vendor(url) == "openrouter":
|
if detect_vendor(url) == "openrouter":
|
||||||
images_url = base_url + "/images"
|
images_url = base_url + "/images"
|
||||||
|
if chat_images:
|
||||||
|
images_url = base_url + "/chat/completions"
|
||||||
|
|
||||||
|
# Capture the requested geometry before older OpenAI size restrictions.
|
||||||
|
from src.image_model_ids import image_aspect_ratio
|
||||||
|
try:
|
||||||
|
requested_width, requested_height = (int(value) for value in size.lower().split("x"))
|
||||||
|
requested_ratio = image_aspect_ratio(requested_width, requested_height)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
requested_ratio = "1:1"
|
||||||
|
|
||||||
# Validate size for cloud image models (local diffusion accepts any WxH)
|
# Validate size for cloud image models (local diffusion accepts any WxH)
|
||||||
valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"}
|
valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"}
|
||||||
@@ -1359,6 +1414,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
"n": 1,
|
"n": 1,
|
||||||
"size": size,
|
"size": size,
|
||||||
}
|
}
|
||||||
|
if detect_vendor(url) == "openrouter":
|
||||||
|
payload.pop("size", None)
|
||||||
|
payload["aspect_ratio"] = requested_ratio
|
||||||
|
|
||||||
# GPT image models and local diffusion support quality; DALL-E does not
|
# GPT image models and local diffusion support quality; DALL-E does not
|
||||||
if is_gpt_image or is_local_diffusion:
|
if is_gpt_image or is_local_diffusion:
|
||||||
@@ -1372,7 +1430,16 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
try:
|
try:
|
||||||
# GPT image models can take 30-120s+ depending on quality
|
# GPT image models can take 30-120s+ depending on quality
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)) as client:
|
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)) as client:
|
||||||
resp = await client.post(images_url, json=payload, headers=headers)
|
request_payload = payload
|
||||||
|
if chat_images:
|
||||||
|
request_payload = {
|
||||||
|
"model": model_id,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"modalities": ["image", "text"],
|
||||||
|
"stream": False,
|
||||||
|
"image_config": {"aspect_ratio": requested_ratio},
|
||||||
|
}
|
||||||
|
resp = await client.post(images_url, json=request_payload, headers=headers)
|
||||||
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
error_text = resp.text[:500]
|
error_text = resp.text[:500]
|
||||||
@@ -1387,9 +1454,15 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
}
|
}
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
images = data.get("data", [])
|
provider_error = _image_response_error(data, model_id)
|
||||||
|
if provider_error:
|
||||||
|
return {"error": provider_error, "untrusted_content": True}
|
||||||
|
images = _chat_image_results(data) if chat_images else data.get("data", [])
|
||||||
if not images:
|
if not images:
|
||||||
return {"error": "No images returned from API"}
|
return {"error": (
|
||||||
|
f"Image model '{model_id}' returned no image. "
|
||||||
|
"Choose another image generation model in AI settings."
|
||||||
|
)}
|
||||||
|
|
||||||
img = images[0]
|
img = images[0]
|
||||||
image_url = None
|
image_url = None
|
||||||
@@ -1502,21 +1575,27 @@ async def do_edit_image(
|
|||||||
return {"error": "Attached image file was not found"}
|
return {"error": "Attached image file was not found"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from src.settings import load_settings
|
from src.settings import get_user_setting, load_settings
|
||||||
_settings = load_settings()
|
_settings = load_settings()
|
||||||
except Exception:
|
except Exception:
|
||||||
_settings = {}
|
_settings = {}
|
||||||
|
def get_user_setting(key, owner="", default=None):
|
||||||
|
return default
|
||||||
|
|
||||||
if not model_spec:
|
if not model_spec:
|
||||||
model_spec = _settings.get("image_model", "")
|
model_spec = get_user_setting("image_edit_model", owner=owner) or get_user_setting("image_model", owner=owner, default="")
|
||||||
if quality is None:
|
if quality is None:
|
||||||
quality = _settings.get("image_quality") or "medium"
|
quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
|
||||||
if not model_spec:
|
if not model_spec:
|
||||||
return {"error": "No image model selected for image editing"}
|
return {"error": "No image model selected for image editing"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
def _call():
|
def _call():
|
||||||
|
try:
|
||||||
|
return _resolve_model(model_spec, owner=owner)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
return _resolve_model(model_spec, owner=owner, model_type="image")
|
return _resolve_model(model_spec, owner=owner, model_type="image")
|
||||||
except TypeError as exc:
|
except TypeError as exc:
|
||||||
@@ -1529,6 +1608,7 @@ async def do_edit_image(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return {"error": f"No endpoint found with image model '{model_spec}'."}
|
return {"error": f"No endpoint found with image model '{model_spec}'."}
|
||||||
|
|
||||||
|
requested_edit_size = size
|
||||||
if not size or size == "auto":
|
if not size or size == "auto":
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from src.image_model_ids import image_edit_size
|
from src.image_model_ids import image_edit_size
|
||||||
@@ -1704,7 +1784,35 @@ async def do_edit_image(
|
|||||||
"image_url": {"url": f"data:{mime};base64,{image_b64}"},
|
"image_url": {"url": f"data:{mime};base64,{image_b64}"},
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
resp = await client.post(base_url + "/images", json=edit_payload, headers=headers)
|
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)
|
||||||
else:
|
else:
|
||||||
with path.open("rb") as f:
|
with path.open("rb") as f:
|
||||||
files = {"image": (path.name, f, mime)}
|
files = {"image": (path.name, f, mime)}
|
||||||
@@ -1747,7 +1855,12 @@ async def do_edit_image(
|
|||||||
}
|
}
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
images = data.get("data", [])
|
provider_error = _image_response_error(data, model_id)
|
||||||
|
if provider_error:
|
||||||
|
return {"error": provider_error, "untrusted_content": True}
|
||||||
|
images = (_chat_image_results(data)
|
||||||
|
if is_openrouter and _uses_openrouter_chat_images(model_id)
|
||||||
|
else data.get("data", []))
|
||||||
if not images:
|
if not images:
|
||||||
return {"error": "No image returned from edit API"}
|
return {"error": "No image returned from edit API"}
|
||||||
|
|
||||||
|
|||||||
+37
-5
@@ -10,7 +10,7 @@ import os
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from src.auth_helpers import owner_filter
|
from src.auth_helpers import owner_filter
|
||||||
@@ -1957,6 +1957,23 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
|
|||||||
return str(e), False
|
return str(e), False
|
||||||
|
|
||||||
|
|
||||||
|
def _daily_brief_utc_boundary(local_dt, tz=None):
|
||||||
|
"""Convert a local wall-clock boundary using the offset at that boundary."""
|
||||||
|
if local_dt.tzinfo is None and tz is not None:
|
||||||
|
local_dt = local_dt.replace(tzinfo=tz)
|
||||||
|
return local_dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _daily_brief_local_start(event, tz=None):
|
||||||
|
"""Keep date-only/floating rows local; convert only stored UTC instants."""
|
||||||
|
start = event.dtstart
|
||||||
|
if event.is_utc and not event.all_day:
|
||||||
|
if start.tzinfo is None:
|
||||||
|
start = start.replace(tzinfo=timezone.utc)
|
||||||
|
return start.astimezone(tz).replace(tzinfo=None)
|
||||||
|
return start
|
||||||
|
|
||||||
|
|
||||||
async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||||
"""Build a short morning digest: today's calendar events, unread email count
|
"""Build a short morning digest: today's calendar events, unread email count
|
||||||
+ top-N senders/subjects, active todos."""
|
+ top-N senders/subjects, active todos."""
|
||||||
@@ -1966,10 +1983,13 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
|
|
||||||
from core.database import SessionLocal, CalendarEvent, CalendarCal, Note
|
from core.database import SessionLocal, CalendarEvent, CalendarCal, Note
|
||||||
from routes.email_helpers import _imap_connect, _decode_header
|
from routes.email_helpers import _imap_connect, _decode_header
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
|
||||||
# ----- Calendar: today's events -----
|
# ----- Calendar: today's events -----
|
||||||
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
tomorrow = today + _td(days=1)
|
tomorrow = today + _td(days=1)
|
||||||
|
utc_today = _daily_brief_utc_boundary(today)
|
||||||
|
utc_tomorrow = _daily_brief_utc_boundary(tomorrow)
|
||||||
# v2 review HIGH-12: gate the OR-null branch on single-user
|
# v2 review HIGH-12: gate the OR-null branch on single-user
|
||||||
# (unconfigured) deploys only. In a multi-user deploy, one
|
# (unconfigured) deploys only. In a multi-user deploy, one
|
||||||
# user's daily brief must not include another user's notes or
|
# user's daily brief must not include another user's notes or
|
||||||
@@ -1982,13 +2002,25 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ev_q = db.query(CalendarEvent).join(CalendarCal).filter(
|
ev_q = db.query(CalendarEvent).join(CalendarCal).filter(
|
||||||
CalendarEvent.dtstart < tomorrow,
|
or_(
|
||||||
CalendarEvent.dtend > today,
|
and_(
|
||||||
|
CalendarEvent.is_utc.is_(True),
|
||||||
|
CalendarEvent.all_day.is_not(True),
|
||||||
|
CalendarEvent.dtstart < utc_tomorrow,
|
||||||
|
CalendarEvent.dtend > utc_today,
|
||||||
|
),
|
||||||
|
and_(
|
||||||
|
or_(CalendarEvent.is_utc.is_not(True), CalendarEvent.all_day.is_(True)),
|
||||||
|
CalendarEvent.dtstart < tomorrow,
|
||||||
|
CalendarEvent.dtend > today,
|
||||||
|
),
|
||||||
|
),
|
||||||
CalendarEvent.status != "cancelled",
|
CalendarEvent.status != "cancelled",
|
||||||
)
|
)
|
||||||
if owner:
|
if owner:
|
||||||
ev_q = owner_filter(ev_q, CalendarCal, owner, include_shared=_allow_null)
|
ev_q = owner_filter(ev_q, CalendarCal, owner, include_shared=_allow_null)
|
||||||
events = ev_q.order_by(CalendarEvent.dtstart).all()
|
events = ev_q.all()
|
||||||
|
events.sort(key=_daily_brief_local_start)
|
||||||
# ----- Notes: pinned + non-archived todos with at least one undone item -----
|
# ----- Notes: pinned + non-archived todos with at least one undone item -----
|
||||||
n_q = db.query(Note).filter(Note.archived == False) # noqa: E712
|
n_q = db.query(Note).filter(Note.archived == False) # noqa: E712
|
||||||
if owner:
|
if owner:
|
||||||
@@ -2058,7 +2090,7 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
if events:
|
if events:
|
||||||
plain.append("Calendar:")
|
plain.append("Calendar:")
|
||||||
for e in events:
|
for e in events:
|
||||||
t = e.dtstart.strftime("%H:%M") if not e.all_day else "all day"
|
t = _daily_brief_local_start(e).strftime("%H:%M") if not e.all_day else "all day"
|
||||||
loc = f" @ {e.location}" if e.location else ""
|
loc = f" @ {e.location}" if e.location else ""
|
||||||
plain.append(f" {t} {e.summary}{loc}")
|
plain.append(f" {t} {e.summary}{loc}")
|
||||||
plain.append("")
|
plain.append("")
|
||||||
|
|||||||
+7
-1
@@ -282,7 +282,13 @@ class ChatHandler:
|
|||||||
_sync_upload_vision_to_gallery(file_info, owner, vl_desc)
|
_sync_upload_vision_to_gallery(file_info, owner, vl_desc)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n{vl_desc}"
|
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n"
|
||||||
|
if vl_desc:
|
||||||
|
# A separate vision call wrote this, but the chat model sees
|
||||||
|
# one message and answers the prose as if the user wrote it
|
||||||
|
# (#5573). Label it below the marker line — the client strips
|
||||||
|
# the block by matching `[Image: <name>]\n`.
|
||||||
|
enhanced_message += f"[Image description — context only, not part of the user's message]:\n{vl_desc}"
|
||||||
# Surface the description to the client live so it renders as a
|
# Surface the description to the client live so it renders as a
|
||||||
# collapsible "image description" on the user bubble (not just
|
# collapsible "image description" on the user bubble (not just
|
||||||
# after a refresh that re-parses the stored message).
|
# after a refresh that re-parses the stored message).
|
||||||
|
|||||||
@@ -142,7 +142,10 @@ def _process_pdf(
|
|||||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||||
temp_img_path = tmp.name
|
temp_img_path = tmp.name
|
||||||
try:
|
try:
|
||||||
img.image.save(temp_img_path, "PNG") # pypdf -> PIL image
|
image = img.image
|
||||||
|
if image.mode == "CMYK":
|
||||||
|
image = image.convert("RGB")
|
||||||
|
image.save(temp_img_path, "PNG")
|
||||||
ocr_text = analyze_image_with_vl(temp_img_path, owner=owner)
|
ocr_text = analyze_image_with_vl(temp_img_path, owner=owner)
|
||||||
if ocr_text and "unavailable" not in ocr_text.lower():
|
if ocr_text and "unavailable" not in ocr_text.lower():
|
||||||
pdf_text += f"\n\n[Page {page_num + 1} image {img_index + 1} text]: {ocr_text}"
|
pdf_text += f"\n\n[Page {page_num + 1} image {img_index + 1} text]: {ocr_text}"
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""The shared model defaults used by Quick Edit's image operations."""
|
||||||
|
from src.settings import get_user_setting, load_settings
|
||||||
|
|
||||||
|
|
||||||
|
def editor_image_models(owner: str = "") -> dict[str, str]:
|
||||||
|
settings = load_settings()
|
||||||
|
generation = ""
|
||||||
|
edit_model = ""
|
||||||
|
if owner:
|
||||||
|
generation = str(get_user_setting("image_model", owner=owner, default="") or "").strip()
|
||||||
|
edit_model = str(get_user_setting("image_edit_model", owner=owner, default="") or "").strip()
|
||||||
|
if not generation:
|
||||||
|
generation = str(settings.get("image_model") or "").strip()
|
||||||
|
if not edit_model:
|
||||||
|
edit_model = str(settings.get("image_edit_model") or generation).strip()
|
||||||
|
return {
|
||||||
|
"generation": generation,
|
||||||
|
"edit": edit_model or generation,
|
||||||
|
}
|
||||||
@@ -40,6 +40,15 @@ def image_edit_size(model_id: str, width: int, height: int) -> str:
|
|||||||
return f"{width}x{height}"
|
return f"{width}x{height}"
|
||||||
|
|
||||||
|
|
||||||
|
def image_aspect_ratio(width: int, height: int) -> str:
|
||||||
|
"""Choose the nearest common Image API ratio, without distorting pixels."""
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise ValueError("Image dimensions must be positive")
|
||||||
|
ratios = ((1, 1), (16, 9), (9, 16), (3, 2), (2, 3), (4, 3), (3, 4), (21, 9))
|
||||||
|
w, h = min(ratios, key=lambda ratio: abs(math.log((ratio[0] / ratio[1]) / (width / height))))
|
||||||
|
return f"{w}:{h}"
|
||||||
|
|
||||||
|
|
||||||
def looks_like_image_generation_model(model_id: str) -> bool:
|
def looks_like_image_generation_model(model_id: str) -> bool:
|
||||||
"""Return True when a model id should use image generation routes.
|
"""Return True when a model id should use image generation routes.
|
||||||
|
|
||||||
|
|||||||
+63
-8
@@ -76,9 +76,23 @@ _ERROR_DETAIL = {
|
|||||||
"auth_or_protocol_error": "authentication or protocol error",
|
"auth_or_protocol_error": "authentication or protocol error",
|
||||||
"no_models": "endpoint returned no models",
|
"no_models": "endpoint returned no models",
|
||||||
"no_host": "no host configured",
|
"no_host": "no host configured",
|
||||||
|
"config_source_error": "configuration source could not be read",
|
||||||
"error": "probe failed",
|
"error": "probe failed",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Config/inventory source behind each network subsystem. `_gather_inputs()`
|
||||||
|
# fails soft, so a source that could not be read hands its probe an empty
|
||||||
|
# inventory — indistinguishable from "nothing configured", which every probe
|
||||||
|
# answers with `disabled`. The collector therefore reports the source failure
|
||||||
|
# instead of running a probe whose verdict would be a lie.
|
||||||
|
CONFIG_SOURCE_ERROR = "config_source_error"
|
||||||
|
_SUBSYSTEM_SOURCE = {
|
||||||
|
"searxng": "settings",
|
||||||
|
"ntfy": "integrations",
|
||||||
|
"email": "accounts",
|
||||||
|
"providers": "endpoints",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _svc(name: str, status: str, detail: str, **meta: Any) -> Dict[str, Any]:
|
def _svc(name: str, status: str, detail: str, **meta: Any) -> Dict[str, Any]:
|
||||||
return {"name": name, "status": status, "detail": detail, "meta": dict(meta)}
|
return {"name": name, "status": status, "detail": detail, "meta": dict(meta)}
|
||||||
@@ -411,26 +425,33 @@ def _gather_inputs() -> Dict[str, Any]:
|
|||||||
"""Pull live config/account/endpoint lists from the app's data sources.
|
"""Pull live config/account/endpoint lists from the app's data sources.
|
||||||
|
|
||||||
Each lookup fails soft: a broken source yields an empty/neutral value so a
|
Each lookup fails soft: a broken source yields an empty/neutral value so a
|
||||||
single failure can't take down the whole health report.
|
single failure can't take down the whole health report. The failure is also
|
||||||
|
*recorded* under `failed` (source -> controlled category), because the empty
|
||||||
|
value alone cannot be told apart from a source that loaded and is genuinely
|
||||||
|
empty — and the probes read empty as `disabled`.
|
||||||
"""
|
"""
|
||||||
settings: Dict[str, Any] = {}
|
settings: Dict[str, Any] = {}
|
||||||
integrations: List[Dict[str, Any]] = []
|
integrations: List[Dict[str, Any]] = []
|
||||||
accounts: List[Dict[str, Any]] = []
|
accounts: List[Dict[str, Any]] = []
|
||||||
endpoints: List[Dict[str, Any]] = []
|
endpoints: List[Dict[str, Any]] = []
|
||||||
|
failed: Dict[str, str] = {}
|
||||||
try:
|
try:
|
||||||
from src.settings import load_settings
|
from src.settings import load_settings
|
||||||
settings = load_settings() or {}
|
settings = load_settings() or {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
failed["settings"] = _classify_error(e)
|
||||||
logger.debug(f"service_health: settings load failed: {e}")
|
logger.debug(f"service_health: settings load failed: {e}")
|
||||||
try:
|
try:
|
||||||
from src.integrations import load_integrations
|
from src.integrations import load_integrations
|
||||||
integrations = load_integrations() or []
|
integrations = load_integrations() or []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
failed["integrations"] = _classify_error(e)
|
||||||
logger.debug(f"service_health: integrations load failed: {e}")
|
logger.debug(f"service_health: integrations load failed: {e}")
|
||||||
try:
|
try:
|
||||||
from routes.email_helpers import _list_email_accounts
|
from routes.email_helpers import _list_email_accounts
|
||||||
accounts = _list_email_accounts() or []
|
accounts = _list_email_accounts() or []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
failed["accounts"] = _classify_error(e)
|
||||||
logger.debug(f"service_health: email accounts load failed: {e}")
|
logger.debug(f"service_health: email accounts load failed: {e}")
|
||||||
try:
|
try:
|
||||||
from core.database import SessionLocal, ModelEndpoint
|
from core.database import SessionLocal, ModelEndpoint
|
||||||
@@ -443,9 +464,10 @@ def _gather_inputs() -> Dict[str, Any]:
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
failed["endpoints"] = _classify_error(e)
|
||||||
logger.debug(f"service_health: endpoint load failed: {e}")
|
logger.debug(f"service_health: endpoint load failed: {e}")
|
||||||
return {"settings": settings, "integrations": integrations,
|
return {"settings": settings, "integrations": integrations,
|
||||||
"accounts": accounts, "endpoints": endpoints}
|
"accounts": accounts, "endpoints": endpoints, "failed": failed}
|
||||||
|
|
||||||
|
|
||||||
async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]:
|
async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]:
|
||||||
@@ -464,6 +486,24 @@ async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]:
|
|||||||
return _svc(name, DOWN, _detail_for(category), error=category)
|
return _svc(name, DOWN, _detail_for(category), error=category)
|
||||||
|
|
||||||
|
|
||||||
|
async def _source_unavailable(name: str, source: str,
|
||||||
|
category: str) -> Dict[str, Any]:
|
||||||
|
"""Controlled entry for a subsystem whose config source could not be read.
|
||||||
|
|
||||||
|
Deliberately not `disabled`: the configured state is *unknown*, not known to
|
||||||
|
be off. `degraded` rather than `down` because the failure is in reading the
|
||||||
|
inventory, not a probe that reached the subsystem and failed — but it still
|
||||||
|
carries non-zero severity, so a report containing one can never roll up to
|
||||||
|
`ok`. `meta` names the source and the controlled category of the underlying
|
||||||
|
exception; never its text.
|
||||||
|
"""
|
||||||
|
return _svc(name, DEGRADED,
|
||||||
|
f"Could not read configuration "
|
||||||
|
f"({_detail_for(CONFIG_SOURCE_ERROR)}).",
|
||||||
|
error=CONFIG_SOURCE_ERROR, source=source,
|
||||||
|
source_error=category)
|
||||||
|
|
||||||
|
|
||||||
async def collect_service_health(rag_manager: Any = None,
|
async def collect_service_health(rag_manager: Any = None,
|
||||||
memory_vector: Any = None) -> Dict[str, Any]:
|
memory_vector: Any = None) -> Dict[str, Any]:
|
||||||
"""Run every probe and return {overall, services, timestamp}.
|
"""Run every probe and return {overall, services, timestamp}.
|
||||||
@@ -472,6 +512,10 @@ async def collect_service_health(rag_manager: Any = None,
|
|||||||
four network subsystems run concurrently, each under `_SUBSYSTEM_DEADLINE`,
|
four network subsystems run concurrently, each under `_SUBSYSTEM_DEADLINE`,
|
||||||
with an overall `_AGGREGATE_DEADLINE` backstop. Per-item probes inside
|
with an overall `_AGGREGATE_DEADLINE` backstop. Per-item probes inside
|
||||||
providers/email are themselves bounded by `_FANOUT_BUDGET`.
|
providers/email are themselves bounded by `_FANOUT_BUDGET`.
|
||||||
|
|
||||||
|
A subsystem whose config/inventory source failed to load is reported via
|
||||||
|
`_source_unavailable()` instead of being probed with an empty inventory,
|
||||||
|
which every probe would otherwise report as `disabled`.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -482,12 +526,23 @@ async def collect_service_health(rag_manager: Any = None,
|
|||||||
chroma = chromadb_health(rag_manager, memory_vector)
|
chroma = chromadb_health(rag_manager, memory_vector)
|
||||||
|
|
||||||
names = ["searxng", "ntfy", "email", "providers"]
|
names = ["searxng", "ntfy", "email", "providers"]
|
||||||
coros = [
|
probes = {
|
||||||
_run_subsystem("searxng", searxng_health, settings),
|
"searxng": (searxng_health, (settings,)),
|
||||||
_run_subsystem("ntfy", ntfy_health, inputs["integrations"], settings),
|
"ntfy": (ntfy_health, (inputs["integrations"], settings)),
|
||||||
_run_subsystem("email", email_health, inputs["accounts"]),
|
"email": (email_health, (inputs["accounts"],)),
|
||||||
_run_subsystem("providers", providers_health, inputs["endpoints"]),
|
"providers": (providers_health, (inputs["endpoints"],)),
|
||||||
]
|
}
|
||||||
|
# A subsystem whose inventory could not be read is reported as such. The
|
||||||
|
# others are still probed normally — one broken source degrades one entry.
|
||||||
|
failed = inputs.get("failed") or {}
|
||||||
|
coros = []
|
||||||
|
for name in names:
|
||||||
|
source = _SUBSYSTEM_SOURCE[name]
|
||||||
|
if source in failed:
|
||||||
|
coros.append(_source_unavailable(name, source, failed[source]))
|
||||||
|
else:
|
||||||
|
fn, args = probes[name]
|
||||||
|
coros.append(_run_subsystem(name, fn, *args))
|
||||||
try:
|
try:
|
||||||
results = await asyncio.wait_for(asyncio.gather(*coros),
|
results = await asyncio.wait_for(asyncio.gather(*coros),
|
||||||
timeout=_AGGREGATE_DEADLINE)
|
timeout=_AGGREGATE_DEADLINE)
|
||||||
|
|||||||
+33
-5
@@ -57,25 +57,53 @@ def _message_to_context(msg: DBChatMessage) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _escape_like(value: str) -> str:
|
def escape_like_pattern(value: str) -> str:
|
||||||
|
"""Escape SQL LIKE wildcards (``%``, ``_``) and the escape char itself.
|
||||||
|
|
||||||
|
Public shared helper for building ``ilike(f"%{...}%", escape="\\\\")``
|
||||||
|
patterns from user input. Must escape backslash first.
|
||||||
|
"""
|
||||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_like(value: str) -> str:
|
||||||
|
return escape_like_pattern(value)
|
||||||
|
|
||||||
|
|
||||||
def _snippet(content: str, query: str, radius: int = 60) -> str:
|
def _snippet(content: str, query: str, radius: int = 60) -> str:
|
||||||
content = content or ""
|
content = content or ""
|
||||||
query = query or ""
|
query = query or ""
|
||||||
if not query:
|
if not query:
|
||||||
return content[: radius * 2]
|
return content[: radius * 2]
|
||||||
|
|
||||||
idx = content.lower().find(query.lower())
|
match_start = -1
|
||||||
if idx == -1:
|
match_length = 0
|
||||||
|
lowered_content = content.lower()
|
||||||
|
for candidate in _query_terms(query):
|
||||||
|
idx = lowered_content.find(candidate.lower())
|
||||||
|
if idx != -1 and (match_start == -1 or idx < match_start):
|
||||||
|
match_start = idx
|
||||||
|
match_length = len(candidate)
|
||||||
|
|
||||||
|
if match_start == -1:
|
||||||
return content[: radius * 2]
|
return content[: radius * 2]
|
||||||
|
|
||||||
start = max(0, idx - radius)
|
start = max(0, match_start - radius)
|
||||||
end = min(len(content), idx + len(query) + radius)
|
end = min(len(content), match_start + match_length + radius)
|
||||||
return ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "")
|
return ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "")
|
||||||
|
|
||||||
|
|
||||||
|
def _query_terms(query: str) -> list[str]:
|
||||||
|
"""Return meaningful quoted phrases and tokens in user-entered order."""
|
||||||
|
terms: list[str] = []
|
||||||
|
for match in re.finditer(r'"([^"]+)"|[\w][\w._-]*', query, flags=re.UNICODE):
|
||||||
|
term = match.group(1) if match.group(1) is not None else match.group(0).strip("._-")
|
||||||
|
term = term.strip()
|
||||||
|
if term:
|
||||||
|
terms.append(term)
|
||||||
|
return terms
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_fts_query(query: str) -> str | None:
|
def _sanitize_fts_query(query: str) -> str | None:
|
||||||
"""Convert free text into a conservative FTS5 MATCH query.
|
"""Convert free text into a conservative FTS5 MATCH query.
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -46,6 +46,7 @@ DEFAULT_SETTINGS = {
|
|||||||
"agent_email_confirm": True,
|
"agent_email_confirm": True,
|
||||||
"image_gen_enabled": False,
|
"image_gen_enabled": False,
|
||||||
"image_model": "",
|
"image_model": "",
|
||||||
|
"image_edit_model": "",
|
||||||
"image_quality": "medium",
|
"image_quality": "medium",
|
||||||
# Optional editor planner/caption endpoints. Empty uses configured chat
|
# Optional editor planner/caption endpoints. Empty uses configured chat
|
||||||
# fallback without probing a developer-specific host.
|
# fallback without probing a developer-specific host.
|
||||||
@@ -294,7 +295,7 @@ def is_setting_overridden(key: str) -> bool:
|
|||||||
# resolved by FastAPI deps; an empty/None owner falls through to the global.
|
# resolved by FastAPI deps; an empty/None owner falls through to the global.
|
||||||
_PER_USER_KEYS = {
|
_PER_USER_KEYS = {
|
||||||
"vision_model", "vision_enabled", "vision_model_fallbacks",
|
"vision_model", "vision_enabled", "vision_model_fallbacks",
|
||||||
"image_model", "image_gen_enabled", "image_quality",
|
"image_model", "image_edit_model", "image_gen_enabled", "image_quality",
|
||||||
# Default chat endpoint / model — without per-user resolution every new
|
# Default chat endpoint / model — without per-user resolution every new
|
||||||
# account inherited whatever the most-recent admin picked, which then
|
# account inherited whatever the most-recent admin picked, which then
|
||||||
# got injected into the chat composer on first open.
|
# got injected into the chat composer on first open.
|
||||||
@@ -313,6 +314,20 @@ def get_user_setting(key: str, owner: str = "", default: Any = None) -> Any:
|
|||||||
Falls back gracefully if the prefs module can't be imported (cycle/early
|
Falls back gracefully if the prefs module can't be imported (cycle/early
|
||||||
boot) — admin-global settings keep working.
|
boot) — admin-global settings keep working.
|
||||||
"""
|
"""
|
||||||
|
if key == "image_gen_enabled":
|
||||||
|
global_val = get_setting("image_gen_enabled", default if default is not None else False)
|
||||||
|
if not global_val:
|
||||||
|
return False
|
||||||
|
if owner:
|
||||||
|
try:
|
||||||
|
from routes.prefs_routes import _load_for_user
|
||||||
|
prefs = _load_for_user(owner) or {}
|
||||||
|
if "image_gen_enabled" in prefs and prefs["image_gen_enabled"] is not None:
|
||||||
|
return bool(prefs["image_gen_enabled"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return bool(global_val)
|
||||||
|
|
||||||
if owner and key in _PER_USER_KEYS:
|
if owner and key in _PER_USER_KEYS:
|
||||||
try:
|
try:
|
||||||
from routes.prefs_routes import _load_for_user
|
from routes.prefs_routes import _load_for_user
|
||||||
|
|||||||
@@ -264,7 +264,42 @@
|
|||||||
/* The brush controls (Color/Size/preset/caret) hug their content; only the
|
/* The brush controls (Color/Size/preset/caret) hug their content; only the
|
||||||
trailing slider section grows, so the Opacity/Flow/Softness group sits
|
trailing slider section grows, so the Opacity/Flow/Softness group sits
|
||||||
right after the caret instead of leaving a wide gap. */
|
right after the caret instead of leaving a wide gap. */
|
||||||
.ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }
|
.ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; flex-direction: row; align-items: center; gap: 8px; }
|
||||||
|
.ge-controls--in-options #ge-brush-softness-row input[type="range"] { width: 88px; flex: 0 0 88px; }
|
||||||
|
#ge-brush-softness-row {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: 14px max-content 88px;
|
||||||
|
column-gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#ge-brush-softness-row:has(> .ge-slider-value) {
|
||||||
|
grid-template-columns: 14px max-content 88px 6ch;
|
||||||
|
}
|
||||||
|
#ge-brush-softness-row label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: max-content;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
#ge-brush-softness-row #ge-brush-softness-label {
|
||||||
|
position: static;
|
||||||
|
min-width: 6ch;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.ge-controls--in-options #ge-brush-controls .ge-brush-size-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(6ch, max-content) 96px auto;
|
||||||
|
column-gap: 8px;
|
||||||
|
}
|
||||||
|
.ge-controls--in-options .ge-brush-size-row label,
|
||||||
|
.ge-controls--in-options .ge-brush-size-row .ge-size-label {
|
||||||
|
min-width: 6ch;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
.ge-controls--in-options .ge-control-row {
|
.ge-controls--in-options .ge-control-row {
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -4607,6 +4642,8 @@ body.ge-editor-open textarea.ge-direct-text-editor {
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
.ge-filter-modal {
|
.ge-filter-modal {
|
||||||
|
max-height: calc(100dvh - 40px);
|
||||||
|
overflow-y: auto;
|
||||||
width: 240px;
|
width: 240px;
|
||||||
background: color-mix(in srgb, var(--panel) 95%, transparent);
|
background: color-mix(in srgb, var(--panel) 95%, transparent);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -4631,6 +4668,9 @@ body.ge-editor-open textarea.ge-direct-text-editor {
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
.ge-filter-row label {
|
.ge-filter-row label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -9732,6 +9772,28 @@ body.ge-editor-open .tour-hint {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
}
|
}
|
||||||
|
.ge-submenu .dropdown-item-compact {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 28px;
|
||||||
|
min-height: 28px;
|
||||||
|
max-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ge-submenu .dropdown-item-compact svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-height: 16px;
|
||||||
|
}
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.ge-submenu .dropdown-item-compact {
|
||||||
|
height: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
max-height: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
.ge-submenu {
|
.ge-submenu {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -1677,6 +1677,10 @@
|
|||||||
<label class="settings-label">Model</label>
|
<label class="settings-label">Model</label>
|
||||||
<select id="set-imgModelSelect" class="settings-select"><option value="">Auto-detect</option></select>
|
<select id="set-imgModelSelect" class="settings-select"><option value="">Auto-detect</option></select>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||||
|
<label class="settings-label" for="set-imgEditModelSelect">Image edit model</label>
|
||||||
|
<select id="set-imgEditModelSelect" class="settings-select"><option value="">Same as generation</option></select>
|
||||||
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||||
<label class="settings-label">Quality</label>
|
<label class="settings-label">Quality</label>
|
||||||
<select id="set-imgQualitySelect" class="settings-select">
|
<select id="set-imgQualitySelect" class="settings-select">
|
||||||
|
|||||||
+159
-50
@@ -336,6 +336,9 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
let _sendInFlight = false; // covers the window from click → streaming start
|
let _sendInFlight = false; // covers the window from click → streaming start
|
||||||
let _displayOverride = null; // Override visible user bubble text (hides injected prompts)
|
let _displayOverride = null; // Override visible user bubble text (hides injected prompts)
|
||||||
let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop)
|
let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop)
|
||||||
|
// The user bubble a regenerate keeps on screen. Its resubmitted message is
|
||||||
|
// a new DB row, so the saved id is attached here.
|
||||||
|
let _regenKeptUserBubble = null;
|
||||||
let _contextHeaderSeq = 0;
|
let _contextHeaderSeq = 0;
|
||||||
let _contextHeaderData = null;
|
let _contextHeaderData = null;
|
||||||
let _contextHeaderBound = false;
|
let _contextHeaderBound = false;
|
||||||
@@ -4995,6 +4998,15 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
refreshChatContextHeader('metrics');
|
refreshChatContextHeader('metrics');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} else if (json.type === 'user_message_saved') {
|
||||||
|
// The user bubble gets its DB id too, so regenerating or
|
||||||
|
// editing it later cuts the chat by id, not page position.
|
||||||
|
if (!_isBg && json.id) {
|
||||||
|
if (_userMsgEl) _userMsgEl.dataset.dbId = json.id;
|
||||||
|
else if (_regenKeptUserBubble) _regenKeptUserBubble.dataset.dbId = json.id;
|
||||||
|
_regenKeptUserBubble = null;
|
||||||
|
}
|
||||||
|
|
||||||
} else if (json.type === 'message_saved') {
|
} else if (json.type === 'message_saved') {
|
||||||
// Wire the persisted DB id onto the just-streamed bubble so it
|
// Wire the persisted DB id onto the just-streamed bubble so it
|
||||||
// can be edited/deleted immediately, without reloading the chat.
|
// can be edited/deleted immediately, without reloading the chat.
|
||||||
@@ -7236,6 +7248,22 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
/**
|
/**
|
||||||
* Edit a user message: show an input, truncate to before it, resubmit the edited text.
|
* Edit a user message: show an input, truncate to before it, resubmit the edited text.
|
||||||
*/
|
*/
|
||||||
|
// Recover attached file IDs from rendered user bubbles, including older
|
||||||
|
// image previews that were rendered before data-file-id was stamped.
|
||||||
|
function _userMessageAttachmentIds(msgElement) {
|
||||||
|
if (!msgElement) return [];
|
||||||
|
const els = msgElement.querySelectorAll('[data-file-id]');
|
||||||
|
const ids = Array.from(els).map(el => el.dataset.fileId).filter(Boolean);
|
||||||
|
if (!ids.length) {
|
||||||
|
const images = msgElement.querySelectorAll('.attach-image-preview img, .attach-card img');
|
||||||
|
for (const image of images) {
|
||||||
|
const match = (image.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
|
||||||
|
if (match && match[1] && !ids.includes(match[1])) ids.push(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
export async function editUserMessage(userMsgElement) {
|
export async function editUserMessage(userMsgElement) {
|
||||||
const box = document.getElementById('chat-history');
|
const box = document.getElementById('chat-history');
|
||||||
const allMsgs = Array.from(box.querySelectorAll('.msg'));
|
const allMsgs = Array.from(box.querySelectorAll('.msg'));
|
||||||
@@ -7283,23 +7311,49 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const newText = editor.value.trim();
|
const newText = editor.value.trim();
|
||||||
if (!newText) return;
|
if (!newText) return;
|
||||||
|
const editAttachmentIds = _userMessageAttachmentIds(userMsgElement);
|
||||||
|
|
||||||
const sessionId = sessionModule.getCurrentSessionId();
|
const sessionId = sessionModule.getCurrentSessionId();
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
// Everything after the original reply to this message would also be
|
||||||
|
// destroyed. Cancelling leaves the editor open with the draft intact.
|
||||||
|
const extraCount = allMsgs.length - msgIndex - 2;
|
||||||
|
const choice = await _confirmTruncateIfDestructive(extraCount, 'edit');
|
||||||
|
if (!choice) return;
|
||||||
|
if (choice === 'fork') {
|
||||||
|
// Branch just before this user turn and send the edit there; this
|
||||||
|
// chat keeps the original message and everything after it.
|
||||||
|
try {
|
||||||
|
await _forkAndSend(msgIndex, userMsgElement.dataset.dbId || '', newText, editAttachmentIds);
|
||||||
|
bodyEl.innerHTML = originalHTML;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fork failed:', err);
|
||||||
|
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const keepCount = msgIndex;
|
const keepCount = msgIndex;
|
||||||
|
// Cut at the message's database id: msgIndex counts only the messages
|
||||||
|
// loaded in the page, so it is too small when older history has not
|
||||||
|
// been scrolled in yet.
|
||||||
|
const editBeforeId = userMsgElement.dataset.dbId || '';
|
||||||
try {
|
try {
|
||||||
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
const editTruncate = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ keep_count: keepCount })
|
body: JSON.stringify(editBeforeId ? { before_msg_id: editBeforeId } : { keep_count: keepCount })
|
||||||
});
|
});
|
||||||
|
if (!editTruncate.ok) throw new Error('Server error ' + editTruncate.status);
|
||||||
|
|
||||||
// Remove DOM elements from msgIndex onward
|
// Remove DOM elements from msgIndex onward
|
||||||
for (let i = allMsgs.length - 1; i >= msgIndex; i--) {
|
for (let i = allMsgs.length - 1; i >= msgIndex; i--) {
|
||||||
allMsgs[i].remove();
|
allMsgs[i].remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The edited user turn still refers to its original attachments.
|
||||||
|
_pendingRegenAttachments = editAttachmentIds;
|
||||||
// Submit the edited text
|
// Submit the edited text
|
||||||
const messageInput = uiModule.el('message');
|
const messageInput = uiModule.el('message');
|
||||||
messageInput.value = newText;
|
messageInput.value = newText;
|
||||||
@@ -7323,6 +7377,59 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regenerate, resend and edit permanently delete (server-side truncate)
|
||||||
|
// everything after the point clicked. Ask first unless nothing comes after
|
||||||
|
// it (extraCount 0), offering a fork that leaves this chat untouched.
|
||||||
|
// Resolves to 'proceed', 'fork', or null (cancelled).
|
||||||
|
const _TRUNCATE_ACTIONS = {
|
||||||
|
regenerate: { lead: 'Regenerating here', confirmText: 'Delete and regenerate' },
|
||||||
|
resend: { lead: 'Resending this message', confirmText: 'Delete and resend' },
|
||||||
|
edit: { lead: 'Sending this edit', confirmText: 'Delete and send' },
|
||||||
|
};
|
||||||
|
async function _confirmTruncateIfDestructive(extraCount, action = 'regenerate') {
|
||||||
|
if (extraCount <= 0) return 'proceed';
|
||||||
|
const { lead, confirmText } = _TRUNCATE_ACTIONS[action] || _TRUNCATE_ACTIONS.regenerate;
|
||||||
|
const result = await uiModule.styledConfirm(
|
||||||
|
`${lead} will also permanently delete the ${extraCount} ` +
|
||||||
|
`message${extraCount === 1 ? '' : 's'} after it. This can't be undone. ` +
|
||||||
|
`Fork from here keeps this chat as it is and continues in a new one instead.`,
|
||||||
|
{ confirmText, alternateText: 'Fork from here', cancelText: 'Cancel', danger: true }
|
||||||
|
);
|
||||||
|
if (result === 'alternate') return 'fork';
|
||||||
|
return result ? 'proceed' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the first `keepCount` messages of the current chat into a new chat
|
||||||
|
// and switch to it. Returns the new session id; throws on failure so callers
|
||||||
|
// that replay an action in the fork can stop before sending anything.
|
||||||
|
// Fork by message id when the element has one (before it, or through it);
|
||||||
|
// keepCount is only a fallback, since it counts loaded messages only.
|
||||||
|
async function _forkSessionAt(keepCount, beforeMsgId = '', throughMsgId = '') {
|
||||||
|
const sessionId = sessionModule.getCurrentSessionId();
|
||||||
|
if (!sessionId) throw new Error('No active chat');
|
||||||
|
const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ keep_count: keepCount,
|
||||||
|
...(beforeMsgId ? { before_msg_id: beforeMsgId } : throughMsgId ? { through_msg_id: throughMsgId } : {}) }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const data = await res.json();
|
||||||
|
await sessionModule.loadSessions();
|
||||||
|
await sessionModule.selectSession(data.id);
|
||||||
|
if (uiModule) uiModule.showToast(`Forked → ${data.name}`);
|
||||||
|
return data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fork before a user message and send `text` there; this chat is untouched.
|
||||||
|
// `attachments` (if given) are re-sent only once the fork has succeeded.
|
||||||
|
async function _forkAndSend(keepCount, beforeMsgId, text, attachments) {
|
||||||
|
await _forkSessionAt(keepCount, beforeMsgId);
|
||||||
|
if (attachments !== undefined) _pendingRegenAttachments = attachments;
|
||||||
|
uiModule.el('message').value = text;
|
||||||
|
document.querySelector('.send-btn')?.click();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resend a user message. By default this replaces from the selected user turn
|
* Resend a user message. By default this replaces from the selected user turn
|
||||||
* so failed attempts do not remain in model context or SFT traces. Callers can
|
* so failed attempts do not remain in model context or SFT traces. Callers can
|
||||||
@@ -7344,18 +7451,7 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
let text = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
|
let text = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
|
||||||
text = text.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
|
text = text.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
|
||||||
|
|
||||||
// Collect file_ids attached to this user message so the resend re-carries
|
const _ids = _userMessageAttachmentIds(userMsgElement);
|
||||||
// the photos / docs (and the chat handler picks up the user-edited OCR
|
|
||||||
// text cached server-side under those file ids).
|
|
||||||
const _attachEls = userMsgElement.querySelectorAll('[data-file-id]');
|
|
||||||
let _ids = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
|
|
||||||
if (!_ids.length) {
|
|
||||||
const _imgs = userMsgElement.querySelectorAll('.attach-image-preview img, .attach-card img');
|
|
||||||
for (const _im of _imgs) {
|
|
||||||
const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
|
|
||||||
if (_m && _m[1] && !_ids.includes(_m[1])) _ids.push(_m[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rescue: legacy bubbles may have stored the filename as the message
|
// Rescue: legacy bubbles may have stored the filename as the message
|
||||||
// content (artifact of earlier broken resends). Don't re-send that as
|
// content (artifact of earlier broken resends). Don't re-send that as
|
||||||
@@ -7378,6 +7474,18 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (replaceFromHere) {
|
if (replaceFromHere) {
|
||||||
|
// Everything strictly after the immediate AI reply to this user
|
||||||
|
// message would also be destroyed — that's the part a click on
|
||||||
|
// Resend / "Regenerate message" doesn't expect to lose.
|
||||||
|
const extraCount = allMsgs.length - msgIndex - 2;
|
||||||
|
const choice = await _confirmTruncateIfDestructive(extraCount, 'resend');
|
||||||
|
if (!choice) return;
|
||||||
|
if (choice === 'fork') {
|
||||||
|
// Branch just before this user turn and send it again there; this
|
||||||
|
// chat keeps every message.
|
||||||
|
await _forkAndSend(msgIndex, userMsgElement.dataset.dbId || '', text, _ids);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Resend/regenerate trims history to this point before resubmitting so
|
// Resend/regenerate trims history to this point before resubmitting so
|
||||||
// the replacement request is the only copy the backend sees.
|
// the replacement request is the only copy the backend sees.
|
||||||
const keepCount = msgIndex;
|
const keepCount = msgIndex;
|
||||||
@@ -7442,25 +7550,14 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect any file_ids attached to the original user message so the
|
// Everything strictly after the AI reply being regenerated would also
|
||||||
// regenerated send re-uses them. Without this the AI is regenerated on
|
// be destroyed — that's the part a click on "Regenerate from here"
|
||||||
// text alone — photos (and the user-edited OCR text cached server-side
|
// doesn't expect to lose.
|
||||||
// under that file_id) would be silently dropped.
|
const extraCount = allMsgs.length - aiIndex - 1;
|
||||||
const _attachEls = userMsgEl ? userMsgEl.querySelectorAll('[data-file-id]') : [];
|
const choice = await _confirmTruncateIfDestructive(extraCount, 'regenerate');
|
||||||
let _regenIds = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
|
if (!choice) return;
|
||||||
// Fallback for bubbles rendered before the data-file-id stamp landed:
|
|
||||||
// sniff the file id straight out of any `.attach-image-preview img`
|
const _regenIds = _userMessageAttachmentIds(userMsgEl);
|
||||||
// src URLs (matches /api/upload/<id>). Otherwise an older bubble would
|
|
||||||
// regen with zero attachments and the photo would be lost from the
|
|
||||||
// resulting message even though the file still exists on disk.
|
|
||||||
if (!_regenIds.length && userMsgEl) {
|
|
||||||
const _imgs = userMsgEl.querySelectorAll('.attach-image-preview img, .attach-card img');
|
|
||||||
for (const _im of _imgs) {
|
|
||||||
const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
|
|
||||||
if (_m && _m[1] && !_regenIds.includes(_m[1])) _regenIds.push(_m[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_pendingRegenAttachments = _regenIds;
|
|
||||||
|
|
||||||
// Rescue: earlier-version regens (before the dataset.raw fix) stored the
|
// Rescue: earlier-version regens (before the dataset.raw fix) stored the
|
||||||
// photo's filename as the user-message content. On a follow-up regen,
|
// photo's filename as the user-message content. On a follow-up regen,
|
||||||
@@ -7469,7 +7566,7 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
// an image file". If userText is just a bare image filename and we have
|
// an image file". If userText is just a bare image filename and we have
|
||||||
// attachments, drop it so the OCR text (or the image bytes for vision
|
// attachments, drop it so the OCR text (or the image bytes for vision
|
||||||
// models) is what the model actually sees.
|
// models) is what the model actually sees.
|
||||||
if (userText && _pendingRegenAttachments.length &&
|
if (userText && _regenIds.length &&
|
||||||
/^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(userText.trim())) {
|
/^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(userText.trim())) {
|
||||||
userText = '';
|
userText = '';
|
||||||
}
|
}
|
||||||
@@ -7477,7 +7574,7 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
// A photo-only message has empty user text — regen must still proceed,
|
// A photo-only message has empty user text — regen must still proceed,
|
||||||
// because the attachments themselves are the message. Bail only if there
|
// because the attachments themselves are the message. Bail only if there
|
||||||
// is no text AND no attachments to send.
|
// is no text AND no attachments to send.
|
||||||
if (!userText && !_pendingRegenAttachments.length) {
|
if (!userText && !_regenIds.length) {
|
||||||
if (uiModule) uiModule.showError('Nothing to regenerate — the user message has no text and no attachments');
|
if (uiModule) uiModule.showError('Nothing to regenerate — the user message has no text and no attachments');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -7485,6 +7582,20 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
const sessionId = sessionModule.getCurrentSessionId();
|
const sessionId = sessionModule.getCurrentSessionId();
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
if (choice === 'fork') {
|
||||||
|
// Branch just before the user turn and send it again there, so the new
|
||||||
|
// chat gets a fresh reply to the same message; this chat keeps every
|
||||||
|
// message. No variants or hidden bubble: the fork starts without them.
|
||||||
|
try {
|
||||||
|
await _forkAndSend(userIndex, (userMsgEl && userMsgEl.dataset.dbId) || '', userText, _regenIds);
|
||||||
|
} catch (err) {
|
||||||
|
if (_pendingRegenAttachments === _regenIds) _pendingRegenAttachments = null;
|
||||||
|
console.error('Fork failed:', err);
|
||||||
|
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Save current response as a variant
|
// Save current response as a variant
|
||||||
const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || '';
|
const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || '';
|
||||||
const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || '';
|
const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || '';
|
||||||
@@ -7496,13 +7607,19 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
}
|
}
|
||||||
|
|
||||||
const keepCount = userIndex;
|
const keepCount = userIndex;
|
||||||
|
// Same as resend: cut at the user message's database id, because
|
||||||
|
// userIndex counts only the messages loaded in the page.
|
||||||
|
const regenBeforeId = (userMsgEl && userMsgEl.dataset.dbId) || '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
const regenTruncate = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ keep_count: keepCount })
|
body: JSON.stringify(regenBeforeId ? { before_msg_id: regenBeforeId } : { keep_count: keepCount })
|
||||||
});
|
});
|
||||||
|
// A refused cut (e.g. a stale id) must not stack a second reply.
|
||||||
|
if (!regenTruncate.ok) throw new Error('Server error ' + regenTruncate.status);
|
||||||
|
_pendingRegenAttachments = _regenIds;
|
||||||
|
|
||||||
// Keep the original user bubble, but remove every rendered trace after
|
// Keep the original user bubble, but remove every rendered trace after
|
||||||
// it, including agent-thread tool history between the user and AI bubble.
|
// it, including agent-thread tool history between the user and AI bubble.
|
||||||
@@ -7519,12 +7636,14 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
_pendingVariantLabel = 'regen';
|
_pendingVariantLabel = 'regen';
|
||||||
|
|
||||||
_hideUserBubble = true;
|
_hideUserBubble = true;
|
||||||
|
_regenKeptUserBubble = userMsgEl;
|
||||||
const messageInput = uiModule.el('message');
|
const messageInput = uiModule.el('message');
|
||||||
messageInput.value = userText;
|
messageInput.value = userText;
|
||||||
const submitBtn = document.querySelector('.send-btn');
|
const submitBtn = document.querySelector('.send-btn');
|
||||||
if (submitBtn) submitBtn.click();
|
if (submitBtn) submitBtn.click();
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (_pendingRegenAttachments === _regenIds) _pendingRegenAttachments = null;
|
||||||
console.error('Regenerate failed:', err);
|
console.error('Regenerate failed:', err);
|
||||||
if (uiModule) uiModule.showError('Regenerate failed: ' + err.message);
|
if (uiModule) uiModule.showError('Regenerate failed: ' + err.message);
|
||||||
}
|
}
|
||||||
@@ -7536,6 +7655,8 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
// File-ids carried over from the original user message during a regen, so
|
// File-ids carried over from the original user message during a regen, so
|
||||||
// photos / OCR overrides survive into the new send. Consumed once.
|
// photos / OCR overrides survive into the new send. Consumed once.
|
||||||
let _pendingRegenAttachments = null;
|
let _pendingRegenAttachments = null;
|
||||||
|
// Read-only view for tests.
|
||||||
|
export function _pendingRegenAttachmentIds() { return _pendingRegenAttachments; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called after streaming completes to attach variant navigation if this was a regen.
|
* Called after streaming completes to attach variant navigation if this was a regen.
|
||||||
@@ -7675,20 +7796,8 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
const sessionId = sessionModule.getCurrentSessionId();
|
const sessionId = sessionModule.getCurrentSessionId();
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
const keepCount = aiIndex + 1;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, {
|
await _forkSessionAt(aiIndex + 1, '', aiMsgElement.dataset.dbId || '');
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ keep_count: keepCount }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
await sessionModule.loadSessions();
|
|
||||||
await sessionModule.selectSession(data.id);
|
|
||||||
if (uiModule) uiModule.showToast(`Forked → ${data.name}`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fork failed:', err);
|
console.error('Fork failed:', err);
|
||||||
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
|
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
* }} deps
|
* }} deps
|
||||||
*/
|
*/
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
|
import { imageResultGeometry } from './image-result-geometry.js';
|
||||||
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
|
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
|
||||||
|
|
||||||
export function wireInpaintButtons({
|
export function wireInpaintButtons({
|
||||||
@@ -184,17 +185,30 @@ export function wireInpaintButtons({
|
|||||||
if (!state.editorOpen) return; // user closed mid-decode
|
if (!state.editorOpen) return; // user closed mid-decode
|
||||||
try {
|
try {
|
||||||
saveState('Inpaint result');
|
saveState('Inpaint result');
|
||||||
// OpenAI returns at one of its allowed sizes (1024²,
|
// Whole-image edits keep the provider's native frame; only masked
|
||||||
// 1024×1536, 1536×1024) which often differs from our
|
// edits need registration with the source document's coordinates.
|
||||||
// canvas. Scale to canvas size with smoothing so the
|
|
||||||
// inpaint blends in regardless of source dims.
|
|
||||||
const shortPrompt = (prompt || '').trim().replace(/\s+/g, ' ').slice(0, 40);
|
const shortPrompt = (prompt || '').trim().replace(/\s+/g, ' ').slice(0, 40);
|
||||||
const layerName = shortPrompt ? `Inpaint: ${shortPrompt}` : 'Inpaint Result';
|
const layerName = shortPrompt ? `Inpaint: ${shortPrompt}` : 'Inpaint Result';
|
||||||
const resultLayer = createLayer(layerName, state.imgWidth, state.imgHeight);
|
const fullFrame = data.full_frame === true;
|
||||||
|
const geometry = imageResultGeometry(resultImg.naturalWidth, resultImg.naturalHeight, state.imgWidth, state.imgHeight);
|
||||||
|
const resultLayer = createLayer(layerName,
|
||||||
|
fullFrame ? geometry.width : state.imgWidth,
|
||||||
|
fullFrame ? geometry.height : state.imgHeight);
|
||||||
resultLayer.ctx.imageSmoothingEnabled = true;
|
resultLayer.ctx.imageSmoothingEnabled = true;
|
||||||
resultLayer.ctx.imageSmoothingQuality = 'high';
|
resultLayer.ctx.imageSmoothingQuality = 'high';
|
||||||
resultLayer.ctx.drawImage(resultImg, 0, 0, state.imgWidth, state.imgHeight);
|
if (fullFrame) {
|
||||||
|
resultLayer.ctx.drawImage(resultImg, geometry.drawX, geometry.drawY, geometry.drawWidth, geometry.drawHeight);
|
||||||
|
state.layerOffsets.set(resultLayer.id, {
|
||||||
|
x: geometry.offsetX,
|
||||||
|
y: geometry.offsetY,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const scale = Math.min(state.imgWidth / resultImg.naturalWidth, state.imgHeight / resultImg.naturalHeight);
|
||||||
|
const width = resultImg.naturalWidth * scale, height = resultImg.naturalHeight * scale;
|
||||||
|
resultLayer.ctx.drawImage(resultImg, (state.imgWidth - width) / 2, (state.imgHeight - height) / 2, width, height);
|
||||||
|
}
|
||||||
// Snapshot the AI result + hard mask used for this run.
|
// Snapshot the AI result + hard mask used for this run.
|
||||||
|
if (!fullFrame) {
|
||||||
const aiSnap = document.createElement('canvas');
|
const aiSnap = document.createElement('canvas');
|
||||||
aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight;
|
aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight;
|
||||||
aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0);
|
aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0);
|
||||||
@@ -205,6 +219,7 @@ export function wireInpaintButtons({
|
|||||||
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
|
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
|
||||||
// Apply initial alpha = hard mask (no feather, no edge shift).
|
// Apply initial alpha = hard mask (no feather, no edge shift).
|
||||||
applyInpaintFeather(resultLayer, 0, 0);
|
applyInpaintFeather(resultLayer, 0, 0);
|
||||||
|
}
|
||||||
state.layers.push(resultLayer);
|
state.layers.push(resultLayer);
|
||||||
state.activeLayerId = resultLayer.id;
|
state.activeLayerId = resultLayer.id;
|
||||||
state.lastInpaintLayerId = resultLayer.id;
|
state.lastInpaintLayerId = resultLayer.id;
|
||||||
@@ -220,6 +235,10 @@ export function wireInpaintButtons({
|
|||||||
}
|
}
|
||||||
composite();
|
composite();
|
||||||
renderLayerPanel();
|
renderLayerPanel();
|
||||||
|
if (fullFrame) {
|
||||||
|
if (uiModule) uiModule.showToast('Image edit complete', 4500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Reveal post-generation Feather + Edge Stroke sliders.
|
// Reveal post-generation Feather + Edge Stroke sliders.
|
||||||
// Cap Edge Stroke at ±padPx so the slider can't ask for
|
// Cap Edge Stroke at ±padPx so the slider can't ask for
|
||||||
// more AI buffer than we generated.
|
// more AI buffer than we generated.
|
||||||
|
|||||||
@@ -54,16 +54,16 @@ export function resolveQuickEditModel() {
|
|||||||
label: ((opt && opt.textContent) || '').trim(),
|
label: ((opt && opt.textContent) || '').trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const inp = document.getElementById('ge-ai-inpaint');
|
const generation = document.getElementById('ge-ai-model');
|
||||||
const opt = resolveInpaintModel(inp);
|
if (generation?.value && generation.value !== '__serve_cookbook__') {
|
||||||
if (!opt) return { endpoint: '', model: '', label: '' };
|
const index = generation.value.indexOf('::');
|
||||||
const raw = opt.value || '';
|
const model = index < 0 ? '' : generation.value.slice(index + 2);
|
||||||
const idx = raw.indexOf('::');
|
if (model && modelCaps(model, '', '').inpaint) {
|
||||||
return {
|
return { endpoint: generation.value.slice(0, index), model,
|
||||||
endpoint: idx < 0 ? raw : raw.slice(0, idx),
|
label: generation.options[generation.selectedIndex]?.textContent?.trim() || model };
|
||||||
model: idx < 0 ? '' : raw.slice(idx + 2),
|
}
|
||||||
label: ((opt.textContent || '').trim().replace(/^Auto\s*\((.*)\)$/, '$1')),
|
}
|
||||||
};
|
return { endpoint: '', model: '', label: 'Auto (saved settings)' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the Quick Edit AGENT (planner) model — the vision-language model
|
// Resolve the Quick Edit AGENT (planner) model — the vision-language model
|
||||||
@@ -144,21 +144,19 @@ export function modelCaps(modelId, endpointName, endpointType) {
|
|||||||
function syncCommandModel(value) {
|
function syncCommandModel(value) {
|
||||||
const select = document.getElementById('ge-ai-command-model');
|
const select = document.getElementById('ge-ai-command-model');
|
||||||
if (!select || !select._modelsReady) return;
|
if (!select || !select._modelsReady) return;
|
||||||
if (value && ![...select.options].some(o => !o.disabled && o.value === value && value !== '__serve_cookbook__')) {
|
|
||||||
if (localStorage.getItem('ge-ai-command-model') === value) localStorage.removeItem('ge-ai-command-model');
|
|
||||||
value = '';
|
|
||||||
}
|
|
||||||
if (select.value !== value) {
|
if (select.value !== value) {
|
||||||
select.value = value;
|
select.value = value;
|
||||||
if (select.value !== value) select.value = '';
|
if (select.value !== value) select.value = '';
|
||||||
updateFooterEditModel();
|
updateFooterEditModel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || ''));
|
if (typeof window !== 'undefined') {
|
||||||
window.addEventListener('storage', e => {
|
window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || ''));
|
||||||
if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || '');
|
window.addEventListener('storage', e => {
|
||||||
});
|
if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || '');
|
||||||
window.addEventListener('ge:model-endpoints-updated', e => {
|
});
|
||||||
|
}
|
||||||
|
globalThis.window?.addEventListener('ge:model-endpoints-updated', e => {
|
||||||
document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || '');
|
document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || '');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -256,9 +254,9 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (listeners.signal.aborted || !root.isConnected) return;
|
if (listeners.signal.aborted || !root.isConnected) return;
|
||||||
const modelsList = data.models || [];
|
const modelsList = data.models || [];
|
||||||
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
|
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">Auto-detect</option>';
|
||||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
|
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
|
||||||
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>';
|
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Same as generation</option>';
|
||||||
const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model'));
|
const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model'));
|
||||||
for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>';
|
for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>';
|
||||||
let firstGen = null;
|
let firstGen = null;
|
||||||
@@ -346,45 +344,15 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
|||||||
// Sentinel changes are handled by the component's delegated listener.
|
// Sentinel changes are handled by the component's delegated listener.
|
||||||
if (aiCommandSelect) {
|
if (aiCommandSelect) {
|
||||||
aiCommandSelect._modelsReady = true;
|
aiCommandSelect._modelsReady = true;
|
||||||
try {
|
// Legacy URL migration check: legacy endpoints with raw URLs are scrubbed.
|
||||||
const rawSaved = localStorage.getItem('ge-ai-command-model') || '';
|
const scrubModelUrl = (saved) => (saved && saved.startsWith('http://') ? '' : saved);
|
||||||
let saved = rawSaved;
|
|
||||||
if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) {
|
|
||||||
const modelPart = saved.includes('::') ? saved.split('::').pop() : '';
|
|
||||||
const matching = loaded.filter(l => l.model === modelPart);
|
|
||||||
if (matching.length === 1) {
|
|
||||||
saved = matching[0].value;
|
|
||||||
} else {
|
|
||||||
saved = '';
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (saved) localStorage.setItem('ge-ai-command-model', saved);
|
|
||||||
else localStorage.removeItem('ge-ai-command-model');
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) {
|
|
||||||
aiCommandSelect.value = saved;
|
|
||||||
} else {
|
|
||||||
aiCommandSelect.value = '';
|
|
||||||
}
|
|
||||||
if (aiCommandSelect.value !== rawSaved) {
|
|
||||||
if (aiCommandSelect.value) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
|
|
||||||
else localStorage.removeItem('ge-ai-command-model');
|
|
||||||
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: aiCommandSelect.value } }));
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (aiCommandSelect._commandWired !== listeners) {
|
if (aiCommandSelect._commandWired !== listeners) {
|
||||||
aiCommandSelect._commandWired = listeners;
|
aiCommandSelect._commandWired = listeners;
|
||||||
listen(aiCommandSelect, 'change', () => {
|
listen(aiCommandSelect, 'change', () => {
|
||||||
if (aiCommandSelect.value === '__serve_cookbook__') return;
|
if (aiCommandSelect.value === '__serve_cookbook__') return;
|
||||||
const val = aiCommandSelect.value || '';
|
const val = scrubModelUrl(aiCommandSelect.value) || '';
|
||||||
try {
|
|
||||||
if (val) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
|
|
||||||
else localStorage.removeItem('ge-ai-command-model');
|
|
||||||
} catch {}
|
|
||||||
updateFooterEditModel();
|
updateFooterEditModel();
|
||||||
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
|
globalThis.window?.dispatchEvent?.(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,6 +377,7 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
_aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() };
|
_aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() };
|
||||||
|
container.dispatchEvent(new Event('ge-image-models-ready'));
|
||||||
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
|
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (listeners.signal.aborted || !root.isConnected) return;
|
if (listeners.signal.aborted || !root.isConnected) return;
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
* Background Remove (rembg) + Sharpen wiring + the live edge-cleanup
|
* Background Remove (rembg) + Sharpen wiring + the live edge-cleanup
|
||||||
* tuner that runs on the most-recent rembg cutout.
|
* tuner that runs on the most-recent rembg cutout.
|
||||||
*
|
*
|
||||||
* rembg-run button: flatten + POST to /api/image/remove-bg with an
|
* rembg-run button: active layer + POST to /api/image/remove-bg with an
|
||||||
* optional hint_mask if the user has a wand/lasso selection
|
* optional hint_mask if the user has a wand/lasso selection
|
||||||
* active. After the new layer lands, hides every previously-
|
* active. Updates only that layer and binds its live edge tuner;
|
||||||
* visible layer so the cutout reads cleanly, and binds the
|
* other layers and their visibility remain unchanged.
|
||||||
* live-tuner to the new layer.
|
|
||||||
*
|
*
|
||||||
* Live edge-cleanup tuner: snapshots the pristine cutout the
|
* Live edge-cleanup tuner: snapshots the pristine cutout the
|
||||||
* moment it lands; subsequent feather/grow slider tweaks
|
* moment it lands; subsequent feather/grow slider tweaks
|
||||||
@@ -65,37 +64,13 @@ export function wireRembgAndSharpen({
|
|||||||
// NB: edge_feather / edge_grow are applied CLIENT-side so the
|
// NB: edge_feather / edge_grow are applied CLIENT-side so the
|
||||||
// sliders can re-tune the cutout without re-running the model.
|
// sliders can re-tune the cutout without re-running the model.
|
||||||
const btn = document.getElementById('ge-rembg-run');
|
const btn = document.getElementById('ge-rembg-run');
|
||||||
const before = state.layers.length;
|
const editedLayer = await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
|
||||||
// Snapshot which layers were visible BEFORE the run so we know
|
|
||||||
// which to hide after a successful cutout.
|
|
||||||
const prevVisible = state.layers.filter(l => l.visible).map(l => l.id);
|
|
||||||
await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
|
|
||||||
// applyImageTool resolves after the returned image is decoded and the
|
|
||||||
// new layer is inserted. Keep a short poll for defensive compatibility
|
|
||||||
// with alternate runners that may still commit on the next frame.
|
|
||||||
let frames = 0;
|
|
||||||
while (state.layers.length <= before && frames < 60) {
|
|
||||||
await new Promise(r => requestAnimationFrame(r));
|
|
||||||
frames++;
|
|
||||||
}
|
|
||||||
if (state.layers.length > before) {
|
|
||||||
const newLayer = state.layers[state.layers.length - 1];
|
|
||||||
bindRembgLiveTuner(newLayer);
|
|
||||||
// Auto-hide underlying layers so the user sees just the
|
|
||||||
// cutout — the eye toggles back on if they re-enable manually.
|
|
||||||
for (const layer of state.layers) {
|
|
||||||
if (prevVisible.includes(layer.id) && layer.id !== newLayer.id) {
|
|
||||||
layer.visible = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composite();
|
|
||||||
renderLayerPanel();
|
|
||||||
}
|
|
||||||
// Reset sliders so the new cutout starts clean.
|
// Reset sliders so the new cutout starts clean.
|
||||||
const f = document.getElementById('ge-rembg-feather');
|
const f = document.getElementById('ge-rembg-feather');
|
||||||
const g = document.getElementById('ge-rembg-grow');
|
const g = document.getElementById('ge-rembg-grow');
|
||||||
if (f) { f.value = 0; document.getElementById('ge-rembg-feather-label').textContent = '0px'; syncRembgFeather(0); }
|
if (f) { f.value = 0; document.getElementById('ge-rembg-feather-label').textContent = '0px'; syncRembgFeather(0); }
|
||||||
if (g) { g.value = 0; document.getElementById('ge-rembg-grow-label').textContent = '0px'; syncRembgGrow(0); }
|
if (g) { g.value = 0; document.getElementById('ge-rembg-grow-label').textContent = '0px'; syncRembgGrow(0); }
|
||||||
|
if (editedLayer) bindRembgLiveTuner(editedLayer);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Live edge-cleanup tuner ──
|
// ── Live edge-cleanup tuner ──
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
* "Double-check AI edits" toggle. Opened from the Quick Edit gear button and
|
* "Double-check AI edits" toggle. Opened from the Quick Edit gear button and
|
||||||
* from the editor setup checklist; keeps the Quick Edit box itself uncluttered.
|
* from the editor setup checklist; keeps the Quick Edit box itself uncluttered.
|
||||||
*/
|
*/
|
||||||
|
import { readImagePreferences, saveImagePreferences } from '../settings/imagePreferences.js';
|
||||||
|
import { updateFooterEditModel } from './ai-models.js';
|
||||||
|
|
||||||
export function createAiSettings({ container }) {
|
export function createAiSettings({ container }) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'ge-ai-settings';
|
el.className = 'ge-ai-settings';
|
||||||
@@ -19,11 +22,14 @@ export function createAiSettings({ container }) {
|
|||||||
<button type="button" class="ge-ai-settings-close" data-ai-settings-close aria-label="Close">×</button>
|
<button type="button" class="ge-ai-settings-close" data-ai-settings-close aria-label="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-ai-settings-body">
|
<div class="ge-ai-settings-body">
|
||||||
|
<label class="ge-ai-settings-label" for="ge-ai-model">Image generation model</label>
|
||||||
|
<select class="ge-ai-command-model" id="ge-ai-model" title="Image generation model" aria-label="Image generation model"></select>
|
||||||
<label class="ge-ai-settings-label" for="ge-ai-command-model">Image edit model</label>
|
<label class="ge-ai-settings-label" for="ge-ai-command-model">Image edit model</label>
|
||||||
<select class="ge-ai-command-model" id="ge-ai-command-model" title="Image edit model" aria-label="Image edit model"></select>
|
<select class="ge-ai-command-model" id="ge-ai-command-model" title="Image edit model" aria-label="Image edit model"></select>
|
||||||
<label class="ge-ai-settings-label" for="ge-ai-command-agent-model">AI agent (vision) model</label>
|
<label class="ge-ai-settings-label" for="ge-ai-command-agent-model">AI agent (vision) model</label>
|
||||||
<select class="ge-ai-command-model" id="ge-ai-command-agent-model" title="Quick Edit agent model" aria-label="Quick Edit agent model"></select>
|
<select class="ge-ai-command-model" id="ge-ai-command-agent-model" title="Quick Edit agent model" aria-label="Quick Edit agent model"></select>
|
||||||
<p class="ge-ai-settings-note">Recommended, not required — the vision agent plans edits and can double-check results.</p>
|
<p class="ge-ai-settings-note">Recommended, not required — the vision agent plans edits and can double-check results.</p>
|
||||||
|
<p class="ge-ai-settings-note" id="ge-ai-settings-save-status" role="status"></p>
|
||||||
<label class="ge-ai-settings-label" for="ge-look-strength">Look strength</label>
|
<label class="ge-ai-settings-label" for="ge-look-strength">Look strength</label>
|
||||||
<div class="ge-ai-settings-strength">
|
<div class="ge-ai-settings-strength">
|
||||||
<input type="range" id="ge-look-strength" min="0.5" max="2" step="0.1" value="1.4" aria-label="Look grade strength" />
|
<input type="range" id="ge-look-strength" min="0.5" max="2" step="0.1" value="1.4" aria-label="Look grade strength" />
|
||||||
@@ -41,6 +47,7 @@ export function createAiSettings({ container }) {
|
|||||||
|
|
||||||
function open(focusId) {
|
function open(focusId) {
|
||||||
el.hidden = false;
|
el.hidden = false;
|
||||||
|
syncModels();
|
||||||
if (focusId) {
|
if (focusId) {
|
||||||
const target = document.getElementById(focusId);
|
const target = document.getElementById(focusId);
|
||||||
if (target) {
|
if (target) {
|
||||||
@@ -56,10 +63,59 @@ export function createAiSettings({ container }) {
|
|||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const onKey = (e) => { if (e.key === 'Escape' && !el.hidden) close(); };
|
const onKey = (e) => {
|
||||||
document.addEventListener('keydown', onKey);
|
if (e.key !== 'Escape' || el.hidden) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey, true);
|
||||||
|
|
||||||
container.appendChild(el);
|
container.appendChild(el);
|
||||||
|
const status = el.querySelector('#ge-ai-settings-save-status');
|
||||||
|
const modelFields = [['ge-ai-model', 'image_model'], ['ge-ai-command-model', 'image_edit_model']];
|
||||||
|
function applyModels(settings) {
|
||||||
|
for (const [id, key] of modelFields) {
|
||||||
|
if (!(key in settings)) continue;
|
||||||
|
const select = el.querySelector(`#${id}`);
|
||||||
|
const model = settings[key] || '';
|
||||||
|
let option = [...select.options].find(option => {
|
||||||
|
const value = option.value;
|
||||||
|
return model ? value.slice(value.indexOf('::') + 2) === model : value === '';
|
||||||
|
});
|
||||||
|
if (!option && model && select.options.length) {
|
||||||
|
option = document.createElement('option');
|
||||||
|
option.value = `::${model}`;
|
||||||
|
option.textContent = `${model} (unavailable)`;
|
||||||
|
option.disabled = true;
|
||||||
|
select.append(option);
|
||||||
|
}
|
||||||
|
if (option) select.value = option.value;
|
||||||
|
}
|
||||||
|
updateFooterEditModel();
|
||||||
|
}
|
||||||
|
async function syncModels() {
|
||||||
|
try { applyModels(await readImagePreferences()); }
|
||||||
|
catch (error) { status.textContent = error.message; }
|
||||||
|
}
|
||||||
|
for (const [id, key] of modelFields) {
|
||||||
|
el.querySelector(`#${id}`).addEventListener('change', async (event) => {
|
||||||
|
const raw = event.target.value;
|
||||||
|
if (raw === '__serve_cookbook__') return;
|
||||||
|
const model = raw.includes('::') ? raw.slice(raw.indexOf('::') + 2) : '';
|
||||||
|
try {
|
||||||
|
status.textContent = 'Saving…';
|
||||||
|
await saveImagePreferences({ [key]: model });
|
||||||
|
status.textContent = 'Saved';
|
||||||
|
} catch (error) {
|
||||||
|
await syncModels();
|
||||||
|
status.textContent = error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const onPreferencesChanged = event => applyModels(event.detail);
|
||||||
|
window.addEventListener('image-preferences-changed', onPreferencesChanged);
|
||||||
|
container.addEventListener('ge-image-models-ready', syncModels);
|
||||||
|
|
||||||
const strengthInput = el.querySelector('#ge-look-strength');
|
const strengthInput = el.querySelector('#ge-look-strength');
|
||||||
const strengthVal = el.querySelector('#ge-look-strength-val');
|
const strengthVal = el.querySelector('#ge-look-strength-val');
|
||||||
@@ -77,7 +133,9 @@ export function createAiSettings({ container }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function destroy() {
|
function destroy() {
|
||||||
document.removeEventListener('keydown', onKey);
|
window.removeEventListener('keydown', onKey, true);
|
||||||
|
window.removeEventListener('image-preferences-changed', onPreferencesChanged);
|
||||||
|
container.removeEventListener('ge-image-models-ready', syncModels);
|
||||||
el.remove();
|
el.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,12 @@ export function createApplyImageTool({
|
|||||||
if (sel.endpoint) extraPayload._endpoint = sel.endpoint;
|
if (sel.endpoint) extraPayload._endpoint = sel.endpoint;
|
||||||
if (sel.model && !extraPayload._model) extraPayload._model = sel.model;
|
if (sel.model && !extraPayload._model) extraPayload._model = sel.model;
|
||||||
}
|
}
|
||||||
const flatCanvas = flatten();
|
const removeBackground = endpoint === '/api/image/remove-bg';
|
||||||
|
const targetLayer = removeBackground ? state.layers.find(layer => layer.id === state.activeLayerId) : null;
|
||||||
|
if (removeBackground && (!targetLayer?.canvas || targetLayer.kind === 'adjustment' || targetLayer.locks?.pixels)) {
|
||||||
|
throw new Error('Select an unlocked image layer first');
|
||||||
|
}
|
||||||
|
const flatCanvas = removeBackground ? targetLayer.canvas : flatten();
|
||||||
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
|
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
|
||||||
const body = { image: imageB64, ...extraPayload };
|
const body = { image: imageB64, ...extraPayload };
|
||||||
const res = await fetch(endpoint, {
|
const res = await fetch(endpoint, {
|
||||||
@@ -96,6 +101,23 @@ export function createApplyImageTool({
|
|||||||
const img = await decodeAIImage(data.image, operation.signal);
|
const img = await decodeAIImage(data.image, operation.signal);
|
||||||
operation.signal.throwIfAborted();
|
operation.signal.throwIfAborted();
|
||||||
if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4)
|
if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4)
|
||||||
|
if (removeBackground) {
|
||||||
|
if (!state.layers.includes(targetLayer)) return;
|
||||||
|
saveState('Remove background');
|
||||||
|
// The returned pixels replace the editable source, not just its preview.
|
||||||
|
targetLayer.kind = 'raster';
|
||||||
|
targetLayer.placed = null;
|
||||||
|
targetLayer.text = null;
|
||||||
|
targetLayer.shape = null;
|
||||||
|
targetLayer.ctx.clearRect(0, 0, targetLayer.canvas.width, targetLayer.canvas.height);
|
||||||
|
targetLayer.ctx.drawImage(img, 0, 0, targetLayer.canvas.width, targetLayer.canvas.height);
|
||||||
|
targetLayer._adjCacheKey = null;
|
||||||
|
targetLayer._adjFinalKey = null;
|
||||||
|
composite();
|
||||||
|
renderLayerPanel();
|
||||||
|
uiModule?.showToast('Background removed');
|
||||||
|
return targetLayer;
|
||||||
|
}
|
||||||
{
|
{
|
||||||
saveState();
|
saveState();
|
||||||
const layer = createLayer(layerName, state.imgWidth, state.imgHeight);
|
const layer = createLayer(layerName, state.imgWidth, state.imgHeight);
|
||||||
|
|||||||
Vendored
+5
-5
@@ -55,6 +55,11 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
|||||||
<label>Color</label>
|
<label>Color</label>
|
||||||
<input type="color" class="ge-color-picker" value="${color}" />
|
<input type="color" class="ge-color-picker" value="${color}" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ge-control-row ge-eraser-row" id="ge-brush-softness-row">
|
||||||
|
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
|
||||||
|
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
|
||||||
|
<input type="range" id="ge-brush-softness" min="0" max="1200" value="100" title="Brush edge softness and feathered falloff" />
|
||||||
|
</div>
|
||||||
<div class="ge-control-row ge-brush-size-row">
|
<div class="ge-control-row ge-brush-size-row">
|
||||||
<label><span class="ge-size-label">${brushSize}px</span></label>
|
<label><span class="ge-size-label">${brushSize}px</span></label>
|
||||||
<input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" />
|
<input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" />
|
||||||
@@ -94,11 +99,6 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
|||||||
<label>Flow <span id="ge-brush-flow-label">100%</span></label>
|
<label>Flow <span id="ge-brush-flow-label">100%</span></label>
|
||||||
<input type="range" id="ge-brush-flow" min="5" max="100" value="100" />
|
<input type="range" id="ge-brush-flow" min="5" max="100" value="100" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-control-row ge-eraser-row" id="ge-brush-softness-row">
|
|
||||||
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
|
|
||||||
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
|
|
||||||
<input type="range" id="ge-brush-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha for a feathered fade at the perimeter." />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ge-brush-stroke-group" data-stroke-tool="eraser">
|
<div class="ge-brush-stroke-group" data-stroke-tool="eraser">
|
||||||
<div class="ge-section-title">Eraser</div>
|
<div class="ge-section-title">Eraser</div>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
|||||||
const UI_CHROME_SELECTOR = '.ge-topbar, .ge-toolbar, .ge-controls, .ge-right-panel, '
|
const UI_CHROME_SELECTOR = '.ge-topbar, .ge-toolbar, .ge-controls, .ge-right-panel, '
|
||||||
+ '.ge-dock, .ge-options-bar, .ge-canvas-toolbar, button, input, select, textarea';
|
+ '.ge-dock, .ge-options-bar, .ge-canvas-toolbar, button, input, select, textarea';
|
||||||
const isUiChrome = e => !!e.target?.closest?.(UI_CHROME_SELECTOR);
|
const isUiChrome = e => !!e.target?.closest?.(UI_CHROME_SELECTOR);
|
||||||
|
const canStartOutside = () => ['marquee', 'lasso', 'crop'].includes(state.tool);
|
||||||
// Only in-progress selection/crop drags are affected by the chrome
|
// Only in-progress selection/crop drags are affected by the chrome
|
||||||
// guard. A committed cropRect/selection is a resting state — clicking
|
// guard. A committed cropRect/selection is a resting state — clicking
|
||||||
// its Apply button must not be treated as "strayed onto chrome".
|
// its Apply button must not be treated as "strayed onto chrome".
|
||||||
@@ -143,15 +144,16 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
|||||||
};
|
};
|
||||||
state.mainCanvas.addEventListener('pointerup', endPen);
|
state.mainCanvas.addEventListener('pointerup', endPen);
|
||||||
state.mainCanvas.addEventListener('pointercancel', endPen);
|
state.mainCanvas.addEventListener('pointercancel', endPen);
|
||||||
// Lasso and Crop can start OUTSIDE the canvas — fallback mousedown on the
|
// Selection and Crop can start OUTSIDE the canvas — fallback mousedown on the
|
||||||
// surrounding canvas-area so the user can begin a path/rect in the empty
|
// surrounding canvas-area so the user can begin a path/rect in the empty
|
||||||
// space around the image. Other tools stay canvas-only.
|
// space around the image. Other tools stay canvas-only.
|
||||||
canvasArea.addEventListener('mousedown', (e) => {
|
canvasArea.addEventListener('mousedown', (e) => {
|
||||||
if (state.tool !== 'lasso' && state.tool !== 'crop') return;
|
if (!canStartOutside() || Date.now() < suppressMouseUntil) return;
|
||||||
|
if (e.button !== 0 || isDirectPanIntent(state.tool, state.spacePanActive, e.button)) return;
|
||||||
if (e.target === state.mainCanvas) return; // already handled
|
if (e.target === state.mainCanvas) return; // already handled
|
||||||
// Ignore presses on editor chrome inside the area (the crop Apply panel,
|
// Ignore presses on editor chrome inside the area (the crop Apply panel,
|
||||||
// popups, buttons) so clicking Apply does not start a fresh crop.
|
// popups, buttons) so clicking Apply does not start a fresh crop.
|
||||||
if (e.target.closest?.('button, input, select, textarea, .ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return;
|
if (isUiChrome(e) || e.target.closest?.('.ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-fx-menu')) return;
|
||||||
beginDraw(e);
|
beginDraw(e);
|
||||||
});
|
});
|
||||||
state.mainCanvas.addEventListener('mouseenter', (e) => {
|
state.mainCanvas.addEventListener('mouseenter', (e) => {
|
||||||
@@ -305,6 +307,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
|||||||
let panning = false;
|
let panning = false;
|
||||||
let pid = null;
|
let pid = null;
|
||||||
let transformPointerId = null;
|
let transformPointerId = null;
|
||||||
|
let outsideDrawPointerId = null;
|
||||||
let startX = 0, startY = 0;
|
let startX = 0, startY = 0;
|
||||||
let startPanX = 0, startPanY = 0;
|
let startPanX = 0, startPanY = 0;
|
||||||
const getOffset = () => ({ x: state.panX || 0, y: state.panY || 0 });
|
const getOffset = () => ({ x: state.panX || 0, y: state.panY || 0 });
|
||||||
@@ -315,10 +318,23 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
|||||||
};
|
};
|
||||||
canvasArea.addEventListener('pointerdown', (e) => {
|
canvasArea.addEventListener('pointerdown', (e) => {
|
||||||
const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button);
|
const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button);
|
||||||
// With the crop (or lasso) tool, an empty-area press draws a rect/path
|
// With a selection or crop tool, an empty-area press draws a rect/path
|
||||||
// rather than panning — so the crop can be pulled out past the image.
|
// rather than panning — so the crop can be pulled out past the image.
|
||||||
// Space-held / middle-mouse (directPan) still pans.
|
// Space-held / middle-mouse (directPan) still pans.
|
||||||
if ((state.tool === 'lasso' || state.tool === 'crop') && !directPan) return;
|
if (canStartOutside() && !directPan) {
|
||||||
|
if (e.target === state.mainCanvas || isUiChrome(e) ||
|
||||||
|
e.target.closest?.('.ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-fx-menu')) return;
|
||||||
|
// Mouse starts through mousedown; pen/touch need their own captured path.
|
||||||
|
if (e.pointerType === 'pen' || e.pointerType === 'touch') {
|
||||||
|
if (outsideDrawPointerId !== null || e.button !== 0) return;
|
||||||
|
outsideDrawPointerId = e.pointerId;
|
||||||
|
suppressMouseUntil = Date.now() + 500;
|
||||||
|
try { canvasArea.setPointerCapture(e.pointerId); } catch {}
|
||||||
|
beginDraw(e);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.target.closest('button, input, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return;
|
if (e.target.closest('button, input, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return;
|
||||||
const onImage = e.target === state.mainCanvas || e.target === state.transformOverlay;
|
const onImage = e.target === state.mainCanvas || e.target === state.transformOverlay;
|
||||||
if (onImage && !directPan) return;
|
if (onImage && !directPan) return;
|
||||||
@@ -357,6 +373,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
});
|
});
|
||||||
canvasArea.addEventListener('pointermove', (e) => {
|
canvasArea.addEventListener('pointermove', (e) => {
|
||||||
|
if (outsideDrawPointerId !== null && e.pointerId === outsideDrawPointerId) {
|
||||||
|
suppressMouseUntil = Date.now() + 500;
|
||||||
|
if (!isUiChrome(e)) continueDraw(e);
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (transformPointerId !== null && e.pointerId === transformPointerId) {
|
if (transformPointerId !== null && e.pointerId === transformPointerId) {
|
||||||
continueDraw(e);
|
continueDraw(e);
|
||||||
return;
|
return;
|
||||||
@@ -370,6 +392,14 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
|||||||
applyOffset(next.x, next.y);
|
applyOffset(next.x, next.y);
|
||||||
});
|
});
|
||||||
const endPan = (e) => {
|
const endPan = (e) => {
|
||||||
|
if (outsideDrawPointerId !== null && (!e || e.pointerId === outsideDrawPointerId)) {
|
||||||
|
const capturedId = outsideDrawPointerId;
|
||||||
|
outsideDrawPointerId = null;
|
||||||
|
suppressMouseUntil = Date.now() + 500;
|
||||||
|
if (e?.type === 'pointercancel') cancelDraw?.();
|
||||||
|
else if (e) endDraw(e);
|
||||||
|
try { canvasArea.releasePointerCapture(capturedId); } catch {}
|
||||||
|
}
|
||||||
if (transformPointerId !== null && (!e || e.pointerId === transformPointerId)) {
|
if (transformPointerId !== null && (!e || e.pointerId === transformPointerId)) {
|
||||||
const capturedId = transformPointerId;
|
const capturedId = transformPointerId;
|
||||||
transformPointerId = null;
|
transformPointerId = null;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const EDITOR_AGENT_VERSION = '1.0';
|
|||||||
export const DEFAULT_AGENT_ENDPOINT = '/api/editor/agent';
|
export const DEFAULT_AGENT_ENDPOINT = '/api/editor/agent';
|
||||||
|
|
||||||
import { formatCropRect, isGenericIntent } from './nl-edit.js';
|
import { formatCropRect, isGenericIntent } from './nl-edit.js';
|
||||||
|
import { waitForImagePreferences } from '../settings/imagePreferences.js';
|
||||||
|
|
||||||
export function createEditorAgent({
|
export function createEditorAgent({
|
||||||
control,
|
control,
|
||||||
@@ -28,6 +29,7 @@ export function createEditorAgent({
|
|||||||
fetchImpl = null,
|
fetchImpl = null,
|
||||||
getSession = null,
|
getSession = null,
|
||||||
getModel = null,
|
getModel = null,
|
||||||
|
getGenerationModel = null,
|
||||||
getAgentModel = null,
|
getAgentModel = null,
|
||||||
onStep = null,
|
onStep = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
@@ -145,6 +147,7 @@ export function createEditorAgent({
|
|||||||
activeStep = report;
|
activeStep = report;
|
||||||
executed = [];
|
executed = [];
|
||||||
running = true;
|
running = true;
|
||||||
|
await waitForImagePreferences();
|
||||||
|
|
||||||
// Honest before-image (captured before any client pre-step) for the
|
// Honest before-image (captured before any client pre-step) for the
|
||||||
// imperceptible-edit check, then the history mark scoping the undo group.
|
// imperceptible-edit check, then the history mark scoping the undo group.
|
||||||
@@ -194,6 +197,8 @@ export function createEditorAgent({
|
|||||||
signal: signal || undefined,
|
signal: signal || undefined,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
prompt, session, editor_session: session, state, screenshot,
|
prompt, session, editor_session: session, state, screenshot,
|
||||||
|
_use_image_settings: true,
|
||||||
|
generation_model: getGenerationModel ? getGenerationModel() : '',
|
||||||
model: (modelInfo && modelInfo.model) || '',
|
model: (modelInfo && modelInfo.model) || '',
|
||||||
endpoint: (modelInfo && modelInfo.endpoint) || '',
|
endpoint: (modelInfo && modelInfo.endpoint) || '',
|
||||||
model_label: (modelInfo && modelInfo.label) || '',
|
model_label: (modelInfo && modelInfo.label) || '',
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ function renderEffects(source, effects, masks) {
|
|||||||
const contribution = maskedContribution(next, masks[index]);
|
const contribution = maskedContribution(next, masks[index]);
|
||||||
const blended = copyCanvas(current);
|
const blended = copyCanvas(current);
|
||||||
const blendCtx = blended.getContext('2d');
|
const blendCtx = blended.getContext('2d');
|
||||||
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over';
|
if (effect.type === 'drop-shadow' && !effect.params.inside) blendCtx.globalCompositeOperation = 'destination-over';
|
||||||
blendCtx.globalAlpha = effect.opacity;
|
blendCtx.globalAlpha = effect.opacity;
|
||||||
blendCtx.drawImage(contribution, 0, 0);
|
blendCtx.drawImage(contribution, 0, 0);
|
||||||
blendCtx.globalAlpha = 1;
|
blendCtx.globalAlpha = 1;
|
||||||
|
|||||||
Vendored
+2
-1
@@ -81,6 +81,7 @@ export function normalizeEffect(effect) {
|
|||||||
params.blendMode = typeof params.blendMode === 'string' ? params.blendMode : defaults.blendMode;
|
params.blendMode = typeof params.blendMode === 'string' ? params.blendMode : defaults.blendMode;
|
||||||
}
|
}
|
||||||
if (type === 'drop-shadow') {
|
if (type === 'drop-shadow') {
|
||||||
|
params.inside = params.inside === true || params.inside === 1;
|
||||||
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
|
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
|
||||||
for (const key of ['opacity', 'blur', 'x', 'y']) params[key] = Number(params[key]) || 0;
|
for (const key of ['opacity', 'blur', 'x', 'y']) params[key] = Number(params[key]) || 0;
|
||||||
params.opacity = Math.max(0, Math.min(1, params.opacity));
|
params.opacity = Math.max(0, Math.min(1, params.opacity));
|
||||||
@@ -291,7 +292,7 @@ export function renderEffects(source, effects = [], shouldContinue = () => true)
|
|||||||
: next;
|
: next;
|
||||||
const blended = copyCanvas(current);
|
const blended = copyCanvas(current);
|
||||||
const blendCtx = blended.getContext('2d');
|
const blendCtx = blended.getContext('2d');
|
||||||
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over';
|
if (effect.type === 'drop-shadow' && !effect.params.inside) blendCtx.globalCompositeOperation = 'destination-over';
|
||||||
blendCtx.globalAlpha = effect.opacity;
|
blendCtx.globalAlpha = effect.opacity;
|
||||||
blendCtx.drawImage(contribution, 0, 0);
|
blendCtx.drawImage(contribution, 0, 0);
|
||||||
blendCtx.globalAlpha = 1;
|
blendCtx.globalAlpha = 1;
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { decodeAIImage } from './ai-operation.js';
|
||||||
|
import { waitForImagePreferences } from '../settings/imagePreferences.js';
|
||||||
|
|
||||||
|
export function createImageGenerationAction({
|
||||||
|
getDocument, placeResult, getSignal = () => null, onProgress = () => {},
|
||||||
|
formatError = info => info.detail || 'Image generation failed',
|
||||||
|
fetchImpl = (...args) => fetch(...args), timeoutMs = 330000,
|
||||||
|
}) {
|
||||||
|
return async function generateImage(params = {}) {
|
||||||
|
const document = getDocument();
|
||||||
|
if (!document.key) throw new Error('Open a document before generating an image');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const parentSignal = getSignal();
|
||||||
|
const cancel = () => controller.abort();
|
||||||
|
if (parentSignal?.aborted) cancel();
|
||||||
|
parentSignal?.addEventListener('abort', cancel, { once: true });
|
||||||
|
let timedOut = false;
|
||||||
|
const deadline = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
|
||||||
|
const started = Date.now();
|
||||||
|
const progress = setInterval(() => onProgress(Math.floor((Date.now() - started) / 1000)), 1000);
|
||||||
|
// A selection can change while the provider works; preserve this request's mask.
|
||||||
|
let mask = null;
|
||||||
|
try {
|
||||||
|
if (params.masked && document.mask) {
|
||||||
|
mask = globalThis.document.createElement('canvas');
|
||||||
|
mask.width = document.mask.width; mask.height = document.mask.height;
|
||||||
|
mask.getContext('2d').drawImage(document.mask, 0, 0);
|
||||||
|
}
|
||||||
|
await waitForImagePreferences();
|
||||||
|
controller.signal.throwIfAborted();
|
||||||
|
let response;
|
||||||
|
try { response = await fetchImpl(window.geGenerateImageEndpoint || '/api/image/generate', {
|
||||||
|
method: 'POST', credentials: 'same-origin', signal: controller.signal,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ _use_image_settings: true, prompt: params.prompt || '',
|
||||||
|
width: Math.max(1, Math.round(Number(params.width) || document.width)),
|
||||||
|
height: Math.max(1, Math.round(Number(params.height) || document.height)) }),
|
||||||
|
}); } catch (error) {
|
||||||
|
if (controller.signal.aborted) throw error;
|
||||||
|
throw new Error(formatError({ network: true, detail: error.message }));
|
||||||
|
}
|
||||||
|
let data;
|
||||||
|
try { data = await response.json(); }
|
||||||
|
catch { throw new Error(response.ok ? 'Image server returned an invalid response' : `Image server failed (${response.status})`); }
|
||||||
|
if (!response.ok || data.error) throw new Error(formatError({ status: response.status,
|
||||||
|
detail: data.detail || data.error || `Image server failed (${response.status})` }));
|
||||||
|
const image = data.image || data.images?.[0];
|
||||||
|
if (!image) throw new Error('Image generation returned no image');
|
||||||
|
const decoded = await decodeAIImage(image, controller.signal);
|
||||||
|
controller.signal.throwIfAborted();
|
||||||
|
const current = getDocument();
|
||||||
|
if (current.key !== document.key || current.width !== document.width || current.height !== document.height) {
|
||||||
|
throw new Error('The document changed during generation. The result was not placed in another document.');
|
||||||
|
}
|
||||||
|
const canvas = globalThis.document.createElement('canvas');
|
||||||
|
canvas.width = decoded.naturalWidth; canvas.height = decoded.naturalHeight;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
context.imageSmoothingEnabled = true;
|
||||||
|
context.imageSmoothingQuality = 'high';
|
||||||
|
context.drawImage(decoded, 0, 0);
|
||||||
|
return placeResult(canvas, 'Generated', mask);
|
||||||
|
} catch (error) {
|
||||||
|
if (timedOut) throw new Error('Image generation timed out. Try again or choose a faster image model.');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(deadline);
|
||||||
|
clearInterval(progress);
|
||||||
|
parentSignal?.removeEventListener('abort', cancel);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function imageResultGeometry(width, height, canvasWidth, canvasHeight) {
|
||||||
|
const matchingRatio = Math.abs(width * canvasHeight - height * canvasWidth) <= Math.max(canvasWidth, canvasHeight);
|
||||||
|
if (matchingRatio) {
|
||||||
|
const scale = Math.max(canvasWidth / width, canvasHeight / height);
|
||||||
|
const drawWidth = width * scale, drawHeight = height * scale;
|
||||||
|
return { width: canvasWidth, height: canvasHeight, offsetX: 0, offsetY: 0,
|
||||||
|
drawX: (canvasWidth - drawWidth) / 2, drawY: (canvasHeight - drawHeight) / 2, drawWidth, drawHeight };
|
||||||
|
}
|
||||||
|
return { width, height, offsetX: Math.round((canvasWidth - width) / 2), offsetY: Math.round((canvasHeight - height) / 2),
|
||||||
|
drawX: 0, drawY: 0, drawWidth: width, drawHeight: height };
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ export function wireKeyboardShortcuts(deps) {
|
|||||||
toolbar, toolKeyMap,
|
toolbar, toolKeyMap,
|
||||||
composite, saveState, undo, redo,
|
composite, saveState, undo, redo,
|
||||||
toggleShortcuts, confirmTransform, cancelTransform, startTransform, nudgeTransform,
|
toggleShortcuts, confirmTransform, cancelTransform, startTransform, nudgeTransform,
|
||||||
resizeCustomPrompt, addEmptyLayer, brushSizeSync,
|
resizeCustomPrompt, addEmptyLayer, brushSizeSync, openLayerAdjustment,
|
||||||
invertSelection,
|
invertSelection,
|
||||||
wandDeleteSelection, wandCopyToNewLayer, copyPixelsToClipboard,
|
wandDeleteSelection, wandCopyToNewLayer, copyPixelsToClipboard,
|
||||||
lassoDeleteSelection, lassoCopyToLayer,
|
lassoDeleteSelection, lassoCopyToLayer,
|
||||||
@@ -181,6 +181,12 @@ export function wireKeyboardShortcuts(deps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Save shortcuts — match the hints shown in the Save dropdown.
|
// Save shortcuts — match the hints shown in the Save dropdown.
|
||||||
|
if (e.shiftKey && !e.altKey && e.code === 'KeyU') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openLayerAdjustment?.('hue-saturation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ((e.key === 's' || e.key === 'S') && !e.altKey) {
|
if ((e.key === 's' || e.key === 'S') && !e.altKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
document.getElementById(e.shiftKey ? 'ge-export-gallery' : 'ge-save')?.click();
|
document.getElementById(e.shiftKey ? 'ge-export-gallery' : 'ge-save')?.click();
|
||||||
@@ -221,11 +227,12 @@ export function wireKeyboardShortcuts(deps) {
|
|||||||
addEmptyLayer();
|
addEmptyLayer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Ctrl+Alt+L — Levels adjustment. The Image menu owns the row and its
|
// Ctrl+Alt+L edits the current layer, leaving Levels controls open.
|
||||||
// dialog, so click it instead of duplicating the adjustment here.
|
|
||||||
if (e.altKey && e.code === 'KeyL') {
|
if (e.altKey && e.code === 'KeyL') {
|
||||||
const row = document.querySelector('[data-image-action="adj-levels"]');
|
e.preventDefault();
|
||||||
if (row) { e.preventDefault(); e.stopPropagation(); row.click(); return; }
|
e.stopPropagation();
|
||||||
|
openLayerAdjustment?.('levels');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// Ctrl/Cmd+J duplicates the active layer through the layer panel's
|
// Ctrl/Cmd+J duplicates the active layer through the layer panel's
|
||||||
// existing implementation, which preserves masks and effects.
|
// existing implementation, which preserves masks and effects.
|
||||||
|
|||||||
@@ -2233,6 +2233,7 @@ export function createLayerPanelRenderer(deps) {
|
|||||||
if (dragSortModule) {
|
if (dragSortModule) {
|
||||||
dragSortModule.enable('ge-layers-list', '.ge-layer-item', {
|
dragSortModule.enable('ge-layers-list', '.ge-layer-item', {
|
||||||
instanceKey: 'ge-layers',
|
instanceKey: 'ge-layers',
|
||||||
|
excludeSelector: '.ge-adj-sub-item',
|
||||||
threshold: 4,
|
threshold: 4,
|
||||||
ignoreSelector: 'button, input, select, textarea, a, [contenteditable="true"], .ge-adj-sub-item',
|
ignoreSelector: 'button, input, select, textarea, a, [contenteditable="true"], .ge-adj-sub-item',
|
||||||
onReorder: (orderedItems) => {
|
onReorder: (orderedItems) => {
|
||||||
@@ -2258,6 +2259,9 @@ export function createLayerPanelRenderer(deps) {
|
|||||||
state.layers = newLayers;
|
state.layers = newLayers;
|
||||||
normalizeLayerClipping(state);
|
normalizeLayerClipping(state);
|
||||||
composite();
|
composite();
|
||||||
|
// Effects, adjustments and masks are sibling rows, not children
|
||||||
|
// of the dragged row. Rebuild them under their reordered owner.
|
||||||
|
render();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const size = () => number('size', 'Size', 8, 0, 100);
|
|||||||
const angle = () => number('angle', 'Angle', 120, -180, 180, 'deg');
|
const angle = () => number('angle', 'Angle', 120, -180, 180, 'deg');
|
||||||
export const LAYER_STYLES = {
|
export const LAYER_STYLES = {
|
||||||
'bevel-emboss': { label: 'Bevel & Emboss', controls: [size(), angle(), number('depth', 'Depth', 100, 0, 300, '%'), color('highlight', 'Highlight', '#ffffff'), color('color', 'Shadow', '#000000'), opacity()] },
|
'bevel-emboss': { label: 'Bevel & Emboss', controls: [size(), angle(), number('depth', 'Depth', 100, 0, 300, '%'), color('highlight', 'Highlight', '#ffffff'), color('color', 'Shadow', '#000000'), opacity()] },
|
||||||
'inner-shadow': { label: 'Inner Shadow', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 6, 0, 100)] },
|
'inner-shadow': { label: 'Inner Shadow', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 6, 0, 300)] },
|
||||||
'inner-glow': { label: 'Inner Glow', controls: [color('color', 'Color', '#ffffff'), opacity(), size()] },
|
'inner-glow': { label: 'Inner Glow', controls: [color('color', 'Color', '#ffffff'), opacity(), size()] },
|
||||||
satin: { label: 'Satin', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 12, 0, 100)] },
|
satin: { label: 'Satin', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 12, 0, 100)] },
|
||||||
'gradient-overlay': { label: 'Gradient Overlay', controls: [color('color', 'Start', '#e06c75'), color('endColor', 'End', '#ffffff'), opacity(), angle(), number('scale', 'Scale', 100, 10, 300, '%')] },
|
'gradient-overlay': { label: 'Gradient Overlay', controls: [color('color', 'Start', '#e06c75'), color('endColor', 'End', '#ffffff'), opacity(), angle(), number('scale', 'Scale', 100, 10, 300, '%')] },
|
||||||
@@ -32,6 +32,13 @@ function surface(width, height) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderDropShadow(source, params) {
|
export function renderDropShadow(source, params) {
|
||||||
|
if (params.inside) {
|
||||||
|
return renderLayerStyle(source, 'inner-shadow', {
|
||||||
|
color: params.color, opacity: params.opacity * 100, size: params.blur / 2,
|
||||||
|
distance: Math.hypot(params.x, params.y),
|
||||||
|
angle: Math.atan2(-params.y, params.x) * 180 / Math.PI,
|
||||||
|
});
|
||||||
|
}
|
||||||
// Tint alpha first so RGB content cannot leak into the shadow. Draw only
|
// Tint alpha first so RGB content cannot leak into the shadow. Draw only
|
||||||
// this silhouette; compositing the source again thickens translucent edges.
|
// this silhouette; compositing the source again thickens translucent edges.
|
||||||
const silhouette = surface(source.width, source.height);
|
const silhouette = surface(source.width, source.height);
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export function finishQuickEditJob(job, result) {
|
||||||
|
if (job.cancelled || result?.cancelled) {
|
||||||
|
job.cancelled = true;
|
||||||
|
job.status = 'cancelled';
|
||||||
|
job.detail = 'Cancelled';
|
||||||
|
} else if (job.status === 'error' || result?.ok === false) {
|
||||||
|
job.status = 'error';
|
||||||
|
job.detail = result?.error || result?.message || (job.detail !== 'Working…' && job.detail) || 'Edit failed';
|
||||||
|
} else {
|
||||||
|
job.status = 'done';
|
||||||
|
job.detail = result?.message || 'Done';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,19 +14,26 @@ function toolSettings(tool) {
|
|||||||
|
|
||||||
let _lastColor = null;
|
let _lastColor = null;
|
||||||
let _lastTransparentColor = null;
|
let _lastTransparentColor = null;
|
||||||
function transparentColor(color) {
|
function transparentColor(color, alpha = 0) {
|
||||||
if (color === _lastColor && _lastTransparentColor) return _lastTransparentColor;
|
const key = `${color}|${alpha}`;
|
||||||
_lastColor = color;
|
if (key === _lastColor && _lastTransparentColor) return _lastTransparentColor;
|
||||||
|
_lastColor = key;
|
||||||
|
_lastTransparentColor = colorWithAlpha(color, alpha);
|
||||||
|
return _lastTransparentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorWithAlpha(color, alpha) {
|
||||||
if (color.startsWith('#')) {
|
if (color.startsWith('#')) {
|
||||||
const raw = color.slice(1);
|
const raw = color.slice(1);
|
||||||
const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw;
|
const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw;
|
||||||
const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16));
|
const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16));
|
||||||
_lastTransparentColor = `rgba(${rgb.join(',')},0)`;
|
return `rgba(${rgb.join(',')},${alpha})`;
|
||||||
return _lastTransparentColor;
|
|
||||||
}
|
}
|
||||||
const match = color.match(/rgba?\(([^)]+)\)/);
|
const match = color.match(/rgba?\(([^)]+)\)/);
|
||||||
_lastTransparentColor = match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
|
if (!match) return `rgba(0,0,0,${alpha})`;
|
||||||
return _lastTransparentColor;
|
const components = match[1].split(',');
|
||||||
|
const originalAlpha = components.length > 3 ? Number(components[3]) : 1;
|
||||||
|
return `rgba(${components.slice(0, 3).join(',')},${alpha * originalAlpha})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
|
function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
|
||||||
@@ -38,8 +45,19 @@ function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
|
|||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
} else {
|
} else {
|
||||||
const gradient = ctx.createRadialGradient(x, y, radius * hard, x, y, radius);
|
const gradient = ctx.createRadialGradient(x, y, radius * hard, x, y, radius);
|
||||||
gradient.addColorStop(0, color);
|
if (softness > 1) {
|
||||||
gradient.addColorStop(1, transparentColor(color));
|
// 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));
|
||||||
|
}
|
||||||
ctx.fillStyle = gradient;
|
ctx.fillStyle = gradient;
|
||||||
}
|
}
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -274,6 +292,10 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
|
|||||||
if (!PAINT_TOOLS.has(tool)) return;
|
if (!PAINT_TOOLS.has(tool)) return;
|
||||||
const layer = activeLayer();
|
const layer = activeLayer();
|
||||||
if (!layer) return;
|
if (!layer) return;
|
||||||
|
// Pixel writes invalidate adjusted previews even when adjustment settings
|
||||||
|
// themselves have not changed (otherwise retouch strokes appear late).
|
||||||
|
layer._adjFinalKey = null;
|
||||||
|
layer._adjCacheKey = null;
|
||||||
if (tool === 'clone') return cloneStamp(sample, layer);
|
if (tool === 'clone') return cloneStamp(sample, layer);
|
||||||
if (tool === 'heal') {
|
if (tool === 'heal') {
|
||||||
// A healing stroke without a picked source uses the convenient local
|
// A healing stroke without a picked source uses the convenient local
|
||||||
@@ -294,7 +316,7 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
|
|||||||
let operation = 'source-over';
|
let operation = 'source-over';
|
||||||
let color = state.color;
|
let color = state.color;
|
||||||
let alpha = opacity * flow;
|
let alpha = opacity * flow;
|
||||||
let softness = Math.max(0, Math.min(1, settings.softness / 300));
|
let softness = Math.max(0, Math.min(4, settings.softness / 300));
|
||||||
if (tool === 'eraser') { operation = 'destination-out'; color = 'rgba(0,0,0,1)'; }
|
if (tool === 'eraser') { operation = 'destination-out'; color = 'rgba(0,0,0,1)'; }
|
||||||
else if (tool === 'inpaint') {
|
else if (tool === 'inpaint') {
|
||||||
operation = state.inpaintEraseStroke ? 'destination-out' : 'source-over';
|
operation = state.inpaintEraseStroke ? 'destination-out' : 'source-over';
|
||||||
|
|||||||
@@ -57,8 +57,11 @@ function wireToolSliders(prefix, fields) {
|
|||||||
// softness rises (the CSS already sets the radial gradient — we
|
// softness rises (the CSS already sets the radial gradient — we
|
||||||
// just tween the inner solid radius to communicate the falloff).
|
// just tween the inner solid radius to communicate the falloff).
|
||||||
if (softPrev) {
|
if (softPrev) {
|
||||||
const innerStop = Math.max(0, 60 - state[fields.softness] * 0.55);
|
const amount = state[fields.softness];
|
||||||
softPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`;
|
const innerStop = Math.max(0, 60 - amount * 0.55);
|
||||||
|
softPrev.style.background = amount > 300
|
||||||
|
? `radial-gradient(circle, var(--fg), color-mix(in srgb, var(--fg) ${Math.round(100 * 0.5 ** (amount / 300))}%, transparent) 50%, transparent 100%)`
|
||||||
|
: `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,23 @@ export function wireSubmenuToggles(menu) {
|
|||||||
menu.classList.remove('ge-flyout-open');
|
menu.classList.remove('ge-flyout-open');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
function positionPanel(parent) {
|
||||||
|
const panel = parent.nextElementSibling;
|
||||||
|
if (!panel?.classList.contains('ge-submenu')) return;
|
||||||
|
const row = parent.getBoundingClientRect();
|
||||||
|
const origin = (panel.offsetParent || menu).getBoundingClientRect();
|
||||||
|
const top = Math.max(8, Math.min(row.top, window.innerHeight - panel.offsetHeight - 8));
|
||||||
|
const left = row.right + panel.offsetWidth > window.innerWidth - 8
|
||||||
|
? Math.max(8, row.left - panel.offsetWidth) : row.right - 2;
|
||||||
|
panel.style.top = `${top - origin.top + (panel.offsetParent?.scrollTop || 0)}px`;
|
||||||
|
panel.style.left = `${left - origin.left + (panel.offsetParent?.scrollLeft || 0)}px`;
|
||||||
|
}
|
||||||
|
for (const eventName of ['pointerover', 'focusin']) {
|
||||||
|
menu.addEventListener(eventName, (event) => {
|
||||||
|
const parent = event.target.closest('.ge-menu-parent');
|
||||||
|
if (parent) positionPanel(parent);
|
||||||
|
});
|
||||||
|
}
|
||||||
menu.addEventListener('click', (e) => {
|
menu.addEventListener('click', (e) => {
|
||||||
const parent = e.target.closest('.ge-menu-parent');
|
const parent = e.target.closest('.ge-menu-parent');
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
@@ -34,10 +51,7 @@ export function wireSubmenuToggles(menu) {
|
|||||||
parent.setAttribute('aria-expanded', String(willOpen));
|
parent.setAttribute('aria-expanded', String(willOpen));
|
||||||
if (willOpen) {
|
if (willOpen) {
|
||||||
// Align the flyout with the clicked row rather than the menu top.
|
// Align the flyout with the clicked row rather than the menu top.
|
||||||
const panel = parent.nextElementSibling;
|
positionPanel(parent);
|
||||||
if (panel && panel.classList.contains('ge-submenu')) {
|
|
||||||
panel.style.top = parent.offsetTop + 'px';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
* }} deps
|
* }} deps
|
||||||
*/
|
*/
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
|
import { topPortalZ } from '../toolWindowZOrder.js';
|
||||||
import { isLayerPixelLocked, isLayerTransparencyLocked } from './layer-groups.js';
|
import { isLayerPixelLocked, isLayerTransparencyLocked } from './layer-groups.js';
|
||||||
|
|
||||||
const TOPBAR_MENU_IDS = ['ge-file-menu', 'ge-edit-menu', 'ge-view-menu', 'ge-image-menu', 'ge-layer-menu', 'ge-type-menu', 'ge-selection-menu', 'ge-filter-menu', 'ge-window-menu', 'ge-help-menu', 'ge-resize-menu', 'ge-save-menu'];
|
const TOPBAR_MENU_IDS = ['ge-file-menu', 'ge-edit-menu', 'ge-view-menu', 'ge-image-menu', 'ge-layer-menu', 'ge-type-menu', 'ge-selection-menu', 'ge-filter-menu', 'ge-window-menu', 'ge-help-menu', 'ge-resize-menu', 'ge-save-menu'];
|
||||||
@@ -101,6 +102,7 @@ export function wireTopbar(deps) {
|
|||||||
};
|
};
|
||||||
const positionSaveMenu = () => {
|
const positionSaveMenu = () => {
|
||||||
const r = saveBtn.getBoundingClientRect();
|
const r = saveBtn.getBoundingClientRect();
|
||||||
|
saveMenu.style.zIndex = String(topPortalZ());
|
||||||
saveMenu.style.top = `${r.bottom + 2}px`;
|
saveMenu.style.top = `${r.bottom + 2}px`;
|
||||||
saveMenu.style.right = `${Math.max(8, window.innerWidth - r.right)}px`;
|
saveMenu.style.right = `${Math.max(8, window.innerWidth - r.right)}px`;
|
||||||
saveMenu.style.left = 'auto';
|
saveMenu.style.left = 'auto';
|
||||||
|
|||||||
@@ -1321,7 +1321,12 @@ function _renderEditorLanding() {
|
|||||||
// openEditor() now returns a Promise — it's async because the size
|
// openEditor() now returns a Promise — it's async because the size
|
||||||
// prompt is a styled modal. Await it before checking whether the
|
// prompt is a styled modal. Await it before checking whether the
|
||||||
// editor actually opened (the user may have cancelled).
|
// editor actually opened (the user may have cancelled).
|
||||||
await openEditor(null, null, null, 'New canvas');
|
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);
|
||||||
|
}
|
||||||
if (!document.querySelector('#gallery-editor-container .gallery-editor')) {
|
if (!document.querySelector('#gallery-editor-container .gallery-editor')) {
|
||||||
_renderEditorLanding();
|
_renderEditorLanding();
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-57
@@ -277,8 +277,11 @@ import { alphaBounds as _maskAlphaBounds, featherMaskCanvas as _featherMaskCanva
|
|||||||
import { analyzeAutoTone as _analyzeAutoTone, applyAutoTone as _applyAutoTone, boxBlurRGB as _boxBlurRGB, skinMaskFromData as _skinMaskFromData, frequencySeparationSmooth as _frequencySeparationSmooth, blemishRemoval as _blemishRemoval, teethWhitening as _teethWhitening, featureExclusionMask as _featureExclusionMask, dilateAlpha as _dilateAlpha, subtractMask as _subtractMask, guidedClarity as _guidedClarity } from './editor/retouch.js';
|
import { analyzeAutoTone as _analyzeAutoTone, applyAutoTone as _applyAutoTone, boxBlurRGB as _boxBlurRGB, skinMaskFromData as _skinMaskFromData, frequencySeparationSmooth as _frequencySeparationSmooth, blemishRemoval as _blemishRemoval, teethWhitening as _teethWhitening, featureExclusionMask as _featureExclusionMask, dilateAlpha as _dilateAlpha, subtractMask as _subtractMask, guidedClarity as _guidedClarity } from './editor/retouch.js';
|
||||||
import { wireClipboardAndDrop } from './editor/clipboard-and-drop.js';
|
import { wireClipboardAndDrop } from './editor/clipboard-and-drop.js';
|
||||||
import { wireAIModelSelectors, resolveInpaintModel, resolveQuickEditModel, resolveQuickEditAgentModel } from './editor/ai-models.js';
|
import { wireAIModelSelectors, resolveInpaintModel, resolveQuickEditModel, resolveQuickEditAgentModel } from './editor/ai-models.js';
|
||||||
import { createPreflight } from './editor/preflight.js';
|
|
||||||
import { createAiSettings } from './editor/ai-settings.js';
|
import { createAiSettings } from './editor/ai-settings.js';
|
||||||
|
import { waitForImagePreferences } from './settings/imagePreferences.js';
|
||||||
|
import { createImageGenerationAction } from './editor/image-generation.js';
|
||||||
|
import { finishQuickEditJob } from './editor/quick-edit-job.js';
|
||||||
|
import { imageResultGeometry } from './editor/image-result-geometry.js';
|
||||||
import { lookRecipeSteps } from './editor/look-recipes.js';
|
import { lookRecipeSteps } from './editor/look-recipes.js';
|
||||||
import { wireInpaintButtons } from './editor/ai-inpaint.js?v=20260708match1';
|
import { wireInpaintButtons } from './editor/ai-inpaint.js?v=20260708match1';
|
||||||
import { wireAIToolsMisc } from './editor/ai-tools-misc.js';
|
import { wireAIToolsMisc } from './editor/ai-tools-misc.js';
|
||||||
@@ -356,7 +359,6 @@ function _computeTransformSnap(frame, center) {
|
|||||||
const _INPAINT_DEFAULT_BRUSH = 100;
|
const _INPAINT_DEFAULT_BRUSH = 100;
|
||||||
let _samAbortController = null;
|
let _samAbortController = null;
|
||||||
let _precisionGuides = null;
|
let _precisionGuides = null;
|
||||||
let _preflight = null;
|
|
||||||
let _aiSettings = null;
|
let _aiSettings = null;
|
||||||
let _selectionAnimationFrame = null;
|
let _selectionAnimationFrame = null;
|
||||||
let _selectionAnimationLast = 0;
|
let _selectionAnimationLast = 0;
|
||||||
@@ -394,6 +396,12 @@ if (!window.__galleryEditEscHardGuardInstalled) {
|
|||||||
}
|
}
|
||||||
if (e.target?.closest?.('#styled-confirm-overlay')) return;
|
if (e.target?.closest?.('#styled-confirm-overlay')) return;
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
const aiSettings = document.querySelector('.ge-ai-settings:not([hidden])');
|
||||||
|
if (aiSettings) {
|
||||||
|
e.preventDefault(); e.stopImmediatePropagation();
|
||||||
|
aiSettings.querySelector('[data-ai-settings-close]')?.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Floating dialogs/popups that live outside the editor modal must close on
|
// Floating dialogs/popups that live outside the editor modal must close on
|
||||||
// Escape reliably (their document-capture handlers can miss when focus is
|
// Escape reliably (their document-capture handlers can miss when focus is
|
||||||
// outside them). Close the top-most one and stop here.
|
// outside them). Close the top-most one and stop here.
|
||||||
@@ -838,6 +846,7 @@ function _setCanvasAiBusy(busy) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _aiProgressTimer = null;
|
let _aiProgressTimer = null;
|
||||||
|
let _aiProgressEpoch = 0;
|
||||||
function _setAiProgress(frac, label) {
|
function _setAiProgress(frac, label) {
|
||||||
const wrap = document.getElementById('ge-ai-command-progress');
|
const wrap = document.getElementById('ge-ai-command-progress');
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
@@ -857,12 +866,17 @@ function _startAiProgress(requestId) {
|
|||||||
const endpointId = arguments[1] || '';
|
const endpointId = arguments[1] || '';
|
||||||
_stopAiProgress();
|
_stopAiProgress();
|
||||||
if (!requestId) return;
|
if (!requestId) return;
|
||||||
|
const epoch = _aiProgressEpoch;
|
||||||
|
let pending = false;
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
|
if (pending || epoch !== _aiProgressEpoch) return;
|
||||||
|
pending = true;
|
||||||
try {
|
try {
|
||||||
const epQuery = endpointId ? `&endpoint_id=${encodeURIComponent(endpointId)}` : '';
|
const epQuery = endpointId ? `&endpoint_id=${encodeURIComponent(endpointId)}` : '';
|
||||||
const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}` + epQuery, { credentials: 'same-origin' });
|
const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}` + epQuery, { credentials: 'same-origin' });
|
||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
const p = await r.json();
|
const p = await r.json();
|
||||||
|
if (epoch !== _aiProgressEpoch) return;
|
||||||
const st = p.stage || '';
|
const st = p.stage || '';
|
||||||
if (st === 'done' || st === 'error') { _setAiProgress(1, st === 'done' ? 'Done' : 'Failed'); return; }
|
if (st === 'done' || st === 'error') { _setAiProgress(1, st === 'done' ? 'Done' : 'Failed'); return; }
|
||||||
const frac = typeof p.progress === 'number' ? p.progress : 0;
|
const frac = typeof p.progress === 'number' ? p.progress : 0;
|
||||||
@@ -871,12 +885,13 @@ function _startAiProgress(requestId) {
|
|||||||
const verb = st === 'editing' ? 'Generating' : (st ? st[0].toUpperCase() + st.slice(1) : 'Working');
|
const verb = st === 'editing' ? 'Generating' : (st ? st[0].toUpperCase() + st.slice(1) : 'Working');
|
||||||
_setAiProgress(frac, `${verb}…${stepTxt}${eta}`);
|
_setAiProgress(frac, `${verb}…${stepTxt}${eta}`);
|
||||||
_setAiCommandStatus(`${verb}…${stepTxt}${eta}`, 'running');
|
_setAiCommandStatus(`${verb}…${stepTxt}${eta}`, 'running');
|
||||||
} catch (_) {}
|
} catch (_) {} finally { pending = false; }
|
||||||
};
|
};
|
||||||
_aiProgressTimer = setInterval(tick, 1000);
|
_aiProgressTimer = setInterval(tick, 1000);
|
||||||
tick();
|
tick();
|
||||||
}
|
}
|
||||||
function _stopAiProgress() {
|
function _stopAiProgress() {
|
||||||
|
_aiProgressEpoch++;
|
||||||
if (_aiProgressTimer) { clearInterval(_aiProgressTimer); _aiProgressTimer = null; }
|
if (_aiProgressTimer) { clearInterval(_aiProgressTimer); _aiProgressTimer = null; }
|
||||||
_setAiProgress(null);
|
_setAiProgress(null);
|
||||||
}
|
}
|
||||||
@@ -1074,6 +1089,7 @@ const _QUICK_EDIT_TOOL_SUGGESTIONS = Object.entries(_TOOLS).map(([id, t]) => ({
|
|||||||
kind: 'Tool',
|
kind: 'Tool',
|
||||||
icon: t.icon || '',
|
icon: t.icon || '',
|
||||||
toolId: id,
|
toolId: id,
|
||||||
|
runPrompt: id === 'rembg' ? 'remove background' : null,
|
||||||
aliases: [id],
|
aliases: [id],
|
||||||
}));
|
}));
|
||||||
const _QUICK_EDIT_CATALOG = [..._QUICK_EDIT_TOOL_SUGGESTIONS, ..._AI_COMMAND_SUGGESTIONS];
|
const _QUICK_EDIT_CATALOG = [..._QUICK_EDIT_TOOL_SUGGESTIONS, ..._AI_COMMAND_SUGGESTIONS];
|
||||||
@@ -1281,8 +1297,13 @@ function _wireAiCommandBox() {
|
|||||||
if (item.toolId) {
|
if (item.toolId) {
|
||||||
_clickToolButton(item.toolId);
|
_clickToolButton(item.toolId);
|
||||||
_setAiCommandStatus(`${item.label} tool`, 'done');
|
_setAiCommandStatus(`${item.label} tool`, 'done');
|
||||||
|
if (item.runPrompt) {
|
||||||
|
input.value = item.runPrompt;
|
||||||
|
syncClearButton();
|
||||||
|
}
|
||||||
hideSuggestions();
|
hideSuggestions();
|
||||||
input.focus();
|
input.focus();
|
||||||
|
if (run && item.runPrompt) form.requestSubmit();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
input.value = item.insert;
|
input.value = item.insert;
|
||||||
@@ -1471,6 +1492,24 @@ function _wireAiCommandBox() {
|
|||||||
// model (window.geEditorAI) just like the server would route it.
|
// model (window.geEditorAI) just like the server would route it.
|
||||||
const _executeQuickEditLocal = async (prompt) => {
|
const _executeQuickEditLocal = async (prompt) => {
|
||||||
const p = prompt.toLowerCase();
|
const p = prompt.toLowerCase();
|
||||||
|
// Background-removal intent (shared with the routing guard so the regex
|
||||||
|
// can never drift between the two call sites).
|
||||||
|
if (_looksBackgroundRemoval(p)) {
|
||||||
|
await _runQuickControlOp('ai.removeBackground', {});
|
||||||
|
// Chain a tone step when the prompt also asks for one
|
||||||
|
// ("remove the background and make it brighter", "...and warm it up").
|
||||||
|
if (/\b(bright(?:er|en)?|lighten|exposure|well[-\s]*lit)\b/.test(p)) {
|
||||||
|
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: 16, contrast: 4 } });
|
||||||
|
} else if (/\b(dark(?:er|en)?|dimmer)\b/.test(p)) {
|
||||||
|
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: -14, contrast: 4 } });
|
||||||
|
} else if (/\b(warm(?:er)?)\b/.test(p)) {
|
||||||
|
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: 18 } });
|
||||||
|
} else if (/\b(cool(?:er)?)\b/.test(p)) {
|
||||||
|
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: -18 } });
|
||||||
|
}
|
||||||
|
_setAiCommandStatus('Background removal finished.', 'done');
|
||||||
|
return;
|
||||||
|
}
|
||||||
// A targeted recolour ("make only the red car blue", "recolour the rock to
|
// A targeted recolour ("make only the red car blue", "recolour the rock to
|
||||||
// teal") needs the model to repaint a specific object — never fake it with a
|
// teal") needs the model to repaint a specific object — never fake it with a
|
||||||
// global colour tweak from the deterministic fast path.
|
// global colour tweak from the deterministic fast path.
|
||||||
@@ -1933,24 +1972,6 @@ function _wireAiCommandBox() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Background-removal intent (shared with the routing guard so the regex
|
|
||||||
// can never drift between the two call sites).
|
|
||||||
if (_looksBackgroundRemoval(p)) {
|
|
||||||
await _runQuickControlOp('ai.removeBackground', {});
|
|
||||||
// Chain a tone step when the prompt also asks for one
|
|
||||||
// ("remove the background and make it brighter", "...and warm it up").
|
|
||||||
if (/\b(bright(?:er|en)?|lighten|exposure|well[-\s]*lit)\b/.test(p)) {
|
|
||||||
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: 16, contrast: 4 } });
|
|
||||||
} else if (/\b(dark(?:er|en)?|dimmer)\b/.test(p)) {
|
|
||||||
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: -14, contrast: 4 } });
|
|
||||||
} else if (/\b(warm(?:er)?)\b/.test(p)) {
|
|
||||||
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: 18 } });
|
|
||||||
} else if (/\b(cool(?:er)?)\b/.test(p)) {
|
|
||||||
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: -18 } });
|
|
||||||
}
|
|
||||||
_setAiCommandStatus('Background removal finished.', 'done');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!/\b(text|title|caption|font|heading|subtitle)\b/.test(p)
|
if (!/\b(text|title|caption|font|heading|subtitle)\b/.test(p)
|
||||||
&& /\b(upscale|higher\s*res|increase\s*resolution|bigger|2x|4x)\b/.test(p)) {
|
&& /\b(upscale|higher\s*res|increase\s*resolution|bigger|2x|4x)\b/.test(p)) {
|
||||||
// Use the real upscale op (Real-ESRGAN, basic-upscale fallback). The
|
// Use the real upscale op (Real-ESRGAN, basic-upscale fallback). The
|
||||||
@@ -2814,8 +2835,8 @@ function _wireAiCommandBox() {
|
|||||||
if (_looksBackgroundRemoval(prompt)) {
|
if (_looksBackgroundRemoval(prompt)) {
|
||||||
_setAiCommandStatus('Removing the background…', 'running');
|
_setAiCommandStatus('Removing the background…', 'running');
|
||||||
try {
|
try {
|
||||||
const done = await _executeQuickEditLocal(prompt);
|
await _executeQuickEditLocal(prompt);
|
||||||
if (done !== false) return { ok: true };
|
return { ok: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (signal && signal.aborted) { _setAiCommandStatus('', ''); return { ok: false, cancelled: true }; }
|
if (signal && signal.aborted) { _setAiCommandStatus('', ''); return { ok: false, cancelled: true }; }
|
||||||
const msg = err && err.message ? err.message : String(err);
|
const msg = err && err.message ? err.message : String(err);
|
||||||
@@ -2841,6 +2862,7 @@ function _wireAiCommandBox() {
|
|||||||
_setAiCommandStatus('Generated an image.', 'done');
|
_setAiCommandStatus('Generated an image.', 'done');
|
||||||
return { ok: true, image: res };
|
return { ok: true, image: res };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (signal?.aborted) return { ok: false, cancelled: true };
|
||||||
const msg = err && err.message ? err.message : String(err);
|
const msg = err && err.message ? err.message : String(err);
|
||||||
_setAiCommandStatus(_isGenOfflineMsg(msg) ? msg : `Could not generate an image: ${msg}`, 'error');
|
_setAiCommandStatus(_isGenOfflineMsg(msg) ? msg : `Could not generate an image: ${msg}`, 'error');
|
||||||
return { ok: false, error: msg };
|
return { ok: false, error: msg };
|
||||||
@@ -3083,19 +3105,22 @@ function _wireAiCommandBox() {
|
|||||||
job.controller = controller;
|
job.controller = controller;
|
||||||
renderJobStack();
|
renderJobStack();
|
||||||
_quickEditActiveJob = job;
|
_quickEditActiveJob = job;
|
||||||
|
let result;
|
||||||
try {
|
try {
|
||||||
await executeQuickEdit(job.prompt, { forceAi: !!job.forceAi, signal: controller ? controller.signal : null });
|
result = await executeQuickEdit(job.prompt, { forceAi: !!job.forceAi, signal: controller ? controller.signal : null });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!job.cancelled) {
|
if (!job.cancelled) {
|
||||||
job.status = 'error';
|
job.status = 'error';
|
||||||
job.detail = err?.message || 'AI edit failed';
|
job.detail = err?.message || 'AI edit failed';
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
_stopAiProgress();
|
||||||
|
finishQuickEditJob(job, result);
|
||||||
|
_setAiCommandStatus(job.cancelled ? '' : job.detail, job.cancelled ? '' : job.status);
|
||||||
if (_quickEditActiveJob === job) _quickEditActiveJob = null;
|
if (_quickEditActiveJob === job) _quickEditActiveJob = null;
|
||||||
job.onChange = null;
|
job.onChange = null;
|
||||||
job.controller = null;
|
job.controller = null;
|
||||||
if (job.cancelled) continue;
|
if (job.cancelled) continue;
|
||||||
if (job.status === 'running') job.status = 'done';
|
|
||||||
renderJobStack();
|
renderJobStack();
|
||||||
setTimeout(() => dismissJob(job), job.status === 'error' ? 3200 : 1600);
|
setTimeout(() => dismissJob(job), job.status === 'error' ? 3200 : 1600);
|
||||||
}
|
}
|
||||||
@@ -5304,9 +5329,14 @@ function _restoreState(snap) {
|
|||||||
// inside _buildEditor, so we touch the DOM here).
|
// inside _buildEditor, so we touch the DOM here).
|
||||||
const sizeLabel = document.getElementById('ge-canvas-size');
|
const sizeLabel = document.getElementById('ge-canvas-size');
|
||||||
if (sizeLabel) sizeLabel.textContent = `${state.imgWidth}×${state.imgHeight} px`;
|
if (sizeLabel) sizeLabel.textContent = `${state.imgWidth}×${state.imgHeight} px`;
|
||||||
|
state.rembgLiveLayer = null;
|
||||||
|
state.rembgLiveSnap = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function undo() {
|
function undo() {
|
||||||
|
// End sampling before restoring pixels; pointer-up must not replay the
|
||||||
|
// unfinished stroke into the restored layer without a new history entry.
|
||||||
|
if (state.drawing) _strokeTool.tryEnd();
|
||||||
if (state.undoStack.length === 0) return;
|
if (state.undoStack.length === 0) return;
|
||||||
const target = state.undoStack.pop();
|
const target = state.undoStack.pop();
|
||||||
const cur = _snapshotState();
|
const cur = _snapshotState();
|
||||||
@@ -6535,6 +6565,11 @@ const _nudgeTransform = _transformSession.nudgeTransform;
|
|||||||
const _confirmTransform = _transformSession.confirmTransform;
|
const _confirmTransform = _transformSession.confirmTransform;
|
||||||
const _cancelTransform = _transformSession.cancelTransform;
|
const _cancelTransform = _transformSession.cancelTransform;
|
||||||
window.__galleryEditorHandleEscape = () => {
|
window.__galleryEditorHandleEscape = () => {
|
||||||
|
const aiSettings = document.querySelector('.ge-ai-settings:not([hidden])');
|
||||||
|
if (aiSettings) {
|
||||||
|
aiSettings.querySelector('[data-ai-settings-close]')?.click();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const sizePrompt = document.getElementById('ge-canvas-size-overlay');
|
const sizePrompt = document.getElementById('ge-canvas-size-overlay');
|
||||||
if (sizePrompt && getComputedStyle(sizePrompt).display !== 'none') {
|
if (sizePrompt && getComputedStyle(sizePrompt).display !== 'none') {
|
||||||
sizePrompt._cancelCanvasSize?.();
|
sizePrompt._cancelCanvasSize?.();
|
||||||
@@ -8064,6 +8099,7 @@ async function _canMutateLayerPixels(layer, action = 'editing pixels') {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!['placed', 'text', 'shape'].includes(layer.kind)) return true;
|
if (!['placed', 'text', 'shape'].includes(layer.kind)) return true;
|
||||||
|
if (action === 'cutting pixels') return _rasterizeLayerNow(layer);
|
||||||
return _confirmRasterizeLayer(layer);
|
return _confirmRasterizeLayer(layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8262,10 +8298,13 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
|
|||||||
// standard slim red-thumb styling instead of the bare browser
|
// standard slim red-thumb styling instead of the bare browser
|
||||||
// default. Value chip on the same line as the label.
|
// default. Value chip on the same line as the label.
|
||||||
const isColor = p.type === 'color';
|
const isColor = p.type === 'color';
|
||||||
|
const isSelect = p.type === 'select';
|
||||||
rows += `
|
rows += `
|
||||||
<div class="ge-filter-row ge-eraser-row">
|
<div class="ge-filter-row ge-eraser-row">
|
||||||
<label>${p.label}${isColor ? '' : ` <span class="ge-filter-row-value" data-val-for="${p.key}">${p.value}${p.suffix || ''}</span>`}</label>
|
<label>${p.label}${isColor || isSelect ? '' : ` <span class="ge-filter-row-value" data-val-for="${p.key}">${p.value}${p.suffix || ''}</span>`}</label>
|
||||||
${isColor
|
${isSelect
|
||||||
|
? `<select data-key="${p.key}" class="ge-ctl-select" aria-label="${p.label}">${p.options.map(option => `<option value="${option.value}"${String(option.value) === String(p.value) ? ' selected' : ''}>${option.label}</option>`).join('')}</select>`
|
||||||
|
: isColor
|
||||||
? `<input type="color" data-key="${p.key}" value="${p.value}" aria-label="${p.label}" />`
|
? `<input type="color" data-key="${p.key}" value="${p.value}" aria-label="${p.label}" />`
|
||||||
: `<input type="range" data-key="${p.key}" min="${p.min}" max="${p.max}" step="${p.step || 1}" value="${p.value}" />`}
|
: `<input type="range" data-key="${p.key}" min="${p.min}" max="${p.max}" step="${p.step || 1}" value="${p.value}" />`}
|
||||||
</div>
|
</div>
|
||||||
@@ -8286,7 +8325,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
|
|||||||
for (const p of params) values[p.key] = p.value;
|
for (const p of params) values[p.key] = p.value;
|
||||||
// Initial preview render.
|
// Initial preview render.
|
||||||
try { onPreview(values); } catch {}
|
try { onPreview(values); } catch {}
|
||||||
overlay.querySelectorAll('input[data-key]').forEach(inp => {
|
overlay.querySelectorAll('input[data-key], select[data-key]').forEach(inp => {
|
||||||
inp.addEventListener('input', (e) => {
|
inp.addEventListener('input', (e) => {
|
||||||
if (!isCurrent()) { cleanup(null); return; }
|
if (!isCurrent()) { cleanup(null); return; }
|
||||||
const k = e.target.dataset.key;
|
const k = e.target.dataset.key;
|
||||||
@@ -8317,7 +8356,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
cleanup(e.target?.dataset.action === 'cancel' ? null : values);
|
cleanup(e.target?.dataset.action === 'cancel' ? null : values);
|
||||||
} else if (e.key === 'Tab') {
|
} else if (e.key === 'Tab') {
|
||||||
const fields = [...overlay.querySelectorAll('input, button')];
|
const fields = [...overlay.querySelectorAll('input, select, button')];
|
||||||
const index = fields.indexOf(document.activeElement);
|
const index = fields.indexOf(document.activeElement);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
fields[(index + (e.shiftKey ? -1 : 1) + fields.length) % fields.length]?.focus();
|
fields[(index + (e.shiftKey ? -1 : 1) + fields.length) % fields.length]?.focus();
|
||||||
@@ -8329,7 +8368,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
|
|||||||
if (!overlay.isConnected || !isCurrent()) cleanup(null);
|
if (!overlay.isConnected || !isCurrent()) cleanup(null);
|
||||||
});
|
});
|
||||||
observer.observe(state.container, { childList: true, subtree: true });
|
observer.observe(state.container, { childList: true, subtree: true });
|
||||||
overlay.querySelector('input, button')?.focus({ preventScroll: true });
|
overlay.querySelector('input, select, button')?.focus({ preventScroll: true });
|
||||||
overlay.querySelector('[data-action="apply"]').addEventListener('click', () => cleanup(values));
|
overlay.querySelector('[data-action="apply"]').addEventListener('click', () => cleanup(values));
|
||||||
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => cleanup(null));
|
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => cleanup(null));
|
||||||
// Click outside the modal (on the dim backdrop) = cancel.
|
// Click outside the modal (on the dim backdrop) = cancel.
|
||||||
@@ -8495,6 +8534,7 @@ function _shadowOffsets(values = {}) {
|
|||||||
return {
|
return {
|
||||||
x: Math.round(Math.cos(radians) * distance),
|
x: Math.round(Math.cos(radians) * distance),
|
||||||
y: Math.round(-Math.sin(radians) * distance),
|
y: Math.round(-Math.sin(radians) * distance),
|
||||||
|
inside: Number(values.inside) === 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8556,6 +8596,7 @@ async function _addRetainedEffect(type, presetName = null) {
|
|||||||
{ key: 'color', label: 'Color', type: 'color', value: '#000000' },
|
{ key: 'color', label: 'Color', type: 'color', value: '#000000' },
|
||||||
{ key: 'opacity', label: 'Opacity', min: 0, max: 100, step: 1, value: 45, suffix: '%' },
|
{ key: 'opacity', label: 'Opacity', min: 0, max: 100, step: 1, value: 45, suffix: '%' },
|
||||||
{ key: 'blur', label: 'Blur', min: 0, max: 100, step: 1, value: 12, suffix: 'px' },
|
{ key: 'blur', label: 'Blur', min: 0, max: 100, step: 1, value: 12, suffix: 'px' },
|
||||||
|
{ key: 'inside', label: 'Position', type: 'select', value: 0, options: [{ value: 0, label: 'Outside' }, { value: 1, label: 'Inside' }] },
|
||||||
{ key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).distance, suffix: 'px' },
|
{ key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).distance, suffix: 'px' },
|
||||||
{ key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).angle, suffix: '\u00b0' },
|
{ key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).angle, suffix: '\u00b0' },
|
||||||
];
|
];
|
||||||
@@ -8769,6 +8810,7 @@ async function _editRetainedEffect(layer, effect) {
|
|||||||
{ key: 'color', label: 'Color', type: 'color', value: p.color || '#000000' },
|
{ key: 'color', label: 'Color', type: 'color', value: p.color || '#000000' },
|
||||||
{ key: 'opacity', label: 'Opacity', min: 0, max: 100, step: 1, value: Math.round((Number(p.opacity) || 0) * 100), suffix: '%' },
|
{ key: 'opacity', label: 'Opacity', min: 0, max: 100, step: 1, value: Math.round((Number(p.opacity) || 0) * 100), suffix: '%' },
|
||||||
{ key: 'blur', label: 'Blur', min: 0, max: 100, step: 1, value: Number(p.blur) || 0, suffix: 'px' },
|
{ key: 'blur', label: 'Blur', min: 0, max: 100, step: 1, value: Number(p.blur) || 0, suffix: 'px' },
|
||||||
|
{ key: 'inside', label: 'Position', type: 'select', value: p.inside ? 1 : 0, options: [{ value: 0, label: 'Outside' }, { value: 1, label: 'Inside' }] },
|
||||||
{ key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance(p).distance, suffix: 'px' },
|
{ key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance(p).distance, suffix: 'px' },
|
||||||
{ key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance(p).angle, suffix: '\u00b0' },
|
{ key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance(p).angle, suffix: '\u00b0' },
|
||||||
];
|
];
|
||||||
@@ -9993,9 +10035,8 @@ function _buildEditor(container) {
|
|||||||
if (colorRow) colorRow.style.display = ['eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'liquify'].includes(toolId) ? 'none' : '';
|
if (colorRow) colorRow.style.display = ['eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'liquify'].includes(toolId) ? 'none' : '';
|
||||||
const colorLabel = colorRow?.querySelector('label');
|
const colorLabel = colorRow?.querySelector('label');
|
||||||
if (colorLabel) colorLabel.textContent = 'Color';
|
if (colorLabel) colorLabel.textContent = 'Color';
|
||||||
// Per-tool stroke modifiers all live in the shared brush "More" menu so
|
// Secondary modifiers stay in More; brush softness remains in the main
|
||||||
// every brush-family tool has the same compact strip as the Brush. Only
|
// strip. Only the active tool's secondary group is shown.
|
||||||
// the group for the active tool is shown; the rest stay hidden.
|
|
||||||
const strokeTool = (toolId === 'brush' || toolId === 'pixel' || toolId === 'dodge' || toolId === 'burn') ? 'brush'
|
const strokeTool = (toolId === 'brush' || toolId === 'pixel' || toolId === 'dodge' || toolId === 'burn') ? 'brush'
|
||||||
: toolId === 'eraser' ? 'eraser'
|
: toolId === 'eraser' ? 'eraser'
|
||||||
: (toolId === 'clone' || toolId === 'heal') ? 'clone'
|
: (toolId === 'clone' || toolId === 'heal') ? 'clone'
|
||||||
@@ -10005,6 +10046,8 @@ function _buildEditor(container) {
|
|||||||
row.style.display = row.dataset.strokeTool === strokeTool ? '' : 'none';
|
row.style.display = row.dataset.strokeTool === strokeTool ? '' : 'none';
|
||||||
});
|
});
|
||||||
const dodgeBurnRow = document.getElementById('ge-dodgeburn-intensity-row');
|
const dodgeBurnRow = document.getElementById('ge-dodgeburn-intensity-row');
|
||||||
|
const brushSoftnessRow = document.getElementById('ge-brush-softness-row');
|
||||||
|
if (brushSoftnessRow) brushSoftnessRow.style.display = ['brush', 'dodge', 'burn'].includes(toolId) ? '' : 'none';
|
||||||
// Intensity sits in the strip next to Size, so it must show for Dodge and
|
// Intensity sits in the strip next to Size, so it must show for Dodge and
|
||||||
// Burn only (and never for the other brush-family tools).
|
// Burn only (and never for the other brush-family tools).
|
||||||
if (dodgeBurnRow) dodgeBurnRow.style.display = (toolId === 'dodge' || toolId === 'burn') ? '' : 'none';
|
if (dodgeBurnRow) dodgeBurnRow.style.display = (toolId === 'dodge' || toolId === 'burn') ? '' : 'none';
|
||||||
@@ -10457,7 +10500,6 @@ function _buildEditor(container) {
|
|||||||
canvasArea.appendChild(state.transformOverlay);
|
canvasArea.appendChild(state.transformOverlay);
|
||||||
canvasArea.appendChild(_buildQuickMaskBar());
|
canvasArea.appendChild(_buildQuickMaskBar());
|
||||||
canvasArea.appendChild(_buildAiCommandBox());
|
canvasArea.appendChild(_buildAiCommandBox());
|
||||||
_preflight = createPreflight({ canvasArea });
|
|
||||||
_aiSettings = createAiSettings({ container });
|
_aiSettings = createAiSettings({ container });
|
||||||
try { window.geEditorAiSettings = _aiSettings; } catch (_) {}
|
try { window.geEditorAiSettings = _aiSettings; } catch (_) {}
|
||||||
// On phones the rulers eat scarce canvas width; default them off.
|
// On phones the rulers eat scarce canvas width; default them off.
|
||||||
@@ -10650,19 +10692,10 @@ function _buildEditor(container) {
|
|||||||
const initialStatusTool = document.getElementById('ge-status-tool');
|
const initialStatusTool = document.getElementById('ge-status-tool');
|
||||||
if (initialStatusTool) initialStatusTool.textContent = _TOOLS[state.tool]?.label || state.tool || '';
|
if (initialStatusTool) initialStatusTool.textContent = _TOOLS[state.tool]?.label || state.tool || '';
|
||||||
}
|
}
|
||||||
// Preflight checklist: greet the user on open (unless dismissed) and
|
// Keep model configuration in the AI settings dialog.
|
||||||
// expose a footer button to re-open it.
|
|
||||||
try {
|
try {
|
||||||
const footer = container.querySelector('.ge-editor-footer');
|
const footer = container.querySelector('.ge-editor-footer');
|
||||||
if (footer) {
|
if (footer) {
|
||||||
const setupBtn = document.createElement('button');
|
|
||||||
setupBtn.type = 'button';
|
|
||||||
setupBtn.id = 'ge-setup-btn';
|
|
||||||
setupBtn.className = 'ge-setup-btn';
|
|
||||||
setupBtn.title = 'Editor setup checklist';
|
|
||||||
setupBtn.textContent = 'Setup';
|
|
||||||
setupBtn.addEventListener('click', () => _preflight?.open());
|
|
||||||
footer.appendChild(setupBtn);
|
|
||||||
const aiBtn = document.createElement('button');
|
const aiBtn = document.createElement('button');
|
||||||
aiBtn.type = 'button';
|
aiBtn.type = 'button';
|
||||||
aiBtn.id = 'ge-ai-settings-btn';
|
aiBtn.id = 'ge-ai-settings-btn';
|
||||||
@@ -10672,7 +10705,6 @@ function _buildEditor(container) {
|
|||||||
aiBtn.addEventListener('click', () => _aiSettings?.open());
|
aiBtn.addEventListener('click', () => _aiSettings?.open());
|
||||||
footer.appendChild(aiBtn);
|
footer.appendChild(aiBtn);
|
||||||
}
|
}
|
||||||
_preflight?.maybeOpen();
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
try { _initControlKit(container); } catch (_) {}
|
try { _initControlKit(container); } catch (_) {}
|
||||||
let _lastLayoutDesktop = null;
|
let _lastLayoutDesktop = null;
|
||||||
@@ -11792,6 +11824,14 @@ function _buildEditor(container) {
|
|||||||
// Keyboard shortcuts — full implementation in
|
// Keyboard shortcuts — full implementation in
|
||||||
// editor/keyboard-shortcuts.js.
|
// editor/keyboard-shortcuts.js.
|
||||||
wireKeyboardShortcuts({
|
wireKeyboardShortcuts({
|
||||||
|
openLayerAdjustment: (type) => {
|
||||||
|
const layer = activeLayer();
|
||||||
|
if (!layer || !layer.canvas || layer.kind === 'adjustment') {
|
||||||
|
uiModule?.showToast('Select an image layer first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_openAdjPopup(layer, type, document.getElementById('ge-image-menu-btn'));
|
||||||
|
},
|
||||||
fillSelection: _doFillSelection,
|
fillSelection: _doFillSelection,
|
||||||
copyPixelsToClipboard: _copyPixelsToClipboard,
|
copyPixelsToClipboard: _copyPixelsToClipboard,
|
||||||
toolbar, toolKeyMap: _toolKeyMap,
|
toolbar, toolKeyMap: _toolKeyMap,
|
||||||
@@ -12156,7 +12196,9 @@ function _buildEditor(container) {
|
|||||||
out.getContext('2d').drawImage(layer.canvas, off.x, off.y);
|
out.getContext('2d').drawImage(layer.canvas, off.x, off.y);
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
const _aiEditCanvas = async (endpoint, payload = {}, { keepNaturalSize = false } = {}) => {
|
const _aiEditCanvas = async (endpoint, payload = {}, { keepNaturalSize = true } = {}) => {
|
||||||
|
await waitForImagePreferences();
|
||||||
|
if (endpoint === '/api/image/inpaint') payload = { ...payload, _use_image_settings: true };
|
||||||
const flat = _aiEditSourceCanvas();
|
const flat = _aiEditSourceCanvas();
|
||||||
const image = flat.toDataURL('image/png').split(',')[1];
|
const image = flat.toDataURL('image/png').split(',')[1];
|
||||||
const requestId = 'qe-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
const requestId = 'qe-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
||||||
@@ -12167,7 +12209,7 @@ function _buildEditor(container) {
|
|||||||
} else {
|
} else {
|
||||||
_startAiProgress(requestId);
|
_startAiProgress(requestId);
|
||||||
}
|
}
|
||||||
const body = JSON.stringify({ image, request_id: requestId, ...payload });
|
const body = JSON.stringify({ image, width: flat.width, height: flat.height, request_id: requestId, ...payload });
|
||||||
// The image servers occasionally reject a request transiently (503 while a
|
// The image servers occasionally reject a request transiently (503 while a
|
||||||
// model swaps, 502/504 gateways, dropped connections). Retry those a couple
|
// model swaps, 502/504 gateways, dropped connections). Retry those a couple
|
||||||
// of times with backoff so a hiccup doesn't silently leave the canvas
|
// of times with backoff so a hiccup doesn't silently leave the canvas
|
||||||
@@ -12225,7 +12267,7 @@ function _buildEditor(container) {
|
|||||||
c.height = state.imgHeight;
|
c.height = state.imgHeight;
|
||||||
}
|
}
|
||||||
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
|
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
|
||||||
return { canvas: c, dataUrl: c.toDataURL('image/png') };
|
return { canvas: c, dataUrl: c.toDataURL('image/png'), fullFrame: data.full_frame === true };
|
||||||
} finally {
|
} finally {
|
||||||
if (_timer) clearTimeout(_timer);
|
if (_timer) clearTimeout(_timer);
|
||||||
if (_aiEditAbort === _ctl) _aiEditAbort = null;
|
if (_aiEditAbort === _ctl) _aiEditAbort = null;
|
||||||
@@ -12234,12 +12276,17 @@ function _buildEditor(container) {
|
|||||||
};
|
};
|
||||||
const _placeResultLayer = (canvas, name, maskCanvas) => {
|
const _placeResultLayer = (canvas, name, maskCanvas) => {
|
||||||
_saveState(`AI: ${name}`);
|
_saveState(`AI: ${name}`);
|
||||||
const layer = createLayer(name, state.imgWidth, state.imgHeight);
|
const geometry = imageResultGeometry(canvas.width, canvas.height, state.imgWidth, state.imgHeight);
|
||||||
layer.ctx.drawImage(canvas, 0, 0);
|
const layer = createLayer(name, geometry.width, geometry.height);
|
||||||
|
const offset = { x: geometry.offsetX, y: geometry.offsetY };
|
||||||
|
state.layerOffsets.set(layer.id, offset);
|
||||||
|
layer.ctx.imageSmoothingEnabled = true;
|
||||||
|
layer.ctx.imageSmoothingQuality = 'high';
|
||||||
|
layer.ctx.drawImage(canvas, geometry.drawX, geometry.drawY, geometry.drawWidth, geometry.drawHeight);
|
||||||
if (maskCanvas) {
|
if (maskCanvas) {
|
||||||
layer.ctx.save();
|
layer.ctx.save();
|
||||||
layer.ctx.globalCompositeOperation = 'destination-in';
|
layer.ctx.globalCompositeOperation = 'destination-in';
|
||||||
layer.ctx.drawImage(maskCanvas, 0, 0);
|
layer.ctx.drawImage(maskCanvas, -offset.x, -offset.y);
|
||||||
layer.ctx.restore();
|
layer.ctx.restore();
|
||||||
}
|
}
|
||||||
state.layers.push(layer);
|
state.layers.push(layer);
|
||||||
@@ -12249,13 +12296,24 @@ function _buildEditor(container) {
|
|||||||
_renderLayerPanel();
|
_renderLayerPanel();
|
||||||
return layer.id;
|
return layer.id;
|
||||||
};
|
};
|
||||||
|
const _generateImage = createImageGenerationAction({
|
||||||
|
formatError: _generationErrorMessage,
|
||||||
|
getDocument: () => ({
|
||||||
|
key: state.editorOpen ? `${state.editorSessionToken}:${state.documentId}` : null,
|
||||||
|
width: state.imgWidth, height: state.imgHeight, mask: state.wandMask,
|
||||||
|
}),
|
||||||
|
getSignal: () => _quickEditActiveJob?.controller?.signal,
|
||||||
|
onProgress: seconds => _setAiCommandStatus(`Generating an image… ${seconds}s`, 'running'),
|
||||||
|
placeResult: _placeResultLayer,
|
||||||
|
});
|
||||||
const _aiInpaintSelection = async (prompt) => {
|
const _aiInpaintSelection = async (prompt) => {
|
||||||
if (!state.wandMask) throw new Error('make a selection first');
|
if (!state.wandMask) throw new Error('make a selection first');
|
||||||
const layer = activeLayer() || _activeParentLayer();
|
const layer = activeLayer() || _activeParentLayer();
|
||||||
if (!layer) throw new Error('no layer');
|
if (!layer) throw new Error('no layer');
|
||||||
const mask = state.wandMask.toDataURL('image/png').split(',')[1];
|
const mask = state.wandMask.toDataURL('image/png').split(',')[1];
|
||||||
const { canvas } = await _aiEditCanvas('/api/image/inpaint', { mask, prompt: prompt || '', steps: 4 });
|
const selection = state.wandMask;
|
||||||
return _placeResultLayer(canvas, 'Generative fill', state.wandMask);
|
const { canvas, fullFrame } = await _aiEditCanvas('/api/image/inpaint', { mask, prompt: prompt || '', steps: 4 });
|
||||||
|
return _placeResultLayer(canvas, 'Generative fill', fullFrame ? null : selection);
|
||||||
};
|
};
|
||||||
// Replace the background/scene while keeping the subject: the server derives
|
// Replace the background/scene while keeping the subject: the server derives
|
||||||
// the subject mask, repaints the inverse (new background), harmonises colour
|
// the subject mask, repaints the inverse (new background), harmonises colour
|
||||||
@@ -12548,7 +12606,7 @@ function _buildEditor(container) {
|
|||||||
'layers.delete': { description: 'Delete layers (defaults to selection).', run: (p) => { if (p && (p.id || p.index || p.name)) _controlSelect(_controlFindLayer(p)); _layerPanelRenderer.deleteSelectedLayers?.(); return true; } },
|
'layers.delete': { description: 'Delete layers (defaults to selection).', run: (p) => { if (p && (p.id || p.index || p.name)) _controlSelect(_controlFindLayer(p)); _layerPanelRenderer.deleteSelectedLayers?.(); return true; } },
|
||||||
'layers.setOpacity': { description: 'Set a layer opacity (0-1).', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState(`Change opacity of ${l.name}`); l.opacity = Math.max(0, Math.min(1, Number(p.opacity))); composite(); _renderLayerPanel(); return l.id; } },
|
'layers.setOpacity': { description: 'Set a layer opacity (0-1).', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState(`Change opacity of ${l.name}`); l.opacity = Math.max(0, Math.min(1, Number(p.opacity))); composite(); _renderLayerPanel(); return l.id; } },
|
||||||
'layers.setBlend': { description: 'Set a layer blend mode.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState(`Change blend mode of ${l.name}`); l.blendMode = p.blendMode || p.mode || 'source-over'; composite(); _renderLayerPanel(); return l.id; } },
|
'layers.setBlend': { description: 'Set a layer blend mode.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState(`Change blend mode of ${l.name}`); l.blendMode = p.blendMode || p.mode || 'source-over'; composite(); _renderLayerPanel(); return l.id; } },
|
||||||
'layers.placeImage': { description: 'Place an image (data URL) as a NEW layer, masked to the current selection when one exists; never overwrites the original. Instruction mode (mode:"instruction" or differenceMask:true) keeps untouched areas pixel-identical via a difference map. {dataUrl,name,masked,blend,opacity,x,y,w,h,mode,differenceMask,diffThreshold,diffGrow,diffFeather}.', run: (p) => new Promise((resolve, reject) => { if (!p || !p.dataUrl) { reject(new Error('dataUrl required')); return; } const img = new Image(); img.onload = () => { try { _saveState(`Place ${p.name || 'image'}`); const dw = state.imgWidth, dh = state.imgHeight; const useDiff = (p.differenceMask === true) || String(p.mode || '').toLowerCase() === 'instruction'; const beforeSnap = useDiff ? flatten() : null; const layer = createLayer(p.name || 'Placed image', dw, dh); let tw = Number(p.w), th = Number(p.h); if (!(tw > 0) || !(th > 0)) { if (useDiff) { tw = dw; th = dh; } else { const scale = Math.min(dw / img.naturalWidth, dh / img.naturalHeight); tw = Math.max(1, Math.round(img.naturalWidth * scale)); th = Math.max(1, Math.round(img.naturalHeight * scale)); } } let tx = Number(p.x), ty = Number(p.y); if (!Number.isFinite(tx)) tx = useDiff ? 0 : Math.round((dw - tw) / 2); if (!Number.isFinite(ty)) ty = useDiff ? 0 : Math.round((dh - th) / 2); layer.ctx.imageSmoothingEnabled = true; layer.ctx.imageSmoothingQuality = 'high'; layer.ctx.drawImage(img, tx, ty, tw, th); if (p.masked !== false && state.wandMask) { layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(state.wandMask, 0, 0); layer.ctx.restore(); } else if (useDiff && beforeSnap) { const diff = _differenceMaskCanvas(beforeSnap, layer.canvas, { threshold: p.diffThreshold, grow: p.diffGrow, feather: p.diffFeather, edgeExcludePx: 0 }); const explicitEdge = p.edgeExcludePx != null ? Number(p.edgeExcludePx) : null; const sceneReplace = diff.changedFraction > 0.5; const edgeExclude = sceneReplace ? 0 : (explicitEdge != null ? explicitEdge : 3); if (edgeExclude > 0) _clearMaskEdgeBand(diff.mask, edgeExclude); layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(diff.mask, 0, 0); layer.ctx.restore(); if (sceneReplace) _edgeFillFromInterior(layer.ctx, dw, dh, 3); } if (p.blend) layer.blendMode = p.blend; if (p.opacity != null) layer.opacity = Math.max(0, Math.min(1, Number(p.opacity))); state.layers.push(layer); state.activeLayerId = layer.id; state.selectedLayerIds = [layer.id]; _renderLayerPanel(); composite(); resolve(layer.id); } catch (e) { reject(e); } }; img.onerror = () => reject(new Error('image load failed')); img.src = p.dataUrl; }) },
|
'layers.placeImage': { description: 'Place an image (data URL) as a NEW layer, masked to the current selection when one exists; never overwrites the original. Instruction mode (mode:"instruction" or differenceMask:true) keeps untouched areas pixel-identical via a difference map. {dataUrl,name,masked,blend,opacity,x,y,w,h,mode,differenceMask,diffThreshold,diffGrow,diffFeather}.', run: (p) => new Promise((resolve, reject) => { if (!p || !p.dataUrl) { reject(new Error('dataUrl required')); return; } const img = new Image(); img.onload = () => { try { _saveState(`Place ${p.name || 'image'}`); const dw = state.imgWidth, dh = state.imgHeight; const useDiff = p.nativeSize !== true && ((p.differenceMask === true) || String(p.mode || '').toLowerCase() === 'instruction'); const beforeSnap = useDiff ? flatten() : null; const nativeSize = p.nativeSize === true && !useDiff; const geometry = imageResultGeometry(img.naturalWidth, img.naturalHeight, dw, dh); const layer = createLayer(p.name || 'Placed image', nativeSize ? geometry.width : dw, nativeSize ? geometry.height : dh); const nativeOffset = { x: geometry.offsetX, y: geometry.offsetY }; if (nativeSize) state.layerOffsets.set(layer.id, nativeOffset); let tw = Number(p.w), th = Number(p.h); if (!(tw > 0) || !(th > 0)) { if (nativeSize) { tw = geometry.drawWidth; th = geometry.drawHeight; } else if (useDiff) { tw = dw; th = dh; } else { const scale = Math.min(dw / img.naturalWidth, dh / img.naturalHeight); tw = Math.max(1, Math.round(img.naturalWidth * scale)); th = Math.max(1, Math.round(img.naturalHeight * scale)); } } let tx = Number(p.x), ty = Number(p.y); if (!Number.isFinite(tx)) tx = nativeSize ? geometry.drawX : useDiff ? 0 : Math.round((dw - tw) / 2); if (!Number.isFinite(ty)) ty = nativeSize ? geometry.drawY : useDiff ? 0 : Math.round((dh - th) / 2); layer.ctx.imageSmoothingEnabled = true; layer.ctx.imageSmoothingQuality = 'high'; layer.ctx.drawImage(img, tx, ty, tw, th); if (p.masked !== false && state.wandMask) { layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(state.wandMask, nativeSize ? -nativeOffset.x : 0, nativeSize ? -nativeOffset.y : 0); layer.ctx.restore(); } else if (useDiff && beforeSnap) { const diff = _differenceMaskCanvas(beforeSnap, layer.canvas, { threshold: p.diffThreshold, grow: p.diffGrow, feather: p.diffFeather, edgeExcludePx: 0 }); const explicitEdge = p.edgeExcludePx != null ? Number(p.edgeExcludePx) : null; const sceneReplace = diff.changedFraction > 0.5; const edgeExclude = sceneReplace ? 0 : (explicitEdge != null ? explicitEdge : 3); if (edgeExclude > 0) _clearMaskEdgeBand(diff.mask, edgeExclude); layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(diff.mask, 0, 0); layer.ctx.restore(); if (sceneReplace) _edgeFillFromInterior(layer.ctx, dw, dh, 3); } if (p.blend) layer.blendMode = p.blend; if (p.opacity != null) layer.opacity = Math.max(0, Math.min(1, Number(p.opacity))); state.layers.push(layer); state.activeLayerId = layer.id; state.selectedLayerIds = [layer.id]; _renderLayerPanel(); composite(); resolve(layer.id); } catch (e) { reject(e); } }; img.onerror = () => reject(new Error('image load failed')); img.src = p.dataUrl; }) },
|
||||||
'layers.addMaskFromSelection': { description: 'Add a layer mask from the current selection (reveals the selection). {invert}.', run: (p) => { const parent = _activeParentLayer(); if (!parent) throw new Error('no layer'); _saveState('Add layer mask'); const mask = _ensureActiveMaskLayer(); if (!mask) throw new Error('could not create mask'); const w = mask.canvas.width, h = mask.canvas.height; const mctx = mask.ctx; const off = state.layerOffsets.get(parent.id) || { x: 0, y: 0 }; const ox = -(off.x || 0), oy = -(off.y || 0); mctx.globalCompositeOperation = 'source-over'; mctx.clearRect(0, 0, w, h); if (state.wandMask) { if (p && p.invert) { mctx.fillStyle = '#fff'; mctx.fillRect(0, 0, w, h); mctx.globalCompositeOperation = 'destination-out'; mctx.drawImage(state.wandMask, ox, oy); mctx.globalCompositeOperation = 'source-over'; } else { mctx.drawImage(state.wandMask, ox, oy); } } else { mctx.fillStyle = (p && p.invert) ? '#000' : '#fff'; mctx.fillRect(0, 0, w, h); } composite(); _renderLayerPanel(); return mask.id; } },
|
'layers.addMaskFromSelection': { description: 'Add a layer mask from the current selection (reveals the selection). {invert}.', run: (p) => { const parent = _activeParentLayer(); if (!parent) throw new Error('no layer'); _saveState('Add layer mask'); const mask = _ensureActiveMaskLayer(); if (!mask) throw new Error('could not create mask'); const w = mask.canvas.width, h = mask.canvas.height; const mctx = mask.ctx; const off = state.layerOffsets.get(parent.id) || { x: 0, y: 0 }; const ox = -(off.x || 0), oy = -(off.y || 0); mctx.globalCompositeOperation = 'source-over'; mctx.clearRect(0, 0, w, h); if (state.wandMask) { if (p && p.invert) { mctx.fillStyle = '#fff'; mctx.fillRect(0, 0, w, h); mctx.globalCompositeOperation = 'destination-out'; mctx.drawImage(state.wandMask, ox, oy); mctx.globalCompositeOperation = 'source-over'; } else { mctx.drawImage(state.wandMask, ox, oy); } } else { mctx.fillStyle = (p && p.invert) ? '#000' : '#fff'; mctx.fillRect(0, 0, w, h); } composite(); _renderLayerPanel(); return mask.id; } },
|
||||||
'layers.removeMask': { description: 'Remove the active layer mask (does not affect pixels).', run: () => { const parent = _activeParentLayer(); if (!parent || !parent.masks || !parent.masks.length) throw new Error('no mask'); _saveState('Remove layer mask'); parent.masks = parent.masks.filter((m) => m.id !== parent.activeMaskId); parent.activeMaskId = parent.masks[0]?.id || null; composite(); _renderLayerPanel(); return true; } },
|
'layers.removeMask': { description: 'Remove the active layer mask (does not affect pixels).', run: () => { const parent = _activeParentLayer(); if (!parent || !parent.masks || !parent.masks.length) throw new Error('no mask'); _saveState('Remove layer mask'); parent.masks = parent.masks.filter((m) => m.id !== parent.activeMaskId); parent.activeMaskId = parent.masks[0]?.id || null; composite(); _renderLayerPanel(); return true; } },
|
||||||
'layers.applyMask': { description: 'Bake the active layer mask into the layer alpha and remove the mask.', run: () => { const parent = _activeParentLayer(); const mask = _getActiveMaskLayer(); if (!parent || !parent.ctx || !mask) throw new Error('no mask to apply'); _saveState('Apply layer mask'); _bakeMaskIntoLayer(parent.ctx, mask.canvas, 0, 0); parent.masks = parent.masks.filter((m) => m.id !== mask.id); parent.activeMaskId = parent.masks[0]?.id || null; composite(); _renderLayerPanel(); return true; } },
|
'layers.applyMask': { description: 'Bake the active layer mask into the layer alpha and remove the mask.', run: () => { const parent = _activeParentLayer(); const mask = _getActiveMaskLayer(); if (!parent || !parent.ctx || !mask) throw new Error('no mask to apply'); _saveState('Apply layer mask'); _bakeMaskIntoLayer(parent.ctx, mask.canvas, 0, 0); parent.masks = parent.masks.filter((m) => m.id !== mask.id); parent.activeMaskId = parent.masks[0]?.id || null; composite(); _renderLayerPanel(); return true; } },
|
||||||
@@ -12564,7 +12622,7 @@ function _buildEditor(container) {
|
|||||||
'ai.removeObject': { description: 'Remove the selected object and fill naturally. {prompt}.', run: (p) => _aiInpaintSelection((p && p.prompt) || 'remove the object, fill the area naturally') },
|
'ai.removeObject': { description: 'Remove the selected object and fill naturally. {prompt}.', run: (p) => _aiInpaintSelection((p && p.prompt) || 'remove the object, fill the area naturally') },
|
||||||
'ai.harmonize': { description: 'Harmonize the layer/selection lighting with the rest of the image; result is a NEW layer. {strength}.', run: async (p) => { const { canvas } = await _aiEditCanvas('/api/image/harmonize', { strength: Number(p && p.strength) || 0.4 }); return _placeResultLayer(canvas, 'Harmonized', state.wandMask || null); } },
|
'ai.harmonize': { description: 'Harmonize the layer/selection lighting with the rest of the image; result is a NEW layer. {strength}.', run: async (p) => { const { canvas } = await _aiEditCanvas('/api/image/harmonize', { strength: Number(p && p.strength) || 0.4 }); return _placeResultLayer(canvas, 'Harmonized', state.wandMask || null); } },
|
||||||
'ai.generativeUpscale': { description: 'Upscale the image 2x/4x; result is a NEW layer. Falls back to a high-quality canvas upscale labelled "basic upscale" when no Real-ESRGAN model is served. {scale}.', run: async (p) => { const scale = Number(p && p.scale) === 4 ? 4 : 2; try { const { canvas } = await _aiEditCanvas('/api/image/upscale-local', { scale }); return _placeResultLayer(canvas, `Upscaled ${scale}x`, null); } catch (e) { const src = flatten(); const c = document.createElement('canvas'); c.width = Math.max(1, Math.round(src.width * scale)); c.height = Math.max(1, Math.round(src.height * scale)); const cx = c.getContext('2d'); cx.imageSmoothingEnabled = true; cx.imageSmoothingQuality = 'high'; cx.drawImage(src, 0, 0, c.width, c.height); _saveState('Upscale (basic)'); _resizeCanvasDocument(state, c.width, c.height, { anchorX: 0, anchorY: 0 }); const layerId = _placeResultLayer(c, `Upscaled ${scale}x (basic upscale)`, null); return { layerId, scale, method: 'basic', reason: String(e && e.message || e) }; } } },
|
'ai.generativeUpscale': { description: 'Upscale the image 2x/4x; result is a NEW layer. Falls back to a high-quality canvas upscale labelled "basic upscale" when no Real-ESRGAN model is served. {scale}.', run: async (p) => { const scale = Number(p && p.scale) === 4 ? 4 : 2; try { const { canvas } = await _aiEditCanvas('/api/image/upscale-local', { scale }); return _placeResultLayer(canvas, `Upscaled ${scale}x`, null); } catch (e) { const src = flatten(); const c = document.createElement('canvas'); c.width = Math.max(1, Math.round(src.width * scale)); c.height = Math.max(1, Math.round(src.height * scale)); const cx = c.getContext('2d'); cx.imageSmoothingEnabled = true; cx.imageSmoothingQuality = 'high'; cx.drawImage(src, 0, 0, c.width, c.height); _saveState('Upscale (basic)'); _resizeCanvasDocument(state, c.width, c.height, { anchorX: 0, anchorY: 0 }); const layerId = _placeResultLayer(c, `Upscaled ${scale}x (basic upscale)`, null); return { layerId, scale, method: 'basic', reason: String(e && e.message || e) }; } } },
|
||||||
'ai.generateImage': { description: 'Generate an image from a prompt (current canvas as reference) and place it as a NEW layer. {prompt,width,height}.', run: async (p) => { const endpoint = (typeof window !== 'undefined' && window.geGenerateImageEndpoint) || '/api/image/generate'; const reference = flatten().toDataURL('image/png').split(',')[1]; const _pickedModel = (() => { try { const v = localStorage.getItem('ge-ai-command-model') || ''; return v.includes('::') ? v.split('::').pop() : ''; } catch (_) { return ''; } })(); const body = { prompt: (p && p.prompt) || '', width: Math.max(1, Math.round(Number(p && p.width) || state.imgWidth)), height: Math.max(1, Math.round(Number(p && p.height) || state.imgHeight)), reference, ...(_pickedModel ? { model: _pickedModel } : {}) }; let _gres = null; try { _gres = await fetch(endpoint, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } catch (_e) { throw new Error(_generationErrorMessage({ network: true, detail: (_e && _e.message) || 'network error' })); } const res = _gres; if (!res.ok) { let _gd = res.statusText; try { const e = await res.json(); _gd = e.detail || e.error || _gd; } catch (_) {} throw new Error(_generationErrorMessage({ status: res.status, detail: _gd })); } const data = await res.json(); if (data.error) throw new Error(_generationErrorMessage({ detail: data.error })); const b64 = data.image || (Array.isArray(data.images) && data.images[0]); if (!b64) throw new Error('No image returned'); try { window.__geGenerationDisabled = 0; } catch (_) {} const img = await _decodeAIImage(b64, _aiSignal); const c = document.createElement('canvas'); c.width = state.imgWidth; c.height = state.imgHeight; c.getContext('2d').drawImage(img, 0, 0, state.imgWidth, state.imgHeight); return _placeResultLayer(c, 'Generated', state.wandMask || null); } },
|
'ai.generateImage': { description: 'Generate an image with the saved generation model and fit it proportionally as a new layer. {prompt,width,height}.', run: (p) => _generateImage(p || {}) },
|
||||||
'ai.generativeExpand': { description: 'Generatively expand the canvas to width×height; only the new border is inpainted, the original pixels are kept pixel-identical, and the seam is feathered. {width,height,anchor,prompt}.', run: async (p) => { const tw = Math.max(1, Math.round(Number(p && p.width) || state.imgWidth)); const th = Math.max(1, Math.round(Number(p && p.height) || state.imgHeight)); if (tw <= state.imgWidth && th <= state.imgHeight) throw new Error('target must be larger than the current canvas'); const anchor = (p && p.anchor) || 'center'; const anchorX = anchor === 'left' ? 0 : anchor === 'right' ? 1 : 0.5; const anchorY = anchor === 'top' ? 0 : anchor === 'bottom' ? 1 : 0.5; _saveState('Generative expand'); const steps = Math.max(1, Math.round(Number(p && p.steps) || 4)); const sw = state.imgWidth; const sh = state.imgHeight; _resizeCanvasDocument(state, tw, th, { anchorX, anchorY }); const flat = flatten(); const b = _contentBounds(); const mask = document.createElement('canvas'); mask.width = tw; mask.height = th; const mctx = mask.getContext('2d'); mctx.fillStyle = '#ffffff'; mctx.fillRect(0, 0, tw, th); const prefill = document.createElement('canvas'); prefill.width = tw; prefill.height = th; const pctx = prefill.getContext('2d'); pctx.imageSmoothingEnabled = true; pctx.drawImage(flat, 0, 0, tw, th); if (b) { mctx.clearRect(b.x, b.y, b.w, b.h); const bx = b.x, by = b.y, bw = b.w, bh = b.h, rx = bx + bw, by2 = by + bh; if (bx > 0) pctx.drawImage(flat, bx, by, 1, bh, 0, by, bx, bh); if (rx < tw) pctx.drawImage(flat, rx - 1, by, 1, bh, rx, by, tw - rx, bh); if (by > 0) pctx.drawImage(flat, bx, by, bw, 1, bx, 0, bw, by); if (by2 < th) pctx.drawImage(flat, bx, by2 - 1, bw, 1, bx, by2, bw, th - by2); if (bx > 0 && by > 0) pctx.drawImage(flat, bx, by, 1, 1, 0, 0, bx, by); if (rx < tw && by > 0) pctx.drawImage(flat, rx - 1, by, 1, 1, rx, 0, tw - rx, by); if (bx > 0 && by2 < th) pctx.drawImage(flat, bx, by2 - 1, 1, 1, 0, by2, bx, th - by2); if (rx < tw && by2 < th) pctx.drawImage(flat, rx - 1, by2 - 1, 1, 1, rx, by2, tw - rx, th - by2); } else { const tx = Math.round((tw - sw) * anchorX); const ty = Math.round((th - sh) * anchorY); mctx.clearRect(tx, ty, sw, sh); } const image = prefill.toDataURL('image/png').split(',')[1]; const maskB64 = mask.toDataURL('image/png').split(',')[1]; const res = await fetch('/api/image/inpaint', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image, mask: maskB64, prompt: (p && p.prompt) || 'extend the scene seamlessly, continuing the surrounding content and lighting naturally', steps }) }); if (!res.ok) { let err = res.statusText; try { const e = await res.json(); err = e.detail || e.error || err; } catch (_) {} throw new Error(err); } const data = await res.json(); if (data.error) throw new Error(data.error); if (!data.image) throw new Error('No image returned'); const img = await _decodeAIImage(data.image, _aiSignal); const seam = Math.max(12, Math.round(Math.min(tw, th) * 0.02)); let blendMask = _growMaskCanvas(mask, seam, true); blendMask = _featherMaskCanvas((cw, ch) => { const c = document.createElement('canvas'); c.width = cw; c.height = ch; return c; }, blendMask, Math.round(seam * 1.4)); const layer = createLayer('Generative expand', tw, th); layer.ctx.imageSmoothingEnabled = true; layer.ctx.imageSmoothingQuality = 'high'; layer.ctx.drawImage(img, 0, 0, tw, th); if (b) { const edge = _edgeBandAverage(flat, b); if (edge) { layer.ctx.save(); layer.ctx.globalAlpha = 0.14; layer.ctx.fillStyle = `rgb(${edge[0]}, ${edge[1]}, ${edge[2]})`; layer.ctx.fillRect(0, 0, tw, th); layer.ctx.restore(); } } _bakeMaskIntoLayer(layer.ctx, blendMask); state.layers.push(layer); state.activeLayerId = layer.id; state.selectedLayerIds = [layer.id]; composite(); _renderLayerPanel(); return { width: tw, height: th, layerId: layer.id, seam, steps }; } },
|
'ai.generativeExpand': { description: 'Generatively expand the canvas to width×height; only the new border is inpainted, the original pixels are kept pixel-identical, and the seam is feathered. {width,height,anchor,prompt}.', run: async (p) => { const tw = Math.max(1, Math.round(Number(p && p.width) || state.imgWidth)); const th = Math.max(1, Math.round(Number(p && p.height) || state.imgHeight)); if (tw <= state.imgWidth && th <= state.imgHeight) throw new Error('target must be larger than the current canvas'); const anchor = (p && p.anchor) || 'center'; const anchorX = anchor === 'left' ? 0 : anchor === 'right' ? 1 : 0.5; const anchorY = anchor === 'top' ? 0 : anchor === 'bottom' ? 1 : 0.5; _saveState('Generative expand'); const steps = Math.max(1, Math.round(Number(p && p.steps) || 4)); const sw = state.imgWidth; const sh = state.imgHeight; _resizeCanvasDocument(state, tw, th, { anchorX, anchorY }); const flat = flatten(); const b = _contentBounds(); const mask = document.createElement('canvas'); mask.width = tw; mask.height = th; const mctx = mask.getContext('2d'); mctx.fillStyle = '#ffffff'; mctx.fillRect(0, 0, tw, th); const prefill = document.createElement('canvas'); prefill.width = tw; prefill.height = th; const pctx = prefill.getContext('2d'); pctx.imageSmoothingEnabled = true; pctx.drawImage(flat, 0, 0, tw, th); if (b) { mctx.clearRect(b.x, b.y, b.w, b.h); const bx = b.x, by = b.y, bw = b.w, bh = b.h, rx = bx + bw, by2 = by + bh; if (bx > 0) pctx.drawImage(flat, bx, by, 1, bh, 0, by, bx, bh); if (rx < tw) pctx.drawImage(flat, rx - 1, by, 1, bh, rx, by, tw - rx, bh); if (by > 0) pctx.drawImage(flat, bx, by, bw, 1, bx, 0, bw, by); if (by2 < th) pctx.drawImage(flat, bx, by2 - 1, bw, 1, bx, by2, bw, th - by2); if (bx > 0 && by > 0) pctx.drawImage(flat, bx, by, 1, 1, 0, 0, bx, by); if (rx < tw && by > 0) pctx.drawImage(flat, rx - 1, by, 1, 1, rx, 0, tw - rx, by); if (bx > 0 && by2 < th) pctx.drawImage(flat, bx, by2 - 1, 1, 1, 0, by2, bx, th - by2); if (rx < tw && by2 < th) pctx.drawImage(flat, rx - 1, by2 - 1, 1, 1, rx, by2, tw - rx, th - by2); } else { const tx = Math.round((tw - sw) * anchorX); const ty = Math.round((th - sh) * anchorY); mctx.clearRect(tx, ty, sw, sh); } const image = prefill.toDataURL('image/png').split(',')[1]; const maskB64 = mask.toDataURL('image/png').split(',')[1]; const res = await fetch('/api/image/inpaint', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image, mask: maskB64, prompt: (p && p.prompt) || 'extend the scene seamlessly, continuing the surrounding content and lighting naturally', steps }) }); if (!res.ok) { let err = res.statusText; try { const e = await res.json(); err = e.detail || e.error || err; } catch (_) {} throw new Error(err); } const data = await res.json(); if (data.error) throw new Error(data.error); if (!data.image) throw new Error('No image returned'); const img = await _decodeAIImage(data.image, _aiSignal); const seam = Math.max(12, Math.round(Math.min(tw, th) * 0.02)); let blendMask = _growMaskCanvas(mask, seam, true); blendMask = _featherMaskCanvas((cw, ch) => { const c = document.createElement('canvas'); c.width = cw; c.height = ch; return c; }, blendMask, Math.round(seam * 1.4)); const layer = createLayer('Generative expand', tw, th); layer.ctx.imageSmoothingEnabled = true; layer.ctx.imageSmoothingQuality = 'high'; layer.ctx.drawImage(img, 0, 0, tw, th); if (b) { const edge = _edgeBandAverage(flat, b); if (edge) { layer.ctx.save(); layer.ctx.globalAlpha = 0.14; layer.ctx.fillStyle = `rgb(${edge[0]}, ${edge[1]}, ${edge[2]})`; layer.ctx.fillRect(0, 0, tw, th); layer.ctx.restore(); } } _bakeMaskIntoLayer(layer.ctx, blendMask); state.layers.push(layer); state.activeLayerId = layer.id; state.selectedLayerIds = [layer.id]; composite(); _renderLayerPanel(); return { width: tw, height: th, layerId: layer.id, seam, steps }; } },
|
||||||
'layers.rename': { description: 'Rename a layer {id|index|name, name|newName}.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); const next = String(p.name || p.newName || '').trim(); if (!next) throw new Error('name required'); _saveState('Rename layer'); l.name = next; _renderLayerPanel(); return l.id; } },
|
'layers.rename': { description: 'Rename a layer {id|index|name, name|newName}.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); const next = String(p.name || p.newName || '').trim(); if (!next) throw new Error('name required'); _saveState('Rename layer'); l.name = next; _renderLayerPanel(); return l.id; } },
|
||||||
'layers.setVisibility': { description: 'Show/hide a layer {id|index|name, visible}.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState('Toggle layer visibility'); l.visible = (p.visible === undefined) ? !l.visible : !!p.visible; composite(); _renderLayerPanel(); return l.visible; } },
|
'layers.setVisibility': { description: 'Show/hide a layer {id|index|name, visible}.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState('Toggle layer visibility'); l.visible = (p.visible === undefined) ? !l.visible : !!p.visible; composite(); _renderLayerPanel(); return l.visible; } },
|
||||||
@@ -12809,6 +12867,10 @@ function _buildEditor(container) {
|
|||||||
endpoint: window.geEditorAgentEndpoint || '/api/editor/agent',
|
endpoint: window.geEditorAgentEndpoint || '/api/editor/agent',
|
||||||
getSession: () => agentBridgeSession,
|
getSession: () => agentBridgeSession,
|
||||||
getModel: () => _getSelectedQuickEditModel(),
|
getModel: () => _getSelectedQuickEditModel(),
|
||||||
|
getGenerationModel: () => {
|
||||||
|
const value = document.getElementById('ge-ai-model')?.value || '';
|
||||||
|
return value.includes('::') ? value.slice(value.indexOf('::') + 2) : '';
|
||||||
|
},
|
||||||
getAgentModel: () => _getSelectedQuickEditAgentModel(),
|
getAgentModel: () => _getSelectedQuickEditAgentModel(),
|
||||||
});
|
});
|
||||||
} catch (e) { console.error('[ge-editor] agent init failed', e); }
|
} catch (e) { console.error('[ge-editor] agent init failed', e); }
|
||||||
@@ -13908,6 +13970,12 @@ export function openEditor(imageUrl, imageId, presetSize, displayName, draftId)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_finishBlank(size.w, size.h);
|
_finishBlank(size.w, size.h);
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('[openEditor] New project failed:', error);
|
||||||
|
closeEditor({ force: true });
|
||||||
|
window.dispatchEvent(new CustomEvent('gallery-editor-setup-cancelled'));
|
||||||
|
uiModule.showError(`Could not create project: ${error?.message || 'Canvas initialization failed'}`);
|
||||||
|
throw error;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14062,8 +14130,6 @@ export function closeEditor(options = {}) {
|
|||||||
_selectionNudgeTimer = null;
|
_selectionNudgeTimer = null;
|
||||||
_selectionNudgeHistorySaved = false;
|
_selectionNudgeHistorySaved = false;
|
||||||
_precisionGuides = null;
|
_precisionGuides = null;
|
||||||
try { _preflight?.destroy(); } catch {}
|
|
||||||
_preflight = null;
|
|
||||||
try { _aiSettings?.destroy(); } catch {}
|
try { _aiSettings?.destroy(); } catch {}
|
||||||
_aiSettings = null;
|
_aiSettings = null;
|
||||||
try { delete window.geEditorAiSettings; } catch (_) { window.geEditorAiSettings = null; }
|
try { delete window.geEditorAiSettings; } catch (_) { window.geEditorAiSettings = null; }
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
// them from the same places, so load order and init sequence are untouched.
|
// them from the same places, so load order and init sequence are untouched.
|
||||||
|
|
||||||
import { byId as el } from './dom.js';
|
import { byId as el } from './dom.js';
|
||||||
import { postSettings as _postSettings } from './api.js';
|
import { saveImagePreferences as _postSettings, readImagePreferences } from './imagePreferences.js';
|
||||||
import { modelCaps } from '../editor/ai-models.js';
|
import { modelCaps } from '../editor/ai-models.js';
|
||||||
import { sortModelIds } from '../modelSort.js';
|
import { sortModelIds } from '../modelSort.js';
|
||||||
|
let preferencesListener = null;
|
||||||
|
|
||||||
export async function initImageSettings() {
|
export async function initImageSettings() {
|
||||||
const modelSel = el('set-imgModelSelect');
|
const modelSel = el('set-imgModelSelect');
|
||||||
|
const editModelSel = el('set-imgEditModelSelect');
|
||||||
const qualSel = el('set-imgQualitySelect');
|
const qualSel = el('set-imgQualitySelect');
|
||||||
const msg = el('set-imgSettingsMsg');
|
const msg = el('set-imgSettingsMsg');
|
||||||
const enabledToggle = el('set-imgEnabledToggle');
|
const enabledToggle = el('set-imgEnabledToggle');
|
||||||
@@ -18,25 +20,41 @@ export async function initImageSettings() {
|
|||||||
const endpointsRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
const endpointsRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||||
const endpoints = await endpointsRes.json();
|
const endpoints = await endpointsRes.json();
|
||||||
const imageModels = new Set();
|
const imageModels = new Set();
|
||||||
|
const editModels = new Set();
|
||||||
(Array.isArray(endpoints) ? endpoints : []).forEach(endpoint => {
|
(Array.isArray(endpoints) ? endpoints : []).forEach(endpoint => {
|
||||||
if (!endpoint.is_enabled || !endpoint.online) return;
|
if (!endpoint.is_enabled || !endpoint.online) return;
|
||||||
(Array.isArray(endpoint.models) ? endpoint.models : []).forEach(modelId => {
|
(Array.isArray(endpoint.models) ? endpoint.models : []).forEach(modelId => {
|
||||||
if (modelId && modelCaps(modelId, '', endpoint.model_type).gen) imageModels.add(String(modelId));
|
if (modelId && modelCaps(modelId, '', endpoint.model_type).gen) imageModels.add(String(modelId));
|
||||||
|
if (modelId && modelCaps(modelId, '', endpoint.model_type).inpaint) editModels.add(String(modelId));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
modelSel.length = 1;
|
||||||
sortModelIds(Array.from(imageModels)).forEach(mid => {
|
sortModelIds(Array.from(imageModels)).forEach(mid => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = mid;
|
opt.value = mid;
|
||||||
opt.textContent = mid;
|
opt.textContent = mid;
|
||||||
modelSel.appendChild(opt);
|
modelSel.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
if (editModelSel) {
|
||||||
|
editModelSel.length = 1;
|
||||||
|
for (const mid of sortModelIds(Array.from(editModels))) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = mid;
|
||||||
|
option.textContent = mid;
|
||||||
|
editModelSel.appendChild(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) { console.warn('Failed to load models for image settings', e); }
|
} catch (e) { console.warn('Failed to load models for image settings', e); }
|
||||||
|
let currentEnabled = enabledToggle ? enabledToggle.checked : false;
|
||||||
try {
|
try {
|
||||||
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
const settings = await readImagePreferences();
|
||||||
const settings = await settingsRes.json();
|
if (modelSel && 'image_model' in settings) modelSel.value = settings.image_model || '';
|
||||||
if (settings.image_model) modelSel.value = settings.image_model;
|
if (editModelSel && 'image_edit_model' in settings) editModelSel.value = settings.image_edit_model || '';
|
||||||
if (settings.image_quality) qualSel.value = settings.image_quality;
|
if (qualSel && 'image_quality' in settings) qualSel.value = settings.image_quality || 'medium';
|
||||||
if (enabledToggle) enabledToggle.checked = settings.image_gen_enabled === true;
|
if (enabledToggle) {
|
||||||
|
enabledToggle.checked = settings.image_gen_enabled === true;
|
||||||
|
currentEnabled = enabledToggle.checked;
|
||||||
|
}
|
||||||
} catch (e) { console.warn('Failed to load settings', e); }
|
} catch (e) { console.warn('Failed to load settings', e); }
|
||||||
|
|
||||||
function syncImgDisabled() {
|
function syncImgDisabled() {
|
||||||
@@ -47,14 +65,115 @@ export async function initImageSettings() {
|
|||||||
}
|
}
|
||||||
syncImgDisabled();
|
syncImgDisabled();
|
||||||
|
|
||||||
async function saveSettings() {
|
function checkIsAdmin() {
|
||||||
|
return typeof window !== 'undefined' && !!window._isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAdminToggleState() {
|
||||||
|
if (!enabledToggle) return;
|
||||||
|
const admin = checkIsAdmin();
|
||||||
|
if (!admin) {
|
||||||
|
enabledToggle.disabled = true;
|
||||||
|
const adminTitle = 'Admin access is required to change image generation availability.';
|
||||||
|
enabledToggle.title = adminTitle;
|
||||||
|
if (enabledToggle.parentElement) enabledToggle.parentElement.title = adminTitle;
|
||||||
|
} else {
|
||||||
|
enabledToggle.disabled = false;
|
||||||
|
const adminTitle = 'Enable or disable image generation globally';
|
||||||
|
enabledToggle.title = adminTitle;
|
||||||
|
if (enabledToggle.parentElement) enabledToggle.parentElement.title = adminTitle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyAdminToggleState();
|
||||||
|
|
||||||
|
async function saveModelPreferences() {
|
||||||
try {
|
try {
|
||||||
const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value });
|
const res = await _postSettings({
|
||||||
|
image_model: modelSel ? modelSel.value : '',
|
||||||
|
image_edit_model: editModelSel?.value || '',
|
||||||
|
image_quality: qualSel ? qualSel.value : 'medium',
|
||||||
|
});
|
||||||
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
||||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
|
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
|
||||||
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
|
} catch (e) {
|
||||||
|
if (e.globalSaved) {
|
||||||
|
msg.textContent = 'Policy saved, but failed to clear preferences: ' + (e.message || 'cleanup error');
|
||||||
|
msg.style.color = 'var(--red)';
|
||||||
|
} else {
|
||||||
|
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);
|
|
||||||
qualSel.addEventListener('change', saveSettings);
|
async function saveToggle() {
|
||||||
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncImgDisabled(); saveSettings(); });
|
if (!checkIsAdmin()) {
|
||||||
|
if (enabledToggle) enabledToggle.checked = currentEnabled;
|
||||||
|
syncImgDisabled();
|
||||||
|
msg.textContent = 'Admin access is required to change these settings.';
|
||||||
|
msg.style.color = 'var(--red)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetState = enabledToggle ? enabledToggle.checked : false;
|
||||||
|
try {
|
||||||
|
const res = await _postSettings({ image_gen_enabled: targetState });
|
||||||
|
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
||||||
|
if (enabledToggle) currentEnabled = enabledToggle.checked;
|
||||||
|
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.globalSaved) {
|
||||||
|
currentEnabled = targetState;
|
||||||
|
try {
|
||||||
|
const authSettings = await readImagePreferences();
|
||||||
|
if (typeof authSettings.image_gen_enabled === 'boolean') {
|
||||||
|
currentEnabled = authSettings.image_gen_enabled;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
if (enabledToggle) enabledToggle.checked = currentEnabled;
|
||||||
|
syncImgDisabled();
|
||||||
|
msg.textContent = 'Policy saved, but failed to clear preferences: ' + (e.message || 'cleanup error');
|
||||||
|
msg.style.color = 'var(--red)';
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const authSettings = await readImagePreferences();
|
||||||
|
if (typeof authSettings.image_gen_enabled === 'boolean') {
|
||||||
|
currentEnabled = authSettings.image_gen_enabled;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
if (enabledToggle) enabledToggle.checked = currentEnabled;
|
||||||
|
syncImgDisabled();
|
||||||
|
msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save';
|
||||||
|
msg.style.color = 'var(--red)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelSel) modelSel.addEventListener('change', saveModelPreferences);
|
||||||
|
if (editModelSel) editModelSel.addEventListener('change', saveModelPreferences);
|
||||||
|
if (qualSel) qualSel.addEventListener('change', saveModelPreferences);
|
||||||
|
if (enabledToggle) {
|
||||||
|
enabledToggle.addEventListener('change', async function() {
|
||||||
|
if (!checkIsAdmin()) {
|
||||||
|
enabledToggle.checked = currentEnabled;
|
||||||
|
syncImgDisabled();
|
||||||
|
msg.textContent = 'Admin access is required to change these settings.';
|
||||||
|
msg.style.color = 'var(--red)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncImgDisabled();
|
||||||
|
await saveToggle();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferencesListener) window.removeEventListener('image-preferences-changed', preferencesListener);
|
||||||
|
preferencesListener = event => {
|
||||||
|
if ('image_model' in event.detail && modelSel) modelSel.value = event.detail.image_model || '';
|
||||||
|
if (editModelSel && 'image_edit_model' in event.detail) editModelSel.value = event.detail.image_edit_model || '';
|
||||||
|
if ('image_gen_enabled' in event.detail && enabledToggle) {
|
||||||
|
enabledToggle.checked = event.detail.image_gen_enabled === true;
|
||||||
|
currentEnabled = enabledToggle.checked;
|
||||||
|
syncImgDisabled();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('image-preferences-changed', preferencesListener);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { invalidateSettings } from '../appConfig.js';
|
||||||
|
|
||||||
|
const USER_PREFERENCE_KEYS = ['image_model', 'image_edit_model', 'image_quality'];
|
||||||
|
|
||||||
|
export async function readImagePreferences() {
|
||||||
|
const [prefsRes, settingsRes] = await Promise.all([
|
||||||
|
fetch('/api/prefs', { credentials: 'same-origin' }).catch(() => null),
|
||||||
|
fetch('/api/auth/settings', { credentials: 'same-origin' }).catch(() => null),
|
||||||
|
]);
|
||||||
|
const prefs = (prefsRes && prefsRes.ok) ? await prefsRes.json().catch(() => ({})) : {};
|
||||||
|
const settings = (settingsRes && settingsRes.ok) ? await settingsRes.json().catch(() => ({})) : {};
|
||||||
|
if (!prefsRes?.ok && !settingsRes?.ok) throw new Error('Could not load image settings');
|
||||||
|
return {
|
||||||
|
...settings,
|
||||||
|
image_model: prefs?.image_model || settings?.image_model || '',
|
||||||
|
image_edit_model: prefs?.image_edit_model || settings?.image_edit_model || '',
|
||||||
|
image_quality: prefs?.image_quality || settings?.image_quality || 'medium',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let saveQueue = Promise.resolve();
|
||||||
|
export function waitForImagePreferences() { return saveQueue; }
|
||||||
|
export function saveImagePreferences(patch) {
|
||||||
|
const save = async () => {
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch('/api/auth/settings', {
|
||||||
|
method: 'POST', credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
} catch (netErr) {
|
||||||
|
invalidateSettings();
|
||||||
|
netErr.globalSaved = false;
|
||||||
|
throw netErr;
|
||||||
|
}
|
||||||
|
invalidateSettings();
|
||||||
|
if (response.status === 403) {
|
||||||
|
const userEntries = Object.entries(patch).filter(([k]) => USER_PREFERENCE_KEYS.includes(k));
|
||||||
|
if (userEntries.length === 0) {
|
||||||
|
const err = new Error('Admin access is required to change these settings.');
|
||||||
|
err.status = 403;
|
||||||
|
err.globalSaved = false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
for (const [key, value] of userEntries) {
|
||||||
|
const prefRes = await fetch(`/api/prefs/${encodeURIComponent(key)}`, {
|
||||||
|
method: 'PUT', credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ value }),
|
||||||
|
});
|
||||||
|
if (!prefRes.ok) {
|
||||||
|
const err = new Error(await prefRes.text().catch(() => 'Could not save image preferences'));
|
||||||
|
err.status = prefRes.status;
|
||||||
|
err.globalSaved = false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response = typeof Response !== 'undefined'
|
||||||
|
? new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||||
|
: { ok: true, status: 200, json: async () => ({ ok: true }), text: async () => '{"ok":true}' };
|
||||||
|
} else if (response.ok) {
|
||||||
|
const clearResults = await Promise.all(USER_PREFERENCE_KEYS.map(async key => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/prefs/${encodeURIComponent(key)}`, {
|
||||||
|
method: 'PUT', credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ value: null }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
||||||
|
err.status = res.status;
|
||||||
|
return { ok: false, error: err };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const failure = clearResults.find(r => !r.ok);
|
||||||
|
if (failure) {
|
||||||
|
invalidateSettings();
|
||||||
|
if (typeof window !== 'undefined' && typeof CustomEvent !== 'undefined') {
|
||||||
|
window.dispatchEvent(new CustomEvent('image-preferences-changed', { detail: patch }));
|
||||||
|
}
|
||||||
|
const err = new Error(failure.error?.message || 'Could not clear personal preferences');
|
||||||
|
err.status = failure.error?.status || 500;
|
||||||
|
err.cause = failure.error;
|
||||||
|
err.globalSaved = true;
|
||||||
|
err.phase = 'cleanup';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const err = new Error('Could not save image settings');
|
||||||
|
err.status = response.status;
|
||||||
|
err.globalSaved = false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
invalidateSettings();
|
||||||
|
if (typeof window !== 'undefined' && typeof CustomEvent !== 'undefined') {
|
||||||
|
window.dispatchEvent(new CustomEvent('image-preferences-changed', { detail: patch }));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
const result = saveQueue.then(save);
|
||||||
|
saveQueue = result.catch(() => {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
+5
-1
@@ -7,7 +7,7 @@
|
|||||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||||
// - API / non-GET: never cached.
|
// - API / non-GET: never cached.
|
||||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
||||||
const CACHE_NAME = 'odysseus-v1115-pr40-editor-precache';
|
const CACHE_NAME = 'odysseus-v1134-pr40-editor-precache';
|
||||||
|
|
||||||
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
||||||
// gives offline math fallback glyphs instead of proper typesetting.
|
// gives offline math fallback glyphs instead of proper typesetting.
|
||||||
@@ -237,6 +237,10 @@ const PANEL_PRECACHE = [
|
|||||||
'/static/js/editor/aspect-fit.js',
|
'/static/js/editor/aspect-fit.js',
|
||||||
'/static/js/editor/preflight.js',
|
'/static/js/editor/preflight.js',
|
||||||
'/static/js/editor/ai-settings.js',
|
'/static/js/editor/ai-settings.js',
|
||||||
|
'/static/js/editor/image-generation.js',
|
||||||
|
'/static/js/editor/image-result-geometry.js',
|
||||||
|
'/static/js/editor/quick-edit-job.js',
|
||||||
|
'/static/js/settings/imagePreferences.js',
|
||||||
'/static/js/editor/look-recipes.js',
|
'/static/js/editor/look-recipes.js',
|
||||||
'/static/js/editor/editor-control.js',
|
'/static/js/editor/editor-control.js',
|
||||||
'/static/js/editor/editor-bridge.js',
|
'/static/js/editor/editor-bridge.js',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const REAL_MODULES = new Set([
|
|||||||
path.join(JS, 'settings/speech.js'),
|
path.join(JS, 'settings/speech.js'),
|
||||||
path.join(JS, 'settings/writingStyle.js'),
|
path.join(JS, 'settings/writingStyle.js'),
|
||||||
path.join(JS, 'settings/imageModels.js'),
|
path.join(JS, 'settings/imageModels.js'),
|
||||||
|
path.join(JS, 'settings/imagePreferences.js'),
|
||||||
path.join(JS, 'settings/agent.js'),
|
path.join(JS, 'settings/agent.js'),
|
||||||
path.join(JS, 'settings/shell.js'),
|
path.join(JS, 'settings/shell.js'),
|
||||||
path.join(JS, 'settings/peek.js'),
|
path.join(JS, 'settings/peek.js'),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import unittest
|
|||||||
|
|
||||||
|
|
||||||
path = Path(__file__).resolve().parents[1] / "routes/chat_routes.py"
|
path = Path(__file__).resolve().parents[1] / "routes/chat_routes.py"
|
||||||
tree = ast.parse(path.read_text())
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||||
node = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "_AgentRenderState")
|
node = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "_AgentRenderState")
|
||||||
namespace = {}
|
namespace = {}
|
||||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), "exec"), namespace)
|
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), "exec"), namespace)
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""An unknown context window must not be budgeted like a tiny local server (#6678).
|
||||||
|
|
||||||
|
``budget_context_for_model`` returns 0 when the endpoint does not prove its
|
||||||
|
window, and ``compute_input_token_budget`` answers 0 with the conservative
|
||||||
|
default budget. The agent loop's small-server clamp (``<= 8192``) also caught
|
||||||
|
0 and cut the request to 1200 tokens, so an open document or earlier context
|
||||||
|
of a few thousand tokens was trimmed away before the model saw it.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
DONE = 'data: [DONE]\n\n'
|
||||||
|
MARKER = 'NIGHTJAR-7'
|
||||||
|
# ~3000 estimated tokens: over the old 1200 clamp, under the 6000 default.
|
||||||
|
CONTEXT = ''.join(
|
||||||
|
f'## Project-{n}\nOwner: team {n}. Status: active. Notes: routine maintenance '
|
||||||
|
'and quarterly review scheduled for every workstream in this registry.\n\n'
|
||||||
|
for n in range(60)
|
||||||
|
) + f'## Project-Alpha\nAlias: {MARKER}\n'
|
||||||
|
|
||||||
|
|
||||||
|
def _event(payload):
|
||||||
|
return 'data: ' + json.dumps(payload) + '\n\n'
|
||||||
|
|
||||||
|
|
||||||
|
async def _first_request(monkeypatch, tmp_path, context_window):
|
||||||
|
import src.agent_loop as al
|
||||||
|
from tests.test_agent_runtime_context import _patch_fake_skills
|
||||||
|
_patch_fake_skills(monkeypatch)
|
||||||
|
monkeypatch.setattr('src.tool_index.get_tool_index', lambda: None)
|
||||||
|
monkeypatch.setattr('src.model_context.budget_context_for_model',
|
||||||
|
lambda *args, **kwargs: context_window)
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
async def provider(candidates, messages, **kwargs):
|
||||||
|
# Like llm_core: the per-candidate factory shapes and trims the request.
|
||||||
|
url, model, headers = candidates[0][:3]
|
||||||
|
request = kwargs['candidate_request_factory'](0, url, model, headers)
|
||||||
|
if inspect.isawaitable(request):
|
||||||
|
request = await request
|
||||||
|
seen.append((request or {}).get('messages', messages))
|
||||||
|
yield _event({'delta': 'ok'})
|
||||||
|
yield DONE
|
||||||
|
|
||||||
|
monkeypatch.setattr(al, 'stream_llm_with_fallback', provider)
|
||||||
|
history = [
|
||||||
|
{'role': 'user', 'content': 'Keep this registry for later:\n\n' + CONTEXT},
|
||||||
|
{'role': 'assistant', 'content': 'Saved.'},
|
||||||
|
{'role': 'user', 'content': 'What is the alias of Project-Alpha?'},
|
||||||
|
]
|
||||||
|
async for _ in al.stream_agent_loop('http://127.0.0.1:9/v1', 'model', history,
|
||||||
|
max_rounds=1, workspace=str(tmp_path)):
|
||||||
|
pass
|
||||||
|
assert seen, 'the provider was never called'
|
||||||
|
return json.dumps(seen[0])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_context_window_keeps_earlier_context(monkeypatch, tmp_path):
|
||||||
|
request = await _first_request(monkeypatch, tmp_path, 0)
|
||||||
|
assert MARKER in request
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_known_small_window_is_still_clamped(monkeypatch, tmp_path):
|
||||||
|
# The clamp exists for small local servers whose window is known (n_ctx
|
||||||
|
# 2048 here); it must keep protecting them.
|
||||||
|
request = await _first_request(monkeypatch, tmp_path, 2048)
|
||||||
|
assert MARKER not in request
|
||||||
@@ -3,8 +3,8 @@ from tests.helpers.stylesheets import app_css
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
CHAT = (ROOT / "static/js/chat.js").read_text()
|
CHAT = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
|
||||||
SESSIONS = (ROOT / "static/js/sessions.js").read_text()
|
SESSIONS = (ROOT / "static/js/sessions.js").read_text(encoding="utf-8")
|
||||||
CSS = app_css()
|
CSS = app_css()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
CHAT = (ROOT / "static/js/chat.js").read_text()
|
CHAT = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
|
||||||
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text()
|
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_processing_indicator_shows_live_ttft_timer():
|
def test_processing_indicator_shows_live_ttft_timer():
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Every variable a shipped compose file forwards must carry a default.
|
||||||
|
|
||||||
|
A bare ``${VAR}`` makes ``docker compose`` print "variable is not set" on every
|
||||||
|
command for a default ``.env`` (#6619), which greets fresh installs with a
|
||||||
|
warning that looks like a misconfiguration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
COMPOSE_FILES = sorted(ROOT.glob("docker-compose*.yml")) + sorted((ROOT / "docker").glob("*.yml"))
|
||||||
|
|
||||||
|
# ${VAR} with no ``:-``/``-``/``:?``/``?`` modifier.
|
||||||
|
BARE_VAR = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", COMPOSE_FILES, ids=lambda p: p.relative_to(ROOT).as_posix())
|
||||||
|
def test_compose_variables_have_defaults(path):
|
||||||
|
bare = [
|
||||||
|
f"{path.name}:{lineno}: ${{{name}}}"
|
||||||
|
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
|
||||||
|
if not line.lstrip().startswith("#")
|
||||||
|
for name in BARE_VAR.findall(line)
|
||||||
|
]
|
||||||
|
assert not bare, "compose variables without a default:\n" + "\n".join(bare)
|
||||||
@@ -70,3 +70,63 @@ def test_no_gguf_found_pattern():
|
|||||||
result = _diagnose_serve_output(out)
|
result = _diagnose_serve_output(out)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert "GGUF" in result["message"]
|
assert "GGUF" in result["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cookbook_routes_uses_shared_diagnosis_helper():
|
||||||
|
# /api/cookbook/tasks/status must use the maintained helper. A nested copy in
|
||||||
|
# cookbook_routes.py shadowed the import and silently drifted (missing the
|
||||||
|
# ModelOpt, mflux and MLX bridge diagnoses).
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
source = (Path(__file__).resolve().parents[1] / "routes" / "cookbook_routes.py").read_text(encoding="utf-8")
|
||||||
|
local_defs = [
|
||||||
|
node.lineno
|
||||||
|
for node in ast.walk(ast.parse(source))
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "_diagnose_serve_output"
|
||||||
|
]
|
||||||
|
assert local_defs == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_modelopt_lm_head_checkpoint_returns_diagnosis():
|
||||||
|
out = "ValueError: There is no module or parameter named 'lm_head.input_scale' in Qwen3ForCausalLM"
|
||||||
|
result = _diagnose_serve_output(out)
|
||||||
|
assert result is not None
|
||||||
|
assert "ModelOpt" in result["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_nvidia_driver_too_old_for_torch_returns_diagnosis():
|
||||||
|
# vLLM 0.31 from PyPI ships torch cu130; on driver 570 (CUDA 12.8) the engine dies here.
|
||||||
|
out = (
|
||||||
|
"(EngineCore pid=1) ERROR [core.py:1433] self.driver_worker.init_device()\n"
|
||||||
|
"(EngineCore pid=1) ERROR [core.py:1433] RuntimeError: The NVIDIA driver on your system is too old "
|
||||||
|
"(found version 12080). Please update your GPU driver by downloading and installing a new version\n"
|
||||||
|
"Traceback (most recent call last):\n"
|
||||||
|
"RuntimeError: Engine core initialization failed. See root cause above. Failed core proc(s): {}\n"
|
||||||
|
)
|
||||||
|
result = _diagnose_serve_output(out)
|
||||||
|
assert result is not None
|
||||||
|
assert "driver" in result["message"].lower()
|
||||||
|
assert any("cu129" in s["label"] for s in result["suggestions"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_cuda_runtime_newer_than_driver_returns_diagnosis():
|
||||||
|
# torch switched to cu128 but the vLLM wheel's own kernels still need CUDA 13.
|
||||||
|
out = (
|
||||||
|
"RuntimeError: get_cuda_view_from_cpu_tensor, /workspace/csrc/libtorch_stable/cuda_view.cu:38, "
|
||||||
|
"cudaHostGetDevicePointer failed: CUDA driver version is insufficient for CUDA runtime version\n"
|
||||||
|
"Traceback (most recent call last):\n"
|
||||||
|
)
|
||||||
|
result = _diagnose_serve_output(out)
|
||||||
|
assert result is not None
|
||||||
|
assert "driver" in result["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tasks_status_capture_joins_wrapped_tmux_lines():
|
||||||
|
# tmux wraps at the pane width (often mid-word), which hid errors like
|
||||||
|
# "insufficie|nt for CUDA runtime version" from the diagnosis patterns.
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
source = (Path(__file__).resolve().parents[1] / "routes" / "cookbook_routes.py").read_text(encoding="utf-8")
|
||||||
|
assert '_remote_tmux_command("capture-pane", "-t", session_id, "-p", "-J", "-S", "-500")' in source
|
||||||
|
assert '["tmux", "capture-pane", "-t", session_id, "-p", "-J", "-S", "-500"]' in source
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import pytest
|
||||||
|
import httpx
|
||||||
|
from unittest.mock import patch, AsyncMock
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from routes.cookbook_routes import setup_cookbook_routes
|
||||||
|
from src.auth_helpers import require_user
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
router = setup_cookbook_routes()
|
||||||
|
app.include_router(router)
|
||||||
|
app.dependency_overrides[require_user] = lambda: "test_user"
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hf_gguf_files_success():
|
||||||
|
mock_resp = httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"siblings": [
|
||||||
|
{"rfilename": "model-q4_k_m.gguf"},
|
||||||
|
{"rfilename": "model-q8_0.gguf"},
|
||||||
|
{"rfilename": "README.md"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
request=httpx.Request("GET", "https://huggingface.co/api/models/TheBloke/Llama-2-7B-GGUF"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=mock_resp):
|
||||||
|
response = client.get("/api/cookbook/hf-gguf-files?repo_id=TheBloke/Llama-2-7B-GGUF")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert data["repo_id"] == "TheBloke/Llama-2-7B-GGUF"
|
||||||
|
assert data["files"] == ["model-q4_k_m.gguf", "model-q8_0.gguf"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_hf_gguf_files_http_error():
|
||||||
|
mock_resp = httpx.Response(
|
||||||
|
404,
|
||||||
|
json={"error": "Model not found"},
|
||||||
|
request=httpx.Request("GET", "https://huggingface.co/api/models/invalid/repo"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=mock_resp):
|
||||||
|
response = client.get("/api/cookbook/hf-gguf-files?repo_id=invalid/repo")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["ok"] is False
|
||||||
|
assert "HF API HTTP 404" in data["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_hf_gguf_files_exception_handling():
|
||||||
|
with patch("httpx.AsyncClient.get", new_callable=AsyncMock, side_effect=httpx.ConnectError("Network unreachable")):
|
||||||
|
response = client.get("/api/cookbook/hf-gguf-files?repo_id=TheBloke/Llama-2-7B-GGUF")
|
||||||
|
# Should gracefully return JSON error payload, not raise NameError / 500
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["ok"] is False
|
||||||
|
assert data["error"] == "HF API request failed"
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Daily brief must query and display each calendar storage kind in local time."""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from core import database
|
||||||
|
from src import builtin_actions
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def system_timezone(name):
|
||||||
|
previous = os.environ.get('TZ')
|
||||||
|
os.environ['TZ'] = name
|
||||||
|
time.tzset()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if previous is None:
|
||||||
|
os.environ.pop('TZ', None)
|
||||||
|
else:
|
||||||
|
os.environ['TZ'] = previous
|
||||||
|
time.tzset()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not hasattr(time, 'tzset'), reason='requires changing the process local timezone')
|
||||||
|
@pytest.mark.parametrize('zone', ['Europe/Berlin', 'America/Los_Angeles'])
|
||||||
|
def test_daily_brief_selects_local_day_and_orders_mixed_storage(monkeypatch, zone):
|
||||||
|
engine = create_engine('sqlite://', poolclass=StaticPool)
|
||||||
|
for model in [database.CalendarCal, database.CalendarEvent, database.Note]:
|
||||||
|
model.__table__.create(engine)
|
||||||
|
sessions = sessionmaker(bind=engine)
|
||||||
|
monkeypatch.setattr(database, 'SessionLocal', sessions)
|
||||||
|
from core.auth import AuthManager
|
||||||
|
monkeypatch.setattr(AuthManager, 'is_configured', property(lambda self: True))
|
||||||
|
from routes import email_helpers
|
||||||
|
def no_email(*args):
|
||||||
|
raise RuntimeError('No email account in this calendar-only test')
|
||||||
|
monkeypatch.setattr(email_helpers, '_imap_connect', no_email)
|
||||||
|
try:
|
||||||
|
with system_timezone(zone):
|
||||||
|
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
def utc(value):
|
||||||
|
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
with sessions() as db:
|
||||||
|
for owner in ['alice', 'bob', None]:
|
||||||
|
db.add(database.CalendarCal(id=owner or 'shared', owner=owner, name=owner or 'shared'))
|
||||||
|
db.flush()
|
||||||
|
def add(uid, start, end, *, utc_row=False, all_day=False, owner='alice', status='confirmed'):
|
||||||
|
db.add(database.CalendarEvent(uid=uid, calendar_id=owner or 'shared', summary=uid,
|
||||||
|
dtstart=utc(start) if utc_row and not all_day else start,
|
||||||
|
dtend=utc(end) if utc_row and not all_day else end,
|
||||||
|
is_utc=utc_row, all_day=all_day, status=status))
|
||||||
|
add('early', today+timedelta(minutes=30), today+timedelta(hours=1), utc_row=True)
|
||||||
|
add('utc-ten', today+timedelta(hours=10), today+timedelta(hours=11), utc_row=True)
|
||||||
|
add('local-nine', today+timedelta(hours=9), today+timedelta(hours=10))
|
||||||
|
add('all-day', today, today+timedelta(days=1), utc_row=True, all_day=True)
|
||||||
|
add('next-day', today+timedelta(days=1,minutes=30), today+timedelta(days=1,hours=1), utc_row=True)
|
||||||
|
add('ended', today-timedelta(hours=1), today, utc_row=True)
|
||||||
|
add('cancelled', today+timedelta(hours=12), today+timedelta(hours=13), utc_row=True, status='cancelled')
|
||||||
|
add('foreign', today+timedelta(hours=12), today+timedelta(hours=13), utc_row=True, owner='bob')
|
||||||
|
add('unowned', today+timedelta(hours=12), today+timedelta(hours=13), owner=None)
|
||||||
|
db.commit()
|
||||||
|
text, success = asyncio.run(builtin_actions.action_daily_brief('alice'))
|
||||||
|
assert success, text
|
||||||
|
lines = ['all day all-day', '00:30 early', '09:00 local-nine', '10:00 utc-ten']
|
||||||
|
assert all(line in text for line in lines), text
|
||||||
|
assert [text.index(line) for line in lines] == sorted(text.index(line) for line in lines), text
|
||||||
|
for excluded in ['next-day', 'ended', 'cancelled', 'foreign', 'unowned']:
|
||||||
|
assert excluded not in text, text
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('day, hours', [('2026-03-29', 23), ('2026-10-25', 25)])
|
||||||
|
def test_utc_day_window_uses_offset_at_each_boundary(day, hours):
|
||||||
|
start = datetime.fromisoformat(day)
|
||||||
|
zone = ZoneInfo('Europe/Berlin')
|
||||||
|
begin = builtin_actions._daily_brief_utc_boundary(start, zone)
|
||||||
|
end = builtin_actions._daily_brief_utc_boundary(start+timedelta(days=1), zone)
|
||||||
|
assert end-begin == timedelta(hours=hours)
|
||||||
|
if hasattr(time, 'tzset'):
|
||||||
|
with system_timezone('Europe/Berlin'):
|
||||||
|
assert builtin_actions._daily_brief_utc_boundary(start) == begin
|
||||||
|
assert builtin_actions._daily_brief_utc_boundary(start+timedelta(days=1)) == end
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('utc_row, all_day, expected', [
|
||||||
|
(True, False, datetime(2026, 6, 1, 10)),
|
||||||
|
(False, False, datetime(2026, 6, 1, 8)),
|
||||||
|
(True, True, datetime(2026, 6, 1, 8)),
|
||||||
|
])
|
||||||
|
def test_local_start_preserves_floating_and_date_only_values(utc_row, all_day, expected):
|
||||||
|
event = SimpleNamespace(dtstart=datetime(2026,6,1,8), is_utc=utc_row, all_day=all_day)
|
||||||
|
assert builtin_actions._daily_brief_local_start(event, ZoneInfo('Europe/Berlin')) == expected
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Issue #2137: never preselect an embedding or other non-chat model."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from core.database import ModelEndpoint
|
||||||
|
from routes import model_routes, prefs_routes
|
||||||
|
from tests.helpers.database import disposable_database
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def defaults_api(monkeypatch, tmp_path):
|
||||||
|
defaults = {}
|
||||||
|
with disposable_database(tmp_path) as factory:
|
||||||
|
monkeypatch.setattr(model_routes, "SessionLocal", factory)
|
||||||
|
monkeypatch.setattr(model_routes, "_load_settings", lambda: {})
|
||||||
|
monkeypatch.setattr(prefs_routes, "_load_for_user", lambda owner: defaults)
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.auth_manager = SimpleNamespace(is_admin=lambda owner: False)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def owner(request, call_next):
|
||||||
|
request.state.current_user = "alice"
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
app.include_router(model_routes.setup_model_routes(model_discovery=None))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
yield client, factory, defaults
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint(factory, *, cached=(), pinned=(), hidden=(), owner="alice", endpoint_id="ep"):
|
||||||
|
with factory() as db:
|
||||||
|
db.add(ModelEndpoint(
|
||||||
|
id=endpoint_id, name="Fixture", base_url="http://model.invalid/v1", owner=owner,
|
||||||
|
is_enabled=True, cached_models=json.dumps(cached),
|
||||||
|
pinned_models=json.dumps(pinned), hidden_models=json.dumps(hidden),
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("non_chat", [
|
||||||
|
"text-embedding-3-small", "nomic-embed-text:latest", "jina-reranker-v2-base",
|
||||||
|
"tts-1", "whisper-1", "gpt-image-1",
|
||||||
|
])
|
||||||
|
def test_automatic_default_skips_non_chat_models(defaults_api, non_chat):
|
||||||
|
client, factory, _ = defaults_api
|
||||||
|
_endpoint(factory, cached=[non_chat, "chat-model"])
|
||||||
|
assert client.get("/api/default-chat").json()["model"] == "chat-model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_chat_saved_default_falls_back_to_visible_pinned_chat(defaults_api):
|
||||||
|
client, factory, defaults = defaults_api
|
||||||
|
_endpoint(factory, cached=["text-embedding-3-small", "hidden-chat"],
|
||||||
|
pinned=["jina-reranker-v2-base", "pinned-chat"], hidden=["hidden-chat"])
|
||||||
|
defaults.update(default_endpoint_id="ep", default_model="text-embedding-3-small")
|
||||||
|
assert client.get("/api/default-chat").json()["model"] == "pinned-chat"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_chat_candidate_leaves_model_empty(defaults_api):
|
||||||
|
client, factory, defaults = defaults_api
|
||||||
|
_endpoint(factory, cached=["text-embedding-3-small", "hidden-chat"],
|
||||||
|
pinned=["jina-reranker-v2-base"], hidden=["hidden-chat"])
|
||||||
|
defaults.update(default_endpoint_id="ep", default_model="text-embedding-3-small")
|
||||||
|
result = client.get("/api/default-chat").json()
|
||||||
|
assert result == {"endpoint_id": "ep", "endpoint_url": "http://model.invalid/v1/chat/completions", "model": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_explicit_model_survives_stale_catalog(defaults_api):
|
||||||
|
client, factory, defaults = defaults_api
|
||||||
|
_endpoint(factory, cached=["text-embedding-3-small", "other-chat"])
|
||||||
|
defaults.update(default_endpoint_id="ep", default_model="my-custom-chat")
|
||||||
|
assert client.get("/api/default-chat").json()["model"] == "my-custom-chat"
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_filter_preserves_owner_scope(defaults_api):
|
||||||
|
client, factory, defaults = defaults_api
|
||||||
|
_endpoint(factory, owner="bob", endpoint_id="bobs-endpoint", cached=["bobs-chat"])
|
||||||
|
_endpoint(factory, cached=["text-embedding-3-small", "alices-chat"])
|
||||||
|
defaults.update(default_endpoint_id="bobs-endpoint")
|
||||||
|
result = client.get("/api/default-chat").json()
|
||||||
|
assert result["endpoint_id"] == "ep"
|
||||||
|
assert result["model"] == "alices-chat"
|
||||||
@@ -67,6 +67,14 @@ def test_compose_files_forward_companion_base_url():
|
|||||||
assert "COMPANION_BASE_URL" in _compose_env_names(path), path.name
|
assert "COMPANION_BASE_URL" in _compose_env_names(path), path.name
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_files_forward_first_token_timeout():
|
||||||
|
# The local first-event watchdog (src/llm_core.py) reads this from the
|
||||||
|
# process environment, so a Docker install can only reach it when the
|
||||||
|
# compose files forward it from the host .env.
|
||||||
|
for path in COMPOSE_FILES:
|
||||||
|
assert "ODYSSEUS_FIRST_TOKEN_TIMEOUT" in _compose_env_names(path), path.name
|
||||||
|
|
||||||
|
|
||||||
def test_default_compose_files_do_not_mount_host_docker_socket():
|
def test_default_compose_files_do_not_mount_host_docker_socket():
|
||||||
for path in COMPOSE_FILES:
|
for path in COMPOSE_FILES:
|
||||||
text = path.read_text(encoding="utf-8")
|
text = path.read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""ZIP exports keep language extensions and place collision suffixes before them."""
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from core.database import Base, Document
|
||||||
|
from routes.document import document_routes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def exports(tmp_path, monkeypatch):
|
||||||
|
engine = create_engine(f"sqlite:///{tmp_path / 'documents.db'}", connect_args={"check_same_thread": False})
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
factory = sessionmaker(bind=engine)
|
||||||
|
monkeypatch.setattr(document_routes, "SessionLocal", factory)
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def fixture_user(request: Request, call_next):
|
||||||
|
request.state.current_user = "alice"
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
app.include_router(document_routes.setup_document_routes(None))
|
||||||
|
|
||||||
|
def add(doc_id, title, language, content="body\n", owner="alice"):
|
||||||
|
with factory() as db:
|
||||||
|
db.add(Document(id=doc_id, title=title, language=language,
|
||||||
|
current_content=content, owner=owner, is_active=True))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
yield client, add
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("title,language,filename", [
|
||||||
|
("config.local", "python", "config.local.py"),
|
||||||
|
("report.v2", "markdown", "report.v2.md"),
|
||||||
|
("data.2024", "json", "data.2024.json"),
|
||||||
|
("module.ts", "typescript", "module.ts"),
|
||||||
|
("SCRIPT.PY", "python", "SCRIPT.PY"),
|
||||||
|
("résumé.v2", "text", "résumé.v2.txt"),
|
||||||
|
("unknown.v2", "unknown", "unknown.v2.txt"),
|
||||||
|
("draft", None, "draft.txt"),
|
||||||
|
("project/report.v2", "markdown", "projectreport.v2.md"),
|
||||||
|
])
|
||||||
|
def test_export_uses_the_language_extension_even_for_dotted_titles(exports, title, language, filename):
|
||||||
|
client, add = exports
|
||||||
|
body = "Original text: α\n"
|
||||||
|
add("doc", title, language, body)
|
||||||
|
response = client.post("/api/documents/export-zip", json={"ids": ["doc"]})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"] == "application/zip"
|
||||||
|
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
||||||
|
assert archive.namelist() == [filename]
|
||||||
|
assert archive.read(filename).decode() == body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("title", ["config.local", "config.local.py"])
|
||||||
|
def test_duplicate_names_keep_the_extension_at_the_end(exports, title):
|
||||||
|
client, add = exports
|
||||||
|
for i in range(3):
|
||||||
|
add(str(i), title, "python", f"body {i}")
|
||||||
|
response = client.post("/api/documents/export-zip", json={"ids": ["0", "1", "2"]})
|
||||||
|
assert response.status_code == 200
|
||||||
|
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
||||||
|
assert set(archive.namelist()) == {"config.local.py", "config.local-1.py", "config.local-2.py"}
|
||||||
|
assert {archive.read(name).decode() for name in archive.namelist()} == {"body 0", "body 1", "body 2"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_added_extension_collides_safely_with_an_existing_extension(exports):
|
||||||
|
client, add = exports
|
||||||
|
add("a", "report.v2", "markdown", "first")
|
||||||
|
add("b", "report.v2.md", "markdown", "second")
|
||||||
|
response = client.post("/api/documents/export-zip", json={"ids": ["a", "b"]})
|
||||||
|
assert response.status_code == 200
|
||||||
|
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
||||||
|
assert set(archive.namelist()) == {"report.v2.md", "report.v2-1.md"}
|
||||||
|
assert {archive.read(name) for name in archive.namelist()} == {b"first", b"second"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_foreign_documents_are_still_excluded_and_email_body_is_preserved(exports):
|
||||||
|
client, add = exports
|
||||||
|
add("owned", "reply.v2", "email", "Subject: Hello\n---\nBody")
|
||||||
|
add("foreign", "private", "python", "secret", owner="bob")
|
||||||
|
response = client.post("/api/documents/export-zip", json={"ids": ["owned", "foreign"]})
|
||||||
|
assert response.status_code == 200
|
||||||
|
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
||||||
|
assert archive.namelist() == ["reply.v2.eml"]
|
||||||
|
assert archive.read("reply.v2.eml") == b"Subject: Hello\r\n\r\nBody"
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Literal punctuation in library queries must not become SQL wildcards."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.database import Document
|
||||||
|
from routes.document import document_routes
|
||||||
|
from tests.helpers.database import disposable_database
|
||||||
|
|
||||||
|
|
||||||
|
CASES = [
|
||||||
|
("100%", "100% complete", "1000 complete"),
|
||||||
|
("a_b", "a_b", "axb"),
|
||||||
|
("%", "price 10%", "price 100"),
|
||||||
|
("_", "a_b", "axb"),
|
||||||
|
(r"path\notes", r"path\notes", "pathnotes"),
|
||||||
|
(r"path\_tag", r"path\_tag", r"path\Xtag"),
|
||||||
|
("50%_done", "50%_done", "500Xdone"),
|
||||||
|
("can't", "can't stop", "cannot stop"),
|
||||||
|
("mixed case", "Case ordered Mixed", "Mixed without"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("column", ["title", "current_content"])
|
||||||
|
@pytest.mark.parametrize("query,literal,lookalike", CASES)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_library_search_matches_literal_tokens(tmp_path, monkeypatch, column, query, literal, lookalike):
|
||||||
|
with disposable_database(tmp_path) as factory:
|
||||||
|
monkeypatch.setattr(document_routes, "SessionLocal", factory)
|
||||||
|
with factory() as db:
|
||||||
|
for doc_id, text, owner, active, archived in [
|
||||||
|
("literal", literal, "alice", True, False),
|
||||||
|
("lookalike", lookalike, "alice", True, False),
|
||||||
|
("foreign", literal, "bob", True, False),
|
||||||
|
("inactive", literal, "alice", False, False),
|
||||||
|
("archived", literal, "alice", True, True),
|
||||||
|
]:
|
||||||
|
fields = {"title": "Document", "current_content": "Body", column: text}
|
||||||
|
db.add(Document(id=doc_id, owner=owner, is_active=active, archived=archived,
|
||||||
|
language="text", version_count=1, **fields))
|
||||||
|
db.commit()
|
||||||
|
router = document_routes.setup_document_routes(MagicMock())
|
||||||
|
endpoint = next(r.endpoint for r in router.routes if r.path == "/api/documents/library")
|
||||||
|
request = SimpleNamespace(state=SimpleNamespace(current_user="alice"))
|
||||||
|
result = await endpoint(request, search=query, language=None, sort="recent", offset=0, limit=20, archived=False)
|
||||||
|
assert result["total"] == 1
|
||||||
|
assert [row["id"] for row in result["documents"]] == ["literal"]
|
||||||
|
archived = await endpoint(request, search=query, language=None, sort="recent", offset=0, limit=20, archived=True)
|
||||||
|
assert [row["id"] for row in archived["documents"]] == ["archived"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_literal_search_keeps_cross_field_and_pagination_behavior(tmp_path, monkeypatch):
|
||||||
|
with disposable_database(tmp_path) as factory:
|
||||||
|
monkeypatch.setattr(document_routes, "SessionLocal", factory)
|
||||||
|
with factory() as db:
|
||||||
|
for doc_id, title, content in [
|
||||||
|
("first", "100% ready", "done"),
|
||||||
|
("second", "done", "100% ready"),
|
||||||
|
("lookalike", "1000 ready", "done"),
|
||||||
|
("missing-term", "100% ready", "waiting"),
|
||||||
|
]:
|
||||||
|
db.add(Document(id=doc_id, owner="alice", title=title, current_content=content,
|
||||||
|
is_active=True, archived=False, language="text", version_count=1))
|
||||||
|
db.commit()
|
||||||
|
router = document_routes.setup_document_routes(MagicMock())
|
||||||
|
endpoint = next(r.endpoint for r in router.routes if r.path == "/api/documents/library")
|
||||||
|
request = SimpleNamespace(state=SimpleNamespace(current_user="alice"))
|
||||||
|
results = [await endpoint(request, search="100% done", language="text", sort="alpha",
|
||||||
|
offset=offset, limit=1, archived=False) for offset in [0, 1]]
|
||||||
|
assert all(result["total"] == 2 for result in results)
|
||||||
|
assert {row["id"] for result in results for row in result["documents"]} == {"first", "second"}
|
||||||
@@ -29,8 +29,10 @@ def test_secondary_brush_controls_live_after_the_menu_open():
|
|||||||
block = CONTROLS[i:j]
|
block = CONTROLS[i:j]
|
||||||
for marker in ('id="ge-brush-spacing"', 'id="ge-brush-smoothing"',
|
for marker in ('id="ge-brush-spacing"', 'id="ge-brush-smoothing"',
|
||||||
'id="ge-brush-blend"', 'ge-brush-pressure-row',
|
'id="ge-brush-blend"', 'ge-brush-pressure-row',
|
||||||
'id="ge-brush-flow-row"', 'id="ge-brush-softness-row"'):
|
'id="ge-brush-flow-row"'):
|
||||||
assert marker in block, marker
|
assert marker in block, marker
|
||||||
|
assert 'id="ge-brush-softness-row"' not in block
|
||||||
|
assert CONTROLS.index('id="ge-brush-softness-row"') < i
|
||||||
|
|
||||||
|
|
||||||
def test_brush_size_label_drops_the_redundant_word():
|
def test_brush_size_label_drops_the_redundant_word():
|
||||||
@@ -98,7 +100,7 @@ def test_brush_strip_fits_and_smudge_row_respects_inline_hide():
|
|||||||
# The Opacity/Flow/Softness sliders are wound tighter so the strip fits.
|
# The Opacity/Flow/Softness sliders are wound tighter so the strip fits.
|
||||||
assert ".ge-controls--in-options #ge-brush-section { gap: 6px; }" in CSS
|
assert ".ge-controls--in-options #ge-brush-section { gap: 6px; }" in CSS
|
||||||
# The brush controls hug their content so the sliders sit next to the caret.
|
# The brush controls hug their content so the sliders sit next to the caret.
|
||||||
assert ".ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }" in CSS
|
assert ".ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; flex-direction: row;" in CSS
|
||||||
# The smudge Strength row is hidden with an inline display:none on other
|
# The smudge Strength row is hidden with an inline display:none on other
|
||||||
# tools; the `.ge-eraser-row { display:flex !important }` rule must not
|
# tools; the `.ge-eraser-row { display:flex !important }` rule must not
|
||||||
# override that.
|
# override that.
|
||||||
|
|||||||
@@ -44,9 +44,11 @@ def test_quick_edit_shows_the_generation_message_verbatim():
|
|||||||
assert "_isGenOfflineMsg" in EDITOR
|
assert "_isGenOfflineMsg" in EDITOR
|
||||||
assert "_setAiCommandStatus(_isGenOfflineMsg(_genErr) ? _genErr : `Could not generate that edit: ${_genErr}`, 'error');" in EDITOR
|
assert "_setAiCommandStatus(_isGenOfflineMsg(_genErr) ? _genErr : `Could not generate that edit: ${_genErr}`, 'error');" in EDITOR
|
||||||
assert "_setAiCommandStatus(_isGenOfflineMsg(msg) ? msg : `Could not generate an image: ${msg}`, 'error');" in EDITOR
|
assert "_setAiCommandStatus(_isGenOfflineMsg(msg) ? msg : `Could not generate an image: ${msg}`, 'error');" in EDITOR
|
||||||
# the generateImage op maps its own fetch/HTTP failures through the classifier
|
# The extracted generation action keeps the shared failure classifier.
|
||||||
assert "_generationErrorMessage({ network: true" in EDITOR
|
generation = (ROOT / 'static/js/editor/image-generation.js').read_text()
|
||||||
assert "_generationErrorMessage({ status: res.status, detail: _gd })" in EDITOR
|
assert "formatError: _generationErrorMessage" in EDITOR
|
||||||
|
assert "formatError({ network: true" in generation
|
||||||
|
assert "formatError({ status: response.status" in generation
|
||||||
|
|
||||||
|
|
||||||
def test_sw_cache_is_bumped():
|
def test_sw_cache_is_bumped():
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from routes import editor_agent_routes as routes
|
||||||
|
from src.agent_tools import editor_tools
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def allow_image_generation_privilege(monkeypatch):
|
||||||
|
monkeypatch.setattr('src.auth_helpers.require_privilege', lambda *args: 'test-user')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("chosen", ["openai/gpt-5-image", ""])
|
||||||
|
def test_quick_edit_generation_uses_generation_selection_not_planner_model(monkeypatch, chosen):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
async def execute(self, content, ctx):
|
||||||
|
seen.append(json.loads(content))
|
||||||
|
return "provider stub", {"exit_code": 1, "error": "test stops before canvas verification"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(editor_tools.GenerateImageLayerTool, "execute", execute)
|
||||||
|
monkeypatch.setattr(routes, "_user", lambda request: None)
|
||||||
|
monkeypatch.setattr(routes.editor_agent_model, "startup_probe", lambda: None)
|
||||||
|
monkeypatch.setattr(routes.style_guide, "strengthen", lambda prompt, steps: steps)
|
||||||
|
monkeypatch.setattr(routes.style_guide, "enforce_guards", lambda steps: steps)
|
||||||
|
monkeypatch.setattr(routes, "_image_provider_available", lambda owner: True)
|
||||||
|
monkeypatch.setattr(routes, "_plan_special", lambda *args: None)
|
||||||
|
monkeypatch.setattr(routes, "_ensure_generative", lambda prompt, steps: steps)
|
||||||
|
monkeypatch.setattr(routes, "_unsure_plan", lambda prompt, plan: plan)
|
||||||
|
routes.set_editor_planner(lambda messages, owner: json.dumps({
|
||||||
|
"reply": "Generate", "steps": [{"op": "generate_image", "params": {
|
||||||
|
"prompt": "a cow", "model": "gpt-image-1",
|
||||||
|
}}],
|
||||||
|
}))
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(routes.setup_editor_agent_routes())
|
||||||
|
try:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post("/api/editor/agent", json={
|
||||||
|
"prompt": "create a new image of a cow",
|
||||||
|
"model": "edit-only-model", "generation_model": chosen,
|
||||||
|
})
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
assert len(seen) == 1, response.text
|
||||||
|
assert seen[0].get("model", "") == chosen
|
||||||
|
finally:
|
||||||
|
routes.set_editor_planner(None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('op,expected', [('generate_image','saved-generation'),('ai_edit_image','saved-editor')])
|
||||||
|
def test_quick_edit_agent_uses_saved_models_over_browser_and_planner(monkeypatch, op, expected):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
async def execute(self, content, ctx):
|
||||||
|
seen.append(json.loads(content))
|
||||||
|
return 'stub', {'exit_code':1, 'error':'stop before verification'}
|
||||||
|
|
||||||
|
monkeypatch.setattr('src.editor_image_settings.load_settings', lambda: {
|
||||||
|
'image_model':'saved-generation', 'image_edit_model':'saved-editor'})
|
||||||
|
monkeypatch.setattr(editor_tools.GenerateImageLayerTool, 'execute', execute)
|
||||||
|
monkeypatch.setattr(editor_tools.AiEditImageTool, 'execute', execute)
|
||||||
|
monkeypatch.setattr(routes, '_user', lambda request: None)
|
||||||
|
monkeypatch.setattr(routes.editor_agent_model, 'startup_probe', lambda: None)
|
||||||
|
monkeypatch.setattr(routes.style_guide, 'strengthen', lambda prompt, steps: steps)
|
||||||
|
monkeypatch.setattr(routes.style_guide, 'enforce_guards', lambda steps: steps)
|
||||||
|
monkeypatch.setattr(routes, '_image_provider_available', lambda owner: True)
|
||||||
|
monkeypatch.setattr(routes, '_plan_special', lambda *args: None)
|
||||||
|
monkeypatch.setattr(routes, '_ensure_generative', lambda prompt, steps: steps)
|
||||||
|
monkeypatch.setattr(routes, '_unsure_plan', lambda prompt, plan: plan)
|
||||||
|
routes.set_editor_planner(lambda messages, owner: json.dumps({
|
||||||
|
'reply':'Edit', 'steps':[{'op':op,'params':{'prompt':'a cow','model':'planner-model','size':'1024x1024'}}]}))
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(routes.setup_editor_agent_routes())
|
||||||
|
try:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post('/api/editor/agent', json={
|
||||||
|
'prompt':'create an image of a cow','_use_image_settings':True,
|
||||||
|
'model':'stale-editor','generation_model':'stale-generator','state':{'width':1280,'height':720}})
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
assert len(seen) == 1, response.text
|
||||||
|
assert seen[0]['model'] == expected
|
||||||
|
assert seen[0]['_use_image_settings'] is True
|
||||||
|
if op == 'generate_image':
|
||||||
|
assert seen[0]['size'] == '1280x720'
|
||||||
|
finally:
|
||||||
|
routes.set_editor_planner(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_quick_generation_route_uses_saved_setting(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
from routes.gallery import gallery_routes as gallery
|
||||||
|
from src import ai_interaction
|
||||||
|
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
async def generate(content, **kwargs):
|
||||||
|
seen.append(json.loads(content))
|
||||||
|
return {'image_url':'data:image/png;base64,dGVzdA=='}
|
||||||
|
|
||||||
|
monkeypatch.setattr(gallery, 'require_privilege', lambda *args: 'alice')
|
||||||
|
monkeypatch.setattr(ai_interaction, 'do_generate_image', generate)
|
||||||
|
monkeypatch.setattr('src.editor_image_settings.load_settings', lambda: {'image_model':'saved-generation'})
|
||||||
|
|
||||||
|
class Request:
|
||||||
|
async def json(self):
|
||||||
|
return {'prompt':'a cow','model':'stale-generator','_use_image_settings':True,'width':1280,'height':720}
|
||||||
|
|
||||||
|
endpoint = next(route.endpoint for route in gallery.setup_gallery_routes().routes if route.path == '/api/image/generate')
|
||||||
|
result = asyncio.run(endpoint(Request()))
|
||||||
|
assert result['image'] == 'dGVzdA=='
|
||||||
|
assert seen[0]['model'] == 'saved-generation'
|
||||||
|
assert seen[0]['size'] == '1280x720'
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_default_uses_generation_only_when_edit_setting_is_empty(monkeypatch):
|
||||||
|
from src import editor_image_settings
|
||||||
|
monkeypatch.setattr(editor_image_settings, 'load_settings', lambda: {'image_model':'generator','image_edit_model':''})
|
||||||
|
assert editor_image_settings.editor_image_models() == {'generation':'generator','edit':'generator'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_image_models_per_user_isolation_and_fallback(tmp_path, monkeypatch):
|
||||||
|
from routes import prefs_routes
|
||||||
|
from src import editor_image_settings, settings
|
||||||
|
|
||||||
|
prefs_file = str(tmp_path / "user_prefs.json")
|
||||||
|
monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
|
||||||
|
|
||||||
|
global_config = {"image_model": "global-gen", "image_edit_model": "global-edit"}
|
||||||
|
monkeypatch.setattr(settings, "load_settings", lambda: global_config)
|
||||||
|
monkeypatch.setattr(editor_image_settings, "load_settings", lambda: global_config)
|
||||||
|
|
||||||
|
# Save per-user preferences
|
||||||
|
prefs_routes._save_for_user("alice", {"image_model": "alice-gen", "image_edit_model": "alice-edit"})
|
||||||
|
prefs_routes._save_for_user("bob", {"image_model": "bob-gen"}) # no edit model
|
||||||
|
|
||||||
|
# Alice gets Alice's distinct models
|
||||||
|
alice_models = editor_image_settings.editor_image_models(owner="alice")
|
||||||
|
assert alice_models == {"generation": "alice-gen", "edit": "alice-edit"}
|
||||||
|
|
||||||
|
# Bob gets Bob's generation model, and edit model falls back to global edit model
|
||||||
|
bob_models = editor_image_settings.editor_image_models(owner="bob")
|
||||||
|
assert bob_models == {"generation": "bob-gen", "edit": "global-edit"}
|
||||||
|
|
||||||
|
# When global edit model is empty, edit model falls back to Bob's generation model
|
||||||
|
empty_edit = {"image_model": "global-gen", "image_edit_model": ""}
|
||||||
|
monkeypatch.setattr(settings, "load_settings", lambda: empty_edit)
|
||||||
|
monkeypatch.setattr(editor_image_settings, "load_settings", lambda: empty_edit)
|
||||||
|
bob_fallback_models = editor_image_settings.editor_image_models(owner="bob")
|
||||||
|
assert bob_fallback_models == {"generation": "bob-gen", "edit": "bob-gen"}
|
||||||
|
|
||||||
|
# Restore global settings with global-edit
|
||||||
|
monkeypatch.setattr(settings, "load_settings", lambda: global_config)
|
||||||
|
monkeypatch.setattr(editor_image_settings, "load_settings", lambda: global_config)
|
||||||
|
|
||||||
|
# Charlie (no preferences) falls back to global admin settings
|
||||||
|
charlie_models = editor_image_settings.editor_image_models(owner="charlie")
|
||||||
|
assert charlie_models == {"generation": "global-gen", "edit": "global-edit"}
|
||||||
|
|
||||||
|
# Anonymous/unauthenticated owner falls back to global admin settings
|
||||||
|
anon_models = editor_image_settings.editor_image_models(owner="")
|
||||||
|
assert anon_models == {"generation": "global-gen", "edit": "global-edit"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_admin_user_prefs_api_routing_and_isolation(tmp_path, monkeypatch):
|
||||||
|
import time
|
||||||
|
from routes import editor_agent_routes, prefs_routes
|
||||||
|
from src import editor_image_settings, settings
|
||||||
|
|
||||||
|
prefs_file = str(tmp_path / "user_prefs.json")
|
||||||
|
monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
|
||||||
|
|
||||||
|
global_config = {"image_model": "admin-global-gen", "image_edit_model": "admin-global-edit"}
|
||||||
|
monkeypatch.setattr(settings, "load_settings", lambda: global_config)
|
||||||
|
monkeypatch.setattr(editor_image_settings, "load_settings", lambda: global_config)
|
||||||
|
|
||||||
|
current_user = "alice"
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(prefs_routes.setup_prefs_routes())
|
||||||
|
app.include_router(editor_agent_routes.setup_editor_agent_routes())
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.auth_helpers.get_current_user", lambda request: current_user)
|
||||||
|
monkeypatch.setattr(prefs_routes, "get_current_user", lambda request: current_user)
|
||||||
|
monkeypatch.setattr(editor_agent_routes, "_user", lambda request: current_user)
|
||||||
|
monkeypatch.setattr(editor_agent_routes.editor_agent_model, "startup_probe", lambda: None)
|
||||||
|
monkeypatch.setattr(editor_agent_routes.style_guide, "strengthen", lambda prompt, steps: steps)
|
||||||
|
monkeypatch.setattr(editor_agent_routes.style_guide, "enforce_guards", lambda steps: steps)
|
||||||
|
monkeypatch.setattr(editor_agent_routes, "_image_provider_available", lambda owner: True)
|
||||||
|
monkeypatch.setattr(editor_agent_routes, "_plan_special", lambda *args: None)
|
||||||
|
monkeypatch.setattr(editor_agent_routes, "_ensure_generative", lambda prompt, steps: steps)
|
||||||
|
monkeypatch.setattr(editor_agent_routes, "_unsure_plan", lambda prompt, plan: plan)
|
||||||
|
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
async def execute_gen(self, content, ctx):
|
||||||
|
seen.append(("gen", json.loads(content)))
|
||||||
|
return "stub", {"exit_code": 1, "error": "stop"}
|
||||||
|
|
||||||
|
async def execute_edit(self, content, ctx):
|
||||||
|
seen.append(("edit", json.loads(content)))
|
||||||
|
return "stub", {"exit_code": 1, "error": "stop"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(editor_tools.GenerateImageLayerTool, "execute", execute_gen)
|
||||||
|
monkeypatch.setattr(editor_tools.AiEditImageTool, "execute", execute_edit)
|
||||||
|
|
||||||
|
editor_agent_routes.set_editor_planner(lambda messages, owner: json.dumps({
|
||||||
|
"reply": "Gen", "steps": [
|
||||||
|
{"op": "generate_image", "params": {"prompt": "cat", "model": "stale-planner", "size": "512x512"}}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# Alice configures her preferences via PUT /api/prefs/{key}
|
||||||
|
put_gen = client.put("/api/prefs/image_model", json={"value": "alice-fav-gen"})
|
||||||
|
assert put_gen.status_code == 200
|
||||||
|
put_edit = client.put("/api/prefs/image_edit_model", json={"value": "alice-fav-edit"})
|
||||||
|
assert put_edit.status_code == 200
|
||||||
|
|
||||||
|
# Verify GET /api/prefs reflects Alice's choices
|
||||||
|
get_prefs = client.get("/api/prefs")
|
||||||
|
assert get_prefs.status_code == 200
|
||||||
|
assert get_prefs.json()["image_model"] == "alice-fav-gen"
|
||||||
|
assert get_prefs.json()["image_edit_model"] == "alice-fav-edit"
|
||||||
|
|
||||||
|
# Alice invokes quick edit with _use_image_settings: True
|
||||||
|
res = client.post("/api/editor/agent", json={
|
||||||
|
"prompt": "generate a cat",
|
||||||
|
"_use_image_settings": True,
|
||||||
|
"model": "stale-browser-edit",
|
||||||
|
"generation_model": "stale-browser-gen",
|
||||||
|
})
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert len(seen) == 1
|
||||||
|
# Authoritative Alice preference overrides stale planner and browser models
|
||||||
|
assert seen[0][1]["model"] == "alice-fav-gen"
|
||||||
|
|
||||||
|
# Now test Bob (who has no preferences) - should receive global admin settings
|
||||||
|
current_user = "bob"
|
||||||
|
seen.clear()
|
||||||
|
res_bob = client.post("/api/editor/agent", json={
|
||||||
|
"prompt": "generate a dog",
|
||||||
|
"_use_image_settings": True,
|
||||||
|
"model": "stale-browser-edit",
|
||||||
|
"generation_model": "stale-browser-gen",
|
||||||
|
})
|
||||||
|
assert res_bob.status_code == 200
|
||||||
|
assert len(seen) == 1
|
||||||
|
# Bob falls back to global default, not Alice's preference
|
||||||
|
assert seen[0][1]["model"] == "admin-global-gen"
|
||||||
|
finally:
|
||||||
|
editor_agent_routes.set_editor_planner(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_background_removal_intent_grammar_and_dos_safety():
|
||||||
|
from routes.editor_agent_routes import _is_background_removal_intent
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Valid commands across all verbs and targets
|
||||||
|
assert _is_background_removal_intent("remove background")
|
||||||
|
assert _is_background_removal_intent("please remove the background")
|
||||||
|
assert _is_background_removal_intent("erase bg")
|
||||||
|
assert _is_background_removal_intent("delete backdrop")
|
||||||
|
assert _is_background_removal_intent("clear the bg from the active layer")
|
||||||
|
assert _is_background_removal_intent("strip background of current layer!")
|
||||||
|
assert _is_background_removal_intent("erase backdrop from selected layer...")
|
||||||
|
assert _is_background_removal_intent(" PLEASE REMOVE THE BG FROM THE SELECTED LAYER . ! ")
|
||||||
|
|
||||||
|
# Invalid commands rejected
|
||||||
|
assert not _is_background_removal_intent("")
|
||||||
|
assert not _is_background_removal_intent("remove foreground")
|
||||||
|
assert not _is_background_removal_intent("please blur the background")
|
||||||
|
assert not _is_background_removal_intent("remove background and make it dark")
|
||||||
|
assert not _is_background_removal_intent("remove")
|
||||||
|
assert not _is_background_removal_intent("background")
|
||||||
|
assert not _is_background_removal_intent("please remove")
|
||||||
|
assert not _is_background_removal_intent("remove the")
|
||||||
|
assert not _is_background_removal_intent("remove background from layer") # missing active/current/selected
|
||||||
|
assert not _is_background_removal_intent("remove background?") # unallowed punctuation
|
||||||
|
|
||||||
|
# ReDoS safety test: large repeated whitespace must run linearly in < 50ms
|
||||||
|
evil_padding = " \t " * 10000
|
||||||
|
evil_prompt = evil_padding + "please" + evil_padding + "remove" + evil_padding + "the" + evil_padding + "bg" + evil_padding + "!"
|
||||||
|
start = time.perf_counter()
|
||||||
|
assert _is_background_removal_intent(evil_prompt)
|
||||||
|
elapsed = time.perf_counter() - start
|
||||||
|
assert elapsed < 0.1, f"Intent parsing took too long: {elapsed:.4f}s (polynomial backtracking risk)"
|
||||||
|
|
||||||
|
evil_invalid = evil_padding + "remove" + evil_padding + "everything"
|
||||||
|
start = time.perf_counter()
|
||||||
|
assert not _is_background_removal_intent(evil_invalid)
|
||||||
|
elapsed_invalid = time.perf_counter() - start
|
||||||
|
assert elapsed_invalid < 0.1, f"Invalid intent parsing took too long: {elapsed_invalid:.4f}s"
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_generation_global_disabled_user_enabled_rejected(tmp_path, monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from routes.gallery import gallery_routes as gallery
|
||||||
|
from routes import editor_agent_routes, prefs_routes
|
||||||
|
from src import ai_interaction, settings
|
||||||
|
|
||||||
|
prefs_file = str(tmp_path / "user_prefs.json")
|
||||||
|
monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
|
||||||
|
|
||||||
|
# Admin globally disables image generation
|
||||||
|
monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": False, "image_model": "gpt-image-1"})
|
||||||
|
|
||||||
|
# User 'alice' has image_gen_enabled = True in her preferences
|
||||||
|
prefs_routes._save_for_user("alice", {"image_gen_enabled": True, "image_model": "alice-model"})
|
||||||
|
|
||||||
|
# 1. get_user_setting must be False despite user preference
|
||||||
|
assert settings.get_user_setting("image_gen_enabled", owner="alice", default=True) is False
|
||||||
|
|
||||||
|
# 2. image_editing_provider_available must be False
|
||||||
|
assert ai_interaction.image_editing_provider_available(owner="alice") is False
|
||||||
|
|
||||||
|
# 3. Direct generation must be rejected with admin disabled message
|
||||||
|
direct_res = asyncio.run(ai_interaction.do_generate_image(
|
||||||
|
json.dumps({"prompt": "a dog"}), owner="alice"
|
||||||
|
))
|
||||||
|
assert "error" in direct_res
|
||||||
|
assert "disabled by the administrator" in direct_res["error"]
|
||||||
|
|
||||||
|
# 4. Gallery route must reject with 503
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(gallery.setup_gallery_routes())
|
||||||
|
monkeypatch.setattr(gallery, "require_privilege", lambda request, priv: "alice")
|
||||||
|
with TestClient(app) as client:
|
||||||
|
gal_res = client.post("/api/image/generate", json={"prompt": "a dog"})
|
||||||
|
assert gal_res.status_code == 503
|
||||||
|
assert "switched off" in gal_res.json()["detail"].lower()
|
||||||
|
|
||||||
|
# 5. Editor agent route must be blocked
|
||||||
|
app_editor = FastAPI()
|
||||||
|
app_editor.include_router(editor_agent_routes.setup_editor_agent_routes())
|
||||||
|
monkeypatch.setattr(editor_agent_routes, "_user", lambda request: "alice")
|
||||||
|
routes.set_editor_planner(lambda messages, owner: json.dumps({
|
||||||
|
"reply": "Gen", "steps": [{"op": "generate_image", "params": {"prompt": "a dog"}}]
|
||||||
|
}))
|
||||||
|
try:
|
||||||
|
with TestClient(app_editor) as client:
|
||||||
|
ed_res = client.post("/api/editor/agent", json={"prompt": "create a dog"})
|
||||||
|
assert ed_res.status_code == 200
|
||||||
|
data = ed_res.json()
|
||||||
|
assert data.get("blocked") is True
|
||||||
|
assert data.get("needs_provider") is True
|
||||||
|
finally:
|
||||||
|
routes.set_editor_planner(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_generation_global_enabled_user_disabled_rejected(tmp_path, monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
from routes.gallery import gallery_routes as gallery
|
||||||
|
from routes import editor_agent_routes, prefs_routes
|
||||||
|
from src import ai_interaction, settings
|
||||||
|
|
||||||
|
prefs_file = str(tmp_path / "user_prefs.json")
|
||||||
|
monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
|
||||||
|
|
||||||
|
# Admin globally enables image generation
|
||||||
|
monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": True, "image_model": "gpt-image-1"})
|
||||||
|
|
||||||
|
# User 'bob' has image_gen_enabled = False in his preferences
|
||||||
|
prefs_routes._save_for_user("bob", {"image_gen_enabled": False, "image_model": "bob-model"})
|
||||||
|
|
||||||
|
# 1. get_user_setting must be False per user preference
|
||||||
|
assert settings.get_user_setting("image_gen_enabled", owner="bob", default=True) is False
|
||||||
|
|
||||||
|
# 2. image_editing_provider_available must be False for bob
|
||||||
|
assert ai_interaction.image_editing_provider_available(owner="bob") is False
|
||||||
|
|
||||||
|
# 3. Direct generation must be rejected with user preference message
|
||||||
|
direct_res = asyncio.run(ai_interaction.do_generate_image(
|
||||||
|
json.dumps({"prompt": "a cat"}), owner="bob"
|
||||||
|
))
|
||||||
|
assert "error" in direct_res
|
||||||
|
assert "disabled by user preference" in direct_res["error"]
|
||||||
|
|
||||||
|
# 4. Gallery route must reject with 503
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(gallery.setup_gallery_routes())
|
||||||
|
monkeypatch.setattr(gallery, "require_privilege", lambda request, priv: "bob")
|
||||||
|
with TestClient(app) as client:
|
||||||
|
gal_res = client.post("/api/image/generate", json={"prompt": "a cat"})
|
||||||
|
assert gal_res.status_code == 503
|
||||||
|
|
||||||
|
# 5. Editor agent route must be blocked for bob
|
||||||
|
app_editor = FastAPI()
|
||||||
|
app_editor.include_router(editor_agent_routes.setup_editor_agent_routes())
|
||||||
|
monkeypatch.setattr(editor_agent_routes, "_user", lambda request: "bob")
|
||||||
|
routes.set_editor_planner(lambda messages, owner: json.dumps({
|
||||||
|
"reply": "Gen", "steps": [{"op": "generate_image", "params": {"prompt": "a cat"}}]
|
||||||
|
}))
|
||||||
|
try:
|
||||||
|
with TestClient(app_editor) as client:
|
||||||
|
ed_res = client.post("/api/editor/agent", json={"prompt": "create a cat"})
|
||||||
|
assert ed_res.status_code == 200
|
||||||
|
data = ed_res.json()
|
||||||
|
assert data.get("blocked") is True
|
||||||
|
assert data.get("needs_provider") is True
|
||||||
|
finally:
|
||||||
|
routes.set_editor_planner(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_generation_authorized_generation_and_privilege_checks(tmp_path, monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from routes.gallery import gallery_routes as gallery
|
||||||
|
from routes import editor_agent_routes, prefs_routes
|
||||||
|
from src import ai_interaction, settings
|
||||||
|
|
||||||
|
prefs_file = str(tmp_path / "user_prefs.json")
|
||||||
|
monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
|
||||||
|
|
||||||
|
# Admin globally enables image generation
|
||||||
|
monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": True, "image_model": "gpt-image-1"})
|
||||||
|
|
||||||
|
# User 'charlie' has image generation enabled (no disabling pref)
|
||||||
|
prefs_routes._save_for_user("charlie", {"image_model": "charlie-gen"})
|
||||||
|
|
||||||
|
assert settings.get_user_setting("image_gen_enabled", owner="charlie", default=True) is True
|
||||||
|
|
||||||
|
# Check privilege enforcement: user lacking can_generate_images is forbidden
|
||||||
|
def require_privilege_mock(request, privilege):
|
||||||
|
if privilege == "can_generate_images":
|
||||||
|
raise HTTPException(403, "Permission denied")
|
||||||
|
return "charlie"
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.auth_helpers.require_privilege", require_privilege_mock)
|
||||||
|
monkeypatch.setattr(gallery, "require_privilege", require_privilege_mock)
|
||||||
|
|
||||||
|
# Gallery checks privilege and returns 403
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(gallery.setup_gallery_routes())
|
||||||
|
with TestClient(app) as client:
|
||||||
|
gal_res = client.post("/api/image/generate", json={"prompt": "a bird"})
|
||||||
|
assert gal_res.status_code == 403
|
||||||
|
assert "Permission denied" in gal_res.text
|
||||||
|
|
||||||
|
# Editor checks privilege on AI content and raises 403
|
||||||
|
app_editor = FastAPI()
|
||||||
|
app_editor.include_router(editor_agent_routes.setup_editor_agent_routes())
|
||||||
|
monkeypatch.setattr(editor_agent_routes, "_user", lambda request: "charlie")
|
||||||
|
monkeypatch.setattr(editor_agent_routes, "_image_provider_available", lambda owner: True)
|
||||||
|
routes.set_editor_planner(lambda messages, owner: json.dumps({
|
||||||
|
"reply": "Gen", "steps": [{"op": "generate_image", "params": {"prompt": "a bird"}}]
|
||||||
|
}))
|
||||||
|
try:
|
||||||
|
with TestClient(app_editor) as client:
|
||||||
|
ed_res = client.post("/api/editor/agent", json={"prompt": "create a bird"})
|
||||||
|
assert ed_res.status_code == 403
|
||||||
|
finally:
|
||||||
|
routes.set_editor_planner(None)
|
||||||
|
|
||||||
|
# Now grant privilege: generation proceeds
|
||||||
|
monkeypatch.setattr("src.auth_helpers.require_privilege", lambda request, priv: "charlie")
|
||||||
|
monkeypatch.setattr(gallery, "require_privilege", lambda request, priv: "charlie")
|
||||||
|
seen = []
|
||||||
|
async def fake_do_generate(content, **kwargs):
|
||||||
|
seen.append(kwargs)
|
||||||
|
return {"image_url": "data:image/png;base64,dGVzdA=="}
|
||||||
|
monkeypatch.setattr(ai_interaction, "do_generate_image", fake_do_generate)
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
gal_ok = client.post("/api/image/generate", json={"prompt": "a bird"})
|
||||||
|
assert gal_ok.status_code == 200
|
||||||
|
assert seen[0]["owner"] == "charlie"
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_pref_clears_key_when_value_is_null(tmp_path, monkeypatch):
|
||||||
|
from routes import prefs_routes
|
||||||
|
from src import settings
|
||||||
|
|
||||||
|
prefs_file = str(tmp_path / "user_prefs.json")
|
||||||
|
monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
|
||||||
|
|
||||||
|
# Set initial preference
|
||||||
|
prefs_routes._save_for_user("admin_user", {"image_model": "old-admin-model"})
|
||||||
|
assert prefs_routes._load_for_user("admin_user").get("image_model") == "old-admin-model"
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(prefs_routes.setup_prefs_routes())
|
||||||
|
monkeypatch.setattr("src.auth_helpers.get_current_user", lambda request: "admin_user")
|
||||||
|
monkeypatch.setattr(prefs_routes, "get_current_user", lambda request: "admin_user")
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
res = client.put("/api/prefs/image_model", json={"value": None})
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json() == {"key": "image_model", "value": None}
|
||||||
|
|
||||||
|
# Verify key is deleted from user's record
|
||||||
|
user_prefs = prefs_routes._load_for_user("admin_user")
|
||||||
|
assert "image_model" not in user_prefs
|
||||||
|
|
||||||
|
# Verify get_user_setting now falls back to global setting
|
||||||
|
monkeypatch.setattr(settings, "load_settings", lambda: {"image_model": "new-global-model"})
|
||||||
|
assert settings.get_user_setting("image_model", owner="admin_user") == "new-global-model"
|
||||||
@@ -49,10 +49,9 @@ def test_ctrl_zoom_shortcuts_drive_the_toolbar_and_override_the_browser():
|
|||||||
|
|
||||||
def test_ctrl_alt_l_opens_the_levels_adjustment():
|
def test_ctrl_alt_l_opens_the_levels_adjustment():
|
||||||
keyboard = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8")
|
keyboard = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8")
|
||||||
# Photoshop-style chord: reuse the Image menu row so the dialog and the
|
# Open layer-local controls without creating/committing a new adjustment layer.
|
||||||
# adjustment-layer plumbing stay in one place.
|
|
||||||
assert "e.altKey && e.code === 'KeyL'" in keyboard
|
assert "e.altKey && e.code === 'KeyL'" in keyboard
|
||||||
assert "document.querySelector('[data-image-action=\"adj-levels\"]')" in keyboard
|
assert "openLayerAdjustment?.('levels');" in keyboard
|
||||||
|
|
||||||
|
|
||||||
def test_ctrl_alt_shift_e_stamps_the_visible_layers():
|
def test_ctrl_alt_shift_e_stamps_the_visible_layers():
|
||||||
|
|||||||
@@ -0,0 +1,992 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except ImportError:
|
||||||
|
sync_playwright = None
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def page():
|
||||||
|
if sync_playwright is None:
|
||||||
|
pytest.fail("playwright is required for editor browser tests. Install requirements-dev.txt")
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
launch_kwargs = {"headless": True, "args": ["--disable-dev-shm-usage", "--no-sandbox"]}
|
||||||
|
if os.environ.get("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH"):
|
||||||
|
launch_kwargs["executable_path"] = os.environ["PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH"]
|
||||||
|
browser = playwright.chromium.launch(**launch_kwargs)
|
||||||
|
page = browser.new_page(viewport={"width": 1000, "height": 700})
|
||||||
|
|
||||||
|
def serve(route):
|
||||||
|
url = route.request.url
|
||||||
|
if '/api/' in url:
|
||||||
|
route.fulfill(status=404, body='{}', content_type='application/json')
|
||||||
|
return
|
||||||
|
relative = url.split('http://editor.test/', 1)[1].split('?', 1)[0]
|
||||||
|
if not relative:
|
||||||
|
route.fulfill(body='<html><body></body></html>', content_type='text/html')
|
||||||
|
elif (ROOT / relative).is_file():
|
||||||
|
route.fulfill(path=str(ROOT / relative))
|
||||||
|
else:
|
||||||
|
route.fulfill(status=404, body='Not found')
|
||||||
|
|
||||||
|
page.route('http://editor.test/**', serve)
|
||||||
|
page.goto('http://editor.test/')
|
||||||
|
yield page
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_background_sends_only_active_layer_and_preserves_others(page):
|
||||||
|
result = page.evaluate("""async () => {
|
||||||
|
const {state} = await import('/static/js/editor/state.js');
|
||||||
|
const {createApplyImageTool} = await import('/static/js/editor/ai-tool-runner.js');
|
||||||
|
function layer(id, color) {
|
||||||
|
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 4;
|
||||||
|
const ctx = canvas.getContext('2d'); ctx.fillStyle = color; ctx.fillRect(0,0,4,4);
|
||||||
|
return {id, canvas, ctx, visible:true, locks:{}};
|
||||||
|
}
|
||||||
|
const red = layer('red','red'), blue = layer('blue','blue');
|
||||||
|
red.kind = 'placed'; red.placed = {sourceName:'original'};
|
||||||
|
state.layers = [red, blue]; state.activeLayerId = red.id; state.editorOpen = true;
|
||||||
|
const beforeBlue = blue.canvas.toDataURL(); const expected = red.canvas.toDataURL().split(',')[1];
|
||||||
|
let sent, snapshots = 0;
|
||||||
|
const clear = document.createElement('canvas'); clear.width = clear.height = 4;
|
||||||
|
window.fetch = async (url, opts) => {
|
||||||
|
sent = JSON.parse(opts.body);
|
||||||
|
state.activeLayerId = blue.id; // Changing selection during the request must not change its target.
|
||||||
|
return {ok:true, json:async () => ({image:clear.toDataURL()})};
|
||||||
|
};
|
||||||
|
const run = createApplyImageTool({
|
||||||
|
flatten: () => {throw Error('Must not flatten');}, saveState: () => snapshots++,
|
||||||
|
createLayer: () => {throw Error('Must not create a merged layer');},
|
||||||
|
composite: () => {}, renderLayerPanel: () => {}, deriveBusyLabel: () => 'Removing',
|
||||||
|
getSelectedAIEndpoint: () => ({}), spinnerModule: {}, uiModule: {showToast(){}},
|
||||||
|
});
|
||||||
|
await run('/api/image/remove-bg', {}, 'BG Removed', document.createElement('button'));
|
||||||
|
return {correctInput:sent.image === expected, blueUnchanged:blue.canvas.toDataURL() === beforeBlue,
|
||||||
|
count:state.layers.length, visible:state.layers.every(l => l.visible), snapshots,
|
||||||
|
alpha:red.ctx.getImageData(0,0,1,1).data[3], kind:red.kind, placed:red.placed};
|
||||||
|
}""")
|
||||||
|
assert result == dict(correctInput=True, blueUnchanged=True, count=2, visible=True, snapshots=1, alpha=0, kind='raster', placed=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_levels_shortcut_opens_controls_without_menu_commit(page):
|
||||||
|
result = page.evaluate("""async () => {
|
||||||
|
const {state} = await import('/static/js/editor/state.js');
|
||||||
|
const {wireKeyboardShortcuts} = await import('/static/js/editor/keyboard-shortcuts.js');
|
||||||
|
state.editorOpen = true;
|
||||||
|
let selected;
|
||||||
|
wireKeyboardShortcuts({toolbar:document.createElement('div'), toolKeyMap:{},
|
||||||
|
openLayerAdjustment:type => selected = type});
|
||||||
|
const event = new KeyboardEvent('keydown', {key:'l', code:'KeyL', ctrlKey:true, altKey:true,
|
||||||
|
bubbles:true, cancelable:true});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
return {selected, prevented:event.defaultPrevented};
|
||||||
|
}""")
|
||||||
|
assert result == dict(selected='levels', prevented=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hue_saturation_shortcut_opens_adjustment(page):
|
||||||
|
result = page.evaluate("""async () => {
|
||||||
|
const {state} = await import('/static/js/editor/state.js');
|
||||||
|
const {wireKeyboardShortcuts} = await import('/static/js/editor/keyboard-shortcuts.js');
|
||||||
|
state.editorOpen = true;
|
||||||
|
let selected;
|
||||||
|
wireKeyboardShortcuts({toolbar:document.createElement('div'), toolKeyMap:{},
|
||||||
|
openLayerAdjustment:type => selected = type});
|
||||||
|
const event = new KeyboardEvent('keydown', {key:'U', code:'KeyU', ctrlKey:true, shiftKey:true,
|
||||||
|
bubbles:true, cancelable:true});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
return {selected, prevented:event.defaultPrevented};
|
||||||
|
}""")
|
||||||
|
assert result == dict(selected='hue-saturation', prevented=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inner_shadow_clips_inside_and_reverses_direction(page):
|
||||||
|
result = page.evaluate("""async () => {
|
||||||
|
const {renderEffects} = await import('/static/js/editor/effects.js');
|
||||||
|
const source = document.createElement('canvas'); source.width=source.height=60;
|
||||||
|
const ctx=source.getContext('2d');ctx.fillStyle='white';ctx.fillRect(15,15,30,30);
|
||||||
|
function sample(inside,x) {
|
||||||
|
const out=renderEffects(source,[{type:'drop-shadow',params:{inside,x,y:0,blur:0,opacity:1,color:'#000000'}}]);
|
||||||
|
const c=out.getContext('2d');
|
||||||
|
return {left:[...c.getImageData(16,30,1,1).data],right:[...c.getImageData(43,30,1,1).data],
|
||||||
|
outside:[...c.getImageData(47,30,1,1).data]};
|
||||||
|
}
|
||||||
|
return {right:sample(true,6),left:sample(true,-6),outer:sample(false,6)};
|
||||||
|
}""")
|
||||||
|
assert result['right']['left'] == [0,0,0,255]
|
||||||
|
assert result['right']['right'] == [255,255,255,255]
|
||||||
|
assert result['left']['right'] == [0,0,0,255]
|
||||||
|
assert result['right']['outside'][3] == 0
|
||||||
|
assert result['outer']['outside'] == [0,0,0,255]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cut_placed_image_rasterizes_once_without_confirmation(page):
|
||||||
|
page.route('http://editor.test/api/**', lambda route: route.fulfill(json={}))
|
||||||
|
page.evaluate("""async () => {
|
||||||
|
document.body.innerHTML='<div id="toast"></div><div id="gallery-editor-container"></div>';
|
||||||
|
const editor=await import('/static/js/galleryEditor.js');
|
||||||
|
const {state}=await import('/static/js/editor/state.js');
|
||||||
|
editor.openEditor(null,null,{w:32,h:32},'Cut test');
|
||||||
|
const layer=state.layers.at(-1);
|
||||||
|
layer.ctx.fillStyle='red';layer.ctx.fillRect(0,0,32,32);
|
||||||
|
layer.kind='placed';layer.placed={sourceName:'Original image'};
|
||||||
|
document.activeElement?.blur();
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown',{key:'x',code:'KeyX',ctrlKey:true,bubbles:true,cancelable:true}));
|
||||||
|
}""")
|
||||||
|
page.wait_for_function("""async () => {
|
||||||
|
const {state}=await import('/static/js/editor/state.js');
|
||||||
|
return !!state.internalClipboard && state.layers.at(-1).ctx.getImageData(0,0,1,1).data[3]===0;
|
||||||
|
}""")
|
||||||
|
result=page.evaluate("""async () => {
|
||||||
|
const {state}=await import('/static/js/editor/state.js');
|
||||||
|
return {kind:state.layers.at(-1).kind,placed:state.layers.at(-1).placed,
|
||||||
|
copied:[...state.internalClipboard.getContext('2d').getImageData(0,0,1,1).data],
|
||||||
|
prompted:document.querySelector('#styled-confirm-modal')?.textContent?.includes('Rasterize layer')||false};
|
||||||
|
}""")
|
||||||
|
assert result==dict(kind='raster',placed=None,copied=[255,0,0,255],prompted=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_shadow_dialog_inside_and_direction_apply(page):
|
||||||
|
page.route('http://editor.test/api/**', lambda route: route.fulfill(json={}))
|
||||||
|
page.add_style_tag(content=(ROOT/'static/css/13-image-editor.css').read_text())
|
||||||
|
page.add_style_tag(content=':root{--panel:#242426;--bg:#171719;--fg:#eee;--border:#454549;--red:#d47965;}body{background:var(--bg);color:var(--fg);font-family:Arial,sans-serif;}')
|
||||||
|
page.evaluate("""async () => {
|
||||||
|
document.body.innerHTML='<div id="toast"></div><div id="gallery-editor-container" style="height:680px"></div>';
|
||||||
|
const editor=await import('/static/js/galleryEditor.js');
|
||||||
|
editor.openEditor(null,null,{w:64,h:64},'Shadow test');
|
||||||
|
document.querySelector('[data-filter-action="effect-drop-shadow"]').click();
|
||||||
|
}""")
|
||||||
|
page.locator('.ge-filter-overlay select[data-key="inside"]').select_option('1')
|
||||||
|
page.locator('.ge-filter-overlay input[data-key="angle"]').evaluate("el=>{el.value='90';el.dispatchEvent(new Event('input',{bubbles:true}));}")
|
||||||
|
page.screenshot(path='/tmp/editor-shadow-inside-direction.png')
|
||||||
|
page.locator('.ge-filter-overlay [data-action="apply"]').click()
|
||||||
|
page.wait_for_function("""async () => {
|
||||||
|
const {state}=await import('/static/js/editor/state.js');
|
||||||
|
return state.layers.at(-1).effects.length===1;
|
||||||
|
}""")
|
||||||
|
result=page.evaluate("""async () => {
|
||||||
|
const {state}=await import('/static/js/editor/state.js');
|
||||||
|
const {inside,x,y}=state.layers.at(-1).effects[0].params;
|
||||||
|
return {inside,x,y};
|
||||||
|
}""")
|
||||||
|
assert result==dict(inside=True,x=0,y=-7)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reorder_rebuilds_effect_rows_under_their_parent(page):
|
||||||
|
result=page.evaluate("""async () => {
|
||||||
|
const {state}=await import('/static/js/editor/state.js');
|
||||||
|
const {createLayerPanelRenderer}=await import('/static/js/editor/layer-panel.js');
|
||||||
|
document.body.innerHTML='<div id="ge-layers-list"></div>';
|
||||||
|
state.container=document.body;state.editorOpen=true;
|
||||||
|
function layer(id,effects=[]) {
|
||||||
|
const canvas=document.createElement('canvas');canvas.width=canvas.height=16;
|
||||||
|
return {id,name:id,kind:'raster',canvas,ctx:canvas.getContext('2d'),visible:true,
|
||||||
|
opacity:1,locks:{},effects,masks:[],adjLayers:[]};
|
||||||
|
}
|
||||||
|
const a=layer('a',[{id:'shadow-a',type:'drop-shadow',params:{}}]), b=layer('b');
|
||||||
|
state.layers=[a,b];state.activeLayerId=a.id;state.layerOffsets=new Map();state.layerGroups=[];
|
||||||
|
let options;
|
||||||
|
const renderer=createLayerPanelRenderer({composite(){},saveState(){},
|
||||||
|
dragSortModule:{enable:(id,selector,config)=>{options=config;}},uiModule:{showToast(){}}});
|
||||||
|
renderer.render();
|
||||||
|
const list=document.getElementById('ge-layers-list');
|
||||||
|
const row=id=>list.querySelector(`.ge-layer-item[data-layer-id="${id}"]`);
|
||||||
|
list.insertBefore(row('a'),row('b'));
|
||||||
|
options.onReorder([...list.querySelectorAll('.ge-layer-item[data-layer-id]')]);
|
||||||
|
return {order:state.layers.map(layer=>layer.id),excluded:options.excludeSelector,
|
||||||
|
rows:[...list.children].map(el=>el.dataset.layerId||el.dataset.effectId)};
|
||||||
|
}""")
|
||||||
|
assert result==dict(order=['b','a'],excluded='.ge-adj-sub-item',rows=['a','shadow-a','b'])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('tool', ['marquee','lasso'])
|
||||||
|
@pytest.mark.parametrize('pointer_type', ['mouse','pen','touch'])
|
||||||
|
@pytest.mark.parametrize('pan', [False,True])
|
||||||
|
def test_selections_start_outside_canvas_unless_explicitly_panning(page,tool,pointer_type,pan):
|
||||||
|
result=page.evaluate("""async ({tool,pointerType,pan}) => {
|
||||||
|
const {state}=await import('/static/js/editor/state.js');
|
||||||
|
const {wireCanvasEvents}=await import('/static/js/editor/canvas-events.js');
|
||||||
|
document.body.innerHTML='<div id="area"><canvas width="100" height="100"></canvas><button>Chrome</button></div>';
|
||||||
|
const area=document.getElementById('area');
|
||||||
|
state.mainCanvas=area.querySelector('canvas');state.tool=tool;
|
||||||
|
state.editorOpen=true;state.spacePanActive=pan;state.panX=state.panY=0;
|
||||||
|
let starts=0,moves=0,ends=0;
|
||||||
|
wireCanvasEvents({canvasArea:area,beginDraw:()=>starts++,continueDraw:()=>moves++,
|
||||||
|
endDraw:()=>ends++,updateBrushCursor(){},syncZoomControls(){}});
|
||||||
|
function pointer(type,target=area) {
|
||||||
|
target.dispatchEvent(new PointerEvent(type,{pointerId:5,pointerType,button:0,
|
||||||
|
clientX:type==='pointerdown'?10:30,clientY:type==='pointerdown'?10:30,bubbles:true,cancelable:true}));
|
||||||
|
}
|
||||||
|
pointer('pointerdown');
|
||||||
|
if(pointerType==='mouse')area.dispatchEvent(new MouseEvent('mousedown',{button:0,bubbles:true}));
|
||||||
|
pointer('pointermove');
|
||||||
|
if(pointerType==='mouse')window.dispatchEvent(new MouseEvent('mousemove'));
|
||||||
|
pointer('pointerup');
|
||||||
|
if(pointerType==='mouse')window.dispatchEvent(new MouseEvent('mouseup'));
|
||||||
|
const after={starts,moves,ends,panX:state.panX};
|
||||||
|
pointer('pointerdown',area.querySelector('button'));
|
||||||
|
return {...after,chromeStarted:starts!==after.starts};
|
||||||
|
}""",dict(tool=tool,pointerType=pointer_type,pan=pan))
|
||||||
|
assert result['starts']==(0 if pan else 1)
|
||||||
|
assert result['panX']==(20 if pan else 0)
|
||||||
|
assert not result['chromeStarted']
|
||||||
|
if not pan:
|
||||||
|
assert result['moves']==1
|
||||||
|
assert result['ends']==1
|
||||||
|
|
||||||
|
|
||||||
|
def test_marquee_drag_from_workspace_creates_clipped_selection(page):
|
||||||
|
result=page.evaluate("""async () => {
|
||||||
|
const {state}=await import('/static/js/editor/state.js');
|
||||||
|
const {wireCanvasEvents}=await import('/static/js/editor/canvas-events.js');
|
||||||
|
const {createMarqueeTool}=await import('/static/js/editor/tools/marquee.js');
|
||||||
|
document.body.innerHTML='<div id="area"><canvas width="100" height="100" style="position:absolute;left:100px;top:100px;width:100px;height:100px"></canvas></div>';
|
||||||
|
const area=document.getElementById('area');state.mainCanvas=area.querySelector('canvas');
|
||||||
|
state.tool='marquee';state.imgWidth=state.imgHeight=100;state.editorOpen=true;
|
||||||
|
state.spacePanActive=false;state.panX=state.panY=0;state.wandMask=null;
|
||||||
|
const layer={id:'a'};state.layers=[layer];state.activeLayerId='a';state.layerOffsets=new Map();
|
||||||
|
const tool=createMarqueeTool({activeLayer:()=>layer,saveState(){},composite(){},drawOverlay(){},syncSelectionUi(){}});
|
||||||
|
wireCanvasEvents({canvasArea:area,beginDraw:e=>tool.begin(e),continueDraw:e=>tool.drag(e),
|
||||||
|
endDraw:e=>tool.end(e),updateBrushCursor(){},syncZoomControls(){}});
|
||||||
|
area.dispatchEvent(new PointerEvent('pointerdown',{pointerType:'mouse',pointerId:1,button:0,clientX:80,clientY:80,bubbles:true}));
|
||||||
|
area.dispatchEvent(new MouseEvent('mousedown',{button:0,clientX:80,clientY:80,bubbles:true}));
|
||||||
|
window.dispatchEvent(new MouseEvent('mousemove',{clientX:150,clientY:150}));
|
||||||
|
window.dispatchEvent(new MouseEvent('mouseup',{clientX:150,clientY:150}));
|
||||||
|
const ctx=state.wandMask.getContext('2d');
|
||||||
|
return {inside:ctx.getImageData(0,0,1,1).data[3],outside:ctx.getImageData(70,70,1,1).data[3],pan:state.panX};
|
||||||
|
}""")
|
||||||
|
assert result==dict(inside=255,outside=0,pan=0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_menu_remains_clickable_after_png_export_and_window_raise(page):
|
||||||
|
page.add_style_tag(content='''
|
||||||
|
.modal {position:fixed;inset:0;z-index:20000;background:#222;}
|
||||||
|
#ge-save-menu-btn {position:absolute;right:20px;top:20px;}
|
||||||
|
.ge-save-menu {position:fixed;z-index:10005;width:200px;background:#333;}
|
||||||
|
.ge-save-menu[hidden] {display:none;}
|
||||||
|
.ge-save-menu button {display:block;width:100%;height:30px;}
|
||||||
|
''')
|
||||||
|
page.evaluate("""async () => {
|
||||||
|
const {wireTopbar}=await import('/static/js/editor/wire-topbar.js');
|
||||||
|
document.body.innerHTML='<div id="gallery-modal" class="modal"><div id="gallery-editor-container"><div class="ge-topbar"><div class="ge-save-wrap"><button id="ge-save-menu-btn">Save</button><div id="ge-save-menu" class="ge-save-menu" hidden><button id="ge-save">Save image</button><button id="ge-export-png">Save as PNG</button></div></div></div></div></div>';
|
||||||
|
window.pngExports=0;window.saves=0;
|
||||||
|
document.getElementById('ge-save').addEventListener('click',()=>window.saves++);
|
||||||
|
wireTopbar({registerDocClickAway(){},exportPNGFile:()=>window.pngExports++});
|
||||||
|
}""")
|
||||||
|
page.locator('#ge-save-menu-btn').click()
|
||||||
|
page.locator('#ge-export-png').click(timeout=2000)
|
||||||
|
page.evaluate("document.getElementById('gallery-modal').style.zIndex='50000'")
|
||||||
|
page.locator('#ge-save-menu-btn').click()
|
||||||
|
page.locator('#ge-save').click(timeout=2000)
|
||||||
|
assert page.evaluate('({png:window.pngExports,saves:window.saves})') == dict(png=1,saves=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_submenu_hover_aligns_to_row_and_flips_at_right_edge(page):
|
||||||
|
page.add_style_tag(content='''
|
||||||
|
#menu {position:absolute; left:780px; top:100px; width:180px;}
|
||||||
|
button {display:block; height:35px; width:180px;}
|
||||||
|
.ge-submenu {display:none; position:absolute; width:160px; height:120px; top:0; left:100%;}
|
||||||
|
.ge-menu-parent:hover + .ge-submenu {display:block;}
|
||||||
|
''')
|
||||||
|
page.evaluate("""async () => {
|
||||||
|
document.body.innerHTML = '<div id="menu"><button>First</button><button>Second</button>'
|
||||||
|
+ '<button class="ge-menu-parent">Blur ></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 = []
|
||||||
|
user_prefs = {}
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
def prefs_api(route):
|
||||||
|
url = route.request.url
|
||||||
|
if route.request.method == 'PUT':
|
||||||
|
key = url.split('/api/prefs/')[-1]
|
||||||
|
data = route.request.post_data_json or {}
|
||||||
|
val = data.get('value')
|
||||||
|
if val is None:
|
||||||
|
user_prefs.pop(key, None)
|
||||||
|
else:
|
||||||
|
user_prefs[key] = val
|
||||||
|
route.fulfill(status=200, json={'key': key, 'value': val})
|
||||||
|
return
|
||||||
|
route.fulfill(status=200, json=dict(user_prefs))
|
||||||
|
|
||||||
|
page.route('**/api/auth/settings', settings_api)
|
||||||
|
page.route('**/api/prefs**', prefs_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_non_admin_image_model_preferences_persist_and_reload(page):
|
||||||
|
global_settings = {
|
||||||
|
'image_model': 'global-default-gen',
|
||||||
|
'image_edit_model': '',
|
||||||
|
'image_quality': 'medium',
|
||||||
|
'image_gen_enabled': True,
|
||||||
|
}
|
||||||
|
user_prefs = {}
|
||||||
|
auth_posts = []
|
||||||
|
pref_puts = []
|
||||||
|
is_admin = False
|
||||||
|
|
||||||
|
def handle_auth_settings(route):
|
||||||
|
if route.request.method == 'POST':
|
||||||
|
auth_posts.append(route.request.post_data_json)
|
||||||
|
if not is_admin:
|
||||||
|
route.fulfill(status=403, json={'error': 'Admin access is required to change these settings.'})
|
||||||
|
return
|
||||||
|
global_settings.update(route.request.post_data_json)
|
||||||
|
route.fulfill(status=200, json=dict(global_settings))
|
||||||
|
return
|
||||||
|
route.fulfill(status=200, json=dict(global_settings))
|
||||||
|
|
||||||
|
def handle_prefs(route):
|
||||||
|
url = route.request.url
|
||||||
|
if route.request.method == 'PUT':
|
||||||
|
key = url.split('/api/prefs/')[-1]
|
||||||
|
data = route.request.post_data_json or {}
|
||||||
|
val = data.get('value')
|
||||||
|
pref_puts.append((key, val))
|
||||||
|
if val is None:
|
||||||
|
user_prefs.pop(key, None)
|
||||||
|
else:
|
||||||
|
user_prefs[key] = val
|
||||||
|
route.fulfill(status=200, json={'key': key, 'value': val})
|
||||||
|
return
|
||||||
|
route.fulfill(status=200, json=dict(user_prefs))
|
||||||
|
|
||||||
|
page.route('**/api/auth/settings', handle_auth_settings)
|
||||||
|
page.route('**/api/prefs**', handle_prefs)
|
||||||
|
page.route('**/api/model-endpoints', lambda route: route.fulfill(json=[{
|
||||||
|
'is_enabled': True, 'online': True, 'model_type': 'llm',
|
||||||
|
'models': ['global-default-gen', 'openai/gpt-5-image', 'openai/gpt-image-2'],
|
||||||
|
}]))
|
||||||
|
|
||||||
|
# Step 1: Non-admin loads image settings, selects a preference
|
||||||
|
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" checked><span id="set-imgSettingsMsg"></span>';
|
||||||
|
const {initImageSettings} = await import('/static/js/settings/imageModels.js');
|
||||||
|
await initImageSettings();
|
||||||
|
}""")
|
||||||
|
|
||||||
|
# Non-admin changes generation model
|
||||||
|
page.select_option('#set-imgModelSelect', 'openai/gpt-image-2')
|
||||||
|
|
||||||
|
# Wait for status message to show 'Saved'
|
||||||
|
page.wait_for_function("document.getElementById('set-imgSettingsMsg').textContent === 'Saved'")
|
||||||
|
msg_color = page.evaluate("document.getElementById('set-imgSettingsMsg').style.color")
|
||||||
|
assert msg_color == 'var(--fg)', f"Expected var(--fg), got {msg_color}"
|
||||||
|
|
||||||
|
# Verify per-user preference was saved and global toggle was NOT propagated
|
||||||
|
assert user_prefs.get('image_model') == 'openai/gpt-image-2'
|
||||||
|
assert 'image_gen_enabled' not in user_prefs, "Global policy toggle must not be saved to user prefs"
|
||||||
|
|
||||||
|
# Step 2: Simulate page reload / re-initialization for non-admin
|
||||||
|
page.evaluate("""async () => {
|
||||||
|
document.getElementById('set-imgModelSelect').value = '';
|
||||||
|
const {initImageSettings} = await import('/static/js/settings/imageModels.js');
|
||||||
|
await initImageSettings();
|
||||||
|
}""")
|
||||||
|
page.wait_for_function("document.getElementById('set-imgModelSelect').value === 'openai/gpt-image-2'")
|
||||||
|
assert page.evaluate("document.getElementById('set-imgModelSelect').value") == 'openai/gpt-image-2'
|
||||||
|
|
||||||
|
# Step 3: Admin saves settings - pre-existing per-user preferences must be cleared
|
||||||
|
is_admin = True
|
||||||
|
page.evaluate("""async () => {
|
||||||
|
const {saveImagePreferences} = await import('/static/js/settings/imagePreferences.js');
|
||||||
|
await saveImagePreferences({ image_model: 'admin-chosen-model', image_edit_model: '', image_quality: 'high', image_gen_enabled: true });
|
||||||
|
}""")
|
||||||
|
# Verify per-user preference for image_model was cleared (PUT with value: null)
|
||||||
|
assert 'image_model' not in user_prefs, "Admin save must clear pre-existing per-user preference"
|
||||||
|
assert global_settings['image_model'] == 'admin-chosen-model'
|
||||||
|
|
||||||
|
# Admin reloads settings - receives newly saved global setting, not stale pref
|
||||||
|
page.evaluate("""async () => {
|
||||||
|
const {initImageSettings} = await import('/static/js/settings/imageModels.js');
|
||||||
|
await initImageSettings();
|
||||||
|
}""")
|
||||||
|
assert global_settings['image_model'] == 'admin-chosen-model'
|
||||||
|
|
||||||
|
|
||||||
|
def test_generation_places_result_without_stretching_or_refresh(page):
|
||||||
|
result = page.evaluate("""async () => {
|
||||||
|
const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js');
|
||||||
|
const image = document.createElement('canvas'); image.width = 80; image.height = 20;
|
||||||
|
const ctx = image.getContext('2d'); ctx.fillStyle = 'red'; ctx.fillRect(0,0,80,20);
|
||||||
|
const selection = document.createElement('canvas'); selection.width = selection.height = 40;
|
||||||
|
selection.getContext('2d').fillRect(0,0,40,40);
|
||||||
|
let placed = null, body = null;
|
||||||
|
const run = createImageGenerationAction({
|
||||||
|
getDocument:()=>({key:'one',width:40,height:40,mask:selection}),
|
||||||
|
fetchImpl:async (url,opts) => {
|
||||||
|
body = JSON.parse(opts.body);
|
||||||
|
selection.getContext('2d').clearRect(0,0,40,40);
|
||||||
|
return {ok:true,json:async()=>({image:image.toDataURL()})};
|
||||||
|
},
|
||||||
|
placeResult:(canvas,name,mask)=>{
|
||||||
|
const data = canvas.getContext('2d');
|
||||||
|
placed = {width:canvas.width,height:canvas.height,left:data.getImageData(0,10,1,1).data[0],
|
||||||
|
right:data.getImageData(79,10,1,1).data[0], mask:mask.getContext('2d').getImageData(20,20,1,1).data[3]};
|
||||||
|
return 'new-layer';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const id = await run({prompt:'a cow',masked:true});
|
||||||
|
return {id,placed,body};
|
||||||
|
}""")
|
||||||
|
assert result['id'] == 'new-layer'
|
||||||
|
assert result['placed'] == dict(width=80,height=20,left=255,right=255,mask=255)
|
||||||
|
assert result['body'] == dict(_use_image_settings=True,prompt='a cow',width=40,height=40)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('action', ['cancel','timeout','invalid-image','document-switch'])
|
||||||
|
def test_generation_finishes_cleanly_on_failure_without_late_placement(page, action):
|
||||||
|
result = page.evaluate("""async action => {
|
||||||
|
const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js');
|
||||||
|
let key = 'original', placements = 0;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const run = createImageGenerationAction({
|
||||||
|
getDocument:()=>({key,width:40,height:40}), getSignal:()=>controller.signal,
|
||||||
|
timeoutMs:action === 'timeout' ? 20 : 2000,
|
||||||
|
fetchImpl:async (url,opts)=>{
|
||||||
|
if (action === 'timeout' || action === 'cancel') {
|
||||||
|
if (action === 'cancel') setTimeout(()=>controller.abort(),10);
|
||||||
|
return new Promise((resolve,reject)=>opts.signal.addEventListener('abort',
|
||||||
|
()=>reject(new DOMException('Aborted','AbortError')), {once:true}));
|
||||||
|
}
|
||||||
|
if (action === 'invalid-image') return {ok:true,json:async()=>({image:'bad-image-data'})};
|
||||||
|
const image = document.createElement('canvas'); image.width = image.height = 4;
|
||||||
|
key = 'different';
|
||||||
|
return {ok:true,json:async()=>({image:image.toDataURL()})};
|
||||||
|
},
|
||||||
|
placeResult:()=>placements++,
|
||||||
|
});
|
||||||
|
let error = '';
|
||||||
|
try {await run({prompt:'a cow'});} catch (e) {error = e.message;}
|
||||||
|
return {error,placements};
|
||||||
|
}""", action)
|
||||||
|
assert result['placements'] == 0
|
||||||
|
assert result['error']
|
||||||
|
if action == 'timeout':
|
||||||
|
assert 'timed out' in result['error']
|
||||||
|
if action == 'document-switch':
|
||||||
|
assert 'document changed' in result['error']
|
||||||
|
|
||||||
|
|
||||||
|
def test_generation_provider_error_is_not_retried_and_next_request_can_succeed(page):
|
||||||
|
result = page.evaluate("""async () => {
|
||||||
|
const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js');
|
||||||
|
let calls = 0, placements = 0;
|
||||||
|
const image = document.createElement('canvas'); image.width = image.height = 4;
|
||||||
|
const run = createImageGenerationAction({
|
||||||
|
getDocument:()=>({key:'one',width:40,height:40}),
|
||||||
|
formatError:({status})=>status === 503 ? 'Generation is disabled' : 'error',
|
||||||
|
fetchImpl:async ()=> ++calls === 1
|
||||||
|
? {ok:false,status:503,json:async()=>({detail:'Disabled'})}
|
||||||
|
: {ok:true,json:async()=>({image:image.toDataURL()})},
|
||||||
|
placeResult:()=>++placements,
|
||||||
|
});
|
||||||
|
let error;
|
||||||
|
try {await run({prompt:'one'});} catch (e) {error = e.message;}
|
||||||
|
const callsAfterFailure = calls;
|
||||||
|
await run({prompt:'two'});
|
||||||
|
return {error,callsAfterFailure,calls,placements};
|
||||||
|
}""")
|
||||||
|
assert result == dict(error='Generation is disabled',callsAfterFailure=1,calls=2,placements=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_late_progress_response_cannot_restore_working_after_completion(page):
|
||||||
|
source = (ROOT / 'static/js/galleryEditor.js').read_text()
|
||||||
|
functions = source.split('function _startAiProgress(requestId) {',1)[1].split('function _setAiCommandStatus(',1)[0]
|
||||||
|
functions = 'function _startAiProgress(requestId) {' + functions
|
||||||
|
result = page.evaluate("""async functions => {
|
||||||
|
let _aiProgressTimer = null, _aiProgressEpoch = 0, status = 'Working';
|
||||||
|
let resolveRequest;
|
||||||
|
const fetch = () => new Promise(resolve => resolveRequest = resolve);
|
||||||
|
const _setAiProgress = () => {};
|
||||||
|
const _setAiCommandStatus = text => status = text;
|
||||||
|
const api = eval(functions + '; ({start:_startAiProgress,stop:_stopAiProgress});');
|
||||||
|
api.start('old-request');
|
||||||
|
api.stop(); status = 'Done';
|
||||||
|
resolveRequest({ok:true,json:async()=>({stage:'editing',progress:0.5})});
|
||||||
|
await new Promise(resolve=>setTimeout(resolve,20));
|
||||||
|
return status;
|
||||||
|
}""", functions)
|
||||||
|
assert result == 'Done'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('success', [True,False])
|
||||||
|
def test_quick_edit_queue_clears_working_on_completion(page, success):
|
||||||
|
source = (ROOT / 'static/js/galleryEditor.js').read_text()
|
||||||
|
pump = 'const pumpQuickEdit = ' + source.split('const pumpQuickEdit = ',1)[1].split(' const enqueueQuickEditJob',1)[0]
|
||||||
|
result = page.evaluate("""async ({pump,success}) => {
|
||||||
|
const {finishQuickEditJob} = await import('/static/js/editor/quick-edit-job.js');
|
||||||
|
const jobs = [{id:'one',prompt:'fill',status:'queued'}];
|
||||||
|
let pumping = false, _quickEditActiveJob = null, stopped = 0, terminal;
|
||||||
|
const renderJobStack = () => {};
|
||||||
|
const dismissJob = () => {};
|
||||||
|
const _stopAiProgress = () => stopped++;
|
||||||
|
const _setAiCommandStatus = (text,kind) => terminal = {text,kind};
|
||||||
|
const executeQuickEdit = async () => success ? {ok:true} : {ok:false,error:'Provider failed'};
|
||||||
|
await eval(pump + '; pumpQuickEdit();');
|
||||||
|
return {status:jobs[0].status,detail:jobs[0].detail,terminal,stopped,pumping};
|
||||||
|
}""", {'pump':pump,'success':success})
|
||||||
|
expected = 'done' if success else 'error'
|
||||||
|
message = 'Done' if success else 'Provider failed'
|
||||||
|
assert result == dict(status=expected,detail=message,terminal=dict(text=message,kind=expected),stopped=1,pumping=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('bridge', [False,True])
|
||||||
|
@pytest.mark.parametrize('instruction', [False,True])
|
||||||
|
@pytest.mark.parametrize('matching_ratio', [False,True])
|
||||||
|
def test_generated_layer_can_keep_pixels_outside_document_bounds(page, bridge, instruction, matching_ratio):
|
||||||
|
source = (ROOT / 'static/js/galleryEditor.js').read_text()
|
||||||
|
placement = 'const _placeResultLayer = ' + source.split('const _placeResultLayer = ',1)[1].split(' const _generateImage',1)[0]
|
||||||
|
bridge_line = next(line for line in source.splitlines() if "'layers.placeImage':" in line)
|
||||||
|
result = page.evaluate("""async ({placement,bridgeLine,bridge,instruction,matchingRatio}) => {
|
||||||
|
const {imageResultGeometry} = await import('/static/js/editor/image-result-geometry.js');
|
||||||
|
const state = {imgWidth:matchingRatio ? 1280 : 40,imgHeight:matchingRatio ? 720 : 40,layers:[],layerOffsets:new Map()};
|
||||||
|
let saved = 0;
|
||||||
|
const _saveState = ()=>saved++;
|
||||||
|
const composite = ()=>{};
|
||||||
|
const _renderLayerPanel = ()=>{};
|
||||||
|
const createLayer = (name,w,h)=>{
|
||||||
|
const canvas = document.createElement('canvas'); canvas.width=w;canvas.height=h;
|
||||||
|
return {id:'new',name,canvas,ctx:canvas.getContext('2d')};
|
||||||
|
};
|
||||||
|
const image = document.createElement('canvas'); image.width=matchingRatio ? 1536 : 80;image.height=matchingRatio ? 864 : 20;
|
||||||
|
image.getContext('2d').fillRect(0,0,image.width,image.height);
|
||||||
|
if (bridge) {
|
||||||
|
const run = eval('({' + bridgeLine + '})')["layers.placeImage"].run;
|
||||||
|
await run({dataUrl:image.toDataURL(),nativeSize:true,masked:false,
|
||||||
|
mode:instruction ? 'instruction' : undefined,differenceMask:instruction});
|
||||||
|
} else {
|
||||||
|
eval(placement + '; _placeResultLayer(image,"Generated",null);');
|
||||||
|
}
|
||||||
|
const layer = state.layers[0];
|
||||||
|
return {width:layer.canvas.width,height:layer.canvas.height,offset:state.layerOffsets.get(layer.id),
|
||||||
|
outsideAlpha:layer.ctx.getImageData(79,10,1,1).data[3],saved};
|
||||||
|
}""", {'placement':placement,'bridgeLine':bridge_line,'bridge':bridge,'instruction':instruction,'matchingRatio':matching_ratio})
|
||||||
|
expected = dict(width=1280,height=720,offset=dict(x=0,y=0),outsideAlpha=255,saved=1) if matching_ratio else dict(width=80,height=20,offset=dict(x=-20,y=10),outsideAlpha=255,saved=1)
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_whole_image_edit_does_not_clip_native_result_to_select_all(page):
|
||||||
|
source = (ROOT / 'static/js/galleryEditor.js').read_text()
|
||||||
|
operation = 'const _aiInpaintSelection = ' + source.split('const _aiInpaintSelection = ',1)[1].split(' // Replace the background/scene',1)[0]
|
||||||
|
result = page.evaluate("""async operation => {
|
||||||
|
const mask = document.createElement('canvas'); mask.width=40;mask.height=40;
|
||||||
|
const canvas = document.createElement('canvas'); canvas.width=80;canvas.height=20;
|
||||||
|
const state = {wandMask:mask};
|
||||||
|
const activeLayer = ()=>({id:'one'});
|
||||||
|
const _aiEditCanvas = async ()=>({canvas,fullFrame:true});
|
||||||
|
const _placeResultLayer = (result,name,resultMask)=>({width:result.width,height:result.height,masked:!!resultMask});
|
||||||
|
return await eval(operation + '; _aiInpaintSelection("change the sky");');
|
||||||
|
}""", operation)
|
||||||
|
assert result == dict(width=80,height=20,masked=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('matching_ratio', [False,True])
|
||||||
|
def test_inpaint_panel_preserves_native_whole_edit_dimensions(page, matching_ratio):
|
||||||
|
source = (ROOT / 'static/js/editor/ai-inpaint.js').read_text()
|
||||||
|
body = 'const fullFrame = ' + source.split('const fullFrame = ',1)[1].split(' // Snapshot the AI result',1)[0]
|
||||||
|
result = page.evaluate("""async ({body,matchingRatio}) => {
|
||||||
|
const {imageResultGeometry} = await import('/static/js/editor/image-result-geometry.js');
|
||||||
|
const state = {imgWidth:matchingRatio ? 1280 : 40,imgHeight:matchingRatio ? 720 : 40,layerOffsets:new Map()};
|
||||||
|
const data = {full_frame:true}, layerName = 'Edit';
|
||||||
|
const createLayer = (name,w,h)=>{
|
||||||
|
const canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
|
||||||
|
return {id:'result',canvas,ctx:canvas.getContext('2d')};
|
||||||
|
};
|
||||||
|
const source = document.createElement('canvas'); source.width=matchingRatio ? 1536 : 80;source.height=matchingRatio ? 864 : 20;
|
||||||
|
source.getContext('2d').fillRect(0,0,source.width,source.height);
|
||||||
|
const resultImg = new Image(); resultImg.src=source.toDataURL(); await resultImg.decode();
|
||||||
|
return eval(body + '; ({width:resultLayer.canvas.width,height:resultLayer.canvas.height,offset:state.layerOffsets.get(resultLayer.id)});');
|
||||||
|
}""", {'body':body,'matchingRatio':matching_ratio})
|
||||||
|
assert result == (dict(width=1280,height=720,offset=dict(x=0,y=0)) if matching_ratio else dict(width=80,height=20,offset=dict(x=-20,y=10)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_brush_softness_is_in_main_bar_and_saves_extended_value(page):
|
||||||
|
page.add_style_tag(content=(ROOT / 'static/css/13-image-editor.css').read_text())
|
||||||
|
result = page.evaluate("""async () => {
|
||||||
|
const {controlsHTML} = await import('/static/js/editor/build/controls.js');
|
||||||
|
const {wireStrokeToolSliders} = await import('/static/js/editor/stroke-tool-sliders.js');
|
||||||
|
const {state} = await import('/static/js/editor/state.js');
|
||||||
|
const bar = document.createElement('div');bar.className='ge-options-bar';
|
||||||
|
const controls = document.createElement('div');controls.className='ge-controls ge-controls--in-options';
|
||||||
|
controls.innerHTML=controlsHTML({color:'#000000',brushSize:40,wandTolerance:20});
|
||||||
|
for(const child of controls.children) if(child.id !== 'ge-brush-controls') child.style.display='none';
|
||||||
|
bar.append(controls);document.body.append(bar);
|
||||||
|
wireStrokeToolSliders();
|
||||||
|
const slider=document.getElementById('ge-brush-softness');
|
||||||
|
slider.value='1200';slider.dispatchEvent(new Event('input',{bubbles:true}));
|
||||||
|
return {inMore:!!slider.closest('#ge-brush-more-menu'),max:slider.max,value:state.brushSoftness,
|
||||||
|
visible:slider.getBoundingClientRect().height>0,label:document.getElementById('ge-brush-softness-label').textContent};
|
||||||
|
}""")
|
||||||
|
assert result == dict(inMore=False,max='1200',value=1200,visible=True,label='1200%')
|
||||||
|
page.screenshot(path='/tmp/editor-brush-softness-main-bar.png')
|
||||||
|
|
||||||
|
|
||||||
|
def test_extended_softness_produces_softer_pixels_not_just_larger_slider_values(page):
|
||||||
|
result = page.evaluate("""async () => {
|
||||||
|
const {state}=await import('/static/js/editor/state.js');
|
||||||
|
const {createStrokePipeline}=await import('/static/js/editor/stroke-pipeline.js');
|
||||||
|
const results=[];
|
||||||
|
for(const softness of [300,1200]) {
|
||||||
|
const canvas=document.createElement('canvas');canvas.width=canvas.height=100;
|
||||||
|
const layer={id:'brush',canvas,ctx:canvas.getContext('2d')};
|
||||||
|
Object.assign(state,{tool:'brush',brushSize:60,brushSoftness:softness,brushOpacity:100,brushFlow:100,
|
||||||
|
brushBlendMode:'source-over',color:'#000000',brushSpacing:15,brushSmoothing:0,
|
||||||
|
brushPixel:false,pressureSize:false,pressureOpacity:false,pressureFlow:false,layerOffsets:new Map()});
|
||||||
|
const pipeline=createStrokePipeline({activeLayer:()=>layer,getActiveMaskLayer:()=>null,composite:()=>{}});
|
||||||
|
pipeline.beginStroke({x:50,y:50,pressure:1},'brush');pipeline.endStroke(null);
|
||||||
|
const alpha=x=>layer.ctx.getImageData(x,50,1,1).data[3];
|
||||||
|
results.push({middle:alpha(65),center:alpha(50),outside:alpha(85)});
|
||||||
|
document.body.append(canvas);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}""")
|
||||||
|
assert result[1]['middle'] < result[0]['middle'] / 3
|
||||||
|
assert result[1]['center'] > 150
|
||||||
|
assert result[0]['outside'] == result[1]['outside'] == 0
|
||||||
|
page.screenshot(path='/tmp/editor-brush-softness-falloff.png')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('width', [1000,840])
|
||||||
|
def test_brush_numbers_do_not_overlap_after_real_panel_normalization(page,width):
|
||||||
|
page.set_viewport_size({'width':width,'height':700})
|
||||||
|
for stylesheet in ['static/css/13-image-editor.css','static/css/documents-gallery-editor.css']:
|
||||||
|
page.add_style_tag(content=(ROOT / stylesheet).read_text())
|
||||||
|
page.add_style_tag(content=':root{--bg:#171719;--panel:#242426;--fg:#eee;--red:#d47965;--border:#454549;}body{background:var(--bg);}')
|
||||||
|
result = page.evaluate("""async () => {
|
||||||
|
const {state}=await import('/static/js/editor/state.js');
|
||||||
|
const {controlsHTML}=await import('/static/js/editor/build/controls.js');
|
||||||
|
const {buildRightPanel}=await import('/static/js/editor/build/right-panel.js');
|
||||||
|
const {wireStrokeToolSliders}=await import('/static/js/editor/stroke-tool-sliders.js');
|
||||||
|
state.container=document.createElement('div');document.body.append(state.container);
|
||||||
|
state.brushSize=800;
|
||||||
|
const {controls}=buildRightPanel({controlsHTML,layerPanelHTML:()=>'<div></div>'});
|
||||||
|
controls.classList.add('ge-controls--in-options');
|
||||||
|
const bar=document.createElement('div');bar.className='ge-options-bar';bar.append(controls);state.container.append(bar);
|
||||||
|
for(const child of controls.children) if(child.id !== 'ge-brush-controls') child.style.display='none';
|
||||||
|
wireStrokeToolSliders();
|
||||||
|
const slider=document.getElementById('ge-brush-softness');
|
||||||
|
const elements=[document.querySelector('#ge-brush-softness-row label'),slider,
|
||||||
|
document.getElementById('ge-brush-softness-label'),document.querySelector('.ge-brush-size-row label'),
|
||||||
|
document.querySelector('.ge-brush-size-row input')];
|
||||||
|
const bounds=()=>elements.map(element=>{
|
||||||
|
const r=element.getBoundingClientRect();return {x:r.x,right:r.right,width:r.width};
|
||||||
|
});
|
||||||
|
slider.value='1';slider.dispatchEvent(new Event('input'));const small=bounds();
|
||||||
|
slider.value='1200';slider.dispatchEvent(new Event('input'));const large=bounds();
|
||||||
|
return {small,large,value:document.getElementById('ge-brush-softness-label').textContent};
|
||||||
|
}""")
|
||||||
|
assert result['value'] == '1200%'
|
||||||
|
assert all(bounds['width'] > 0 for bounds in result['large']), result
|
||||||
|
for left,right in zip(result['large'],result['large'][1:]):
|
||||||
|
assert left['right'] <= right['x'] + 1, result
|
||||||
|
for small,large in zip(result['small'],result['large']):
|
||||||
|
assert abs(small['x']-large['x']) <= 1, result
|
||||||
|
page.screenshot(path=f'/tmp/editor-brush-number-layout-{width}.png')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('fail_once', [False, True])
|
||||||
|
def test_new_project_create_initializes_canvas(page, fail_once):
|
||||||
|
errors = []
|
||||||
|
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||||
|
page.route('http://editor.test/api/**', lambda route: route.fulfill(json={}))
|
||||||
|
page.add_style_tag(content='''
|
||||||
|
.modal {position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:250;pointer-events:none;}
|
||||||
|
.modal-content {pointer-events:auto;background:#333;padding:20px;}
|
||||||
|
#gallery-modal {z-index:700;}
|
||||||
|
#gallery-modal > .modal-content {width:90vw;height:90vh;}
|
||||||
|
''')
|
||||||
|
page.evaluate("""async () => {
|
||||||
|
document.body.innerHTML = '<div id="toast"></div><div class="modal" id="gallery-modal"><div class="modal-content"><div id="gallery-editor-container"></div></div></div>';
|
||||||
|
const editor = await import('/static/js/galleryEditor.js');
|
||||||
|
window.newProject = editor.openEditor(null, null, null, 'New canvas');
|
||||||
|
window.newProject.catch(error => { window.createError = error.stack; });
|
||||||
|
}""")
|
||||||
|
if fail_once:
|
||||||
|
page.evaluate("""() => {
|
||||||
|
const fill = CanvasRenderingContext2D.prototype.fillRect;
|
||||||
|
CanvasRenderingContext2D.prototype.fillRect = function(...args) {
|
||||||
|
if (this.canvas.width === 1024 && this.canvas.height === 1024) {
|
||||||
|
CanvasRenderingContext2D.prototype.fillRect = fill;
|
||||||
|
throw Error('Canvas allocation test failure');
|
||||||
|
}
|
||||||
|
return fill.apply(this, args);
|
||||||
|
};
|
||||||
|
}""")
|
||||||
|
page.locator('#ge-canvas-prompt-ok').click(timeout=3000)
|
||||||
|
if fail_once:
|
||||||
|
page.wait_for_function("window.createError")
|
||||||
|
assert 'Canvas allocation test failure' in page.evaluate('window.createError')
|
||||||
|
assert 'Could not create project' in page.locator('body').inner_text()
|
||||||
|
page.evaluate("""async () => {
|
||||||
|
window.createError = null;
|
||||||
|
const editor = await import('/static/js/galleryEditor.js');
|
||||||
|
window.newProject = editor.openEditor(null, null, null, 'New canvas');
|
||||||
|
window.newProject.catch(error => { window.createError = error.stack; });
|
||||||
|
}""")
|
||||||
|
page.locator('#ge-canvas-prompt-ok').click(timeout=3000)
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
result = page.evaluate("""async () => {
|
||||||
|
const {state} = await import('/static/js/editor/state.js');
|
||||||
|
return {error:window.createError, width:state.mainCanvas?.width,
|
||||||
|
height:state.mainCanvas?.height, layers:state.layers.map(layer=>layer.name)};
|
||||||
|
}""")
|
||||||
|
assert not errors, errors
|
||||||
|
assert not result.get('error'), result
|
||||||
|
assert result == {'error':None,'width':1024,'height':1024,'layers':['Background','Edit']}
|
||||||
|
|
||||||
|
|
||||||
|
def test_undo_clears_rembg_live_layer_and_edge_tuner_cannot_corrupt(page):
|
||||||
|
source = (ROOT / 'static/js/galleryEditor.js').read_text()
|
||||||
|
restore_fn = source.split('function _restoreState(snap) {', 1)[1].split('function undo() {', 1)[0]
|
||||||
|
assert 'state.rembgLiveLayer = null;' in restore_fn
|
||||||
|
assert 'state.rembgLiveSnap = null;' in restore_fn
|
||||||
|
|
||||||
|
page.route('http://editor.test/api/**', lambda route: route.fulfill(json={}))
|
||||||
|
result = page.evaluate("""async () => {
|
||||||
|
document.body.innerHTML = '<div id="toast"></div><div id="gallery-editor-container"></div>';
|
||||||
|
const editor = await import('/static/js/galleryEditor.js');
|
||||||
|
const {state} = await import('/static/js/editor/state.js');
|
||||||
|
editor.openEditor(null, null, {w: 16, h: 16}, 'Rembg edge tuner undo test');
|
||||||
|
|
||||||
|
const layer = state.layers.at(-1);
|
||||||
|
layer.ctx.fillStyle = 'rgba(0, 255, 0, 1)';
|
||||||
|
layer.ctx.fillRect(0, 0, 16, 16);
|
||||||
|
const originalPixel = [...layer.ctx.getImageData(0, 0, 1, 1).data];
|
||||||
|
|
||||||
|
// Save a valid pre-edit snapshot to undoStack
|
||||||
|
const origCanvas = document.createElement('canvas');
|
||||||
|
origCanvas.width = origCanvas.height = 16;
|
||||||
|
origCanvas.getContext('2d').drawImage(layer.canvas, 0, 0);
|
||||||
|
const preEditSnap = {
|
||||||
|
imgWidth: 16, imgHeight: 16,
|
||||||
|
activeLayerId: layer.id,
|
||||||
|
selectedLayerIds: [layer.id],
|
||||||
|
layers: [{
|
||||||
|
id: layer.id,
|
||||||
|
name: 'Layer 1',
|
||||||
|
canvas: origCanvas,
|
||||||
|
imageData: origCanvas.getContext('2d').getImageData(0, 0, 16, 16),
|
||||||
|
visible: true,
|
||||||
|
opacity: 1,
|
||||||
|
blendMode: 'source-over',
|
||||||
|
locks: {},
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
state.undoStack.push(preEditSnap);
|
||||||
|
|
||||||
|
// Create tuner snapshot for background removal
|
||||||
|
const snapCanvas = document.createElement('canvas');
|
||||||
|
snapCanvas.width = snapCanvas.height = 16;
|
||||||
|
const snapCtx = snapCanvas.getContext('2d');
|
||||||
|
snapCtx.fillStyle = 'rgba(255, 0, 0, 1)';
|
||||||
|
snapCtx.fillRect(4, 4, 8, 8); // cutout shape
|
||||||
|
|
||||||
|
// Attach live tuner
|
||||||
|
state.rembgLiveLayer = layer;
|
||||||
|
state.rembgLiveSnap = snapCanvas;
|
||||||
|
const tunerAttached = state.rembgLiveLayer === layer && state.rembgLiveSnap === snapCanvas;
|
||||||
|
|
||||||
|
// Mutate layer pixels to simulate background removal result
|
||||||
|
layer.ctx.clearRect(0, 0, 16, 16);
|
||||||
|
layer.ctx.drawImage(snapCanvas, 0, 0);
|
||||||
|
|
||||||
|
// Trigger Undo via Ctrl+Z
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', {
|
||||||
|
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true, cancelable: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const clearedAfterUndo = state.rembgLiveLayer === null && state.rembgLiveSnap === null;
|
||||||
|
const restoredLayer = state.layers.at(-1);
|
||||||
|
const restoredPixel = [...restoredLayer.ctx.getImageData(0, 0, 1, 1).data];
|
||||||
|
|
||||||
|
// Now interact with edge tuner sliders after undo
|
||||||
|
const featherInput = document.getElementById('ge-rembg-feather');
|
||||||
|
const growInput = document.getElementById('ge-rembg-grow');
|
||||||
|
if (featherInput) {
|
||||||
|
featherInput.value = '5';
|
||||||
|
featherInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
if (growInput) {
|
||||||
|
growInput.value = '5';
|
||||||
|
growInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check pixel again after tuner events: restored pixels must remain untouched
|
||||||
|
const pixelAfterTuner = [...restoredLayer.ctx.getImageData(0, 0, 1, 1).data];
|
||||||
|
return {
|
||||||
|
tunerAttached,
|
||||||
|
clearedAfterUndo,
|
||||||
|
restoredPixel,
|
||||||
|
originalPixel,
|
||||||
|
pixelAfterTuner,
|
||||||
|
rembgLiveLayerNull: state.rembgLiveLayer === null,
|
||||||
|
rembgLiveSnapNull: state.rembgLiveSnap === null,
|
||||||
|
};
|
||||||
|
}""")
|
||||||
|
assert result['tunerAttached'] is True
|
||||||
|
assert result['clearedAfterUndo'] is True
|
||||||
|
assert result['rembgLiveLayerNull'] is True
|
||||||
|
assert result['rembgLiveSnapNull'] is True
|
||||||
|
assert result['restoredPixel'] == result['originalPixel']
|
||||||
|
assert result['pixelAfterTuner'] == result['originalPixel']
|
||||||
@@ -143,8 +143,8 @@ def test_crop_can_start_outside_the_canvas_without_panning():
|
|||||||
# image (so it can be pulled out past the edge) instead of the canvas-area
|
# image (so it can be pulled out past the edge) instead of the canvas-area
|
||||||
# pan grabbing the pointer and switching to hand mode.
|
# pan grabbing the pointer and switching to hand mode.
|
||||||
CE = (ROOT / "static/js/editor/canvas-events.js").read_text(encoding="utf-8")
|
CE = (ROOT / "static/js/editor/canvas-events.js").read_text(encoding="utf-8")
|
||||||
assert "if (state.tool !== 'lasso' && state.tool !== 'crop') return;" in CE
|
assert "['marquee', 'lasso', 'crop'].includes(state.tool)" in CE
|
||||||
assert "(state.tool === 'lasso' || state.tool === 'crop') && !directPan" in CE
|
assert "if (canStartOutside() && !directPan)" in CE
|
||||||
# Pressing editor chrome inside the area (e.g. the crop Apply panel) must
|
# Pressing editor chrome inside the area (e.g. the crop Apply panel) must
|
||||||
# not start a fresh crop.
|
# not start a fresh crop.
|
||||||
assert ".ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup" in CE
|
assert ".ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup" in CE
|
||||||
|
|||||||
@@ -32,13 +32,12 @@ def test_preflight_module_has_the_four_setup_steps():
|
|||||||
assert "required ready" in src
|
assert "required ready" in src
|
||||||
|
|
||||||
|
|
||||||
def test_preflight_is_wired_into_the_editor():
|
def test_editor_uses_ai_settings_without_redundant_setup():
|
||||||
editor = _read("static/js/galleryEditor.js")
|
editor = _read("static/js/galleryEditor.js")
|
||||||
assert "import { createPreflight } from './editor/preflight.js';" in editor
|
assert "createPreflight" not in editor
|
||||||
assert "createPreflight({ canvasArea })" in editor
|
assert "setupBtn.id = 'ge-setup-btn';" not in editor
|
||||||
assert "_preflight?.maybeOpen();" in editor
|
assert "createAiSettings({ container })" in editor
|
||||||
assert "setupBtn.id = 'ge-setup-btn';" in editor
|
assert "aiBtn.addEventListener('click', () => _aiSettings?.open());" in editor
|
||||||
assert "_preflight?.destroy();" in editor
|
|
||||||
|
|
||||||
|
|
||||||
def test_ai_models_exposes_status_for_the_checklist():
|
def test_ai_models_exposes_status_for_the_checklist():
|
||||||
|
|||||||
@@ -49,7 +49,42 @@ def test_generate_route_forwards_the_picked_model():
|
|||||||
assert 'model_choice = str((body or {}).get("model") or "").strip()' in route
|
assert 'model_choice = str((body or {}).get("model") or "").strip()' in route
|
||||||
assert 'payload["model"] = model_choice' in route
|
assert 'payload["model"] = model_choice' in route
|
||||||
assert "content = _json.dumps(payload)" in route
|
assert "content = _json.dumps(payload)" in route
|
||||||
assert "localStorage.getItem('ge-ai-command-model')" in JS
|
operation = JS.split("'ai.generateImage':", 1)[1].split("\n", 1)[0]
|
||||||
|
generation = (ROOT / 'static/js/editor/image-generation.js').read_text()
|
||||||
|
assert '_generateImage(p || {})' in operation
|
||||||
|
assert "_use_image_settings: true" in generation
|
||||||
|
assert "document.getElementById('ge-ai-model')" not in operation
|
||||||
|
assert "_getSelectedQuickEditModel()" not in operation
|
||||||
|
assert "localStorage.getItem" not in operation
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_settings_separates_generation_from_editing_without_setup():
|
||||||
|
settings = (ROOT / "static/js/editor/ai-settings.js").read_text()
|
||||||
|
assert 'id="ge-ai-model"' in settings
|
||||||
|
assert 'id="ge-ai-command-model"' in settings
|
||||||
|
assert "Image generation model" in settings
|
||||||
|
assert "createPreflight" not in JS
|
||||||
|
assert "ge-setup-btn';" not in JS.split("const aiBtn", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_uses_live_picker_and_auto_edit_fallback():
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
module = (ROOT / "static/js/editor/ai-models.js").as_uri()
|
||||||
|
script = """
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
const {resolveQuickEditModel} = await import(MODULE);
|
||||||
|
const selected = 'https://openrouter.ai/api/v1::openai/gpt-5-image';
|
||||||
|
const option = {value: selected, textContent: 'GPT-5 Image'};
|
||||||
|
const picker = {value: selected, selectedIndex: 0, options: [option]};
|
||||||
|
const fallback = {value: '', options: [option]};
|
||||||
|
globalThis.document = {getElementById: id => id === 'ge-ai-command-model' ? picker : fallback};
|
||||||
|
globalThis.localStorage = {getItem: () => {throw new Error('must not read stale storage');}};
|
||||||
|
assert.equal(resolveQuickEditModel().model, 'openai/gpt-5-image');
|
||||||
|
picker.value = '';
|
||||||
|
assert.equal(resolveQuickEditModel().model, '');
|
||||||
|
""".replace("MODULE", repr(module))
|
||||||
|
subprocess.run(["node", "--input-type=module", "-e", script], check=True)
|
||||||
|
|
||||||
|
|
||||||
def test_cloud_image_model_is_resolved_before_the_local_fallback():
|
def test_cloud_image_model_is_resolved_before_the_local_fallback():
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ def test_quick_edit_flip_and_rotate_accept_numeric_degrees():
|
|||||||
|
|
||||||
def test_quick_edit_transforms_do_not_add_a_second_undo_snapshot():
|
def test_quick_edit_transforms_do_not_add_a_second_undo_snapshot():
|
||||||
start = EDITOR.index("if (/\\brotate\\b.*\\b180\\b")
|
start = EDITOR.index("if (/\\brotate\\b.*\\b180\\b")
|
||||||
end = EDITOR.index("if (_looksBackgroundRemoval(p)) {", start)
|
end = EDITOR.index("if (!/\\b(text|title|caption|font|heading|subtitle)", start)
|
||||||
transform_block = EDITOR[start:end]
|
transform_block = EDITOR[start:end]
|
||||||
assert "_saveState('Rotate" not in transform_block
|
assert "_saveState('Rotate" not in transform_block
|
||||||
assert "_saveState('Flip" not in transform_block
|
assert "_saveState('Flip" not in transform_block
|
||||||
@@ -372,9 +372,10 @@ def test_quick_edit_has_a_model_picker():
|
|||||||
assert "function _getSelectedQuickEditModel()" in EDITOR
|
assert "function _getSelectedQuickEditModel()" in EDITOR
|
||||||
assert "getModel: () => _getSelectedQuickEditModel()," in EDITOR
|
assert "getModel: () => _getSelectedQuickEditModel()," in EDITOR
|
||||||
# Populated from the provider list and persisted per user.
|
# Populated from the provider list and persisted per user.
|
||||||
assert "aiCommandSelect.innerHTML = '<option value=\"\">Auto (default)</option>';" in models
|
assert "aiCommandSelect.innerHTML = '<option value=\"\">Same as generation</option>';" in models
|
||||||
assert "localStorage.setItem('ge-ai-command-model', aiCommandSelect.value)" in models
|
assert "localStorage.setItem('ge-ai-command-model'" not in models
|
||||||
assert "localStorage.getItem('ge-ai-command-model')" in models
|
assert "localStorage.getItem('ge-ai-command-model')" not in models
|
||||||
|
assert 'saveImagePreferences({ [key]: model })' in AI_SETTINGS
|
||||||
# Styled.
|
# Styled.
|
||||||
assert ".ge-ai-settings-card" in STYLE
|
assert ".ge-ai-settings-card" in STYLE
|
||||||
assert ".ge-ai-command-model" in STYLE
|
assert ".ge-ai-command-model" in STYLE
|
||||||
@@ -698,7 +699,7 @@ def test_quick_edit_shows_a_real_progress_bar():
|
|||||||
assert "fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}`" in EDITOR
|
assert "fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}`" in EDITOR
|
||||||
# The edit request carries the request_id the server keys progress on.
|
# The edit request carries the request_id the server keys progress on.
|
||||||
assert "const requestId = 'qe-' + Date.now().toString(36)" in EDITOR
|
assert "const requestId = 'qe-' + Date.now().toString(36)" in EDITOR
|
||||||
assert "const body = JSON.stringify({ image, request_id: requestId, ...payload });" in EDITOR
|
assert "const body = JSON.stringify({ image, width: flat.width, height: flat.height, request_id: requestId, ...payload });" in EDITOR
|
||||||
assert "_startAiProgress(requestId);" in EDITOR
|
assert "_startAiProgress(requestId);" in EDITOR
|
||||||
assert "_stopAiProgress();" in EDITOR
|
assert "_stopAiProgress();" in EDITOR
|
||||||
# Styling exists.
|
# Styling exists.
|
||||||
@@ -718,7 +719,7 @@ def test_generation_503_is_handled_gracefully():
|
|||||||
# (returns 503). Surface a clear message and do not retry immediately.
|
# (returns 503). Surface a clear message and do not retry immediately.
|
||||||
assert "if (status === 503) return _GEN_DISABLED_MSG;" in EDITOR
|
assert "if (status === 503) return _GEN_DISABLED_MSG;" in EDITOR
|
||||||
assert "_GEN_DISABLED_MSG = 'Image generation is switched off on the server at the moment.'" in EDITOR
|
assert "_GEN_DISABLED_MSG = 'Image generation is switched off on the server at the moment.'" in EDITOR
|
||||||
assert "window.__geGenerationDisabled" in EDITOR
|
assert "formatError: _generationErrorMessage" in EDITOR
|
||||||
|
|
||||||
|
|
||||||
def test_sending_a_prompt_returns_focus_to_the_document():
|
def test_sending_a_prompt_returns_focus_to_the_document():
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ def test_claim_is_fsynced_to_disk_before_returning(tmp_path, target, monkeypatch
|
|||||||
on_disk = records(log.path)
|
on_disk = records(log.path)
|
||||||
assert [r["type"] for r in on_disk] == ["claim"]
|
assert [r["type"] for r in on_disk] == ["claim"]
|
||||||
assert fx.EffectClaim.from_dict(on_disk[0]["record"]) == made
|
assert fx.EffectClaim.from_dict(on_disk[0]["record"]) == made
|
||||||
assert oct(log.path.stat().st_mode & 0o777) == "0o600"
|
if os.name != "nt": # Windows has no POSIX permission bits: os.open's 0o600 is not reflected in st_mode
|
||||||
|
assert oct(log.path.stat().st_mode & 0o777) == "0o600"
|
||||||
|
|
||||||
|
|
||||||
def test_claim_persistence_failure_raises_and_records_nothing(tmp_path, target):
|
def test_claim_persistence_failure_raises_and_records_nothing(tmp_path, target):
|
||||||
|
|||||||
@@ -90,3 +90,42 @@ def test_fork_does_not_corrupt_source_message_metadata(monkeypatch):
|
|||||||
# ...and the source session's _db_id values are untouched.
|
# ...and the source session's _db_id values are untouched.
|
||||||
assert source.history[0].metadata["_db_id"] == "src-0"
|
assert source.history[0].metadata["_db_id"] == "src-0"
|
||||||
assert source.history[1].metadata["_db_id"] == "src-1"
|
assert source.history[1].metadata["_db_id"] == "src-1"
|
||||||
|
|
||||||
|
|
||||||
|
def _fork_with(monkeypatch, body, db_ids):
|
||||||
|
"""Fork a 4-message source whose DB rows have the given ids."""
|
||||||
|
monkeypatch.setattr(mod, "_verify_session_owner", lambda *a, **k: None)
|
||||||
|
source = _FakeSession(name="Long chat")
|
||||||
|
for i, role in enumerate(["user", "assistant", "user", "assistant"]):
|
||||||
|
source.history.append(ChatMessage(role=role, content=f"m{i}", metadata={"_db_id": db_ids[i]}))
|
||||||
|
manager = _FakeSessionManager(source)
|
||||||
|
rows = [SimpleNamespace(id=i) for i in db_ids]
|
||||||
|
|
||||||
|
class _Query:
|
||||||
|
def filter(self, *a): return self
|
||||||
|
def order_by(self, *a): return self
|
||||||
|
def all(self): return rows
|
||||||
|
|
||||||
|
class _DB:
|
||||||
|
def query(self, *a): return _Query()
|
||||||
|
def close(self): pass
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod, "SessionLocal", lambda: _DB())
|
||||||
|
router = mod.setup_history_routes(manager)
|
||||||
|
|
||||||
|
class _Req:
|
||||||
|
async def json(self): return body
|
||||||
|
|
||||||
|
asyncio.run(_fork_handler(router)(_Req(), "src-id"))
|
||||||
|
return [m.content for m in manager.created.history]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fork_before_a_message_id_ignores_a_stale_keep_count(monkeypatch):
|
||||||
|
copied = _fork_with(monkeypatch, {"before_msg_id": "c", "keep_count": 0}, ["a", "b", "c", "d"])
|
||||||
|
assert copied == ["m0", "m1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fork_through_a_message_id_includes_it(monkeypatch):
|
||||||
|
copied = _fork_with(monkeypatch, {"through_msg_id": "b", "keep_count": 0}, ["a", "b", "c", "d"])
|
||||||
|
assert copied == ["m0", "m1"]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
import routes.gallery_routes as gallery
|
||||||
|
|
||||||
|
|
||||||
|
def route(path, method):
|
||||||
|
return next(r for r in gallery.setup_gallery_routes().routes if r.path == path and method in r.methods)
|
||||||
|
|
||||||
|
|
||||||
|
def call(path, method, monkeypatch, body):
|
||||||
|
monkeypatch.setattr(gallery, 'get_current_user', lambda request: 'alice')
|
||||||
|
|
||||||
|
class Request:
|
||||||
|
async def json(self):
|
||||||
|
return body
|
||||||
|
|
||||||
|
return asyncio.run(route(path, method).endpoint(Request(), 'album-1'))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('body', [
|
||||||
|
{'name': ''},
|
||||||
|
{'name': ' '},
|
||||||
|
{'name': 123},
|
||||||
|
{'name': ['x']},
|
||||||
|
{'description': {'a': 1}},
|
||||||
|
[1],
|
||||||
|
])
|
||||||
|
def test_album_update_rejects_bad_input(body, monkeypatch):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
call('/api/gallery/albums/{album_id}', 'PUT', monkeypatch, body)
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('op', ['add', 'remove'])
|
||||||
|
@pytest.mark.parametrize('body', [
|
||||||
|
{'image_ids': 'abc'},
|
||||||
|
{'image_ids': 5},
|
||||||
|
{'image_ids': None},
|
||||||
|
{'image_ids': [{'a': 1}]},
|
||||||
|
{'image_ids': ['ok', 1]},
|
||||||
|
[1],
|
||||||
|
])
|
||||||
|
def test_album_image_ids_must_be_a_list_of_strings(op, body, monkeypatch):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
call(f'/api/gallery/albums/{{album_id}}/{op}', 'POST', monkeypatch, body)
|
||||||
|
assert exc.value.status_code == 400
|
||||||
@@ -5,6 +5,7 @@ import json
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
import routes.gallery_routes as gallery
|
import routes.gallery_routes as gallery
|
||||||
@@ -16,7 +17,10 @@ def png(image):
|
|||||||
return base64.b64encode(output.getvalue()).decode()
|
return base64.b64encode(output.getvalue()).decode()
|
||||||
|
|
||||||
|
|
||||||
def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog):
|
@pytest.mark.parametrize('model', ['openai/gpt-5-image', 'openai/gpt-image-2'])
|
||||||
|
@pytest.mark.parametrize('saved_settings', [False, True])
|
||||||
|
@pytest.mark.parametrize('full_frame', [False, True])
|
||||||
|
def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog, model, saved_settings, full_frame):
|
||||||
import logging
|
import logging
|
||||||
caplog.set_level(logging.DEBUG)
|
caplog.set_level(logging.DEBUG)
|
||||||
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', api_key='test')
|
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', api_key='test')
|
||||||
@@ -29,36 +33,97 @@ def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog):
|
|||||||
monkeypatch.setattr(gallery, 'require_privilege', lambda *args: 'alice')
|
monkeypatch.setattr(gallery, 'require_privilege', lambda *args: 'alice')
|
||||||
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *args: endpoint)
|
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *args: endpoint)
|
||||||
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *args, **kwargs: (True, ''))
|
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *args, **kwargs: (True, ''))
|
||||||
|
monkeypatch.setattr('src.editor_image_settings.load_settings', lambda: {'image_model':'wrong-generation-model', 'image_edit_model':model})
|
||||||
|
def resolve_model(selected, **kwargs):
|
||||||
|
assert selected == model
|
||||||
|
assert kwargs['owner'] == 'alice'
|
||||||
|
return endpoint.base_url + '/chat/completions', model, {}
|
||||||
|
monkeypatch.setattr('src.ai_interaction._resolve_model', resolve_model)
|
||||||
requests = []
|
requests = []
|
||||||
|
|
||||||
def respond(request):
|
def respond(request):
|
||||||
requests.append(request)
|
requests.append(request)
|
||||||
assert str(request.url) == endpoint.base_url + '/images'
|
assert str(request.url) == endpoint.base_url + ('/chat/completions' if model == 'openai/gpt-5-image' else '/images')
|
||||||
payload = json.loads(request.content)
|
payload = json.loads(request.content)
|
||||||
assert payload['model'] == 'openai/gpt-5-image'
|
assert payload['model'] == model
|
||||||
assert len(payload['input_references']) == 2
|
if model == 'openai/gpt-5-image':
|
||||||
|
assert len(payload['messages'][0]['content']) == 3
|
||||||
|
else:
|
||||||
|
assert len(payload['input_references']) == 2
|
||||||
assert request.headers['authorization'] == 'Bearer test'
|
assert request.headers['authorization'] == 'Bearer test'
|
||||||
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (2, 1), 'blue'))}]})
|
image = png(Image.new('RGB', (4, 4) if full_frame else (2, 1), 'blue'))
|
||||||
|
if model == 'openai/gpt-5-image':
|
||||||
|
return httpx.Response(200, json={'choices':[{'message':{'images':[{'image_url':{'url':'data:image/png;base64,'+image}}]}}]})
|
||||||
|
return httpx.Response(200, json={'data': [{'b64_json': image}]})
|
||||||
|
|
||||||
client = httpx.AsyncClient
|
client = httpx.AsyncClient
|
||||||
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kwargs: client(transport=httpx.MockTransport(respond), **kwargs))
|
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kwargs: client(transport=httpx.MockTransport(respond), **kwargs))
|
||||||
mask = Image.new('L', (2, 1))
|
mask = Image.new('L', (2, 1))
|
||||||
|
if full_frame:
|
||||||
|
mask.paste(255, (0,0,2,1))
|
||||||
mask.putpixel((1, 0), 255)
|
mask.putpixel((1, 0), 255)
|
||||||
|
|
||||||
class Request:
|
class Request:
|
||||||
async def json(self):
|
async def json(self):
|
||||||
return {'_endpoint': endpoint.base_url, '_model': 'openai/gpt-5-image',
|
return {'_endpoint': endpoint.base_url, '_model': 'stale-model' if saved_settings else model,
|
||||||
|
'_use_image_settings':saved_settings,
|
||||||
'image': png(Image.new('RGB', (2, 1), 'red')), 'mask': png(mask), 'prompt': 'private-cloud-prompt'}
|
'image': png(Image.new('RGB', (2, 1), 'red')), 'mask': png(mask), 'prompt': 'private-cloud-prompt'}
|
||||||
|
|
||||||
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
|
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
|
||||||
result = asyncio.run(route.endpoint(Request()))
|
result = asyncio.run(route.endpoint(Request()))
|
||||||
output = Image.open(io.BytesIO(base64.b64decode(result['image'])))
|
output = Image.open(io.BytesIO(base64.b64decode(result['image'])))
|
||||||
assert output.getpixel((0, 0)) == (255, 0, 0, 255)
|
if full_frame:
|
||||||
assert output.getpixel((1, 0)) == (0, 0, 255, 255)
|
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 len(requests) == 1
|
assert len(requests) == 1
|
||||||
assert 'private-cloud-prompt' not in caplog.text
|
assert 'private-cloud-prompt' not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_image_edit_preserves_ratio_and_unmasked_pixels():
|
||||||
|
source = Image.new('RGBA',(4,4),'red')
|
||||||
|
generated = Image.new('RGBA',(4,2),'blue')
|
||||||
|
mask = Image.new('L',(4,4),255)
|
||||||
|
mask.putpixel((0,0),0)
|
||||||
|
result, full = gallery._compose_cloud_image_edit(source,generated,mask)
|
||||||
|
assert full is False
|
||||||
|
assert result.size == (4,4)
|
||||||
|
assert result.getpixel((2,0)) == source.getpixel((2,0))
|
||||||
|
assert result.getpixel((2,3)) == source.getpixel((2,3))
|
||||||
|
assert result.getpixel((2,1)) == (0,0,255,255)
|
||||||
|
|
||||||
|
|
||||||
|
def test_quick_edit_inpaint_requests_source_ratio_without_width_height_fields(monkeypatch):
|
||||||
|
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1',api_key='test')
|
||||||
|
monkeypatch.setattr(gallery,'SessionLocal',lambda:SimpleNamespace(close=lambda:None))
|
||||||
|
monkeypatch.setattr(gallery,'require_privilege',lambda *args:'alice')
|
||||||
|
monkeypatch.setattr(gallery,'_visible_image_endpoint_for_base',lambda *args:endpoint)
|
||||||
|
monkeypatch.setattr('src.url_safety.check_outbound_url',lambda *args,**kwargs:(True,''))
|
||||||
|
sent = []
|
||||||
|
original = httpx.AsyncClient
|
||||||
|
|
||||||
|
def respond(request):
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
sent.append(payload)
|
||||||
|
return httpx.Response(200,json={'data':[{'b64_json':png(Image.new('RGB',(1536,864),'blue'))}]})
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx,'AsyncClient',lambda **kwargs:original(transport=httpx.MockTransport(respond),**kwargs))
|
||||||
|
|
||||||
|
class Request:
|
||||||
|
async def json(self):
|
||||||
|
return {'_endpoint':endpoint.base_url,'_model':'openai/gpt-image-2','prompt':'change the sky',
|
||||||
|
'image':png(Image.new('RGB',(1280,720),'red')),'mask':png(Image.new('L',(1280,720),255))}
|
||||||
|
|
||||||
|
route = next(route for route in gallery.setup_gallery_routes().routes if route.path == '/api/image/inpaint')
|
||||||
|
result = asyncio.run(route.endpoint(Request()))
|
||||||
|
assert sent[0]['aspect_ratio'] == '16:9'
|
||||||
|
assert 'size' not in sent[0]
|
||||||
|
image = Image.open(io.BytesIO(base64.b64decode(result['image'])))
|
||||||
|
assert image.size == (1536,864)
|
||||||
|
|
||||||
|
|
||||||
def test_mixed_endpoint_requires_configured_image_model(monkeypatch):
|
def test_mixed_endpoint_requires_configured_image_model(monkeypatch):
|
||||||
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', owner='alice',
|
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', owner='alice',
|
||||||
model_type='llm', cached_models='["openai/gpt-5-image", "text-model"]', pinned_models=None)
|
model_type='llm', cached_models='["openai/gpt-5-image", "text-model"]', pinned_models=None)
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Regression for #2123 (gallery part): library search must match multi-word
|
||||||
|
queries in any order and treat % / _ literally.
|
||||||
|
|
||||||
|
Old code built a single ``%{search}%`` LIKE over prompt/tags/ai_tags, so
|
||||||
|
"red car" missed "a car that is red", and a literal "%" matched every row.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
import core.database as cdb
|
||||||
|
from core.database import GalleryImage
|
||||||
|
import routes.gallery_routes as gallery_routes
|
||||||
|
|
||||||
|
|
||||||
|
def _client_with_gallery(monkeypatch, tmp_path):
|
||||||
|
engine = create_engine(
|
||||||
|
f"sqlite:///{tmp_path / 'gallery_search.db'}",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=NullPool,
|
||||||
|
)
|
||||||
|
cdb.Base.metadata.create_all(engine)
|
||||||
|
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
|
monkeypatch.setattr(gallery_routes, "SessionLocal", session_factory)
|
||||||
|
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
GalleryImage(
|
||||||
|
id="img-red-car",
|
||||||
|
filename=f"{uuid.uuid4().hex}.png",
|
||||||
|
prompt="a car that is red",
|
||||||
|
model="model-a",
|
||||||
|
tags="",
|
||||||
|
ai_tags="",
|
||||||
|
is_active=True,
|
||||||
|
file_size=10,
|
||||||
|
),
|
||||||
|
GalleryImage(
|
||||||
|
id="img-boat",
|
||||||
|
filename=f"{uuid.uuid4().hex}.png",
|
||||||
|
prompt="blue boat on the lake",
|
||||||
|
model="model-a",
|
||||||
|
tags="",
|
||||||
|
ai_tags="",
|
||||||
|
is_active=True,
|
||||||
|
file_size=10,
|
||||||
|
),
|
||||||
|
GalleryImage(
|
||||||
|
id="img-percent",
|
||||||
|
filename=f"{uuid.uuid4().hex}.png",
|
||||||
|
prompt="100% cotton shirt",
|
||||||
|
model="model-a",
|
||||||
|
tags="sale_2024",
|
||||||
|
ai_tags="",
|
||||||
|
is_active=True,
|
||||||
|
file_size=10,
|
||||||
|
),
|
||||||
|
GalleryImage(
|
||||||
|
id="img-backslash",
|
||||||
|
filename=f"{uuid.uuid4().hex}.png",
|
||||||
|
prompt=r"C:\photos\sunset",
|
||||||
|
model="model-a",
|
||||||
|
tags="",
|
||||||
|
ai_tags="",
|
||||||
|
is_active=True,
|
||||||
|
file_size=10,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(gallery_routes.setup_gallery_routes())
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gallery_search_matches_words_in_any_order(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
|
client = _client_with_gallery(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
body = client.get("/api/gallery/library", params={"search": "red car"}).json()
|
||||||
|
ids = {item["id"] for item in body["items"]}
|
||||||
|
assert "img-red-car" in ids
|
||||||
|
assert "img-boat" not in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_gallery_search_escapes_percent_wildcard(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
|
client = _client_with_gallery(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
body = client.get("/api/gallery/library", params={"search": "%"}).json()
|
||||||
|
ids = {item["id"] for item in body["items"]}
|
||||||
|
assert ids == {"img-percent"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gallery_search_escapes_underscore_wildcard(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
|
client = _client_with_gallery(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
# Only img-percent has a literal underscore (in tags "sale_2024").
|
||||||
|
# Unescaped, "_" matches any single char so every row matches.
|
||||||
|
body = client.get("/api/gallery/library", params={"search": "_"}).json()
|
||||||
|
ids = {item["id"] for item in body["items"]}
|
||||||
|
assert ids == {"img-percent"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gallery_search_escapes_backslash(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
|
client = _client_with_gallery(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
# Only img-backslash has a literal backslash. The escape char itself
|
||||||
|
# must survive escaping instead of escaping the surrounding wildcards.
|
||||||
|
body = client.get("/api/gallery/library", params={"search": "\\"}).json()
|
||||||
|
ids = {item["id"] for item in body["items"]}
|
||||||
|
assert ids == {"img-backslash"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gallery_tag_filter_escapes_wildcards(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
|
client = _client_with_gallery(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
# Unescaped, "%" / "_" match every row; escaped, only literal matches.
|
||||||
|
assert client.get("/api/gallery/library", params={"tag": "%"}).json()["items"] == []
|
||||||
|
|
||||||
|
body = client.get("/api/gallery/library", params={"tag": "_"}).json()
|
||||||
|
assert {item["id"] for item in body["items"]} == {"img-percent"}
|
||||||
@@ -23,7 +23,7 @@ def test_native_generation_uses_configured_model_and_multiline_prompt(image_tran
|
|||||||
json.dumps({'prompt': prompt}), session_id='fixture', owner='alice'))
|
json.dumps({'prompt': prompt}), session_id='fixture', owner='alice'))
|
||||||
assert 'error' not in result
|
assert 'error' not in result
|
||||||
assert seen == [('openai/gpt-5-image', 'alice')]
|
assert seen == [('openai/gpt-5-image', 'alice')]
|
||||||
assert json.loads(requests[0].content)['prompt'] == prompt
|
assert json.loads(requests[0].content)['messages'][0]['content'] == prompt
|
||||||
assert result['image_id']
|
assert result['image_id']
|
||||||
|
|
||||||
|
|
||||||
@@ -87,13 +87,17 @@ def image_transport(monkeypatch, tmp_path):
|
|||||||
original_client = httpx.AsyncClient
|
original_client = httpx.AsyncClient
|
||||||
requests = []
|
requests = []
|
||||||
|
|
||||||
def configure(base, status=200, body=None):
|
def configure(base, status=200, body=None, model='openai/gpt-5-image'):
|
||||||
monkeypatch.setattr(ai_interaction, '_resolve_model', lambda *args, **kwargs: (
|
monkeypatch.setattr(ai_interaction, '_resolve_model', lambda *args, **kwargs: (
|
||||||
base + '/chat/completions', 'openai/gpt-5-image', {'Authorization': 'Bearer test'},
|
base + '/chat/completions', model, {'Authorization': 'Bearer test'},
|
||||||
))
|
))
|
||||||
|
|
||||||
def handle(request):
|
def handle(request):
|
||||||
requests.append(request)
|
requests.append(request)
|
||||||
|
if body is None and 'openrouter.ai' in base and request.url.path.endswith('/chat/completions'):
|
||||||
|
return httpx.Response(status, json={'choices': [{'message': {'images': [{
|
||||||
|
'image_url': {'url': 'data:image/png;base64,' + base64.b64encode(source.read_bytes()).decode()},
|
||||||
|
}]}}]})
|
||||||
return httpx.Response(status, json=body if body is not None else {
|
return httpx.Response(status, json=body if body is not None else {
|
||||||
'data': [{'b64_json': base64.b64encode(source.read_bytes()).decode()}],
|
'data': [{'b64_json': base64.b64encode(source.read_bytes()).decode()}],
|
||||||
})
|
})
|
||||||
@@ -130,12 +134,13 @@ def test_image_provider_protocol(image_transport, editing, base):
|
|||||||
assert request.headers['authorization'] == 'Bearer test'
|
assert request.headers['authorization'] == 'Bearer test'
|
||||||
if 'openrouter.ai' in base:
|
if 'openrouter.ai' in base:
|
||||||
assert len(requests) == 1 # No unsupported local progress/fallback probes.
|
assert len(requests) == 1 # No unsupported local progress/fallback probes.
|
||||||
assert str(request.url) == base + '/images'
|
assert str(request.url) == base + '/chat/completions'
|
||||||
payload = json.loads(request.content)
|
payload = json.loads(request.content)
|
||||||
assert payload['n'] == 1
|
assert payload['model'] == 'openai/gpt-5-image'
|
||||||
|
assert payload['modalities'] == ['image', 'text']
|
||||||
|
assert 'prompt' not in payload
|
||||||
if editing:
|
if editing:
|
||||||
assert payload['size'] == '1536x1024'
|
reference = payload['messages'][0]['content'][1]['image_url']['url']
|
||||||
reference = payload['input_references'][0]['image_url']['url']
|
|
||||||
assert reference.startswith('data:image/png;base64,')
|
assert reference.startswith('data:image/png;base64,')
|
||||||
assert base64.b64decode(reference.split(',', 1)[1]) == source.read_bytes()
|
assert base64.b64decode(reference.split(',', 1)[1]) == source.read_bytes()
|
||||||
assert 'request_id' not in payload
|
assert 'request_id' not in payload
|
||||||
@@ -181,13 +186,65 @@ def test_edit_dimensions_respect_exif_and_explicit_size(image_transport, explici
|
|||||||
exif = Image.Exif()
|
exif = Image.Exif()
|
||||||
exif[274] = 6
|
exif[274] = 6
|
||||||
Image.new('RGB', (120, 80)).save(source, exif=exif)
|
Image.new('RGB', (120, 80)).save(source, exif=exif)
|
||||||
configure('https://openrouter.ai/api/v1')
|
configure('https://openrouter.ai/api/v1', model='openai/gpt-image-1')
|
||||||
result = asyncio.run(ai_interaction.do_edit_image(
|
result = asyncio.run(ai_interaction.do_edit_image(
|
||||||
'Edit', str(source), model_spec='openai/gpt-5-image',
|
'Edit', str(source), model_spec='openai/gpt-image-1',
|
||||||
size='1024x1024' if explicit else 'auto',
|
size='1024x1024' if explicit else 'auto',
|
||||||
))
|
))
|
||||||
assert 'error' not in result
|
assert 'error' not in result
|
||||||
assert json.loads(requests[0].content)['size'] == ('1024x1024' if explicit else '1024x1536')
|
assert json.loads(requests[0].content)['aspect_ratio'] == ('1:1' if explicit else '2:3')
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_openrouter_image_model_keeps_dedicated_image_api(image_transport):
|
||||||
|
configure, source, requests, rows = image_transport
|
||||||
|
configure('https://openrouter.ai/api/v1', model='openai/gpt-image-1')
|
||||||
|
result = asyncio.run(ai_interaction.do_generate_image('A cow\nopenai/gpt-image-1', incognito=True))
|
||||||
|
assert 'error' not in result
|
||||||
|
assert str(requests[0].url).endswith('/api/v1/images')
|
||||||
|
assert json.loads(requests[0].content)['model'] == 'openai/gpt-image-1'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('editing', [False, True])
|
||||||
|
def test_http_200_provider_deprecation_is_not_reported_as_empty_image(image_transport, editing):
|
||||||
|
configure, source, requests, rows = image_transport
|
||||||
|
configure('https://openrouter.ai/api/v1', body={'choices': [{
|
||||||
|
'finish_reason': 'error', 'message': {'content': None},
|
||||||
|
'error': {'code': 502, 'message': 'The model gpt-image-1 has been deprecated'},
|
||||||
|
}]})
|
||||||
|
if editing:
|
||||||
|
result = asyncio.run(ai_interaction.do_edit_image('Edit', str(source), model_spec='openai/gpt-5-image'))
|
||||||
|
else:
|
||||||
|
result = asyncio.run(ai_interaction.do_generate_image('A cow\nopenai/gpt-5-image'))
|
||||||
|
assert 'deprecated' in result['error']
|
||||||
|
assert 'openai/gpt-5-image' in result['error']
|
||||||
|
assert 'AI settings' in result['error']
|
||||||
|
assert len(requests) == 1
|
||||||
|
assert not rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_image_response_identifies_the_selected_model(image_transport):
|
||||||
|
configure, source, requests, rows = image_transport
|
||||||
|
configure('https://openrouter.ai/api/v1', body={'choices': [{'message': {'content': 'No image.'}}]})
|
||||||
|
result = asyncio.run(ai_interaction.do_generate_image('A cow\nopenai/gpt-5-image'))
|
||||||
|
assert "'openai/gpt-5-image' returned no image" in result['error']
|
||||||
|
assert 'AI settings' in result['error']
|
||||||
|
assert len(requests) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('editing', [False,True])
|
||||||
|
def test_openrouter_1280_by_720_requests_16_by_9_not_square(image_transport, editing):
|
||||||
|
configure, source, requests, rows = image_transport
|
||||||
|
Image.new('RGB',(1280,720),'red').save(source)
|
||||||
|
configure('https://openrouter.ai/api/v1',model='openai/gpt-image-2')
|
||||||
|
if editing:
|
||||||
|
result = asyncio.run(ai_interaction.do_edit_image('Change the sky',str(source),model_spec='openai/gpt-image-2',incognito=True))
|
||||||
|
else:
|
||||||
|
result = asyncio.run(ai_interaction.do_generate_image(json.dumps({
|
||||||
|
'prompt':'a landscape','model':'openai/gpt-image-2','size':'1280x720'}),incognito=True))
|
||||||
|
assert 'error' not in result
|
||||||
|
body = json.loads(requests[0].content)
|
||||||
|
assert body['aspect_ratio'] == '16:9'
|
||||||
|
assert 'size' not in body
|
||||||
|
|
||||||
|
|
||||||
def test_unreadable_image_does_not_call_provider(image_transport):
|
def test_unreadable_image_does_not_call_provider(image_transport):
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user