mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-02 19:22:22 +01:00
Compare commits
134
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 | ||
|
|
bebf53be3c | ||
|
|
a8e0ff1c8e | ||
|
|
31685b050a | ||
|
|
03d25943ee | ||
|
|
71e3649985 | ||
|
|
45a57c0c38 | ||
|
|
71f6dc4e5d | ||
|
|
0c8a06a937 | ||
|
|
eea39ce103 | ||
|
|
9a5a15e17f | ||
|
|
f141ad2be5 | ||
|
|
a6e9294394 | ||
|
|
cfbe4aeca4 | ||
|
|
6f0679bb25 | ||
|
|
93c93bf076 | ||
|
|
f0f7464dfa | ||
|
|
1e57f774d3 | ||
|
|
2b310010cf | ||
|
|
b88154ecf9 | ||
|
|
e7796f71d3 | ||
|
|
412789051e | ||
|
|
b8adc4eb60 | ||
|
|
2246215b79 | ||
|
|
06fee0a3d8 | ||
|
|
f3813c261c | ||
|
|
e7a376b161 | ||
|
|
fec2bb11d6 | ||
|
|
c811f76d44 | ||
|
|
02d28c06d0 | ||
|
|
00ecf1c74e | ||
|
|
63feb227bb | ||
|
|
12fcddefab | ||
|
|
b46aa02ce1 | ||
|
|
352ee706f3 | ||
|
|
98b359cd4c | ||
|
|
2a2256af0e | ||
|
|
4f131d7c21 | ||
|
|
945e747eb4 | ||
|
|
61051d28f0 | ||
|
|
d87ae2a7f5 | ||
|
|
0e49c4ef40 | ||
|
|
1aff71836b | ||
|
|
b1c3125865 | ||
|
|
dbee1155e6 | ||
|
|
56c7cdf804 | ||
|
|
fe5d5caf34 | ||
|
|
bb654482f3 | ||
|
|
f45724faed | ||
|
|
6fc6095aec | ||
|
|
ef16398c5c | ||
|
|
6037707430 | ||
|
|
5c542d03ac | ||
|
|
4fe8fb28e5 | ||
|
|
84e232e7ee | ||
|
|
2cace27723 | ||
|
|
508c7b3d9d | ||
|
|
5b8a72931d | ||
|
|
2e7edf908d | ||
|
|
b80f9afb3e | ||
|
|
533d48ad8d | ||
|
|
d5bc7fb70e | ||
|
|
3b8c60ac1e | ||
|
|
d237c3fbba | ||
|
|
f510b1973a | ||
|
|
fe81c491cd | ||
|
|
1339efa856 | ||
|
|
6f5c28b345 | ||
|
|
aa2db8d87c | ||
|
|
6b284d2bc5 | ||
|
|
8acaec20ee | ||
|
|
5f18bcf6f8 | ||
|
|
18b6140511 | ||
|
|
eeb1107f7b | ||
|
|
4795c8d462 | ||
|
|
5c738a5a8e | ||
|
|
0ff5d47a8c | ||
|
|
f780f1dce9 | ||
|
|
1c5853003e | ||
|
|
0f8b137713 | ||
|
|
2c5d5005ba | ||
|
|
901b3ff42d | ||
|
|
c207ed1ed8 | ||
|
|
8b3a866899 | ||
|
|
4d8d363681 | ||
|
|
a7e8a6ef2e | ||
|
|
12631da4d3 | ||
|
|
927985f847 | ||
|
|
2046ce893f | ||
|
|
35ef91b25c | ||
|
|
91922d854c | ||
|
|
641752387b | ||
|
|
b3f86a0724 | ||
|
|
94dc74e500 | ||
|
|
fd27e9a429 | ||
|
|
bf3c000d1c | ||
|
|
7fd4e0e9bb | ||
|
|
8cd96aad46 | ||
|
|
6123b70b5c | ||
|
|
bfa514f6e8 | ||
|
|
1ff2fd0054 | ||
|
|
ad3e309074 | ||
|
|
15c4cef36a | ||
|
|
84cdd866c5 | ||
|
|
50b05bf54f | ||
|
|
dbfc96b8f3 | ||
|
|
a7c03f85bf |
@@ -76,7 +76,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
# Byte-compile sources — catches syntax errors without installing deps.
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: "22.23.4"
|
||||
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
||||
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'
|
||||
with:
|
||||
python-version: "3.11"
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
|
||||
# uv resolves the same versions as pip but installs ~5x faster, and its
|
||||
# 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'
|
||||
with:
|
||||
version: "0.13.5"
|
||||
@@ -179,8 +179,9 @@ jobs:
|
||||
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
||||
python scripts/check_ca_trust.py --python
|
||||
uv pip install --system -r requirements.txt
|
||||
uv pip install --system "playwright==1.62.0"
|
||||
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'
|
||||
with:
|
||||
node-version: "22.23.4"
|
||||
@@ -256,3 +257,10 @@ jobs:
|
||||
env:
|
||||
PYTEST_SHARD: ${{ matrix.shard }}
|
||||
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
|
||||
|
||||
- name: Playwright mobile layout checks (shard 1/4 only)
|
||||
if: steps.docs-check.outputs.docs_only != 'true' && matrix.shard == '1/4'
|
||||
run: |
|
||||
ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \
|
||||
--config tests/e2e/ios-layout.config.cjs \
|
||||
--reporter=line
|
||||
|
||||
@@ -31,11 +31,11 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: none
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Lint Dockerfile
|
||||
uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
|
||||
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
|
||||
with:
|
||||
dockerfile: Dockerfile
|
||||
# DL3008: pinning apt package versions is impractical on a -slim base
|
||||
|
||||
@@ -61,14 +61,14 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
with:
|
||||
driver: docker
|
||||
|
||||
# Build without pushing so a broken Dockerfile is caught here, and the
|
||||
# exact image we ship is what gets scanned.
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
@@ -107,12 +107,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
with:
|
||||
driver: docker
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
|
||||
|
||||
- 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:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
||||
@@ -47,4 +47,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
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
|
||||
run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||
- 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
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
pull: true
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: digest-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
@@ -107,15 +107,15 @@ jobs:
|
||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
- 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
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -62,9 +62,9 @@ jobs:
|
||||
env:
|
||||
SEARXNG_TEST_IMAGE: ${{ steps.image.outputs.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
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: docker/searxng/Dockerfile
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
||||
@@ -82,13 +82,15 @@ services:
|
||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||
- 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
|
||||
# user-neutral; configure these in a local .env or use the host-workspace
|
||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||
- 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:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
- 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
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./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_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||
- 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
|
||||
# user-neutral; configure these in a local .env or use the host-workspace
|
||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||
- 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:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
- 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
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./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_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||
- 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
|
||||
# user-neutral; configure these in a local .env or use the host-workspace
|
||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||
- 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:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
- 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
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
|
||||
@@ -31,21 +31,43 @@ for spec in $SPECS; do
|
||||
name="${spec%%==*}"
|
||||
ver="${spec##*==}"
|
||||
# pip download builds metadata (and trips the same bug), so fetch the raw
|
||||
# sdist URL from the PyPI JSON API instead.
|
||||
url="$(python - "$name" "$ver" <<'PY'
|
||||
import json, sys, urllib.request
|
||||
name, ver = sys.argv[1], sys.argv[2]
|
||||
data = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
|
||||
for f in data["urls"]:
|
||||
if f["packagetype"] == "sdist":
|
||||
print(f["url"]); break
|
||||
# sdist URL from the PyPI JSON API instead. Use curl with retries to
|
||||
# survive transient TLS / network failures that urllib cannot recover from.
|
||||
metadata="${name}-${ver}.json"
|
||||
curl --fail --silent --show-error --location \
|
||||
--retry 8 \
|
||||
--retry-delay 3 \
|
||||
--retry-max-time 120 \
|
||||
--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:
|
||||
sys.exit(f"no sdist found for {name}=={ver}")
|
||||
raise SystemExit("no sdist found")
|
||||
PY
|
||||
)"
|
||||
rm -f "$metadata"
|
||||
|
||||
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"
|
||||
rm -f "${name}.tar.gz"
|
||||
done
|
||||
|
||||
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 |
@@ -33,7 +33,7 @@ async def list_tools() -> list[Tool]:
|
||||
"prompt": {"type": "string", "description": "Image description prompt"},
|
||||
"model": {"type": "string", "description": "Model name (auto-detects if omitted)"},
|
||||
"size": {"type": "string", "description": "Image size (default 1024x1024)"},
|
||||
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default medium)"},
|
||||
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default configured quality, otherwise medium)"},
|
||||
},
|
||||
"required": ["prompt"],
|
||||
},
|
||||
@@ -66,7 +66,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
|
||||
if not model_spec:
|
||||
model_spec = _settings.get("image_model", "")
|
||||
if quality == "medium" and _settings.get("image_quality"):
|
||||
if "quality" not in arguments and _settings.get("image_quality"):
|
||||
quality = _settings["image_quality"]
|
||||
|
||||
# Auto-detect best available image model
|
||||
|
||||
+5
-1
@@ -9,7 +9,11 @@
|
||||
"test:photo-editor": "playwright test --config tests/e2e/playwright.config.js",
|
||||
"test:photo-editor:install": "playwright install chromium firefox webkit",
|
||||
"test:photo-editor:firefox": "PHOTO_EDITOR_E2E_BROWSER=firefox playwright test --config tests/e2e/playwright.config.js",
|
||||
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js"
|
||||
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js",
|
||||
"test:ios-layout": "playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||
"test:ios-layout:chromium": "ODYSSEUS_LAYOUT_BROWSER=chromium playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||
"test:ios-layout:webkit": "ODYSSEUS_LAYOUT_BROWSER=webkit playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||
"test:ios-layout:install": "playwright install chromium webkit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antithesishq/bombadil": "^0.7.0",
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
-r requirements.txt
|
||||
# psutil lets `-n auto` use physical cores instead of logical CPU threads.
|
||||
pytest-xdist[psutil]>=3.8,<4
|
||||
playwright==1.62.0
|
||||
|
||||
@@ -887,6 +887,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
"""Create a new integration (admin only)."""
|
||||
_require_admin_user(request)
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
item = add_integration(body)
|
||||
return {"ok": True, "integration": mask_integration_secret(item)}
|
||||
|
||||
@@ -895,6 +897,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
"""Update an existing integration (admin only)."""
|
||||
_require_admin_user(request)
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
item = update_integration(integration_id, body)
|
||||
if not item:
|
||||
raise HTTPException(404, "Integration not found")
|
||||
|
||||
+16
-16
@@ -80,6 +80,18 @@ def _require_user(request: Request) -> str:
|
||||
return FALLBACK_OWNER
|
||||
|
||||
|
||||
async def _json_object_body(request: Request) -> dict:
|
||||
"""Parse a JSON object body. An unreadable body counts as empty; a body
|
||||
that is valid JSON but not an object (a list, string or number) is a 400."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return {}
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
return body
|
||||
|
||||
|
||||
def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal:
|
||||
cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first()
|
||||
if not cal:
|
||||
@@ -994,10 +1006,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
async def save_config(request: Request):
|
||||
"""Legacy single-account endpoint — upserts the first account."""
|
||||
owner = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object_body(request)
|
||||
accounts = _get_caldav_accounts(owner)
|
||||
if not (body.get("url") or "").strip():
|
||||
_save_caldav_accounts(owner, [])
|
||||
@@ -1052,10 +1061,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
"""Add a new CalDAV account."""
|
||||
import uuid as _uuid
|
||||
owner = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object_body(request)
|
||||
from src.caldav_sync import validate_caldav_url
|
||||
try:
|
||||
url = validate_caldav_url(body.get("url", ""))
|
||||
@@ -1080,10 +1086,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
async def update_caldav_account(account_id: str, request: Request):
|
||||
"""Update an existing CalDAV account by id."""
|
||||
owner = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object_body(request)
|
||||
accounts = _get_caldav_accounts(owner)
|
||||
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
|
||||
if idx is None:
|
||||
@@ -1124,10 +1127,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
test an already-saved account. Falls back to the first saved account
|
||||
when nothing is provided."""
|
||||
owner = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object_body(request)
|
||||
url = (body.get("url") or "").strip()
|
||||
user = (body.get("username") or "").strip()
|
||||
pw = body.get("password") or ""
|
||||
|
||||
+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:
|
||||
"""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
|
||||
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))
|
||||
except Exception:
|
||||
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}:
|
||||
return False
|
||||
if is_chatgpt_subscription:
|
||||
@@ -3923,6 +3926,19 @@ def setup_chat_routes(
|
||||
if ctx.preprocessed.attachment_meta:
|
||||
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
|
||||
# PDF → editable markdown) so the editor pane switches to them
|
||||
# before the model starts streaming.
|
||||
|
||||
+179
-1
@@ -14,6 +14,7 @@ from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from routes._validators import validate_remote_host, validate_ssh_port
|
||||
from routes.cookbook_output import HF_PARTIAL_BLOB_HELPER
|
||||
from core.platform_compat import _ssh_exec_argv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -378,6 +379,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
||||
"""
|
||||
lines = [
|
||||
"import json, os, re, shutil, subprocess, urllib.request",
|
||||
*HF_PARTIAL_BLOB_HELPER.splitlines(),
|
||||
"models = []",
|
||||
"seen = set()",
|
||||
"BLOCKED_ROOTS = ('/sys', '/proc', '/dev', '/run', '/var/run')",
|
||||
@@ -436,8 +438,10 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
||||
" sz, nf, ic = 0, 0, False",
|
||||
" if os.path.isdir(blobs):",
|
||||
" for f in os.scandir(blobs):",
|
||||
" if f.name.endswith('.incomplete'):",
|
||||
" if not is_partial_blob(f.path): continue",
|
||||
" ic = True",
|
||||
" if f.is_file(): nf += 1; sz += f.stat().st_size",
|
||||
" if f.name.endswith('.incomplete'): ic = True",
|
||||
" snap = os.path.join(cache, d, 'snapshots')",
|
||||
" def snapshot_size():",
|
||||
" total, count, incomplete = 0, 0, False",
|
||||
@@ -1030,6 +1034,166 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None:
|
||||
runner_lines.append(' fi # end _odysseus_have_prebuilt guard')
|
||||
|
||||
|
||||
ODYSSEUS_LLAMA_SHIM_MARKER = "# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike"
|
||||
|
||||
|
||||
def _append_llama_server_resolution_definition_lines(runner_lines: list[str]) -> None:
|
||||
"""Define the helper shell function that discovers, reconciles and prioritizes llama-server."""
|
||||
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
|
||||
runner_lines.append('_odysseus_resolve_llama_server() {')
|
||||
runner_lines.append(' _odysseus_llama_bin=""')
|
||||
runner_lines.append(f' local _marker={marker}')
|
||||
runner_lines.append(' local _saved_ifs="$IFS"')
|
||||
runner_lines.append(" IFS=':'")
|
||||
runner_lines.append(' local -a _parts')
|
||||
runner_lines.append(' read -r -a _parts <<< "$PATH"')
|
||||
runner_lines.append(' IFS="$_saved_ifs"')
|
||||
runner_lines.append(' local _dir _cand')
|
||||
runner_lines.append(' for _dir in "${_parts[@]}"; do')
|
||||
runner_lines.append(' [ -n "$_dir" ] || continue')
|
||||
runner_lines.append(' _cand="$_dir/llama-server"')
|
||||
runner_lines.append(' [ -x "$_cand" ] || continue')
|
||||
runner_lines.append(' if [ -f "$_cand" ] && [ ! -L "$_cand" ] && grep -Fxq "$_marker" "$_cand" 2>/dev/null; then')
|
||||
runner_lines.append(' continue')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' if "$_cand" --version >/dev/null 2>&1; then')
|
||||
runner_lines.append(' _odysseus_llama_bin="$_cand"')
|
||||
runner_lines.append(' break')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' done')
|
||||
runner_lines.append(' if [ -f "$HOME/bin/llama-server" ] && [ ! -L "$HOME/bin/llama-server" ] && grep -Fxq "$_marker" "$HOME/bin/llama-server" 2>/dev/null; then')
|
||||
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
|
||||
runner_lines.append(' echo "[odysseus] Native llama-server detected — retiring Odysseus compatibility shim in ~/bin"')
|
||||
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
|
||||
runner_lines.append(' hash -r')
|
||||
runner_lines.append(' elif python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||
runner_lines.append(' _odysseus_llama_bin="$HOME/bin/llama-server"')
|
||||
runner_lines.append(' else')
|
||||
runner_lines.append(' echo "[odysseus] Removing stale Odysseus llama-server shim (python3 -m llama_cpp.server unavailable)"')
|
||||
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
|
||||
runner_lines.append(' hash -r')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
|
||||
runner_lines.append(' local _cand_dir')
|
||||
runner_lines.append(' _cand_dir="$(dirname "$_odysseus_llama_bin")"')
|
||||
runner_lines.append(' if [ "${PATH%%:*}" != "$_cand_dir" ]; then')
|
||||
runner_lines.append(' export PATH="$_cand_dir:$PATH"')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' hash -r')
|
||||
runner_lines.append(' return 0')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' return 1')
|
||||
runner_lines.append('}')
|
||||
|
||||
|
||||
def _append_llama_server_shim_definition_lines(runner_lines: list[str]) -> None:
|
||||
"""Define the helper shell function that generates the Odysseus compatibility shim."""
|
||||
_append_llama_server_resolution_definition_lines(runner_lines)
|
||||
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
|
||||
runner_lines.append('_odysseus_create_llama_shim() {')
|
||||
runner_lines.append(' local _target="$HOME/bin/llama-server"')
|
||||
runner_lines.append(f' local _marker={marker}')
|
||||
runner_lines.append(' if [ -e "$_target" ] || [ -L "$_target" ]; then')
|
||||
runner_lines.append(' if [ -f "$_target" ] && [ ! -L "$_target" ] && grep -Fxq "$_marker" "$_target" 2>/dev/null; then')
|
||||
runner_lines.append(' : # existing Odysseus shim — safe to replace')
|
||||
runner_lines.append(' else')
|
||||
runner_lines.append(' echo "[odysseus] WARNING: $_target already exists and is not an Odysseus-managed shim; leaving untouched." >&2')
|
||||
runner_lines.append(' return 1')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' mkdir -p "$HOME/bin" || return 1')
|
||||
runner_lines.append(' local _tmp')
|
||||
runner_lines.append(' _tmp="$(mktemp "$HOME/bin/.llama-server.odysseus.tmp.XXXXXX")" || {')
|
||||
runner_lines.append(' echo "[odysseus] ERROR: unable to create secure temporary llama-server shim in $HOME/bin" >&2')
|
||||
runner_lines.append(' return 1')
|
||||
runner_lines.append(' }')
|
||||
runner_lines.append(" if ! cat > \"$_tmp\" <<'_ODY_LLAMA_SHIM_EOF'")
|
||||
runner_lines.append('#!/usr/bin/env bash')
|
||||
runner_lines.append(ODYSSEUS_LLAMA_SHIM_MARKER)
|
||||
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
|
||||
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
|
||||
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
|
||||
runner_lines.append('ARGS=()')
|
||||
runner_lines.append('while [ $# -gt 0 ]; do')
|
||||
runner_lines.append(' case "$1" in')
|
||||
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
|
||||
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
|
||||
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
|
||||
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
|
||||
runner_lines.append(' --no-warmup) shift ;; # native-only')
|
||||
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
|
||||
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
|
||||
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
|
||||
runner_lines.append(' esac')
|
||||
runner_lines.append('done')
|
||||
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
|
||||
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
|
||||
runner_lines.append(' then')
|
||||
runner_lines.append(' rm -f -- "$_tmp"')
|
||||
runner_lines.append(' return 1')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' chmod +x "$_tmp" || { rm -f -- "$_tmp"; return 1; }')
|
||||
runner_lines.append(' mv -f -- "$_tmp" "$_target" || { rm -f -- "$_tmp"; return 1; }')
|
||||
runner_lines.append(' hash -r')
|
||||
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
|
||||
runner_lines.append('}')
|
||||
|
||||
|
||||
def _append_llama_server_shim_reconciliation_lines(runner_lines: list[str]) -> None:
|
||||
"""Reconcile an existing ~/bin/llama-server if it is owned by Odysseus Cookbook.
|
||||
|
||||
Invariants:
|
||||
- Never touch an arbitrary user-owned executable or symlink.
|
||||
- If ~/bin/llama-server is an Odysseus generated shim:
|
||||
- If a native llama-server is present and healthy elsewhere on PATH, remove the shim
|
||||
so the native binary is used.
|
||||
- If python3 -m llama_cpp.server is unavailable, remove the stale shim
|
||||
and hash -r so the fallback/build path is triggered.
|
||||
- If no healthy native server exists and llama_cpp.server is working, preserve it.
|
||||
- If a healthy native candidate exists on PATH (even later in PATH), prioritize it and
|
||||
ensure the launched executable is the validated candidate.
|
||||
"""
|
||||
runner_lines.append('[ -r ~/.config/odysseus-llama-cpp-env ] && . ~/.config/odysseus-llama-cpp-env')
|
||||
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
|
||||
_append_llama_server_resolution_definition_lines(runner_lines)
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append('_odysseus_resolve_llama_server || true')
|
||||
|
||||
|
||||
def _append_llama_server_finalization_lines(runner_lines: list[str], platform_hint: str = "") -> None:
|
||||
"""Reconcile post-install launcher availability and enforce fail-closed invariant.
|
||||
|
||||
Guarantees that if python3 -m llama_cpp.server is usable but the `llama-server`
|
||||
command is unresolvable, the compatibility launcher is created. If no usable
|
||||
launcher exists after all build/install attempts, sets ODYSSEUS_PREFLIGHT_EXIT=127.
|
||||
"""
|
||||
err_msg = (
|
||||
f"ERROR: llama.cpp serving is not available on {platform_hint} after install attempts."
|
||||
if platform_hint
|
||||
else "ERROR: llama.cpp serving is not available after install/build attempts."
|
||||
)
|
||||
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
|
||||
_append_llama_server_resolution_definition_lines(runner_lines)
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append('if [ -z "$_odysseus_llama_bin" ] && python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||
runner_lines.append(' _odysseus_create_llama_shim')
|
||||
runner_lines.append(' _odysseus_resolve_llama_server || true')
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append('if [ -z "$_odysseus_llama_bin" ]; then')
|
||||
runner_lines.append(f' echo "{err_msg}"')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append('fi')
|
||||
|
||||
|
||||
def _llama_cpp_rebuild_cmd(update_source: bool = False) -> str:
|
||||
"""Shell command that clears the Cookbook-managed llama.cpp build.
|
||||
|
||||
@@ -1327,6 +1491,20 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
||||
"Port is already in use.",
|
||||
[{"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",
|
||||
"No GPUs are visible to the serve process.",
|
||||
|
||||
@@ -8,6 +8,20 @@ import re
|
||||
|
||||
_FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
|
||||
|
||||
# Shared source embedded in local/remote inventory and status probes. A blank
|
||||
# retry marker beside its completed, nonempty content-addressed blob is stale;
|
||||
# a blank marker alone may be a download that has not received its first byte.
|
||||
HF_PARTIAL_BLOB_HELPER = """def is_partial_blob(path):
|
||||
if not path.endswith('.incomplete'):
|
||||
return False
|
||||
try:
|
||||
completed = path[:-len('.incomplete')]
|
||||
return (os.path.getsize(path) != 0 or not os.path.isfile(completed)
|
||||
or os.path.getsize(completed) == 0)
|
||||
except OSError:
|
||||
return True
|
||||
"""
|
||||
|
||||
# Probe scripts for the dead-session download check, run as
|
||||
# `python3 -c <PROBE> <repo_id> <cache_root>` (locally or over SSH).
|
||||
# cache_root is the task's custom download dir, '' for the default HF cache.
|
||||
@@ -15,7 +29,7 @@ _FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
|
||||
# HF_HOME=<local_dir>, so that task's cache lives under <local_dir>/hub, and
|
||||
# the probe process's own environment knows nothing about it.
|
||||
HF_CACHE_COMPLETE_PROBE = (
|
||||
"import os,sys;"
|
||||
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
|
||||
"repo=sys.argv[1];"
|
||||
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
||||
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
||||
@@ -24,18 +38,18 @@ HF_CACHE_COMPLETE_PROBE = (
|
||||
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
|
||||
"inc=False;"
|
||||
"blobs=os.path.join(d,'blobs');"
|
||||
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
||||
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
|
||||
"sys.exit(0 if ok and not inc else 1)"
|
||||
)
|
||||
|
||||
HF_CACHE_INCOMPLETE_PROBE = (
|
||||
"import os,sys;"
|
||||
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
|
||||
"repo=sys.argv[1];"
|
||||
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
||||
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
||||
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
|
||||
"blobs=os.path.join(d,'blobs');"
|
||||
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
||||
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
|
||||
"sys.exit(0 if inc else 1)"
|
||||
)
|
||||
|
||||
|
||||
+22
-229
@@ -57,9 +57,11 @@ from routes.cookbook_helpers import (
|
||||
_pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
||||
_diagnose_serve_output, run_ssh_command_async,
|
||||
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
|
||||
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
||||
_append_pip_install_runner_lines, _pip_install_command_without_break_system_packages,
|
||||
_normalize_llama_cpp_python_cache_types,
|
||||
_append_llama_server_shim_definition_lines,
|
||||
_append_llama_server_shim_reconciliation_lines,
|
||||
_append_llama_server_finalization_lines,
|
||||
ModelDownloadRequest, ServeRequest,
|
||||
)
|
||||
|
||||
@@ -466,175 +468,6 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
task["payload"].pop("hf_token", None)
|
||||
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):
|
||||
"""Return cookbook state without raw secrets for browser clients."""
|
||||
_strip_task_secrets(state)
|
||||
@@ -2161,7 +1994,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
ps_lines.append('}')
|
||||
elif "llama_cpp" in req.cmd or "llama-server" in req.cmd:
|
||||
ps_lines.append('# Auto-install llama-cpp-python if missing')
|
||||
ps_lines.append('try { python -c "import llama_cpp" 2>$null } catch {}')
|
||||
ps_lines.append('try { python -c "import llama_cpp.server" 2>$null } catch {}')
|
||||
ps_lines.append('if ($LASTEXITCODE -ne 0) {')
|
||||
ps_lines.append(' Write-Host "Installing llama-cpp-python..."')
|
||||
ps_lines.append(' python -m pip install llama-cpp-python[server]')
|
||||
@@ -2247,14 +2080,17 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# ollama is found (otherwise macOS falls back to a slow source build).
|
||||
# /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux.
|
||||
runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
|
||||
_append_llama_server_shim_definition_lines(runner_lines)
|
||||
_append_llama_server_shim_reconciliation_lines(runner_lines)
|
||||
runner_lines.append('if [ -d /data/data/com.termux ]; then')
|
||||
runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).')
|
||||
runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
||||
runner_lines.append(' if ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||
runner_lines.append(' pkg install -y cmake 2>/dev/null')
|
||||
runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null')
|
||||
runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append('elif ! command -v llama-server &>/dev/null; then')
|
||||
_append_llama_server_finalization_lines(runner_lines, platform_hint="Termux")
|
||||
runner_lines.append('elif [ -z "$_odysseus_llama_bin" ]; then')
|
||||
runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."')
|
||||
runner_lines.append(' mkdir -p ~/bin')
|
||||
runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp')
|
||||
@@ -2271,8 +2107,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is
|
||||
# explicit so the binary is optimized (Metal auto-enables on macOS).
|
||||
runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\')
|
||||
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server \\')
|
||||
runner_lines.append(' && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server')
|
||||
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server')
|
||||
runner_lines.append(' else')
|
||||
_append_llama_cpp_linux_accel_build_lines(runner_lines)
|
||||
runner_lines.append(' fi')
|
||||
@@ -2296,58 +2131,13 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' fi')
|
||||
# SHORT-CIRCUIT before the build/pip fallback: if the
|
||||
# native binary is missing but llama_cpp Python is already
|
||||
# installed, drop a wrapper at ~/bin/llama-server that
|
||||
# translates llama-server CLI args to llama_cpp.server's
|
||||
# underscore-style flags. The user's serve command stays
|
||||
# `llama-server ...` and "just works" — no build, no cmake,
|
||||
# no second install. This is the path that unblocks every
|
||||
# remote where pip-installed llama-cpp-python is already
|
||||
# working but Cookbook used to insist on a native binary.
|
||||
runner_lines.append(' if ! command -v llama-server >/dev/null 2>&1 && python3 -c "import llama_cpp" 2>/dev/null; then')
|
||||
runner_lines.append(' mkdir -p ~/bin')
|
||||
runner_lines.append(' cat > ~/bin/llama-server <<\'_ODY_LLAMA_SHIM_EOF\'')
|
||||
runner_lines.append('#!/usr/bin/env bash')
|
||||
runner_lines.append('# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike')
|
||||
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
|
||||
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
|
||||
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
|
||||
runner_lines.append('ARGS=()')
|
||||
runner_lines.append('while [ $# -gt 0 ]; do')
|
||||
runner_lines.append(' case "$1" in')
|
||||
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
|
||||
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
|
||||
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
|
||||
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
|
||||
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
|
||||
runner_lines.append(' --no-warmup) shift ;; # native-only')
|
||||
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
|
||||
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
|
||||
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
|
||||
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
|
||||
runner_lines.append(' esac')
|
||||
runner_lines.append('done')
|
||||
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
|
||||
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
|
||||
runner_lines.append(' chmod +x ~/bin/llama-server')
|
||||
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' # If the native build failed, fall back to the Python bindings.')
|
||||
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
||||
runner_lines.append(' _odysseus_resolve_llama_server || true')
|
||||
# If the native build failed, fall back to the Python bindings.
|
||||
runner_lines.append(' if [ -z "$_odysseus_llama_bin" ] && ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
|
||||
runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."')
|
||||
runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true")
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
|
||||
runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install/build attempts."')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append(' fi')
|
||||
_append_llama_server_finalization_lines(runner_lines)
|
||||
runner_lines.append('fi')
|
||||
elif re.search(r"\bollama\s+serve\b", req.cmd):
|
||||
handled_ollama_serve = True
|
||||
@@ -3971,7 +3761,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
return {"ok": False, "files": [], "error": f"HF API HTTP {resp.status_code}"}
|
||||
data = resp.json()
|
||||
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"}
|
||||
files = [
|
||||
str(s.get("rfilename") or "")
|
||||
@@ -4343,7 +4133,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
tmux output can stop at a stale progress line if the pane/session
|
||||
disappears before Cookbook captures the final DOWNLOAD_OK marker.
|
||||
In that case, trust the cache shape: a snapshot directory with files
|
||||
and no *.incomplete blobs means HuggingFace finished materializing the
|
||||
and no unfinished blobs means HuggingFace finished materializing the
|
||||
model. cache_root is the task's custom download dir — the runner
|
||||
pointed HF_HOME there, so the cache lives under <cache_root>/hub,
|
||||
not wherever this probe's environment says.
|
||||
@@ -4368,7 +4158,8 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
"""Best-effort check for resumable HF partial blobs.
|
||||
|
||||
A lost SSH/tmux session can leave a real download still incomplete.
|
||||
Treat any *.incomplete blob as stronger evidence than stale
|
||||
Treat unfinished blobs (excluding empty markers paired with a
|
||||
completed blob) as stronger evidence than stale
|
||||
"100%" lines in the captured pane output.
|
||||
"""
|
||||
if not repo_id or "/" not in repo_id:
|
||||
@@ -4486,7 +4277,9 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# fills the visible tail. Without this, output_tail ends up
|
||||
# as just "Locale: C / Ubuntu_Odysseus ❯" and the agent
|
||||
# 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:
|
||||
# LOCAL Windows task: launched as a detached process (no tmux).
|
||||
# Liveness comes from the <session>.pid file, output from the
|
||||
@@ -4495,7 +4288,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
capture_cmd = None
|
||||
else:
|
||||
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
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from core.database import SessionLocal, Document, DocumentVersion
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import get_current_user, _auth_disabled
|
||||
from src.constants import MAIL_ATTACHMENTS_DIR
|
||||
from src.session_search import _escape_like
|
||||
from src.upload_handler import reserve_upload_references
|
||||
|
||||
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.
|
||||
if search:
|
||||
for tok in search.split():
|
||||
term = f"%{tok}%"
|
||||
term = f"%{_escape_like(tok)}%"
|
||||
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
|
||||
@@ -781,9 +783,11 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse export request body, defaulting to empty", exc_info=e)
|
||||
data = {}
|
||||
ids = data.get("ids") or []
|
||||
ids = data.get("ids") if isinstance(data, dict) else None
|
||||
if not ids:
|
||||
raise HTTPException(400, "No documents specified")
|
||||
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
|
||||
raise HTTPException(400, "ids must be a list of document ids")
|
||||
_ext = {
|
||||
"javascript": ".js", "python": ".py", "html": ".html", "css": ".css",
|
||||
"markdown": ".md", "json": ".json", "yaml": ".yml", "bash": ".sh",
|
||||
@@ -810,10 +814,13 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
ext = _ext.get(doc.language or "text", ".txt")
|
||||
base = (doc.title or "document").strip() or "document"
|
||||
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
|
||||
while name in used:
|
||||
name = f"{base}-{i}" + ("" if "." in base else ext)
|
||||
name = f"{stem}-{i}{suffix}"
|
||||
i += 1
|
||||
used.add(name)
|
||||
content = doc.current_content or ""
|
||||
|
||||
@@ -846,6 +846,64 @@ def _plan_psd_steps(prompt: str) -> Optional[List[Dict[str, Any]]]:
|
||||
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(
|
||||
prompt: str, state: Any, provider_available: bool,
|
||||
) -> 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
|
||||
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)
|
||||
# and must never be left to the model, which sometimes substitutes a
|
||||
# generative placeImage. Highest priority so it can never vary.
|
||||
@@ -1647,6 +1707,13 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
screenshot = body.get("screenshot")
|
||||
# Optional user-picked image model (from the editor model selector).
|
||||
model_choice = str(body.get("model") or body.get("image_model") or "").strip()
|
||||
generation_model_choice = str(body.get("generation_model") or "").strip()
|
||||
use_image_settings = body.get("_use_image_settings") is True
|
||||
if use_image_settings:
|
||||
from src.editor_image_settings import editor_image_models
|
||||
image_models = editor_image_models(owner=owner)
|
||||
model_choice = image_models["edit"]
|
||||
generation_model_choice = image_models["generation"]
|
||||
# Optional user-picked PLANNER model (the editor agent/planner model).
|
||||
agent_model = str(body.get("agent_model") or "").strip()
|
||||
tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
|
||||
@@ -1797,9 +1864,22 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
# Planner output is untrusted: it cannot redirect this turn to
|
||||
# another editor session or override an explicit model choice.
|
||||
tool_payload["session"] = session
|
||||
if use_image_settings:
|
||||
tool_payload["_use_image_settings"] = True
|
||||
if op == "generate_image" and state.get("width") and state.get("height"):
|
||||
try:
|
||||
width, height = int(state["width"]), int(state["height"])
|
||||
if width > 0 and height > 0:
|
||||
tool_payload["size"] = f"{width}x{height}"
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
# Route to the user-picked model when one is selected.
|
||||
if model_choice:
|
||||
tool_payload["model"] = model_choice
|
||||
selected_model = generation_model_choice if op == "generate_image" else model_choice
|
||||
if selected_model:
|
||||
tool_payload["model"] = selected_model
|
||||
elif op == "generate_image" or use_image_settings:
|
||||
# Let server settings resolve Auto, not a planner-invented model.
|
||||
tool_payload.pop("model", None)
|
||||
# Hand the AI step the canvas image the client already captured
|
||||
# for this request. Without it the tool falls back to a separate
|
||||
# bridge screenshot, which is not reliably answered right after a
|
||||
|
||||
@@ -19,9 +19,10 @@ def _split_family_token(token):
|
||||
def _derive_family(filename):
|
||||
"""Derive a font-family name from a filename like 'JetBrainsMono-Regular.woff2' → 'JetBrains Mono'."""
|
||||
name = os.path.splitext(filename)[0]
|
||||
# Strip common weight/style suffixes
|
||||
# Strip weight/style suffixes, including BoldItalic and Bold-Italic.
|
||||
name = re.sub(
|
||||
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)$',
|
||||
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)'
|
||||
r'(?:[-_ ]?(?:Italic|Oblique))?$',
|
||||
'', name, flags=re.IGNORECASE
|
||||
)
|
||||
# Replace dashes/underscores with spaces
|
||||
|
||||
@@ -18,6 +18,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
|
||||
from core.database import Session as DbSession
|
||||
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 (
|
||||
read_upload_limited,
|
||||
GALLERY_UPLOAD_MAX_BYTES,
|
||||
@@ -37,6 +38,24 @@ _SAM_STATE: Dict[str, Any] = {}
|
||||
_GROUNDING_STATE: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def _compose_cloud_image_edit(source, generated, mask, *, instruction=False):
|
||||
from PIL import Image, ImageChops, ImageOps
|
||||
|
||||
full_frame = instruction or mask.getextrema() == (255, 255)
|
||||
if full_frame:
|
||||
return generated, True
|
||||
# Partial edits must share the source coordinate system, but resizing
|
||||
# width/height independently distorts the subject when the provider changes ratio.
|
||||
fitted = ImageOps.contain(generated, source.size, Image.Resampling.LANCZOS)
|
||||
offset = ((source.width - fitted.width) // 2, (source.height - fitted.height) // 2)
|
||||
aligned = Image.new("RGBA", source.size)
|
||||
aligned.paste(fitted, offset)
|
||||
coverage = Image.new("L", source.size)
|
||||
coverage.paste(255, (offset[0], offset[1], offset[0] + fitted.width, offset[1] + fitted.height))
|
||||
effective_mask = ImageChops.multiply(mask, coverage)
|
||||
return Image.composite(aligned, source, effective_mask), False
|
||||
|
||||
|
||||
def _rss_mb() -> float:
|
||||
"""Current process RSS in MB (from /proc; -1.0 when unavailable)."""
|
||||
try:
|
||||
@@ -305,6 +324,23 @@ def _b64_bytes(value: str, *, label: str = "image") -> bytes:
|
||||
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:
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format=fmt)
|
||||
@@ -568,6 +604,7 @@ def _is_openai_api_base(url: str) -> bool:
|
||||
|
||||
|
||||
_GALLERY_ENDPOINT_PATHS = frozenset({
|
||||
"/chat/completions",
|
||||
"/images",
|
||||
"/images/edits",
|
||||
"/images/generations",
|
||||
@@ -1184,14 +1221,17 @@ def setup_gallery_routes() -> APIRouter:
|
||||
)
|
||||
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:
|
||||
term = f"%{search}%"
|
||||
from sqlalchemy import or_
|
||||
for tok in search.split():
|
||||
term = f"%{escape_like_pattern(tok)}%"
|
||||
q = q.filter(or_(
|
||||
GalleryImage.prompt.ilike(term),
|
||||
GalleryImage.tags.ilike(term),
|
||||
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
|
||||
@@ -1203,9 +1243,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
for one in (t.strip() for t in tag.split(",")):
|
||||
if not one:
|
||||
continue
|
||||
safe_tag = f"%{escape_like_pattern(one)}%"
|
||||
q = q.filter(_or(
|
||||
GalleryImage.tags.ilike(f"%{one}%"),
|
||||
GalleryImage.ai_tags.ilike(f"%{one}%"),
|
||||
GalleryImage.tags.ilike(safe_tag, escape="\\"),
|
||||
GalleryImage.ai_tags.ilike(safe_tag, escape="\\"),
|
||||
))
|
||||
|
||||
# Model filter
|
||||
@@ -1745,6 +1786,17 @@ def setup_gallery_routes() -> APIRouter:
|
||||
if not ok:
|
||||
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
|
||||
chosen_model = (body.pop("_model", "") or "").strip()
|
||||
if body.pop("_use_image_settings", False) is True:
|
||||
from src.editor_image_settings import editor_image_models
|
||||
from src.ai_interaction import _resolve_model
|
||||
chosen_model = editor_image_models(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
|
||||
# provider's step progress for this op. Ignored by providers that do not
|
||||
# implement the progress contract.
|
||||
@@ -1862,13 +1914,29 @@ def setup_gallery_routes() -> APIRouter:
|
||||
mask_png.save(reference_mask, format="PNG")
|
||||
references = [src_buf.getvalue(), reference_mask.getvalue()]
|
||||
payload = {
|
||||
"model": oa_model, "size": size, "n": 1,
|
||||
"model": oa_model, "n": 1,
|
||||
"output_format": "png",
|
||||
"prompt": "Edit the first image. The second image is a mask: white marks the region to edit, black marks the region to preserve. Keep composition and framing unchanged. Requested edit: " + str(body.get("prompt", "")),
|
||||
"input_references": [{"type": "image_url", "image_url": {
|
||||
"url": "data:image/png;base64," + base64.b64encode(value).decode(),
|
||||
}} for value in references],
|
||||
}
|
||||
from src.image_model_ids import image_aspect_ratio
|
||||
payload["aspect_ratio"] = image_aspect_ratio(source_png.width, source_png.height)
|
||||
from src.ai_interaction import _uses_openrouter_chat_images
|
||||
if _uses_openrouter_chat_images(oa_model):
|
||||
payload = {
|
||||
"model": oa_model,
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": payload["prompt"]},
|
||||
*[{"type": "image_url", "image_url": ref["image_url"]}
|
||||
for ref in payload["input_references"]],
|
||||
]}],
|
||||
"modalities": ["image", "text"], "stream": False,
|
||||
"image_config": {"aspect_ratio": payload["aspect_ratio"]},
|
||||
}
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/chat/completions"), headers=headers, json=payload)
|
||||
else:
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images"), headers=headers, json=payload)
|
||||
else:
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files)
|
||||
@@ -1885,6 +1953,12 @@ def setup_gallery_routes() -> APIRouter:
|
||||
pass
|
||||
raise HTTPException(r.status_code, f"{_detail} (provider HTTP {r.status_code})")
|
||||
result = r.json()
|
||||
from src.ai_interaction import _chat_image_results, _image_response_error
|
||||
provider_error = _image_response_error(result, oa_model)
|
||||
if provider_error:
|
||||
raise HTTPException(400 if "deprecated" in provider_error.lower() else 502, provider_error)
|
||||
if is_openrouter and _uses_openrouter_chat_images(oa_model):
|
||||
result = {"data": _chat_image_results(result)}
|
||||
raw_b64 = None
|
||||
if result.get("data"):
|
||||
item = result["data"][0]
|
||||
@@ -1904,17 +1978,13 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# the masked region actually changes.
|
||||
try:
|
||||
generated = Image.open(io.BytesIO(base64.b64decode(raw_b64))).convert("RGBA")
|
||||
# Match the generated image to the source dims.
|
||||
if generated.size != source_png.size:
|
||||
generated = generated.resize(source_png.size, Image.LANCZOS)
|
||||
# mask_png: white = regenerate (use generated),
|
||||
# black = keep (use source).
|
||||
# Composite: result = source * (1 - mask_norm) + generated * mask_norm
|
||||
# Image.composite does exactly that with `mask`.
|
||||
blended = Image.composite(generated, source_png, mask_png)
|
||||
blended, full_frame = _compose_cloud_image_edit(
|
||||
source_png, generated, mask_png,
|
||||
instruction=body.get("mode") == "instruction",
|
||||
)
|
||||
out_buf = io.BytesIO()
|
||||
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:
|
||||
# Never return a full-image edit when masking fails.
|
||||
logger.warning(f"Inpaint compose failed: {comp_err}")
|
||||
@@ -2433,11 +2503,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
image_b64 = body.get("image")
|
||||
amount = body.get("amount", 50) / 100.0
|
||||
|
||||
from PIL import Image, ImageFilter
|
||||
from PIL import ImageFilter
|
||||
import base64, io
|
||||
|
||||
img_bytes = _b64_bytes(image_b64)
|
||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
img = _b64_to_pil_image(image_b64, mode="RGB")
|
||||
|
||||
# Unsharp mask: radius=2, percent=amount*200, threshold=3
|
||||
sharpened = img.filter(ImageFilter.UnsharpMask(radius=2, percent=int(amount * 200), threshold=3))
|
||||
@@ -2472,8 +2541,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
except ImportError:
|
||||
raise HTTPException(500, "Server missing a required dependency")
|
||||
# Decode source image (RGB; Real-ESRGAN doesn't preserve alpha).
|
||||
img_bytes = _b64_bytes(image_b64)
|
||||
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
src = _b64_to_pil_image(image_b64, mode="RGB")
|
||||
try:
|
||||
patch_realesrgan_torchvision_compat()
|
||||
from realesrgan import RealESRGANer
|
||||
@@ -2525,8 +2593,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
import numpy as np
|
||||
except ImportError:
|
||||
raise HTTPException(500, "Server missing a required dependency")
|
||||
img_bytes = _b64_bytes(image_b64)
|
||||
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
src = _b64_to_pil_image(image_b64, mode="RGB")
|
||||
try:
|
||||
patch_realesrgan_torchvision_compat()
|
||||
from basicsr.archs.rrdbnet_arch import RRDBNet
|
||||
@@ -2574,7 +2641,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
height = int((body or {}).get("height") or 0)
|
||||
except (TypeError, ValueError):
|
||||
width = height = 0
|
||||
if height > width:
|
||||
if width > 0 and height > 0:
|
||||
size = f"{width}x{height}"
|
||||
elif height > width:
|
||||
size = "1024x1536"
|
||||
elif width > height:
|
||||
size = "1536x1024"
|
||||
@@ -2587,6 +2656,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# The editor's model picker may choose a specific (cloud) image model;
|
||||
# forward it so settings defaults only apply when none is selected.
|
||||
model_choice = str((body or {}).get("model") or "").strip()
|
||||
if body.get("_use_image_settings") is True:
|
||||
from src.editor_image_settings import editor_image_models
|
||||
model_choice = editor_image_models(owner=user)["generation"]
|
||||
payload = {"prompt": prompt, "size": size}
|
||||
if model_choice:
|
||||
payload["model"] = model_choice
|
||||
@@ -2778,8 +2850,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
from PIL import Image
|
||||
import base64, io
|
||||
|
||||
img_bytes = _b64_bytes(image_b64)
|
||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
|
||||
img = _b64_to_pil_image(image_b64)
|
||||
W, H = img.size
|
||||
|
||||
hint = None
|
||||
@@ -2868,8 +2939,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
from PIL import Image, ImageFilter, ImageEnhance
|
||||
import numpy as np
|
||||
|
||||
img_bytes = _b64_bytes(image_b64)
|
||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
img = _b64_to_pil_image(image_b64, mode="RGB")
|
||||
|
||||
# Try GFPGAN first (AI face restoration)
|
||||
try:
|
||||
@@ -2944,14 +3014,22 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.put("/api/gallery/albums/{album_id}")
|
||||
async def update_album(request: Request, album_id: str):
|
||||
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()
|
||||
try:
|
||||
album = _get_or_404_album(db, album_id, user)
|
||||
if data.get("name") is not None:
|
||||
album.name = data["name"]
|
||||
if data.get("description") is not None:
|
||||
album.description = data["description"]
|
||||
if name is not None:
|
||||
album.name = name
|
||||
if description is not None:
|
||||
album.description = description
|
||||
if data.get("cover_id") is not None:
|
||||
cover_id = data["cover_id"] or None
|
||||
if cover_id:
|
||||
@@ -2981,8 +3059,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.post("/api/gallery/albums/{album_id}/add")
|
||||
async def add_to_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
data = await request.json()
|
||||
ids = data.get("image_ids", [])
|
||||
ids = await _image_ids(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_get_or_404_album(db, album_id, user)
|
||||
@@ -2999,8 +3076,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.post("/api/gallery/albums/{album_id}/remove")
|
||||
async def remove_from_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
data = await request.json()
|
||||
ids = data.get("image_ids", [])
|
||||
ids = await _image_ids(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_get_or_404_album(db, album_id, user)
|
||||
|
||||
@@ -825,6 +825,23 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
try:
|
||||
body = await request.json()
|
||||
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,
|
||||
# so this must go through get_session — reading the cache directly
|
||||
|
||||
@@ -659,7 +659,7 @@ _NON_CHAT_CONTAINS = (
|
||||
"gliner", "riva", "-parse", "-embedqa", "-nemoretriever",
|
||||
"topic-control", "calibration",
|
||||
"ai-synthetic-video", "cosmos-reason2",
|
||||
"bge", "llama-guard",
|
||||
"bge", "llama-guard", "-embed", "rerank",
|
||||
)
|
||||
_NON_CHAT_EXACT_PREFIXES = (
|
||||
"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": ""}
|
||||
base = _normalize_base(ep.base_url)
|
||||
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)):
|
||||
try:
|
||||
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
|
||||
if visible:
|
||||
model = visible[0]
|
||||
model = next((mid for mid in visible if _is_chat_model(mid)), "")
|
||||
except Exception:
|
||||
pass
|
||||
return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model}
|
||||
|
||||
@@ -728,32 +728,35 @@ def setup_note_routes(task_scheduler=None, upload_handler=None):
|
||||
)
|
||||
if body.title is not None:
|
||||
note.title = body.title
|
||||
if body.content is not None:
|
||||
# Omitted fields preserve their values; explicit null clears the
|
||||
# nullable fields used by the editor's removal controls.
|
||||
supplied = body.model_fields_set
|
||||
if "content" in supplied:
|
||||
note.content = body.content
|
||||
if body.items is not None:
|
||||
note.items = json.dumps(body.items)
|
||||
if "items" in supplied:
|
||||
note.items = json.dumps(body.items) if body.items is not None else None
|
||||
flag_modified(note, "items")
|
||||
if body.note_type is not None:
|
||||
note.note_type = body.note_type
|
||||
if body.color is not None:
|
||||
if "color" in supplied:
|
||||
note.color = body.color
|
||||
if body.label is not None:
|
||||
if "label" in supplied:
|
||||
note.label = body.label
|
||||
if body.pinned is not None:
|
||||
note.pinned = body.pinned
|
||||
if body.archived is not None:
|
||||
note.archived = body.archived
|
||||
if body.due_date is not None:
|
||||
if "due_date" in supplied:
|
||||
note.due_date = body.due_date
|
||||
if body.image_url is not None:
|
||||
if "image_url" in supplied:
|
||||
note.image_url = body.image_url
|
||||
if body.gallery_id is not None:
|
||||
if "gallery_id" in supplied:
|
||||
note.gallery_id = body.gallery_id
|
||||
if body.repeat is not None:
|
||||
note.repeat = body.repeat
|
||||
if body.sort_order is not None:
|
||||
note.sort_order = body.sort_order
|
||||
if body.agent_session_id is not None:
|
||||
if "agent_session_id" in supplied:
|
||||
note.agent_session_id = body.agent_session_id
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -118,8 +118,12 @@ def setup_prefs_routes():
|
||||
async def set_pref(request: Request, key: str, body: dict):
|
||||
user = get_current_user(request)
|
||||
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)
|
||||
return {"key": key, "value": prefs[key]}
|
||||
return {"key": key, "value": prefs.get(key)}
|
||||
|
||||
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.
|
||||
"""
|
||||
from src.endpoint_resolver import (
|
||||
_endpoint_enabled_models,
|
||||
build_chat_url,
|
||||
build_headers,
|
||||
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()
|
||||
if not ep_model:
|
||||
try:
|
||||
models = json.loads(ep.cached_models) if ep.cached_models else []
|
||||
models = _endpoint_enabled_models(ep)
|
||||
if models:
|
||||
ep_model = _first_chat_model(models)
|
||||
except Exception:
|
||||
@@ -824,23 +825,13 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
if not ep_url or not ep_model:
|
||||
# Last resort: this user's enabled endpoint, plus legacy shared rows.
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = _owned_enabled_endpoint(db, user)
|
||||
if ep:
|
||||
base = normalize_base(ep.base_url)
|
||||
fallback_url = build_chat_url(base)
|
||||
fallback_headers = build_headers(ep.api_key, base)
|
||||
fallback_model = ""
|
||||
if ep.cached_models:
|
||||
try:
|
||||
models = json.loads(ep.cached_models)
|
||||
if models:
|
||||
fallback_model = _first_chat_model(models)
|
||||
except Exception:
|
||||
pass
|
||||
_merge(fallback_url, fallback_model, fallback_headers)
|
||||
resolved = _resolve_endpoint_runtime(ep, owner=user)
|
||||
if resolved:
|
||||
_merge(*resolved)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -1315,7 +1315,7 @@ def setup_session_routes(
|
||||
def auto_sort_sessions(request: Request, skip_llm: bool = False):
|
||||
"""Use AI to categorize all sessions into folders.
|
||||
|
||||
Phase 1 deletes empty/throwaway sessions and Phase 2 asks the LLM
|
||||
Phase 1 deletes empty/Incognito sessions and Phase 2 asks the LLM
|
||||
to assign folders. When `skip_llm=true` the endpoint returns
|
||||
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
|
||||
users can clean junk without spending tokens.
|
||||
@@ -1326,21 +1326,11 @@ def setup_session_routes(
|
||||
single_user_mode = not user and _auth_disabled()
|
||||
user_sessions = session_manager.get_sessions_for_user(user)
|
||||
|
||||
# Delete empty and throwaway sessions before sorting
|
||||
# Saved conversations are never disposable based on their title or prompt.
|
||||
from core.database import ChatMessage as DbMsg
|
||||
db = SessionLocal()
|
||||
deleted_empty = 0
|
||||
deleted_throwaway = 0
|
||||
# Names that indicate a throwaway/test session (case-insensitive exact or prefix match)
|
||||
_THROWAWAY_NAMES = {
|
||||
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
|
||||
"yo", "sup", "hola", "hii", "hiii", "heyo",
|
||||
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
|
||||
"new chat", "delete", "remove", "junk", "trash", "xxx",
|
||||
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
|
||||
"ok", "lol", "bruh", "hmm", "hm", "meh",
|
||||
}
|
||||
_THROWAWAY_MAX_MESSAGES = 4 # only delete if <= this many messages
|
||||
try:
|
||||
rows_q = db.query(DbSession).filter(DbSession.archived == False)
|
||||
if user:
|
||||
@@ -1349,15 +1339,11 @@ def setup_session_routes(
|
||||
rows_q = rows_q.filter(DbSession.owner == user)
|
||||
rows = rows_q.limit(2000).all()
|
||||
folder_map = {r.id: r.folder for r in rows}
|
||||
# Precompute per-session message counts in TWO aggregate queries
|
||||
# Precompute per-session message counts in one aggregate query
|
||||
# instead of 1–3 queries PER session — with many chats the per-row
|
||||
# loop was doing thousands of round-trips and blowing the timeout.
|
||||
from sqlalchemy import func as _sa_func
|
||||
_counts = dict(db.query(DbMsg.session_id, _sa_func.count(DbMsg.id)).group_by(DbMsg.session_id).all())
|
||||
_asst_counts = dict(
|
||||
db.query(DbMsg.session_id, _sa_func.count(DbMsg.id))
|
||||
.filter(DbMsg.role == "assistant").group_by(DbMsg.session_id).all()
|
||||
)
|
||||
cleanup_now = utcnow_naive()
|
||||
for row in rows:
|
||||
# Never delete important sessions
|
||||
@@ -1367,9 +1353,10 @@ def setup_session_routes(
|
||||
if (row.name or "").strip() == "Incognito":
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
db.delete(row)
|
||||
if hasattr(session_manager, 'delete_session'):
|
||||
session_manager.delete_session(row.id)
|
||||
else:
|
||||
db.delete(row)
|
||||
continue
|
||||
if is_session_recently_active(row, now=cleanup_now):
|
||||
continue
|
||||
@@ -1378,31 +1365,11 @@ def setup_session_routes(
|
||||
if msg_count == 0:
|
||||
should_delete = True
|
||||
deleted_empty += 1
|
||||
elif msg_count <= _THROWAWAY_MAX_MESSAGES:
|
||||
name = (row.name or "").strip().lower()
|
||||
# Check first user message content (AI renames sessions, so
|
||||
# "hi" becomes "Casual Greeting Exchange" — name alone won't match)
|
||||
first_msg = db.query(DbMsg.content).filter(
|
||||
DbMsg.session_id == row.id, DbMsg.role == "user"
|
||||
).order_by(DbMsg.timestamp).first()
|
||||
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
|
||||
# Count assistant messages — if user sent something but AI never replied, it's dead
|
||||
assistant_count = _asst_counts.get(row.id, 0)
|
||||
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
# Single user message with no AI response = dead session
|
||||
elif msg_count == 1 and assistant_count == 0:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
# Short phrase (1-3 words) with no real AI conversation (<=2 msgs)
|
||||
elif msg_count <= 2 and first_text and len(first_text.split()) <= 3 and len(first_text) <= 40:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
if should_delete:
|
||||
db.delete(row)
|
||||
if hasattr(session_manager, 'delete_session'):
|
||||
session_manager.delete_session(row.id)
|
||||
else:
|
||||
db.delete(row)
|
||||
if deleted_empty or deleted_throwaway:
|
||||
db.commit()
|
||||
logger.info(f"Auto-sort: deleted {deleted_empty} empty + {deleted_throwaway} throwaway sessions")
|
||||
|
||||
@@ -15,6 +15,7 @@ import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.memory.skill_format import slugify
|
||||
from services.memory.skills import SkillsManager
|
||||
from src.auth_helpers import get_current_user
|
||||
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)
|
||||
if not updates:
|
||||
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)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
|
||||
+11
-17
@@ -20,7 +20,7 @@ from src.task_action_policy import (
|
||||
is_admin_only_task_action,
|
||||
owner_has_admin_task_privileges,
|
||||
)
|
||||
from src.task_scheduler import compute_next_run, HOUSEKEEPING_DEFAULTS
|
||||
from src.task_scheduler import compute_next_run, is_valid_cron, HOUSEKEEPING_DEFAULTS
|
||||
from routes.prefs_routes import _load_for_user, _save_for_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -289,15 +289,15 @@ def _resolve_run_endpoint(db, task: ScheduledTask, run: TaskRun) -> str:
|
||||
|
||||
try:
|
||||
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:
|
||||
cached = []
|
||||
if ep.cached_models:
|
||||
try:
|
||||
cached = json.loads(ep.cached_models) or []
|
||||
except Exception:
|
||||
cached = []
|
||||
if model in cached:
|
||||
if model in _endpoint_enabled_models(ep):
|
||||
return ep.base_url or ""
|
||||
except Exception:
|
||||
pass
|
||||
@@ -475,10 +475,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
if req.trigger_type == "schedule" and req.schedule == "cron" and not req.cron_expression:
|
||||
raise HTTPException(400, "Cron expression is required for cron schedule")
|
||||
if req.trigger_type == "schedule" and req.schedule == "cron" and req.cron_expression:
|
||||
try:
|
||||
from croniter import croniter
|
||||
croniter(req.cron_expression)
|
||||
except Exception:
|
||||
if not is_valid_cron(req.cron_expression):
|
||||
raise HTTPException(400, "Invalid cron expression")
|
||||
if req.trigger_type == "event" and not req.trigger_event:
|
||||
raise HTTPException(400, "Event name is required for event-triggered tasks")
|
||||
@@ -788,10 +785,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
task.character_id = req.character_id or None
|
||||
if req.cron_expression is not None:
|
||||
if req.cron_expression:
|
||||
try:
|
||||
from croniter import croniter
|
||||
croniter(req.cron_expression)
|
||||
except Exception:
|
||||
if not is_valid_cron(req.cron_expression):
|
||||
raise HTTPException(400, "Invalid cron expression")
|
||||
task.cron_expression = req.cron_expression or None
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ that are awkward at the shell). Filters images by tag, prompt, favorite,
|
||||
album, and surfaces EXIF metadata.
|
||||
|
||||
odysseus-gallery list --limit 20 --favorites
|
||||
odysseus-gallery list --tag "sunset,beach" # all tags, manual or AI
|
||||
odysseus-gallery show IMAGE_ID
|
||||
odysseus-gallery albums | jq -r '.[] | .name'
|
||||
odysseus-gallery search "sunset" # matches prompt + tags
|
||||
@@ -83,7 +84,13 @@ def cmd_list(args):
|
||||
fail(f"no album named {args.album!r}")
|
||||
q = q.filter(GalleryImage.album_id == al.id)
|
||||
if args.tag:
|
||||
q = q.filter(GalleryImage.tags.ilike(f"%{args.tag}%"))
|
||||
for tag in (value.strip() for value in args.tag.split(",")):
|
||||
if not tag:
|
||||
continue
|
||||
q = q.filter(
|
||||
GalleryImage.tags.ilike(f"%{tag}%") |
|
||||
GalleryImage.ai_tags.ilike(f"%{tag}%")
|
||||
)
|
||||
q = q.order_by(GalleryImage.created_at.desc()).limit(args.limit)
|
||||
emit([_serialize_image(i) for i in q.all()], args)
|
||||
finally:
|
||||
@@ -159,7 +166,7 @@ def _build_parser():
|
||||
pl.add_argument("--limit", type=int, default=50)
|
||||
pl.add_argument("--favorites", action="store_true")
|
||||
pl.add_argument("--album", help="filter by album name")
|
||||
pl.add_argument("--tag", help="substring match against tags column")
|
||||
pl.add_argument("--tag", help="comma-separated tags; each matches manual or AI tags by substring")
|
||||
pl.set_defaults(func=cmd_list)
|
||||
|
||||
psh = sub.add_parser("show", parents=[common])
|
||||
|
||||
+12
-6
@@ -43,7 +43,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Anchor the path so the script works when invoked from anywhere.
|
||||
@@ -136,7 +136,7 @@ def cmd_list(args) -> None:
|
||||
emit([], args)
|
||||
return
|
||||
all_uids = data[0].split()
|
||||
# Newest first, limit
|
||||
# Keep a bounded arrival batch; order its decoded rows by Date below.
|
||||
uids = list(reversed(all_uids))[: args.limit]
|
||||
out = []
|
||||
for uid in uids:
|
||||
@@ -165,10 +165,15 @@ def cmd_list(args) -> None:
|
||||
sender_name, sender_addr = parseaddr(sender)
|
||||
date_raw = msg.get("Date", "")
|
||||
try:
|
||||
iso = parsedate_to_datetime(date_raw).isoformat() if date_raw else ""
|
||||
parsed_date = parsedate_to_datetime(date_raw) if date_raw else None
|
||||
iso = parsed_date.isoformat() if parsed_date else ""
|
||||
if parsed_date is not None and parsed_date.tzinfo is None:
|
||||
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
|
||||
date_epoch = parsed_date.timestamp() if parsed_date else float("-inf")
|
||||
except Exception:
|
||||
iso = ""
|
||||
out.append({
|
||||
date_epoch = float("-inf")
|
||||
out.append((date_epoch, {
|
||||
"uid": uid.decode(),
|
||||
"date": iso,
|
||||
"from_addr": sender_addr,
|
||||
@@ -176,10 +181,11 @@ def cmd_list(args) -> None:
|
||||
"subject": subject,
|
||||
"is_read": "\\Seen" in flags,
|
||||
"is_answered": "\\Answered" in flags,
|
||||
})
|
||||
}))
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"warn: skipping uid {uid!r}: {e}\n")
|
||||
emit(out, args)
|
||||
out.sort(key=lambda item: item[0], reverse=True)
|
||||
emit([row for _, row in out], args)
|
||||
|
||||
|
||||
# ─── read ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -11,6 +11,38 @@ import time
|
||||
from urllib.request import urlopen
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
def _stop_owned_server(server, identity):
|
||||
"""Close only identity-bound descendants, including MCP's new sessions.
|
||||
|
||||
Capture before signalling the parent: an orphaned stderr holder can no
|
||||
longer be attributed by ancestry. Never search by executable name or kill
|
||||
the caller's process group.
|
||||
"""
|
||||
from src import process_lifecycle as lifecycle
|
||||
|
||||
descendants = (lifecycle.bind_descendants([identity.pid])
|
||||
if identity.verdict() == lifecycle.OWNED else [])
|
||||
if os.name == "nt":
|
||||
if identity.verdict() == lifecycle.OWNED:
|
||||
lifecycle.taskkill_tree(identity.pid)
|
||||
else:
|
||||
outcome = lifecycle.terminate_identities(
|
||||
[*(entry.identity for entry in reversed(descendants)), identity],
|
||||
steps=lifecycle.term_kill_steps(10),
|
||||
)
|
||||
if outcome.survivors or outcome.unverified:
|
||||
raise RuntimeError(f"Disposable Notes cleanup could not verify exit: {outcome}")
|
||||
try:
|
||||
logs, _ = server.communicate(timeout=2)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
# Do not turn an unidentified pipe holder into an unbounded wait.
|
||||
server.stdout.close()
|
||||
raise RuntimeError("Disposable Notes output pipe remained open after owned cleanup") from error
|
||||
return logs
|
||||
|
||||
|
||||
def main():
|
||||
@@ -36,7 +68,10 @@ def main():
|
||||
server = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)],
|
||||
cwd=target, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
start_new_session=(os.name != "nt"),
|
||||
)
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
server_identity = ProcessIdentity.capture(server.pid)
|
||||
result = None
|
||||
try:
|
||||
origin = f"http://127.0.0.1:{port}"
|
||||
@@ -62,12 +97,7 @@ def main():
|
||||
print(result.stderr, file=sys.stderr)
|
||||
return result.returncode
|
||||
finally:
|
||||
server.terminate()
|
||||
try:
|
||||
logs, _ = server.communicate(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
logs, _ = server.communicate()
|
||||
logs = _stop_owned_server(server, server_identity)
|
||||
if result is None or result.returncode:
|
||||
print(logs[-8000:], file=sys.stderr)
|
||||
|
||||
|
||||
@@ -202,17 +202,35 @@ def _infer_use_case(repo_id, pipeline_tag):
|
||||
return "general"
|
||||
|
||||
|
||||
# HF collections mix in models no Cookbook LLM backend can serve (vision
|
||||
# encoders, time-series forecasters, image/video generators). Image models
|
||||
# have their own registry, so keep these out of the LLM catalog.
|
||||
_NON_LLM_PIPELINE_TAGS = frozenset({
|
||||
"time-series-forecasting",
|
||||
"zero-shot-image-classification",
|
||||
"image-classification",
|
||||
"image-feature-extraction",
|
||||
"text-to-image",
|
||||
"image-to-video",
|
||||
"text-to-video",
|
||||
})
|
||||
|
||||
|
||||
def _entry_from_collection_item(collection, item, source):
|
||||
repo_id = item.get("id") or ""
|
||||
if item.get("type") != "model" or not repo_id:
|
||||
return None
|
||||
if (item.get("pipeline_tag") or "").strip().lower() in _NON_LLM_PIPELINE_TAGS:
|
||||
return None
|
||||
repo_prefix = source.get("repo_prefix")
|
||||
if repo_prefix and not repo_id.startswith(repo_prefix):
|
||||
return None
|
||||
raw_params = item.get("numParameters") or 0
|
||||
active = None
|
||||
# Read the name even when HF gives a total: it is the only source of the
|
||||
# MoE active size (e.g. "-35B-A3B"), which the speed estimate depends on.
|
||||
name_params, active = _parse_params_from_name(repo_id)
|
||||
if not raw_params:
|
||||
raw_params, active = _parse_params_from_name(repo_id)
|
||||
raw_params = name_params
|
||||
param_label, raw_params = _format_params(raw_params)
|
||||
if not raw_params:
|
||||
return None
|
||||
|
||||
@@ -84,10 +84,14 @@ _FM_KEY_RE = re.compile(r"^([a-z_][a-z0-9_]*):\s*(.*)$", re.IGNORECASE)
|
||||
_FM_BLOCK_LIST_RE = re.compile(r"^\s*-\s*(.*)$")
|
||||
|
||||
|
||||
def _parse_scalar(raw: str) -> Any:
|
||||
def _parse_scalar(raw: str, *, string_only: bool = False) -> Any:
|
||||
raw = raw.strip()
|
||||
if raw == "":
|
||||
return ""
|
||||
# String fields keep their spelling; quoted values still use the decoder
|
||||
# below so existing escapes round-trip as before.
|
||||
if string_only and not (raw[0] == raw[-1] and raw[0] in ("'", '"')):
|
||||
return raw
|
||||
if raw.startswith("[") and raw.endswith("]"):
|
||||
inner = raw[1:-1].strip()
|
||||
if not inner:
|
||||
@@ -126,10 +130,15 @@ def _parse_scalar(raw: str) -> Any:
|
||||
def _split_top_level(s: str, sep: str) -> List[str]:
|
||||
"""Split `s` on `sep` ignoring separators inside [] or quotes."""
|
||||
out, buf, depth, quote = [], [], 0, None
|
||||
escaped = False
|
||||
for ch in s:
|
||||
if quote:
|
||||
buf.append(ch)
|
||||
if ch == quote:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif ch == "\\" and quote == '"':
|
||||
escaped = True
|
||||
elif ch == quote:
|
||||
quote = None
|
||||
continue
|
||||
if ch in ("'", '"'):
|
||||
@@ -171,7 +180,7 @@ def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
|
||||
pending_key = key
|
||||
fm[key] = []
|
||||
else:
|
||||
fm[key] = _parse_scalar(val)
|
||||
fm[key] = _parse_scalar(val, string_only=(key == "version"))
|
||||
pending_key = None
|
||||
continue
|
||||
m2 = _FM_BLOCK_LIST_RE.match(line)
|
||||
|
||||
@@ -500,6 +500,19 @@ class SkillsManager:
|
||||
sk.path = self._skill_file(cat, nm)
|
||||
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:
|
||||
"""`skill_id` is the slug name. Allows updating any field plus
|
||||
renames if `name` changes (file is moved on disk).
|
||||
@@ -551,6 +564,9 @@ class SkillsManager:
|
||||
# Rename
|
||||
new_name = slugify(updates.get("name") or 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
|
||||
|
||||
# Write to potentially new path
|
||||
|
||||
+13
-1
@@ -24193,11 +24193,14 @@ async def stream_agent_loop(
|
||||
budget_is_explicit,
|
||||
hard_max=hard_max,
|
||||
)
|
||||
if candidate_context <= 8192:
|
||||
if 0 < candidate_context <= 8192:
|
||||
# Small local servers often tokenize chat wrappers and tool
|
||||
# results much more generously than our rough estimator. Keep
|
||||
# substantial headroom for those wrappers and generation so a
|
||||
# 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,
|
||||
max(1200, int(candidate_context * 0.40)),
|
||||
@@ -27833,7 +27836,16 @@ async def stream_agent_loop(
|
||||
if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text):
|
||||
_streamed_tool_markup += _delta_text
|
||||
if not _streamed_tool_markup_complete(_streamed_tool_markup):
|
||||
if _streamed_tool_markup_starts(_streamed_tool_markup):
|
||||
continue
|
||||
# False alarm: a bare "<" (e.g. ``x < 2``) looked like
|
||||
# a tag prefix, but the buffer can no longer become a
|
||||
# tool tag. Release it as prose instead of holding the
|
||||
# rest of the answer forever.
|
||||
_delta_text = _streamed_tool_markup
|
||||
_streamed_tool_markup = ""
|
||||
data["delta"] = _delta_text
|
||||
else:
|
||||
_visible_markup_tail = strip_tool_blocks(
|
||||
_streamed_tool_markup,
|
||||
skip_fenced=True,
|
||||
|
||||
@@ -191,7 +191,7 @@ def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[
|
||||
db.close()
|
||||
|
||||
|
||||
async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None) -> 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] = {
|
||||
"dataUrl": data_url,
|
||||
"name": name,
|
||||
@@ -199,6 +199,8 @@ async def _place_layer(data_url: str, name: str, owner: Optional[str], session:
|
||||
"blend": blend or "normal",
|
||||
"opacity": opacity,
|
||||
}
|
||||
if native_size:
|
||||
params["nativeSize"] = True
|
||||
# Forward the edit mode so the client can build a difference mask for
|
||||
# instruction-mode results: the whole image was regenerated under an
|
||||
# instruction prompt, so untouched pixels must stay pixel-identical (the
|
||||
@@ -506,6 +508,8 @@ class AiEditImageTool:
|
||||
}
|
||||
_t["image"] = time.perf_counter()
|
||||
payload: Dict[str, Any] = {"image": image}
|
||||
if args.get("_use_image_settings") is True:
|
||||
payload["_use_image_settings"] = True
|
||||
model_choice = str(args.get("model") or "").strip()
|
||||
mask_used = False
|
||||
place_mode: Optional[str] = None
|
||||
@@ -609,10 +613,11 @@ class AiEditImageTool:
|
||||
f"AI {op}",
|
||||
owner,
|
||||
session,
|
||||
masked=mask_used,
|
||||
masked=mask_used and data.get("full_frame") is not True,
|
||||
blend=str(args.get("blend") or "normal"),
|
||||
opacity=float(args.get("opacity") or 100),
|
||||
mode=place_mode,
|
||||
native_size=data.get("full_frame") is True,
|
||||
)
|
||||
info["placed"] = placed
|
||||
if placed:
|
||||
@@ -687,6 +692,7 @@ class GenerateImageLayerTool:
|
||||
str(args.get("name") or "Generated image"),
|
||||
owner,
|
||||
session,
|
||||
native_size=True,
|
||||
masked=bool(args.get("mask")),
|
||||
blend=str(args.get("blend") or "normal"),
|
||||
opacity=float(args.get("opacity") or 100),
|
||||
|
||||
+135
-22
@@ -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
|
||||
# so chat sessions using the HF repo id still resolve. Do not use
|
||||
# stale cached aliases when the endpoint itself is unreachable.
|
||||
if model_type == "image" and endpoint_reachable:
|
||||
for extra in _json_list(getattr(ep, "pinned_models", None)) + _json_list(getattr(ep, "cached_models", None)):
|
||||
if endpoint_reachable:
|
||||
# Gateway image models may live in a dedicated catalogue,
|
||||
# so explicitly pinned IDs are valid on mixed endpoints too.
|
||||
extras = _json_list(getattr(ep, "pinned_models", None))
|
||||
if model_type == "image":
|
||||
extras += _json_list(getattr(ep, "cached_models", None))
|
||||
for extra in extras:
|
||||
if extra not in model_ids:
|
||||
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.
|
||||
"""
|
||||
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
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1200,6 +1205,39 @@ def image_editing_provider_available(owner: Optional[str] = None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _uses_openrouter_chat_images(model_id: str) -> bool:
|
||||
# GPT-5 Image is an LLM wrapper, unlike native gpt-image-* image models.
|
||||
leaf = model_id_leaf(model_id).lower()
|
||||
return leaf.startswith("gpt-") and "-image" in leaf and not leaf.startswith("gpt-image-")
|
||||
|
||||
|
||||
def _chat_image_results(data: dict) -> list:
|
||||
images = []
|
||||
for choice in data.get("choices") or []:
|
||||
message = choice.get("message") or {}
|
||||
for item in message.get("images") or []:
|
||||
url = (item.get("image_url") or {}).get("url")
|
||||
if not isinstance(url, str):
|
||||
continue
|
||||
if url.startswith("data:image/") and ";base64," in url:
|
||||
images.append({"b64_json": url.split(",", 1)[1]})
|
||||
elif url.startswith(("https://", "http://")):
|
||||
images.append({"url": url})
|
||||
return images
|
||||
|
||||
|
||||
def _image_response_error(data: dict, model_id: str) -> Optional[str]:
|
||||
errors = [data.get("error")]
|
||||
errors.extend(choice.get("error") for choice in data.get("choices") or [])
|
||||
for error in errors:
|
||||
if not error:
|
||||
continue
|
||||
message = str(error.get("message") or "Image provider failed") if isinstance(error, dict) else str(error)
|
||||
hint = " Choose a currently supported image model in AI settings." if "deprecated" in message.lower() else ""
|
||||
return f"Image provider failed for '{model_id}': {message}{hint}"
|
||||
return None
|
||||
|
||||
|
||||
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict:
|
||||
"""Generate an image using an image-capable model (e.g. gpt-image-1).
|
||||
|
||||
@@ -1207,7 +1245,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
Line 1: prompt describing the image
|
||||
Line 2: model name (optional, default auto-detects: prefers gpt-image-1.5 > gpt-image-1)
|
||||
Line 3: size (optional, defaults to 1024x1024)
|
||||
Line 4: quality (optional, defaults to medium — options: low, medium, high, auto)
|
||||
Line 4: quality (optional, defaults to configured quality or medium — low, medium, high, auto)
|
||||
"""
|
||||
import base64
|
||||
import httpx
|
||||
@@ -1225,32 +1263,38 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
prompt = str(args.get('prompt') or '').strip()
|
||||
model_spec = str(args.get('model') or '').strip()
|
||||
size = str(args.get('size') or '1024x1024')
|
||||
quality = str(args.get('quality') or 'medium')
|
||||
quality = str(args.get('quality') or '')
|
||||
else:
|
||||
lines = content.strip().split("\n")
|
||||
prompt = lines[0].strip() if lines else ""
|
||||
model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() else ""
|
||||
size = lines[2].strip() if len(lines) > 2 and lines[2].strip() else "1024x1024"
|
||||
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else "medium"
|
||||
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else ""
|
||||
|
||||
if not prompt:
|
||||
return {"error": "Image prompt is required (line 1)"}
|
||||
|
||||
# Load admin settings for defaults
|
||||
# Load settings for defaults
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
from src.settings import get_user_setting, load_settings
|
||||
_settings = load_settings()
|
||||
except Exception:
|
||||
_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):
|
||||
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:
|
||||
model_spec = _settings.get("image_model", "")
|
||||
if quality == "medium" and _settings.get("image_quality"):
|
||||
quality = _settings["image_quality"]
|
||||
model_spec = get_user_setting("image_model", owner=owner, default="") or ""
|
||||
if not quality:
|
||||
quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
|
||||
|
||||
# Auto-detect best available image model if still not set
|
||||
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("/")
|
||||
images_url = base_url + "/images/generations"
|
||||
from src.model_capability_readers.base import detect_vendor
|
||||
chat_images = detect_vendor(url) == "openrouter" and _uses_openrouter_chat_images(model_id)
|
||||
if detect_vendor(url) == "openrouter":
|
||||
images_url = base_url + "/images"
|
||||
if chat_images:
|
||||
images_url = base_url + "/chat/completions"
|
||||
|
||||
# Capture the requested geometry before older OpenAI size restrictions.
|
||||
from src.image_model_ids import image_aspect_ratio
|
||||
try:
|
||||
requested_width, requested_height = (int(value) for value in size.lower().split("x"))
|
||||
requested_ratio = image_aspect_ratio(requested_width, requested_height)
|
||||
except (TypeError, ValueError):
|
||||
requested_ratio = "1:1"
|
||||
|
||||
# Validate size for cloud image models (local diffusion accepts any WxH)
|
||||
valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"}
|
||||
@@ -1359,6 +1414,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
"n": 1,
|
||||
"size": size,
|
||||
}
|
||||
if detect_vendor(url) == "openrouter":
|
||||
payload.pop("size", None)
|
||||
payload["aspect_ratio"] = requested_ratio
|
||||
|
||||
# GPT image models and local diffusion support quality; DALL-E does not
|
||||
if is_gpt_image or is_local_diffusion:
|
||||
@@ -1372,7 +1430,16 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
try:
|
||||
# GPT image models can take 30-120s+ depending on quality
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)) as client:
|
||||
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:
|
||||
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()
|
||||
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:
|
||||
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]
|
||||
image_url = None
|
||||
@@ -1482,7 +1555,7 @@ async def do_edit_image(
|
||||
session_id: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
size: str = "auto",
|
||||
quality: str = "medium",
|
||||
quality: Optional[str] = None,
|
||||
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||
incognito: bool = False,
|
||||
) -> Dict:
|
||||
@@ -1502,21 +1575,27 @@ async def do_edit_image(
|
||||
return {"error": "Attached image file was not found"}
|
||||
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
from src.settings import get_user_setting, load_settings
|
||||
_settings = load_settings()
|
||||
except Exception:
|
||||
_settings = {}
|
||||
def get_user_setting(key, owner="", default=None):
|
||||
return default
|
||||
|
||||
if not model_spec:
|
||||
model_spec = _settings.get("image_model", "")
|
||||
if quality == "medium" and _settings.get("image_quality"):
|
||||
quality = _settings["image_quality"]
|
||||
model_spec = get_user_setting("image_edit_model", owner=owner) or get_user_setting("image_model", owner=owner, default="")
|
||||
if quality is None:
|
||||
quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
|
||||
if not model_spec:
|
||||
return {"error": "No image model selected for image editing"}
|
||||
|
||||
try:
|
||||
try:
|
||||
def _call():
|
||||
try:
|
||||
return _resolve_model(model_spec, owner=owner)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return _resolve_model(model_spec, owner=owner, model_type="image")
|
||||
except TypeError as exc:
|
||||
@@ -1529,6 +1608,7 @@ async def do_edit_image(
|
||||
except ValueError:
|
||||
return {"error": f"No endpoint found with image model '{model_spec}'."}
|
||||
|
||||
requested_edit_size = size
|
||||
if not size or size == "auto":
|
||||
from PIL import Image
|
||||
from src.image_model_ids import image_edit_size
|
||||
@@ -1704,6 +1784,34 @@ async def do_edit_image(
|
||||
"image_url": {"url": f"data:{mime};base64,{image_b64}"},
|
||||
}],
|
||||
}
|
||||
from src.image_model_ids import image_aspect_ratio
|
||||
from PIL import Image as ReferenceImage
|
||||
with ReferenceImage.open(path) as reference:
|
||||
reference_width, reference_height = reference.size
|
||||
if reference.getexif().get(274) in {5, 6, 7, 8}:
|
||||
reference_width, reference_height = reference_height, reference_width
|
||||
if requested_edit_size and requested_edit_size != "auto":
|
||||
try:
|
||||
reference_width, reference_height = (int(value) for value in requested_edit_size.lower().split("x"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
edit_payload.pop("size", None)
|
||||
edit_payload["aspect_ratio"] = image_aspect_ratio(reference_width, reference_height)
|
||||
if _uses_openrouter_chat_images(model_id):
|
||||
edit_payload = {
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {
|
||||
"url": f"data:{mime};base64,{image_b64}",
|
||||
}},
|
||||
]}],
|
||||
"modalities": ["image", "text"],
|
||||
"stream": False,
|
||||
"image_config": {"aspect_ratio": edit_payload["aspect_ratio"]},
|
||||
}
|
||||
resp = await client.post(base_url + "/chat/completions", json=edit_payload, headers=headers)
|
||||
else:
|
||||
resp = await client.post(base_url + "/images", json=edit_payload, headers=headers)
|
||||
else:
|
||||
with path.open("rb") as f:
|
||||
@@ -1747,7 +1855,12 @@ async def do_edit_image(
|
||||
}
|
||||
|
||||
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:
|
||||
return {"error": "No image returned from edit API"}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ def compact_browser_observation(value, budget=8000):
|
||||
# Snapshot text already contains labels and refs in DOM order.
|
||||
# The refs mapping repeats them and buries menus in raw JSON.
|
||||
lines = [line for line in snapshot.splitlines()
|
||||
if not re.fullmatch(r'\s*-?\s*generic(?:\s+\[ref=e\d+\])?:?\s*', line)]
|
||||
if not re.fullmatch(r'\s*(?:-\s*)?generic(?:\s+\[ref=e\d+\])?:?\s*', line)]
|
||||
pages.append(('URL: ' + str(url) + '\n' if url else '') + '\n'.join(lines))
|
||||
elif url:
|
||||
notices.append('URL: ' + str(url))
|
||||
@@ -37,7 +37,8 @@ def compact_browser_observation(value, budget=8000):
|
||||
parsed = json.loads(item)
|
||||
except (ValueError, TypeError):
|
||||
# CLI status text precedes the JSON post-interaction state.
|
||||
parts = re.split(r'\n\n\[(?:post-[^\]]+|page state after failed [^\]]+)\]\n', item)
|
||||
# Labels are one line; stopping at \n keeps the split linear.
|
||||
parts = re.split(r'\n\n\[(?:post-[^\]\n]+|page state after failed [^\]\n]+)\]\n', item)
|
||||
if len(parts) > 1:
|
||||
for part in parts:
|
||||
visit(part)
|
||||
|
||||
+35
-3
@@ -10,7 +10,7 @@ import os
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Tuple
|
||||
|
||||
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
|
||||
|
||||
|
||||
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]:
|
||||
"""Build a short morning digest: today's calendar events, unread email count
|
||||
+ 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 routes.email_helpers import _imap_connect, _decode_header
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
# ----- Calendar: today's events -----
|
||||
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
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
|
||||
# (unconfigured) deploys only. In a multi-user deploy, one
|
||||
# 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()
|
||||
try:
|
||||
ev_q = db.query(CalendarEvent).join(CalendarCal).filter(
|
||||
or_(
|
||||
and_(
|
||||
CalendarEvent.is_utc.is_(True),
|
||||
CalendarEvent.all_day.is_not(True),
|
||||
CalendarEvent.dtstart < utc_tomorrow,
|
||||
CalendarEvent.dtend > utc_today,
|
||||
),
|
||||
and_(
|
||||
or_(CalendarEvent.is_utc.is_not(True), CalendarEvent.all_day.is_(True)),
|
||||
CalendarEvent.dtstart < tomorrow,
|
||||
CalendarEvent.dtend > today,
|
||||
),
|
||||
),
|
||||
CalendarEvent.status != "cancelled",
|
||||
)
|
||||
if owner:
|
||||
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 -----
|
||||
n_q = db.query(Note).filter(Note.archived == False) # noqa: E712
|
||||
if owner:
|
||||
@@ -2058,7 +2090,7 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
if events:
|
||||
plain.append("Calendar:")
|
||||
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 ""
|
||||
plain.append(f" {t} {e.summary}{loc}")
|
||||
plain.append("")
|
||||
|
||||
+7
-1
@@ -282,7 +282,13 @@ class ChatHandler:
|
||||
_sync_upload_vision_to_gallery(file_info, owner, vl_desc)
|
||||
except Exception:
|
||||
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
|
||||
# collapsible "image description" on the user bubble (not just
|
||||
# after a refresh that re-parses the stored message).
|
||||
|
||||
@@ -1229,7 +1229,7 @@ def tasks_terminal_response(raw, *, user_text='', max_items=20):
|
||||
text = str(payload or '').strip()
|
||||
rows = []
|
||||
for line in text.splitlines():
|
||||
match = re.match(r'^\s*\d+\.\s+(.*?)\s+\(([^)]+)\)\s+—\s+(.+)$', line)
|
||||
match = re.match(r'^\s*\d+\.\s(.*?)\s\(([^()]+)\)\s+—\s+(.+)$', line)
|
||||
if match:
|
||||
details = match[3].strip()
|
||||
rows.append({
|
||||
@@ -1346,7 +1346,7 @@ def skills_terminal_response(raw, *, user_text='', max_items=20):
|
||||
continue
|
||||
match = re.match(r'^\s*-\s+\*\*([^*]+)\*\*(?:\s+\(([^)]+)\))?', line)
|
||||
if not match:
|
||||
match = re.match(r'^\s*-\s+([^:]+?)(?:\s+\(([^)]+)\))?(?:\s*:|$)', line)
|
||||
match = re.match(r'^\s*-\s+([^\s:][^:]*?)(?:\s\(([^()]+)\)(?:\s*:|$)|:|$)', line)
|
||||
if match and status:
|
||||
rows.append((status, match.group(1).strip(), (match.group(2) or '').strip()))
|
||||
if not rows:
|
||||
@@ -5065,7 +5065,7 @@ def email_search_result_empty(value):
|
||||
try:
|
||||
decoded = json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
return bool(re.fullmatch(r'\s*No emails matched [^\n]+\.?\s*', value, re.I))
|
||||
return bool(re.fullmatch(r'\s*No emails matched [^\n]+(?:\n\s*)?', value, re.I))
|
||||
if decoded == value:
|
||||
return False
|
||||
value = decoded
|
||||
@@ -5339,6 +5339,16 @@ def private_browser_effective_url(result):
|
||||
return urls[-1] if urls else ''
|
||||
|
||||
|
||||
def is_google_sorry_url(url):
|
||||
"""Recognize Google's rate-limit interstitial."""
|
||||
# Matching the host separately keeps this linear; one combined pattern
|
||||
# rescans the host from every dot.
|
||||
return any(
|
||||
re.fullmatch(r'(?:[^/]+\.)?google\.[^/]+', host[1], re.I)
|
||||
for host in re.finditer(r'https?://([^/]+)/sorry/', url, re.I)
|
||||
)
|
||||
|
||||
|
||||
def browser_observation_page_missing(raw):
|
||||
"""Recognize a rendered error page, rather than an article mentioning 404."""
|
||||
text = str(raw or '')
|
||||
@@ -8213,11 +8223,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
browser_search_url = requested_browser_url or effective_browser_url
|
||||
is_search_engine_navigation = contains_search_engine_navigation(
|
||||
browser_search_url
|
||||
) or bool(re.search(
|
||||
r'https?://(?:[^/]+\.)?google\.[^/]+/sorry/',
|
||||
effective_browser_url,
|
||||
re.I,
|
||||
))
|
||||
) or is_google_sorry_url(effective_browser_url)
|
||||
has_native_search = any(
|
||||
canonical(schema['function']['name']) == 'web_search'
|
||||
for schema in offered
|
||||
|
||||
@@ -142,7 +142,10 @@ def _process_pdf(
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
temp_img_path = tmp.name
|
||||
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)
|
||||
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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
def image_aspect_ratio(width: int, height: int) -> str:
|
||||
"""Choose the nearest common Image API ratio, without distorting pixels."""
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("Image dimensions must be positive")
|
||||
ratios = ((1, 1), (16, 9), (9, 16), (3, 2), (2, 3), (4, 3), (3, 4), (21, 9))
|
||||
w, h = min(ratios, key=lambda ratio: abs(math.log((ratio[0] / ratio[1]) / (width / height))))
|
||||
return f"{w}:{h}"
|
||||
|
||||
|
||||
def looks_like_image_generation_model(model_id: str) -> bool:
|
||||
"""Return True when a model id should use image generation routes.
|
||||
|
||||
|
||||
+49
-11
@@ -422,7 +422,18 @@ def _stream_delta_event(text: str, *, thinking: bool = False) -> str:
|
||||
return f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
_DEGENERATE_WORD_RE = re.compile(r"[A-Za-z0-9_\u0370-\u03ff\u0400-\u04ff]+")
|
||||
_WORD_CHARS = r"A-Za-z0-9_\u0370-\u03ff\u0400-\u04ff"
|
||||
_DELIM_RE = re.compile(rf"[^{_WORD_CHARS}+\-.]")
|
||||
_NUMERIC_PATTERN = rf"(?<![{_WORD_CHARS}])[+-]?(?:[0-9]+\.[0-9]+|\.[0-9]+|[0-9]+)(?:[eE][+-]?[0-9]+)?(?![{_WORD_CHARS}])"
|
||||
_NUMERIC_FULLMATCH_RE = re.compile(rf"^[+-]?(?:[0-9]+\.[0-9]+|\.[0-9]+|[0-9]+)(?:[eE][+-]?[0-9]+)?$")
|
||||
_DEGENERATE_WORD_RE = re.compile(rf"[{_WORD_CHARS}]+")
|
||||
_TOKEN_RE = re.compile(rf"(?P<NUM>{_NUMERIC_PATTERN})|(?P<WORD>[{_WORD_CHARS}]+)")
|
||||
|
||||
|
||||
def _is_numeric_token(token: str) -> bool:
|
||||
if token.isdigit():
|
||||
return True
|
||||
return _NUMERIC_FULLMATCH_RE.fullmatch(token) is not None
|
||||
|
||||
|
||||
class _DegenerateStreamGuard:
|
||||
@@ -432,32 +443,50 @@ class _DegenerateStreamGuard:
|
||||
"Summer Summer ..."). This is not a useful response and can burn context,
|
||||
browser memory, and GPU time. Keep the guard conservative: only fire on long
|
||||
same-token runs or a very dominant repeated token in the recent window.
|
||||
Repetition made only of numbers is data (a zero or identity matrix, a
|
||||
padded buffer), so it only stops at a long all-numeric backstop.
|
||||
"""
|
||||
|
||||
def __init__(self, model: str):
|
||||
self.model = model or "model"
|
||||
self.last_token = ""
|
||||
self.same_run = 0
|
||||
self.numeric_run = 0
|
||||
self.recent_tokens: List[str] = []
|
||||
self.periodic_tokens: List[str] = []
|
||||
self.total_chars = 0
|
||||
self._buffer = ""
|
||||
|
||||
def check(self, text: str) -> Optional[str]:
|
||||
if not text:
|
||||
return None
|
||||
self.total_chars += len(text)
|
||||
# Keep one-character tokens. Structured output often varies through
|
||||
# numeric selectors or coordinates; dropping those tokens can make
|
||||
# valid CSS/SVG rows look like an exact repeated phrase.
|
||||
tokens = [t.lower() for t in _DEGENERATE_WORD_RE.findall(text)]
|
||||
self._buffer += text
|
||||
|
||||
m_delims = list(_DELIM_RE.finditer(self._buffer))
|
||||
if m_delims:
|
||||
split_idx = m_delims[-1].end()
|
||||
head = self._buffer[:split_idx]
|
||||
self._buffer = self._buffer[split_idx:]
|
||||
elif len(self._buffer) >= 256:
|
||||
head = self._buffer
|
||||
self._buffer = ""
|
||||
else:
|
||||
return None
|
||||
|
||||
tokens = []
|
||||
for m in _TOKEN_RE.finditer(head):
|
||||
tokens.append((m.group(0).lower(), m.lastgroup == "NUM"))
|
||||
if not tokens:
|
||||
return None
|
||||
for token in tokens:
|
||||
|
||||
for token, is_num in tokens:
|
||||
if token == self.last_token:
|
||||
self.same_run += 1
|
||||
else:
|
||||
self.last_token = token
|
||||
self.same_run = 1
|
||||
self.numeric_run = self.numeric_run + 1 if is_num else 0
|
||||
self.recent_tokens.append(token)
|
||||
self.periodic_tokens.append(token)
|
||||
if len(self.recent_tokens) > 96:
|
||||
@@ -466,14 +495,20 @@ class _DegenerateStreamGuard:
|
||||
self.periodic_tokens = self.periodic_tokens[-2048:]
|
||||
|
||||
reason = None
|
||||
if self.same_run >= 28 and self.total_chars >= 100:
|
||||
if self.same_run >= 28 and self.total_chars >= 100 and not _is_numeric_token(self.last_token):
|
||||
reason = f"repeated '{self.last_token}' {self.same_run} times"
|
||||
elif len(self.recent_tokens) >= 72:
|
||||
top = max(set(self.recent_tokens), key=self.recent_tokens.count)
|
||||
count = self.recent_tokens.count(top)
|
||||
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78:
|
||||
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78 and not _is_numeric_token(top):
|
||||
reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens"
|
||||
if not reason and len(self.recent_tokens) >= 48:
|
||||
if (
|
||||
not reason
|
||||
and len(self.recent_tokens) >= 48
|
||||
# A window that is mostly numbers is matrix/table data; its
|
||||
# periodicity is real. Mixed loops still hit the exact-block check.
|
||||
and sum(_is_numeric_token(t) for t in self.recent_tokens) * 2 < len(self.recent_tokens)
|
||||
):
|
||||
# Detect a periodic suffix instead of counting repeated n-grams.
|
||||
# Reused property names are normal in CSS/SVG/JSON; a collapsed
|
||||
# generation repeats nearly every token at a short fixed period.
|
||||
@@ -508,12 +543,15 @@ class _DegenerateStreamGuard:
|
||||
if history[previous:previous + anchor_size] != anchor:
|
||||
continue
|
||||
period = current_anchor - previous
|
||||
if period < 24 or len(history) < period * 2:
|
||||
if period < 24 or len(history) < period * 2 or self.numeric_run >= period:
|
||||
continue
|
||||
if history[-2 * period:-period] == history[-period:]:
|
||||
if history[-2 * period:-period] == history[-period:] and not all(_is_numeric_token(t) for t in history[-period:]):
|
||||
reason = f"repeated an exact {period}-token block twice"
|
||||
break
|
||||
|
||||
if not reason and self.numeric_run >= 2048:
|
||||
reason = f"emitted {self.numeric_run} numbers in a row"
|
||||
|
||||
if not reason:
|
||||
return None
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import contextlib
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
from src.database import McpServer, SessionLocal
|
||||
|
||||
@@ -383,12 +384,21 @@ class McpManager:
|
||||
|
||||
provider = build_provider(server_id, url, on_redirect=_on_redirect)
|
||||
stack = AsyncExitStack()
|
||||
try:
|
||||
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
|
||||
read_stream, write_stream, _get_session_id = transport
|
||||
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
|
||||
await session.initialize()
|
||||
|
||||
tools_result = await session.list_tools()
|
||||
except BaseException:
|
||||
# Close the half-open transport here, in the task that entered
|
||||
# it. Left for the garbage collector, its AnyIO cancel scope is
|
||||
# exited from the wrong task and the event loop keeps spinning
|
||||
# (#5518). Its own teardown error would only mask the real one.
|
||||
with contextlib.suppress(Exception):
|
||||
await stack.aclose()
|
||||
raise
|
||||
tools = []
|
||||
for tool in tools_result.tools:
|
||||
tools.append({
|
||||
|
||||
@@ -193,7 +193,10 @@ class ModelDiscovery:
|
||||
data = r.json()
|
||||
# Some OpenAI-compatible servers return a bare list, not {"data": [...]}.
|
||||
items = data if isinstance(data, list) else ((data or {}).get("data") or [])
|
||||
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
|
||||
ids = [
|
||||
m["id"] for m in items
|
||||
if isinstance(m, dict) and isinstance(m.get("id"), str) and m["id"]
|
||||
]
|
||||
if ids:
|
||||
return {
|
||||
"host": host,
|
||||
|
||||
+4
-2
@@ -102,8 +102,10 @@ _PLAIN_FRONT_MATTER_RE = re.compile(
|
||||
# - **label:** value <!-- field=NAME-ENC type=text -->
|
||||
# - **label** [opts]: value <!-- field=NAME-ENC type=choice -->
|
||||
# - [x] **label** <!-- field=NAME-ENC type=checkbox -->
|
||||
# Accept Unicode word characters for older saved markers, whose encoder left
|
||||
# non-ASCII letters/numbers literal. Newly rendered names are ASCII-safe.
|
||||
_FIELD_BULLET_RE = re.compile(
|
||||
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[A-Za-z0-9_.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
|
||||
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[\w.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
|
||||
)
|
||||
|
||||
|
||||
@@ -115,7 +117,7 @@ def _encode_name(name: str) -> str:
|
||||
"""
|
||||
out = []
|
||||
for ch in name or "":
|
||||
if ch.isalnum() or ch in ("_", ".", "-"):
|
||||
if (ch.isascii() and ch.isalnum()) or ch in ("_", ".", "-"):
|
||||
out.append(ch)
|
||||
else:
|
||||
for b in ch.encode("utf-8"):
|
||||
|
||||
+63
-8
@@ -76,9 +76,23 @@ _ERROR_DETAIL = {
|
||||
"auth_or_protocol_error": "authentication or protocol error",
|
||||
"no_models": "endpoint returned no models",
|
||||
"no_host": "no host configured",
|
||||
"config_source_error": "configuration source could not be read",
|
||||
"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]:
|
||||
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.
|
||||
|
||||
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] = {}
|
||||
integrations: List[Dict[str, Any]] = []
|
||||
accounts: List[Dict[str, Any]] = []
|
||||
endpoints: List[Dict[str, Any]] = []
|
||||
failed: Dict[str, str] = {}
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
settings = load_settings() or {}
|
||||
except Exception as e:
|
||||
failed["settings"] = _classify_error(e)
|
||||
logger.debug(f"service_health: settings load failed: {e}")
|
||||
try:
|
||||
from src.integrations import load_integrations
|
||||
integrations = load_integrations() or []
|
||||
except Exception as e:
|
||||
failed["integrations"] = _classify_error(e)
|
||||
logger.debug(f"service_health: integrations load failed: {e}")
|
||||
try:
|
||||
from routes.email_helpers import _list_email_accounts
|
||||
accounts = _list_email_accounts() or []
|
||||
except Exception as e:
|
||||
failed["accounts"] = _classify_error(e)
|
||||
logger.debug(f"service_health: email accounts load failed: {e}")
|
||||
try:
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
@@ -443,9 +464,10 @@ def _gather_inputs() -> Dict[str, Any]:
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
failed["endpoints"] = _classify_error(e)
|
||||
logger.debug(f"service_health: endpoint load failed: {e}")
|
||||
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]:
|
||||
@@ -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)
|
||||
|
||||
|
||||
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,
|
||||
memory_vector: Any = None) -> Dict[str, Any]:
|
||||
"""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`,
|
||||
with an overall `_AGGREGATE_DEADLINE` backstop. Per-item probes inside
|
||||
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
|
||||
|
||||
@@ -482,12 +526,23 @@ async def collect_service_health(rag_manager: Any = None,
|
||||
chroma = chromadb_health(rag_manager, memory_vector)
|
||||
|
||||
names = ["searxng", "ntfy", "email", "providers"]
|
||||
coros = [
|
||||
_run_subsystem("searxng", searxng_health, settings),
|
||||
_run_subsystem("ntfy", ntfy_health, inputs["integrations"], settings),
|
||||
_run_subsystem("email", email_health, inputs["accounts"]),
|
||||
_run_subsystem("providers", providers_health, inputs["endpoints"]),
|
||||
]
|
||||
probes = {
|
||||
"searxng": (searxng_health, (settings,)),
|
||||
"ntfy": (ntfy_health, (inputs["integrations"], settings)),
|
||||
"email": (email_health, (inputs["accounts"],)),
|
||||
"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:
|
||||
results = await asyncio.wait_for(asyncio.gather(*coros),
|
||||
timeout=_AGGREGATE_DEADLINE)
|
||||
|
||||
+8
-51
@@ -12,16 +12,6 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Names that indicate a throwaway/test session
|
||||
_THROWAWAY_NAMES = {
|
||||
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
|
||||
"yo", "sup", "hola", "hii", "hiii", "heyo",
|
||||
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
|
||||
"new chat", "delete", "remove", "junk", "trash", "xxx",
|
||||
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
|
||||
"ok", "lol", "bruh", "hmm", "hm", "meh",
|
||||
}
|
||||
_THROWAWAY_MAX_MESSAGES = 4
|
||||
_FRESH_EMPTY_SESSION_GRACE = timedelta(minutes=10)
|
||||
_FRESH_SESSION_GRACE = _FRESH_EMPTY_SESSION_GRACE
|
||||
|
||||
@@ -54,14 +44,15 @@ def is_session_recently_active(row, now=None, grace=_FRESH_SESSION_GRACE) -> boo
|
||||
|
||||
|
||||
async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bool = True) -> str:
|
||||
"""Run session cleanup + (optional) AI folder sort for the given owner.
|
||||
"""Remove empty/Incognito sessions and optionally organize saved chats.
|
||||
|
||||
Args:
|
||||
owner: user whose sessions to process
|
||||
skip_llm: when True, do only Phase 1 (delete empty/throwaway sessions);
|
||||
skip_llm: when True, do only Phase 1 (delete empty/Incognito sessions);
|
||||
skip Phase 2 (AI folder assignment). Used by the built-in daily
|
||||
background sweep so it never burns LLM tokens.
|
||||
delete_throwaway: when False, only empty/incognito sessions are deleted.
|
||||
delete_throwaway: retained for caller compatibility. Ordinary sessions
|
||||
with saved messages are always preserved, regardless of this flag.
|
||||
|
||||
Returns a human-readable summary of what was done.
|
||||
"""
|
||||
@@ -71,7 +62,7 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# ── Phase 1: Delete empty/throwaway sessions ──
|
||||
# ── Phase 1: Delete only empty and explicitly Incognito sessions ──
|
||||
deleted_empty = 0
|
||||
deleted_throwaway = 0
|
||||
|
||||
@@ -93,47 +84,13 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
||||
if is_session_recently_active(row, now=cleanup_now):
|
||||
continue
|
||||
|
||||
msg_count = db.query(DbMsg.id).filter(
|
||||
has_messages = db.query(DbMsg.id).filter(
|
||||
DbMsg.session_id == row.id
|
||||
).limit(_THROWAWAY_MAX_MESSAGES + 1).count()
|
||||
should_delete = False
|
||||
|
||||
if msg_count == 0:
|
||||
).first() is not None
|
||||
if not has_messages:
|
||||
if is_fresh:
|
||||
continue
|
||||
should_delete = True
|
||||
deleted_empty += 1
|
||||
elif delete_throwaway and msg_count <= _THROWAWAY_MAX_MESSAGES:
|
||||
name = (row.name or "").strip().lower()
|
||||
first_msg = db.query(DbMsg.content).filter(
|
||||
DbMsg.session_id == row.id, DbMsg.role == "user"
|
||||
).order_by(DbMsg.timestamp).first()
|
||||
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
|
||||
assistant_count = db.query(DbMsg.id).filter(
|
||||
DbMsg.session_id == row.id, DbMsg.role == "assistant"
|
||||
).limit(1).count()
|
||||
|
||||
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
elif msg_count == 1 and assistant_count == 0:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
elif msg_count <= 4 and first_text and len(first_text.split()) <= 8 and len(first_text) <= 80:
|
||||
# Short trivial chats — e.g. "write hi to a friend" → "Hi!"
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
else:
|
||||
# Aggressive: total message text under 250 chars combined = trivial
|
||||
msg_rows = db.query(DbMsg.content).filter(
|
||||
DbMsg.session_id == row.id
|
||||
).all()
|
||||
total_chars = sum(len(m[0] or "") for m in msg_rows)
|
||||
if total_chars <= 250:
|
||||
should_delete = True
|
||||
deleted_throwaway += 1
|
||||
|
||||
if should_delete:
|
||||
db.delete(row)
|
||||
|
||||
if deleted_empty or deleted_throwaway:
|
||||
|
||||
+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("_", "\\_")
|
||||
|
||||
|
||||
def _escape_like(value: str) -> str:
|
||||
return escape_like_pattern(value)
|
||||
|
||||
|
||||
def _snippet(content: str, query: str, radius: int = 60) -> str:
|
||||
content = content or ""
|
||||
query = query or ""
|
||||
if not query:
|
||||
return content[: radius * 2]
|
||||
|
||||
idx = content.lower().find(query.lower())
|
||||
if idx == -1:
|
||||
match_start = -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]
|
||||
|
||||
start = max(0, idx - radius)
|
||||
end = min(len(content), idx + len(query) + radius)
|
||||
start = max(0, match_start - radius)
|
||||
end = min(len(content), match_start + match_length + radius)
|
||||
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:
|
||||
"""Convert free text into a conservative FTS5 MATCH query.
|
||||
|
||||
|
||||
+16
-1
@@ -46,6 +46,7 @@ DEFAULT_SETTINGS = {
|
||||
"agent_email_confirm": True,
|
||||
"image_gen_enabled": False,
|
||||
"image_model": "",
|
||||
"image_edit_model": "",
|
||||
"image_quality": "medium",
|
||||
# Optional editor planner/caption endpoints. Empty uses configured chat
|
||||
# fallback without probing a developer-specific host.
|
||||
@@ -294,7 +295,7 @@ def is_setting_overridden(key: str) -> bool:
|
||||
# resolved by FastAPI deps; an empty/None owner falls through to the global.
|
||||
_PER_USER_KEYS = {
|
||||
"vision_model", "vision_enabled", "vision_model_fallbacks",
|
||||
"image_model", "image_gen_enabled", "image_quality",
|
||||
"image_model", "image_edit_model", "image_gen_enabled", "image_quality",
|
||||
# Default chat endpoint / model — without per-user resolution every new
|
||||
# account inherited whatever the most-recent admin picked, which then
|
||||
# got injected into the chat composer on first open.
|
||||
@@ -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
|
||||
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:
|
||||
try:
|
||||
from routes.prefs_routes import _load_for_user
|
||||
|
||||
+22
-2
@@ -132,6 +132,23 @@ async def _cached(key: Tuple, ttl: float, fetch: Callable[[], Awaitable[Any]]) -
|
||||
_shared_cache_pending.pop(key, None)
|
||||
|
||||
|
||||
def is_valid_cron(cron_expression: str) -> bool:
|
||||
"""True for a standard 5-field cron expression that croniter can parse.
|
||||
|
||||
croniter also accepts 6- and 7-field expressions with a seconds field, but
|
||||
the scheduler works at one-minute resolution and would run such a task
|
||||
every second.
|
||||
"""
|
||||
if len((cron_expression or "").split()) != 5:
|
||||
return False
|
||||
try:
|
||||
from croniter import croniter
|
||||
croniter(cron_expression)
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def compute_next_run(schedule: str, scheduled_time: str,
|
||||
scheduled_day: int = None,
|
||||
scheduled_date: datetime = None,
|
||||
@@ -175,6 +192,9 @@ def compute_next_run(schedule: str, scheduled_time: str,
|
||||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if schedule == "cron" and cron_expression:
|
||||
if not is_valid_cron(cron_expression):
|
||||
logger.warning(f"Invalid cron expression '{cron_expression}'")
|
||||
return None
|
||||
try:
|
||||
from croniter import croniter
|
||||
cron = croniter(cron_expression, now)
|
||||
@@ -306,7 +326,7 @@ def _digest_windows(now):
|
||||
|
||||
|
||||
def _checkin_calendar_events(db, owner, start, end):
|
||||
"""Calendar events in [start, end] for ONE owner, for the check-in digest.
|
||||
"""Calendar events in [start, end) for ONE owner, for the check-in digest.
|
||||
|
||||
Ownership lives on CalendarCal.owner; events inherit it via calendar_id.
|
||||
The digest query had no owner scope, so it pulled EVERY user's events into
|
||||
@@ -320,7 +340,7 @@ def _checkin_calendar_events(db, owner, start, end):
|
||||
.filter(
|
||||
_CC.owner == owner,
|
||||
_CE.dtstart >= start,
|
||||
_CE.dtstart <= end,
|
||||
_CE.dtstart < end,
|
||||
_CE.status != "cancelled",
|
||||
)
|
||||
.order_by(_CE.dtstart)
|
||||
|
||||
@@ -41,6 +41,7 @@ _REQUIRED_NATIVE_TOOL_ARGS = {
|
||||
"write_file": ("path",),
|
||||
"edit_file": ("path",),
|
||||
"apply_patch": ("patch_text", "patchText", "patch"),
|
||||
"generate_image": ("prompt",),
|
||||
"host_shell": ("command",),
|
||||
}
|
||||
|
||||
|
||||
@@ -4592,7 +4592,9 @@ body.bg-pattern-ascii-fireflies {
|
||||
width:100%; background:var(--bg); color:var(--fg);
|
||||
border:1px solid var(--border); border-radius:6px;
|
||||
padding:8px; font-family:inherit; font-size:0.95rem;
|
||||
resize:vertical; outline:none;
|
||||
resize:both; outline:none;
|
||||
field-sizing:content;
|
||||
max-width:100%; box-sizing:border-box;
|
||||
min-height:80px;
|
||||
}
|
||||
.edit-textarea:focus { border-color:var(--red); }
|
||||
|
||||
@@ -264,7 +264,42 @@
|
||||
/* The brush controls (Color/Size/preset/caret) hug their content; only the
|
||||
trailing slider section grows, so the Opacity/Flow/Softness group sits
|
||||
right after the caret instead of leaving a wide gap. */
|
||||
.ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }
|
||||
.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 {
|
||||
margin: 0 !important;
|
||||
flex: 0 0 auto;
|
||||
@@ -4607,6 +4642,8 @@ body.ge-editor-open textarea.ge-direct-text-editor {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.ge-filter-modal {
|
||||
max-height: calc(100dvh - 40px);
|
||||
overflow-y: auto;
|
||||
width: 240px;
|
||||
background: color-mix(in srgb, var(--panel) 95%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
@@ -4631,6 +4668,9 @@ body.ge-editor-open textarea.ge-direct-text-editor {
|
||||
gap: 2px;
|
||||
}
|
||||
.ge-filter-row label {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
@@ -9732,6 +9772,28 @@ body.ge-editor-open .tour-hint {
|
||||
margin-left: auto;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.ge-submenu .dropdown-item-compact {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
max-height: 28px;
|
||||
padding: 0 10px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ge-submenu .dropdown-item-compact svg {
|
||||
flex-shrink: 0;
|
||||
max-height: 16px;
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.ge-submenu .dropdown-item-compact {
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
}
|
||||
}
|
||||
.ge-submenu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
|
||||
@@ -2690,11 +2690,14 @@ body.theme-frosted .modal {
|
||||
max-height: 95dvh !important;
|
||||
}
|
||||
|
||||
/* Notes and Documents must respect the same viewport and safe-area
|
||||
top as other mobile full-height sheets. Keep these authoritative rules
|
||||
consistent with 05-documents.css and 14-email.css. */
|
||||
body.doc-view .doc-editor-pane,
|
||||
body.notes-view .notes-pane {
|
||||
inset: auto 0 0 !important;
|
||||
height: 95dvh !important;
|
||||
max-height: 95dvh !important;
|
||||
inset: var(--sheet-top) 0 0 !important;
|
||||
height: calc(var(--app-height) - var(--sheet-top)) !important;
|
||||
max-height: calc(var(--app-height) - var(--sheet-top)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-7
@@ -16,13 +16,6 @@
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
window._odysseusLoadTime = Date.now();
|
||||
// Stops iOS focus-zoom on every field; iOS still allows pinch-zoom, other platforms would not.
|
||||
(function(){
|
||||
var ua = navigator.userAgent || '';
|
||||
var isIOS = /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
|
||||
var vp = document.querySelector('meta[name="viewport"]');
|
||||
if (isIOS && vp) vp.setAttribute('content', vp.getAttribute('content') + ', maximum-scale=1');
|
||||
})();
|
||||
(function(){
|
||||
try {
|
||||
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
|
||||
@@ -1684,6 +1677,10 @@
|
||||
<label class="settings-label">Model</label>
|
||||
<select id="set-imgModelSelect" class="settings-select"><option value="">Auto-detect</option></select>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<label class="settings-label" for="set-imgEditModelSelect">Image edit model</label>
|
||||
<select id="set-imgEditModelSelect" class="settings-select"><option value="">Same as generation</option></select>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<label class="settings-label">Quality</label>
|
||||
<select id="set-imgQualitySelect" class="settings-select">
|
||||
|
||||
@@ -771,7 +771,13 @@ function _getModal() {
|
||||
<button class="cal-side-nav cal-side-nav-next" id="cal-side-next" title="Next" aria-label="Next">›</button>
|
||||
</div>`;
|
||||
document.body.appendChild(_modal);
|
||||
_modal.querySelector('#cal-close').addEventListener('click', closeCalendar);
|
||||
_modal.querySelector('#cal-close').addEventListener('click', () => {
|
||||
if (_modal.querySelector('.cal-form')) {
|
||||
_render();
|
||||
return;
|
||||
}
|
||||
closeCalendar();
|
||||
});
|
||||
_modal.querySelector('#cal-side-prev')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
+160
-51
@@ -336,6 +336,9 @@ import { invalidateSettings } from './appConfig.js';
|
||||
let _sendInFlight = false; // covers the window from click → streaming start
|
||||
let _displayOverride = null; // Override visible user bubble text (hides injected prompts)
|
||||
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 _contextHeaderData = null;
|
||||
let _contextHeaderBound = false;
|
||||
@@ -4995,6 +4998,15 @@ import { invalidateSettings } from './appConfig.js';
|
||||
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') {
|
||||
// Wire the persisted DB id onto the just-streamed bubble so it
|
||||
// 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.
|
||||
*/
|
||||
// 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) {
|
||||
const box = document.getElementById('chat-history');
|
||||
const allMsgs = Array.from(box.querySelectorAll('.msg'));
|
||||
@@ -7283,23 +7311,49 @@ import { invalidateSettings } from './appConfig.js';
|
||||
e.stopPropagation();
|
||||
const newText = editor.value.trim();
|
||||
if (!newText) return;
|
||||
const editAttachmentIds = _userMessageAttachmentIds(userMsgElement);
|
||||
|
||||
const sessionId = sessionModule.getCurrentSessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
const keepCount = msgIndex;
|
||||
// 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 fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||
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;
|
||||
// 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 {
|
||||
const editTruncate = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||
method: 'POST',
|
||||
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
|
||||
for (let i = allMsgs.length - 1; i >= msgIndex; i--) {
|
||||
allMsgs[i].remove();
|
||||
}
|
||||
|
||||
// The edited user turn still refers to its original attachments.
|
||||
_pendingRegenAttachments = editAttachmentIds;
|
||||
// Submit the edited text
|
||||
const messageInput = uiModule.el('message');
|
||||
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
|
||||
* 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();
|
||||
text = text.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
|
||||
|
||||
// Collect file_ids attached to this user message so the resend re-carries
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
const _ids = _userMessageAttachmentIds(userMsgElement);
|
||||
|
||||
// Rescue: legacy bubbles may have stored the filename as the message
|
||||
// content (artifact of earlier broken resends). Don't re-send that as
|
||||
@@ -7378,6 +7474,18 @@ import { invalidateSettings } from './appConfig.js';
|
||||
|
||||
try {
|
||||
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
|
||||
// the replacement request is the only copy the backend sees.
|
||||
const keepCount = msgIndex;
|
||||
@@ -7442,25 +7550,14 @@ import { invalidateSettings } from './appConfig.js';
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect any file_ids attached to the original user message so the
|
||||
// regenerated send re-uses them. Without this the AI is regenerated on
|
||||
// text alone — photos (and the user-edited OCR text cached server-side
|
||||
// under that file_id) would be silently dropped.
|
||||
const _attachEls = userMsgEl ? userMsgEl.querySelectorAll('[data-file-id]') : [];
|
||||
let _regenIds = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
|
||||
// Fallback for bubbles rendered before the data-file-id stamp landed:
|
||||
// sniff the file id straight out of any `.attach-image-preview img`
|
||||
// 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;
|
||||
// Everything strictly after the AI reply being regenerated would also
|
||||
// be destroyed — that's the part a click on "Regenerate from here"
|
||||
// doesn't expect to lose.
|
||||
const extraCount = allMsgs.length - aiIndex - 1;
|
||||
const choice = await _confirmTruncateIfDestructive(extraCount, 'regenerate');
|
||||
if (!choice) return;
|
||||
|
||||
const _regenIds = _userMessageAttachmentIds(userMsgEl);
|
||||
|
||||
// Rescue: earlier-version regens (before the dataset.raw fix) stored the
|
||||
// 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
|
||||
// attachments, drop it so the OCR text (or the image bytes for vision
|
||||
// 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())) {
|
||||
userText = '';
|
||||
}
|
||||
@@ -7477,7 +7574,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
// A photo-only message has empty user text — regen must still proceed,
|
||||
// because the attachments themselves are the message. Bail only if there
|
||||
// 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');
|
||||
return;
|
||||
}
|
||||
@@ -7485,6 +7582,20 @@ import { invalidateSettings } from './appConfig.js';
|
||||
const sessionId = sessionModule.getCurrentSessionId();
|
||||
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
|
||||
const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || '';
|
||||
const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || '';
|
||||
@@ -7496,13 +7607,19 @@ import { invalidateSettings } from './appConfig.js';
|
||||
}
|
||||
|
||||
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 {
|
||||
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||
const regenTruncate = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||
method: 'POST',
|
||||
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
|
||||
// it, including agent-thread tool history between the user and AI bubble.
|
||||
@@ -7519,12 +7636,14 @@ import { invalidateSettings } from './appConfig.js';
|
||||
_pendingVariantLabel = 'regen';
|
||||
|
||||
_hideUserBubble = true;
|
||||
_regenKeptUserBubble = userMsgEl;
|
||||
const messageInput = uiModule.el('message');
|
||||
messageInput.value = userText;
|
||||
const submitBtn = document.querySelector('.send-btn');
|
||||
if (submitBtn) submitBtn.click();
|
||||
|
||||
} catch (err) {
|
||||
if (_pendingRegenAttachments === _regenIds) _pendingRegenAttachments = null;
|
||||
console.error('Regenerate failed:', err);
|
||||
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
|
||||
// photos / OCR overrides survive into the new send. Consumed once.
|
||||
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.
|
||||
@@ -7675,20 +7796,8 @@ import { invalidateSettings } from './appConfig.js';
|
||||
const sessionId = sessionModule.getCurrentSessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
const keepCount = aiIndex + 1;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, {
|
||||
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}`);
|
||||
await _forkSessionAt(aiIndex + 1, '', aiMsgElement.dataset.dbId || '');
|
||||
} catch (err) {
|
||||
console.error('Fork failed:', err);
|
||||
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
function streamError(payload) {
|
||||
const detail = payload?.data ?? payload;
|
||||
const candidates = [detail?.message, detail?.text, detail?.error?.message,
|
||||
detail?.error, payload?.message, payload?.text, payload?.error?.message,
|
||||
payload?.error, typeof payload === 'string' ? payload : null];
|
||||
const message = candidates.find(value => typeof value === 'string' && value.trim());
|
||||
return new Error(message || 'Model request failed.');
|
||||
}
|
||||
|
||||
/** Decode complete SSE frames, requiring the backend's terminal DONE event. */
|
||||
export async function consumeCompareEvents(response, onEvent, onActivity) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let line = '', event = 'message', data = [], skipLF = false;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (!done) onActivity?.();
|
||||
let chunk = decoder.decode(value, { stream: !done });
|
||||
if (skipLF && chunk.length) {
|
||||
if (chunk.startsWith('\n')) chunk = chunk.slice(1);
|
||||
skipLF = false;
|
||||
}
|
||||
const separators = /\r\n|\r|\n/g;
|
||||
let start = 0, separator;
|
||||
while ((separator = separators.exec(chunk))) {
|
||||
line += chunk.slice(start, separator.index);
|
||||
start = separator.index + separator[0].length;
|
||||
skipLF = separator[0] === '\r' && start === chunk.length;
|
||||
if (!line) {
|
||||
if (data.length || event === 'error') {
|
||||
const raw = data.join('\n');
|
||||
if (event !== 'error' && raw === '[DONE]') return;
|
||||
let payload;
|
||||
try { payload = JSON.parse(raw); }
|
||||
catch (_) {
|
||||
if (event === 'error') throw streamError(raw);
|
||||
throw new Error('Invalid model stream data.');
|
||||
}
|
||||
if (event === 'error' || payload?.type === 'error') throw streamError(payload);
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new Error('Invalid model stream data.');
|
||||
}
|
||||
await onEvent(payload);
|
||||
}
|
||||
event = 'message';
|
||||
data = [];
|
||||
} else if (!line.startsWith(':')) {
|
||||
const colon = line.indexOf(':');
|
||||
const field = colon < 0 ? line : line.slice(0, colon);
|
||||
let value = colon < 0 ? '' : line.slice(colon + 1);
|
||||
if (value.startsWith(' ')) value = value.slice(1);
|
||||
if (field === 'data') data.push(value);
|
||||
else if (field === 'event') event = value || 'message';
|
||||
}
|
||||
line = '';
|
||||
}
|
||||
line += chunk.slice(start);
|
||||
if (done) throw new Error('Stream ended before completion.');
|
||||
}
|
||||
} finally {
|
||||
try { await reader.cancel(); } catch (_) {}
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
+12
-22
@@ -1,5 +1,6 @@
|
||||
// compare/stream.js — SSE streaming to panes
|
||||
import state from './state.js';
|
||||
import { consumeCompareEvents } from './sse.js';
|
||||
import { addFinishBadge } from './vote.js?v=20260828resendcaldrag1';
|
||||
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260914metricssummary1';
|
||||
import markdownModule from '../markdown.js';
|
||||
@@ -472,25 +473,9 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
_resetIdleTimeout(); // any chunk = stream is alive
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') break;
|
||||
await consumeCompareEvents(response,
|
||||
(json) => {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (json.type === 'metrics') {
|
||||
metrics = json.data;
|
||||
|
||||
@@ -672,7 +657,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||
} else if (json.delta) {
|
||||
// Skip text deltas if we already rendered an image
|
||||
if (aiMsgEl._imageData) continue;
|
||||
if (aiMsgEl._imageData) return;
|
||||
// Capture TTFT on very first text delta
|
||||
if (!accumulated && !_ttft) _ttft = performance.now() - _timerStart;
|
||||
// On first delta, destroy spinner and prepare text area
|
||||
@@ -701,8 +686,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
_scheduleLiveRender(target);
|
||||
}
|
||||
} catch (e) { console.warn('Compare stream render error:', e); }
|
||||
}
|
||||
}
|
||||
}, _resetIdleTimeout);
|
||||
|
||||
streamOk = true;
|
||||
// Destroy any remaining spinner
|
||||
@@ -900,9 +884,15 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
}
|
||||
} else {
|
||||
console.error('Compare stream error:', error);
|
||||
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(error.message) + '</span>';
|
||||
awaitingChoice = false;
|
||||
const message = state._blindMode ? 'Model request failed. Try again.' : error.message;
|
||||
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(message) + '</span>';
|
||||
}
|
||||
} finally {
|
||||
if (aiMsgEl._spinner) {
|
||||
aiMsgEl._spinner.destroy();
|
||||
aiMsgEl._spinner = null;
|
||||
}
|
||||
clearTimeout(timeoutId);
|
||||
_timerDone = true;
|
||||
cancelAnimationFrame(_rafId);
|
||||
|
||||
@@ -1195,11 +1195,12 @@ async function _fetchDependencies() {
|
||||
// For backends with a recipe catalog (vllm / sglang / llama_cpp),
|
||||
// append a caret button that toggles a per-row recipe panel below.
|
||||
const hasRecipe = RECIPE_BACKENDS.has(pkg.name);
|
||||
// Standalone recipe-caret button removed — the "Pick install
|
||||
// command" action lives inside the Installed ▾ dropdown menu
|
||||
// (see _showDepMenu) so each row only has ONE caret to click.
|
||||
// Kept the variable so downstream concat code stays the same.
|
||||
const recipeCaret = '';
|
||||
// The caret is the only way to open the recipe panel (the Installed ▾
|
||||
// menu has no recipe item), and the HW Fit / serve-card deep links
|
||||
// open it by clicking this caret (#6747).
|
||||
const recipeCaret = hasRecipe
|
||||
? `<button class="cookbook-dep-tag cookbook-dep-recipe-caret" data-dep-recipe-toggle="${esc(pkg.name)}" title="Pick a model to see the exact install commands" aria-expanded="false" style="background:none;border:1px solid var(--border);padding:2px 6px;display:inline-flex;align-items:center;cursor:pointer;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transition:transform 0.15s"><polyline points="6 9 12 15 18 9"/></svg></button>`
|
||||
: '';
|
||||
const recipePanel = hasRecipe ? _recipePanelHtml(pkg.name) : '';
|
||||
// When llama_cpp (or any future engine) reports build_deps_missing
|
||||
// from its system_prereqs probe, surface a one-tap install button
|
||||
|
||||
@@ -5232,7 +5232,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
}
|
||||
|
||||
async function _handleAttachUpload(e) {
|
||||
const files = e.target.files;
|
||||
const files = Array.from(e.target.files || []);
|
||||
e.target.value = ''; // reset for next upload
|
||||
await _uploadComposeFiles(files);
|
||||
}
|
||||
@@ -5963,7 +5963,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
}
|
||||
|
||||
async function _handleMarkdownImageUpload(e) {
|
||||
const files = e.target.files;
|
||||
const files = Array.from(e.target.files || []);
|
||||
e.target.value = '';
|
||||
await _uploadMarkdownImages(files);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { imageResultGeometry } from './image-result-geometry.js';
|
||||
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
|
||||
|
||||
export function wireInpaintButtons({
|
||||
@@ -184,17 +185,30 @@ export function wireInpaintButtons({
|
||||
if (!state.editorOpen) return; // user closed mid-decode
|
||||
try {
|
||||
saveState('Inpaint result');
|
||||
// OpenAI returns at one of its allowed sizes (1024²,
|
||||
// 1024×1536, 1536×1024) which often differs from our
|
||||
// canvas. Scale to canvas size with smoothing so the
|
||||
// inpaint blends in regardless of source dims.
|
||||
// Whole-image edits keep the provider's native frame; only masked
|
||||
// edits need registration with the source document's coordinates.
|
||||
const shortPrompt = (prompt || '').trim().replace(/\s+/g, ' ').slice(0, 40);
|
||||
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.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.
|
||||
if (!fullFrame) {
|
||||
const aiSnap = document.createElement('canvas');
|
||||
aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight;
|
||||
aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0);
|
||||
@@ -205,6 +219,7 @@ export function wireInpaintButtons({
|
||||
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
|
||||
// Apply initial alpha = hard mask (no feather, no edge shift).
|
||||
applyInpaintFeather(resultLayer, 0, 0);
|
||||
}
|
||||
state.layers.push(resultLayer);
|
||||
state.activeLayerId = resultLayer.id;
|
||||
state.lastInpaintLayerId = resultLayer.id;
|
||||
@@ -220,6 +235,10 @@ export function wireInpaintButtons({
|
||||
}
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
if (fullFrame) {
|
||||
if (uiModule) uiModule.showToast('Image edit complete', 4500);
|
||||
return;
|
||||
}
|
||||
// Reveal post-generation Feather + Edge Stroke sliders.
|
||||
// Cap Edge Stroke at ±padPx so the slider can't ask for
|
||||
// more AI buffer than we generated.
|
||||
|
||||
@@ -54,16 +54,16 @@ export function resolveQuickEditModel() {
|
||||
label: ((opt && opt.textContent) || '').trim(),
|
||||
};
|
||||
}
|
||||
const inp = document.getElementById('ge-ai-inpaint');
|
||||
const opt = resolveInpaintModel(inp);
|
||||
if (!opt) return { endpoint: '', model: '', label: '' };
|
||||
const raw = opt.value || '';
|
||||
const idx = raw.indexOf('::');
|
||||
return {
|
||||
endpoint: idx < 0 ? raw : raw.slice(0, idx),
|
||||
model: idx < 0 ? '' : raw.slice(idx + 2),
|
||||
label: ((opt.textContent || '').trim().replace(/^Auto\s*\((.*)\)$/, '$1')),
|
||||
};
|
||||
const generation = document.getElementById('ge-ai-model');
|
||||
if (generation?.value && generation.value !== '__serve_cookbook__') {
|
||||
const index = generation.value.indexOf('::');
|
||||
const model = index < 0 ? '' : generation.value.slice(index + 2);
|
||||
if (model && modelCaps(model, '', '').inpaint) {
|
||||
return { endpoint: generation.value.slice(0, index), model,
|
||||
label: generation.options[generation.selectedIndex]?.textContent?.trim() || model };
|
||||
}
|
||||
}
|
||||
return { endpoint: '', model: '', label: 'Auto (saved settings)' };
|
||||
}
|
||||
|
||||
// Resolve the Quick Edit AGENT (planner) model — the vision-language model
|
||||
@@ -144,21 +144,19 @@ export function modelCaps(modelId, endpointName, endpointType) {
|
||||
function syncCommandModel(value) {
|
||||
const select = document.getElementById('ge-ai-command-model');
|
||||
if (!select || !select._modelsReady) return;
|
||||
if (value && ![...select.options].some(o => !o.disabled && o.value === value && value !== '__serve_cookbook__')) {
|
||||
if (localStorage.getItem('ge-ai-command-model') === value) localStorage.removeItem('ge-ai-command-model');
|
||||
value = '';
|
||||
}
|
||||
if (select.value !== value) {
|
||||
select.value = value;
|
||||
if (select.value !== value) select.value = '';
|
||||
updateFooterEditModel();
|
||||
}
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || ''));
|
||||
window.addEventListener('storage', e => {
|
||||
if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || '');
|
||||
});
|
||||
window.addEventListener('ge:model-endpoints-updated', e => {
|
||||
}
|
||||
globalThis.window?.addEventListener('ge:model-endpoints-updated', e => {
|
||||
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();
|
||||
if (listeners.signal.aborted || !root.isConnected) return;
|
||||
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 (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'));
|
||||
for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>';
|
||||
let firstGen = null;
|
||||
@@ -346,45 +344,15 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
// Sentinel changes are handled by the component's delegated listener.
|
||||
if (aiCommandSelect) {
|
||||
aiCommandSelect._modelsReady = true;
|
||||
try {
|
||||
const rawSaved = localStorage.getItem('ge-ai-command-model') || '';
|
||||
let saved = rawSaved;
|
||||
if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) {
|
||||
const modelPart = saved.includes('::') ? saved.split('::').pop() : '';
|
||||
const matching = loaded.filter(l => l.model === modelPart);
|
||||
if (matching.length === 1) {
|
||||
saved = matching[0].value;
|
||||
} else {
|
||||
saved = '';
|
||||
}
|
||||
try {
|
||||
if (saved) localStorage.setItem('ge-ai-command-model', saved);
|
||||
else localStorage.removeItem('ge-ai-command-model');
|
||||
} catch {}
|
||||
}
|
||||
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) {
|
||||
aiCommandSelect.value = saved;
|
||||
} else {
|
||||
aiCommandSelect.value = '';
|
||||
}
|
||||
if (aiCommandSelect.value !== rawSaved) {
|
||||
if (aiCommandSelect.value) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
|
||||
else localStorage.removeItem('ge-ai-command-model');
|
||||
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: aiCommandSelect.value } }));
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Legacy URL migration check: legacy endpoints with raw URLs are scrubbed.
|
||||
const scrubModelUrl = (saved) => (saved && saved.startsWith('http://') ? '' : saved);
|
||||
if (aiCommandSelect._commandWired !== listeners) {
|
||||
aiCommandSelect._commandWired = listeners;
|
||||
listen(aiCommandSelect, 'change', () => {
|
||||
if (aiCommandSelect.value === '__serve_cookbook__') return;
|
||||
const val = aiCommandSelect.value || '';
|
||||
try {
|
||||
if (val) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
|
||||
else localStorage.removeItem('ge-ai-command-model');
|
||||
} catch {}
|
||||
const val = scrubModelUrl(aiCommandSelect.value) || '';
|
||||
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() };
|
||||
container.dispatchEvent(new Event('ge-image-models-ready'));
|
||||
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
|
||||
} catch (e) {
|
||||
if (listeners.signal.aborted || !root.isConnected) return;
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
* Background Remove (rembg) + Sharpen wiring + the live edge-cleanup
|
||||
* 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
|
||||
* active. After the new layer lands, hides every previously-
|
||||
* visible layer so the cutout reads cleanly, and binds the
|
||||
* live-tuner to the new layer.
|
||||
* active. Updates only that layer and binds its live edge tuner;
|
||||
* other layers and their visibility remain unchanged.
|
||||
*
|
||||
* Live edge-cleanup tuner: snapshots the pristine cutout the
|
||||
* 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
|
||||
// sliders can re-tune the cutout without re-running the model.
|
||||
const btn = document.getElementById('ge-rembg-run');
|
||||
const before = state.layers.length;
|
||||
// Snapshot which layers were visible BEFORE the run so we know
|
||||
// which to hide after a successful cutout.
|
||||
const prevVisible = state.layers.filter(l => l.visible).map(l => l.id);
|
||||
await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
|
||||
// applyImageTool resolves after the returned image is decoded and the
|
||||
// new layer is inserted. Keep a short poll for defensive compatibility
|
||||
// with alternate runners that may still commit on the next frame.
|
||||
let frames = 0;
|
||||
while (state.layers.length <= before && frames < 60) {
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
frames++;
|
||||
}
|
||||
if (state.layers.length > before) {
|
||||
const newLayer = state.layers[state.layers.length - 1];
|
||||
bindRembgLiveTuner(newLayer);
|
||||
// Auto-hide underlying layers so the user sees just the
|
||||
// cutout — the eye toggles back on if they re-enable manually.
|
||||
for (const layer of state.layers) {
|
||||
if (prevVisible.includes(layer.id) && layer.id !== newLayer.id) {
|
||||
layer.visible = false;
|
||||
}
|
||||
}
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
}
|
||||
const editedLayer = await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
|
||||
// Reset sliders so the new cutout starts clean.
|
||||
const f = document.getElementById('ge-rembg-feather');
|
||||
const g = document.getElementById('ge-rembg-grow');
|
||||
if (f) { f.value = 0; document.getElementById('ge-rembg-feather-label').textContent = '0px'; syncRembgFeather(0); }
|
||||
if (g) { g.value = 0; document.getElementById('ge-rembg-grow-label').textContent = '0px'; syncRembgGrow(0); }
|
||||
if (editedLayer) bindRembgLiveTuner(editedLayer);
|
||||
});
|
||||
|
||||
// ── Live edge-cleanup tuner ──
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
* "Double-check AI edits" toggle. Opened from the Quick Edit gear button and
|
||||
* from the editor setup checklist; keeps the Quick Edit box itself uncluttered.
|
||||
*/
|
||||
import { readImagePreferences, saveImagePreferences } from '../settings/imagePreferences.js';
|
||||
import { updateFooterEditModel } from './ai-models.js';
|
||||
|
||||
export function createAiSettings({ container }) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'ge-ai-settings';
|
||||
@@ -19,11 +22,14 @@ export function createAiSettings({ container }) {
|
||||
<button type="button" class="ge-ai-settings-close" data-ai-settings-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="ge-ai-settings-body">
|
||||
<label class="ge-ai-settings-label" for="ge-ai-model">Image generation model</label>
|
||||
<select class="ge-ai-command-model" id="ge-ai-model" title="Image generation model" aria-label="Image generation model"></select>
|
||||
<label class="ge-ai-settings-label" for="ge-ai-command-model">Image edit model</label>
|
||||
<select class="ge-ai-command-model" id="ge-ai-command-model" title="Image edit model" aria-label="Image edit model"></select>
|
||||
<label class="ge-ai-settings-label" for="ge-ai-command-agent-model">AI agent (vision) model</label>
|
||||
<select class="ge-ai-command-model" id="ge-ai-command-agent-model" title="Quick Edit agent model" aria-label="Quick Edit agent model"></select>
|
||||
<p class="ge-ai-settings-note">Recommended, not required — the vision agent plans edits and can double-check results.</p>
|
||||
<p class="ge-ai-settings-note" id="ge-ai-settings-save-status" role="status"></p>
|
||||
<label class="ge-ai-settings-label" for="ge-look-strength">Look strength</label>
|
||||
<div class="ge-ai-settings-strength">
|
||||
<input type="range" id="ge-look-strength" min="0.5" max="2" step="0.1" value="1.4" aria-label="Look grade strength" />
|
||||
@@ -41,6 +47,7 @@ export function createAiSettings({ container }) {
|
||||
|
||||
function open(focusId) {
|
||||
el.hidden = false;
|
||||
syncModels();
|
||||
if (focusId) {
|
||||
const target = document.getElementById(focusId);
|
||||
if (target) {
|
||||
@@ -56,10 +63,59 @@ export function createAiSettings({ container }) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
const onKey = (e) => { if (e.key === 'Escape' && !el.hidden) close(); };
|
||||
document.addEventListener('keydown', onKey);
|
||||
const onKey = (e) => {
|
||||
if (e.key !== 'Escape' || el.hidden) return;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
close();
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
|
||||
container.appendChild(el);
|
||||
const status = el.querySelector('#ge-ai-settings-save-status');
|
||||
const modelFields = [['ge-ai-model', 'image_model'], ['ge-ai-command-model', 'image_edit_model']];
|
||||
function applyModels(settings) {
|
||||
for (const [id, key] of modelFields) {
|
||||
if (!(key in settings)) continue;
|
||||
const select = el.querySelector(`#${id}`);
|
||||
const model = settings[key] || '';
|
||||
let option = [...select.options].find(option => {
|
||||
const value = option.value;
|
||||
return model ? value.slice(value.indexOf('::') + 2) === model : value === '';
|
||||
});
|
||||
if (!option && model && select.options.length) {
|
||||
option = document.createElement('option');
|
||||
option.value = `::${model}`;
|
||||
option.textContent = `${model} (unavailable)`;
|
||||
option.disabled = true;
|
||||
select.append(option);
|
||||
}
|
||||
if (option) select.value = option.value;
|
||||
}
|
||||
updateFooterEditModel();
|
||||
}
|
||||
async function syncModels() {
|
||||
try { applyModels(await readImagePreferences()); }
|
||||
catch (error) { status.textContent = error.message; }
|
||||
}
|
||||
for (const [id, key] of modelFields) {
|
||||
el.querySelector(`#${id}`).addEventListener('change', async (event) => {
|
||||
const raw = event.target.value;
|
||||
if (raw === '__serve_cookbook__') return;
|
||||
const model = raw.includes('::') ? raw.slice(raw.indexOf('::') + 2) : '';
|
||||
try {
|
||||
status.textContent = 'Saving…';
|
||||
await saveImagePreferences({ [key]: model });
|
||||
status.textContent = 'Saved';
|
||||
} catch (error) {
|
||||
await syncModels();
|
||||
status.textContent = error.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
const onPreferencesChanged = event => applyModels(event.detail);
|
||||
window.addEventListener('image-preferences-changed', onPreferencesChanged);
|
||||
container.addEventListener('ge-image-models-ready', syncModels);
|
||||
|
||||
const strengthInput = el.querySelector('#ge-look-strength');
|
||||
const strengthVal = el.querySelector('#ge-look-strength-val');
|
||||
@@ -77,7 +133,9 @@ export function createAiSettings({ container }) {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,12 @@ export function createApplyImageTool({
|
||||
if (sel.endpoint) extraPayload._endpoint = sel.endpoint;
|
||||
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 body = { image: imageB64, ...extraPayload };
|
||||
const res = await fetch(endpoint, {
|
||||
@@ -96,6 +101,23 @@ export function createApplyImageTool({
|
||||
const img = await decodeAIImage(data.image, operation.signal);
|
||||
operation.signal.throwIfAborted();
|
||||
if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4)
|
||||
if (removeBackground) {
|
||||
if (!state.layers.includes(targetLayer)) return;
|
||||
saveState('Remove background');
|
||||
// The returned pixels replace the editable source, not just its preview.
|
||||
targetLayer.kind = 'raster';
|
||||
targetLayer.placed = null;
|
||||
targetLayer.text = null;
|
||||
targetLayer.shape = null;
|
||||
targetLayer.ctx.clearRect(0, 0, targetLayer.canvas.width, targetLayer.canvas.height);
|
||||
targetLayer.ctx.drawImage(img, 0, 0, targetLayer.canvas.width, targetLayer.canvas.height);
|
||||
targetLayer._adjCacheKey = null;
|
||||
targetLayer._adjFinalKey = null;
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
uiModule?.showToast('Background removed');
|
||||
return targetLayer;
|
||||
}
|
||||
{
|
||||
saveState();
|
||||
const layer = createLayer(layerName, state.imgWidth, state.imgHeight);
|
||||
|
||||
Vendored
+5
-5
@@ -55,6 +55,11 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<label>Color</label>
|
||||
<input type="color" class="ge-color-picker" value="${color}" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row" id="ge-brush-softness-row">
|
||||
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
|
||||
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
|
||||
<input type="range" id="ge-brush-softness" min="0" max="1200" value="100" title="Brush edge softness and feathered falloff" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-brush-size-row">
|
||||
<label><span class="ge-size-label">${brushSize}px</span></label>
|
||||
<input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" />
|
||||
@@ -94,11 +99,6 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<label>Flow <span id="ge-brush-flow-label">100%</span></label>
|
||||
<input type="range" id="ge-brush-flow" min="5" max="100" value="100" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row" id="ge-brush-softness-row">
|
||||
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
|
||||
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
|
||||
<input type="range" id="ge-brush-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha for a feathered fade at the perimeter." />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-brush-stroke-group" data-stroke-tool="eraser">
|
||||
<div class="ge-section-title">Eraser</div>
|
||||
|
||||
@@ -78,6 +78,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
const UI_CHROME_SELECTOR = '.ge-topbar, .ge-toolbar, .ge-controls, .ge-right-panel, '
|
||||
+ '.ge-dock, .ge-options-bar, .ge-canvas-toolbar, button, input, select, textarea';
|
||||
const isUiChrome = e => !!e.target?.closest?.(UI_CHROME_SELECTOR);
|
||||
const canStartOutside = () => ['marquee', 'lasso', 'crop'].includes(state.tool);
|
||||
// Only in-progress selection/crop drags are affected by the chrome
|
||||
// guard. A committed cropRect/selection is a resting state — clicking
|
||||
// its Apply button must not be treated as "strayed onto chrome".
|
||||
@@ -143,15 +144,16 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
};
|
||||
state.mainCanvas.addEventListener('pointerup', 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
|
||||
// space around the image. Other tools stay canvas-only.
|
||||
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
|
||||
// Ignore presses on editor chrome inside the area (the crop Apply panel,
|
||||
// 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);
|
||||
});
|
||||
state.mainCanvas.addEventListener('mouseenter', (e) => {
|
||||
@@ -305,6 +307,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
let panning = false;
|
||||
let pid = null;
|
||||
let transformPointerId = null;
|
||||
let outsideDrawPointerId = null;
|
||||
let startX = 0, startY = 0;
|
||||
let startPanX = 0, startPanY = 0;
|
||||
const getOffset = () => ({ x: state.panX || 0, y: state.panY || 0 });
|
||||
@@ -315,10 +318,23 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
};
|
||||
canvasArea.addEventListener('pointerdown', (e) => {
|
||||
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.
|
||||
// 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;
|
||||
const onImage = e.target === state.mainCanvas || e.target === state.transformOverlay;
|
||||
if (onImage && !directPan) return;
|
||||
@@ -357,6 +373,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
e.preventDefault();
|
||||
});
|
||||
canvasArea.addEventListener('pointermove', (e) => {
|
||||
if (outsideDrawPointerId !== null && e.pointerId === outsideDrawPointerId) {
|
||||
suppressMouseUntil = Date.now() + 500;
|
||||
if (!isUiChrome(e)) continueDraw(e);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (transformPointerId !== null && e.pointerId === transformPointerId) {
|
||||
continueDraw(e);
|
||||
return;
|
||||
@@ -370,6 +392,14 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
applyOffset(next.x, next.y);
|
||||
});
|
||||
const endPan = (e) => {
|
||||
if (outsideDrawPointerId !== null && (!e || e.pointerId === outsideDrawPointerId)) {
|
||||
const capturedId = outsideDrawPointerId;
|
||||
outsideDrawPointerId = null;
|
||||
suppressMouseUntil = Date.now() + 500;
|
||||
if (e?.type === 'pointercancel') cancelDraw?.();
|
||||
else if (e) endDraw(e);
|
||||
try { canvasArea.releasePointerCapture(capturedId); } catch {}
|
||||
}
|
||||
if (transformPointerId !== null && (!e || e.pointerId === transformPointerId)) {
|
||||
const capturedId = transformPointerId;
|
||||
transformPointerId = null;
|
||||
|
||||
@@ -21,6 +21,7 @@ export const EDITOR_AGENT_VERSION = '1.0';
|
||||
export const DEFAULT_AGENT_ENDPOINT = '/api/editor/agent';
|
||||
|
||||
import { formatCropRect, isGenericIntent } from './nl-edit.js';
|
||||
import { waitForImagePreferences } from '../settings/imagePreferences.js';
|
||||
|
||||
export function createEditorAgent({
|
||||
control,
|
||||
@@ -28,6 +29,7 @@ export function createEditorAgent({
|
||||
fetchImpl = null,
|
||||
getSession = null,
|
||||
getModel = null,
|
||||
getGenerationModel = null,
|
||||
getAgentModel = null,
|
||||
onStep = null,
|
||||
} = {}) {
|
||||
@@ -145,6 +147,7 @@ export function createEditorAgent({
|
||||
activeStep = report;
|
||||
executed = [];
|
||||
running = true;
|
||||
await waitForImagePreferences();
|
||||
|
||||
// Honest before-image (captured before any client pre-step) for the
|
||||
// imperceptible-edit check, then the history mark scoping the undo group.
|
||||
@@ -194,6 +197,8 @@ export function createEditorAgent({
|
||||
signal: signal || undefined,
|
||||
body: JSON.stringify({
|
||||
prompt, session, editor_session: session, state, screenshot,
|
||||
_use_image_settings: true,
|
||||
generation_model: getGenerationModel ? getGenerationModel() : '',
|
||||
model: (modelInfo && modelInfo.model) || '',
|
||||
endpoint: (modelInfo && modelInfo.endpoint) || '',
|
||||
model_label: (modelInfo && modelInfo.label) || '',
|
||||
|
||||
@@ -132,7 +132,7 @@ function renderEffects(source, effects, masks) {
|
||||
const contribution = maskedContribution(next, masks[index]);
|
||||
const blended = copyCanvas(current);
|
||||
const blendCtx = blended.getContext('2d');
|
||||
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over';
|
||||
if (effect.type === 'drop-shadow' && !effect.params.inside) blendCtx.globalCompositeOperation = 'destination-over';
|
||||
blendCtx.globalAlpha = effect.opacity;
|
||||
blendCtx.drawImage(contribution, 0, 0);
|
||||
blendCtx.globalAlpha = 1;
|
||||
|
||||
Vendored
+2
-1
@@ -81,6 +81,7 @@ export function normalizeEffect(effect) {
|
||||
params.blendMode = typeof params.blendMode === 'string' ? params.blendMode : defaults.blendMode;
|
||||
}
|
||||
if (type === 'drop-shadow') {
|
||||
params.inside = params.inside === true || params.inside === 1;
|
||||
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
|
||||
for (const key of ['opacity', 'blur', 'x', 'y']) params[key] = Number(params[key]) || 0;
|
||||
params.opacity = Math.max(0, Math.min(1, params.opacity));
|
||||
@@ -291,7 +292,7 @@ export function renderEffects(source, effects = [], shouldContinue = () => true)
|
||||
: next;
|
||||
const blended = copyCanvas(current);
|
||||
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.drawImage(contribution, 0, 0);
|
||||
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,
|
||||
composite, saveState, undo, redo,
|
||||
toggleShortcuts, confirmTransform, cancelTransform, startTransform, nudgeTransform,
|
||||
resizeCustomPrompt, addEmptyLayer, brushSizeSync,
|
||||
resizeCustomPrompt, addEmptyLayer, brushSizeSync, openLayerAdjustment,
|
||||
invertSelection,
|
||||
wandDeleteSelection, wandCopyToNewLayer, copyPixelsToClipboard,
|
||||
lassoDeleteSelection, lassoCopyToLayer,
|
||||
@@ -181,6 +181,12 @@ export function wireKeyboardShortcuts(deps) {
|
||||
return;
|
||||
}
|
||||
// Save shortcuts — match the hints shown in the Save dropdown.
|
||||
if (e.shiftKey && !e.altKey && e.code === 'KeyU') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openLayerAdjustment?.('hue-saturation');
|
||||
return;
|
||||
}
|
||||
if ((e.key === 's' || e.key === 'S') && !e.altKey) {
|
||||
e.preventDefault();
|
||||
document.getElementById(e.shiftKey ? 'ge-export-gallery' : 'ge-save')?.click();
|
||||
@@ -221,11 +227,12 @@ export function wireKeyboardShortcuts(deps) {
|
||||
addEmptyLayer();
|
||||
return;
|
||||
}
|
||||
// Ctrl+Alt+L — Levels adjustment. The Image menu owns the row and its
|
||||
// dialog, so click it instead of duplicating the adjustment here.
|
||||
// Ctrl+Alt+L edits the current layer, leaving Levels controls open.
|
||||
if (e.altKey && e.code === 'KeyL') {
|
||||
const row = document.querySelector('[data-image-action="adj-levels"]');
|
||||
if (row) { e.preventDefault(); e.stopPropagation(); row.click(); return; }
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openLayerAdjustment?.('levels');
|
||||
return;
|
||||
}
|
||||
// Ctrl/Cmd+J duplicates the active layer through the layer panel's
|
||||
// existing implementation, which preserves masks and effects.
|
||||
|
||||
@@ -2233,6 +2233,7 @@ export function createLayerPanelRenderer(deps) {
|
||||
if (dragSortModule) {
|
||||
dragSortModule.enable('ge-layers-list', '.ge-layer-item', {
|
||||
instanceKey: 'ge-layers',
|
||||
excludeSelector: '.ge-adj-sub-item',
|
||||
threshold: 4,
|
||||
ignoreSelector: 'button, input, select, textarea, a, [contenteditable="true"], .ge-adj-sub-item',
|
||||
onReorder: (orderedItems) => {
|
||||
@@ -2258,6 +2259,9 @@ export function createLayerPanelRenderer(deps) {
|
||||
state.layers = newLayers;
|
||||
normalizeLayerClipping(state);
|
||||
composite();
|
||||
// Effects, adjustments and masks are sibling rows, not children
|
||||
// of the dragged row. Rebuild them under their reordered owner.
|
||||
render();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ const size = () => number('size', 'Size', 8, 0, 100);
|
||||
const angle = () => number('angle', 'Angle', 120, -180, 180, 'deg');
|
||||
export const LAYER_STYLES = {
|
||||
'bevel-emboss': { label: 'Bevel & Emboss', controls: [size(), angle(), number('depth', 'Depth', 100, 0, 300, '%'), color('highlight', 'Highlight', '#ffffff'), color('color', 'Shadow', '#000000'), opacity()] },
|
||||
'inner-shadow': { label: 'Inner Shadow', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 6, 0, 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()] },
|
||||
satin: { label: 'Satin', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 12, 0, 100)] },
|
||||
'gradient-overlay': { label: 'Gradient Overlay', controls: [color('color', 'Start', '#e06c75'), color('endColor', 'End', '#ffffff'), opacity(), angle(), number('scale', 'Scale', 100, 10, 300, '%')] },
|
||||
@@ -32,6 +32,13 @@ function surface(width, height) {
|
||||
}
|
||||
|
||||
export function renderDropShadow(source, params) {
|
||||
if (params.inside) {
|
||||
return renderLayerStyle(source, 'inner-shadow', {
|
||||
color: params.color, opacity: params.opacity * 100, size: params.blur / 2,
|
||||
distance: Math.hypot(params.x, params.y),
|
||||
angle: Math.atan2(-params.y, params.x) * 180 / Math.PI,
|
||||
});
|
||||
}
|
||||
// Tint alpha first so RGB content cannot leak into the shadow. Draw only
|
||||
// this silhouette; compositing the source again thickens translucent edges.
|
||||
const silhouette = surface(source.width, source.height);
|
||||
|
||||
@@ -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 _lastTransparentColor = null;
|
||||
function transparentColor(color) {
|
||||
if (color === _lastColor && _lastTransparentColor) return _lastTransparentColor;
|
||||
_lastColor = color;
|
||||
function transparentColor(color, alpha = 0) {
|
||||
const key = `${color}|${alpha}`;
|
||||
if (key === _lastColor && _lastTransparentColor) return _lastTransparentColor;
|
||||
_lastColor = key;
|
||||
_lastTransparentColor = colorWithAlpha(color, alpha);
|
||||
return _lastTransparentColor;
|
||||
}
|
||||
|
||||
function colorWithAlpha(color, alpha) {
|
||||
if (color.startsWith('#')) {
|
||||
const raw = color.slice(1);
|
||||
const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw;
|
||||
const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16));
|
||||
_lastTransparentColor = `rgba(${rgb.join(',')},0)`;
|
||||
return _lastTransparentColor;
|
||||
return `rgba(${rgb.join(',')},${alpha})`;
|
||||
}
|
||||
const match = color.match(/rgba?\(([^)]+)\)/);
|
||||
_lastTransparentColor = match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
|
||||
return _lastTransparentColor;
|
||||
if (!match) return `rgba(0,0,0,${alpha})`;
|
||||
const components = match[1].split(',');
|
||||
const originalAlpha = components.length > 3 ? Number(components[3]) : 1;
|
||||
return `rgba(${components.slice(0, 3).join(',')},${alpha * originalAlpha})`;
|
||||
}
|
||||
|
||||
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;
|
||||
} else {
|
||||
const gradient = ctx.createRadialGradient(x, y, radius * hard, x, y, radius);
|
||||
if (softness > 1) {
|
||||
// Above the old maximum, remove the firm core with a progressively
|
||||
// gentler alpha profile while keeping the selected brush diameter.
|
||||
ctx.fillStyle = color;
|
||||
const normalized = ctx.fillStyle;
|
||||
for (let step = 0; step <= 16; step++) {
|
||||
const t = step / 16;
|
||||
gradient.addColorStop(t, transparentColor(normalized, (1 - t) ** softness));
|
||||
}
|
||||
} else {
|
||||
gradient.addColorStop(0, color);
|
||||
gradient.addColorStop(1, transparentColor(color));
|
||||
}
|
||||
ctx.fillStyle = gradient;
|
||||
}
|
||||
ctx.beginPath();
|
||||
@@ -274,6 +292,10 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
|
||||
if (!PAINT_TOOLS.has(tool)) return;
|
||||
const layer = activeLayer();
|
||||
if (!layer) return;
|
||||
// Pixel writes invalidate adjusted previews even when adjustment settings
|
||||
// themselves have not changed (otherwise retouch strokes appear late).
|
||||
layer._adjFinalKey = null;
|
||||
layer._adjCacheKey = null;
|
||||
if (tool === 'clone') return cloneStamp(sample, layer);
|
||||
if (tool === 'heal') {
|
||||
// A healing stroke without a picked source uses the convenient local
|
||||
@@ -294,7 +316,7 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
|
||||
let operation = 'source-over';
|
||||
let color = state.color;
|
||||
let alpha = opacity * flow;
|
||||
let softness = Math.max(0, Math.min(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)'; }
|
||||
else if (tool === 'inpaint') {
|
||||
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
|
||||
// just tween the inner solid radius to communicate the falloff).
|
||||
if (softPrev) {
|
||||
const innerStop = Math.max(0, 60 - state[fields.softness] * 0.55);
|
||||
softPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`;
|
||||
const amount = state[fields.softness];
|
||||
const innerStop = Math.max(0, 60 - amount * 0.55);
|
||||
softPrev.style.background = amount > 300
|
||||
? `radial-gradient(circle, var(--fg), color-mix(in srgb, var(--fg) ${Math.round(100 * 0.5 ** (amount / 300))}%, transparent) 50%, transparent 100%)`
|
||||
: `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,23 @@ export function wireSubmenuToggles(menu) {
|
||||
menu.classList.remove('ge-flyout-open');
|
||||
}
|
||||
});
|
||||
function positionPanel(parent) {
|
||||
const panel = parent.nextElementSibling;
|
||||
if (!panel?.classList.contains('ge-submenu')) return;
|
||||
const row = parent.getBoundingClientRect();
|
||||
const origin = (panel.offsetParent || menu).getBoundingClientRect();
|
||||
const top = Math.max(8, Math.min(row.top, window.innerHeight - panel.offsetHeight - 8));
|
||||
const left = row.right + panel.offsetWidth > window.innerWidth - 8
|
||||
? Math.max(8, row.left - panel.offsetWidth) : row.right - 2;
|
||||
panel.style.top = `${top - origin.top + (panel.offsetParent?.scrollTop || 0)}px`;
|
||||
panel.style.left = `${left - origin.left + (panel.offsetParent?.scrollLeft || 0)}px`;
|
||||
}
|
||||
for (const eventName of ['pointerover', 'focusin']) {
|
||||
menu.addEventListener(eventName, (event) => {
|
||||
const parent = event.target.closest('.ge-menu-parent');
|
||||
if (parent) positionPanel(parent);
|
||||
});
|
||||
}
|
||||
menu.addEventListener('click', (e) => {
|
||||
const parent = e.target.closest('.ge-menu-parent');
|
||||
if (!parent) return;
|
||||
@@ -34,10 +51,7 @@ export function wireSubmenuToggles(menu) {
|
||||
parent.setAttribute('aria-expanded', String(willOpen));
|
||||
if (willOpen) {
|
||||
// Align the flyout with the clicked row rather than the menu top.
|
||||
const panel = parent.nextElementSibling;
|
||||
if (panel && panel.classList.contains('ge-submenu')) {
|
||||
panel.style.top = parent.offsetTop + 'px';
|
||||
}
|
||||
positionPanel(parent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { topPortalZ } from '../toolWindowZOrder.js';
|
||||
import { isLayerPixelLocked, isLayerTransparencyLocked } from './layer-groups.js';
|
||||
|
||||
const TOPBAR_MENU_IDS = ['ge-file-menu', 'ge-edit-menu', 'ge-view-menu', 'ge-image-menu', 'ge-layer-menu', 'ge-type-menu', 'ge-selection-menu', 'ge-filter-menu', 'ge-window-menu', 'ge-help-menu', 'ge-resize-menu', 'ge-save-menu'];
|
||||
@@ -101,6 +102,7 @@ export function wireTopbar(deps) {
|
||||
};
|
||||
const positionSaveMenu = () => {
|
||||
const r = saveBtn.getBoundingClientRect();
|
||||
saveMenu.style.zIndex = String(topPortalZ());
|
||||
saveMenu.style.top = `${r.bottom + 2}px`;
|
||||
saveMenu.style.right = `${Math.max(8, window.innerWidth - r.right)}px`;
|
||||
saveMenu.style.left = 'auto';
|
||||
|
||||
@@ -2650,7 +2650,14 @@ export function openEmailLibrary(opts = {}) {
|
||||
}
|
||||
|
||||
// Wire events
|
||||
document.getElementById('email-lib-close').addEventListener('click', closeEmailLibrary);
|
||||
document.getElementById('email-lib-close').addEventListener('click', () => {
|
||||
const modal = document.getElementById('email-lib-modal');
|
||||
if (modal?.classList.contains('email-reading') || modal?.querySelector('.doclib-card-expanded')) {
|
||||
_exitEmailReaderModeForList();
|
||||
return;
|
||||
}
|
||||
closeEmailLibrary();
|
||||
});
|
||||
document.getElementById('email-settings-header-back')?.addEventListener('click', _hideEmailSettingsPage);
|
||||
|
||||
// Clicking the modal header (anywhere except buttons/inputs) collapses
|
||||
|
||||
@@ -73,16 +73,24 @@ function _isDangerousSrcset(value) {
|
||||
|
||||
// Escape + linkify URLs and email addresses. Returns innerHTML-safe markup.
|
||||
export function _escLinkify(text) {
|
||||
const escaped = _esc(text);
|
||||
// URLs: http(s)://... or www....
|
||||
const urlRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?)\]])/g;
|
||||
const mailRe = /\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g;
|
||||
return escaped
|
||||
.replace(urlRe, (m) => {
|
||||
const href = m.startsWith('www.') ? `https://${m}` : m;
|
||||
return `<a href="${_attrEsc(href)}" target="_blank" rel="noopener noreferrer">${m}</a>`;
|
||||
})
|
||||
.replace(mailRe, (m) => `<a href="${_attrEsc(`mailto:${m}`)}">${m}</a>`);
|
||||
const raw = String(text || '');
|
||||
// Match raw text once so generated anchors are never linkified again.
|
||||
const tokenRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?)\]])|\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g;
|
||||
const parts = [];
|
||||
let offset = 0;
|
||||
for (const match of raw.matchAll(tokenRe)) {
|
||||
parts.push(_esc(raw.slice(offset, match.index)));
|
||||
const token = match[0];
|
||||
const isUrl = !!match[1];
|
||||
const href = isUrl
|
||||
? (token.startsWith('www.') ? `https://${token}` : token)
|
||||
: `mailto:${token}`;
|
||||
const attrs = isUrl ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
parts.push(`<a href="${_attrEsc(_esc(href))}"${attrs}>${_esc(token)}</a>`);
|
||||
offset = match.index + token.length;
|
||||
}
|
||||
parts.push(_esc(raw.slice(offset)));
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
// Pull display name out of "Name <email@x>"; fallback to local-part of
|
||||
|
||||
@@ -1321,7 +1321,12 @@ function _renderEditorLanding() {
|
||||
// openEditor() now returns a Promise — it's async because the size
|
||||
// prompt is a styled modal. Await it before checking whether the
|
||||
// editor actually opened (the user may have cancelled).
|
||||
try {
|
||||
await openEditor(null, null, null, 'New canvas');
|
||||
} catch (error) {
|
||||
// openEditor reports the initialization error and tears down its shell.
|
||||
console.error('[gallery] New canvas failed', error);
|
||||
}
|
||||
if (!document.querySelector('#gallery-editor-container .gallery-editor')) {
|
||||
_renderEditorLanding();
|
||||
}
|
||||
|
||||
+151
-59
@@ -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 { wireClipboardAndDrop } from './editor/clipboard-and-drop.js';
|
||||
import { wireAIModelSelectors, resolveInpaintModel, resolveQuickEditModel, resolveQuickEditAgentModel } from './editor/ai-models.js';
|
||||
import { createPreflight } from './editor/preflight.js';
|
||||
import { createAiSettings } from './editor/ai-settings.js';
|
||||
import { waitForImagePreferences } from './settings/imagePreferences.js';
|
||||
import { createImageGenerationAction } from './editor/image-generation.js';
|
||||
import { finishQuickEditJob } from './editor/quick-edit-job.js';
|
||||
import { imageResultGeometry } from './editor/image-result-geometry.js';
|
||||
import { lookRecipeSteps } from './editor/look-recipes.js';
|
||||
import { wireInpaintButtons } from './editor/ai-inpaint.js?v=20260708match1';
|
||||
import { wireAIToolsMisc } from './editor/ai-tools-misc.js';
|
||||
@@ -356,7 +359,6 @@ function _computeTransformSnap(frame, center) {
|
||||
const _INPAINT_DEFAULT_BRUSH = 100;
|
||||
let _samAbortController = null;
|
||||
let _precisionGuides = null;
|
||||
let _preflight = null;
|
||||
let _aiSettings = null;
|
||||
let _selectionAnimationFrame = null;
|
||||
let _selectionAnimationLast = 0;
|
||||
@@ -394,6 +396,12 @@ if (!window.__galleryEditEscHardGuardInstalled) {
|
||||
}
|
||||
if (e.target?.closest?.('#styled-confirm-overlay')) return;
|
||||
if (e.key === 'Escape') {
|
||||
const aiSettings = document.querySelector('.ge-ai-settings:not([hidden])');
|
||||
if (aiSettings) {
|
||||
e.preventDefault(); e.stopImmediatePropagation();
|
||||
aiSettings.querySelector('[data-ai-settings-close]')?.click();
|
||||
return;
|
||||
}
|
||||
// Floating dialogs/popups that live outside the editor modal must close on
|
||||
// Escape reliably (their document-capture handlers can miss when focus is
|
||||
// outside them). Close the top-most one and stop here.
|
||||
@@ -838,6 +846,7 @@ function _setCanvasAiBusy(busy) {
|
||||
}
|
||||
|
||||
let _aiProgressTimer = null;
|
||||
let _aiProgressEpoch = 0;
|
||||
function _setAiProgress(frac, label) {
|
||||
const wrap = document.getElementById('ge-ai-command-progress');
|
||||
if (!wrap) return;
|
||||
@@ -857,12 +866,17 @@ function _startAiProgress(requestId) {
|
||||
const endpointId = arguments[1] || '';
|
||||
_stopAiProgress();
|
||||
if (!requestId) return;
|
||||
const epoch = _aiProgressEpoch;
|
||||
let pending = false;
|
||||
const tick = async () => {
|
||||
if (pending || epoch !== _aiProgressEpoch) return;
|
||||
pending = true;
|
||||
try {
|
||||
const epQuery = endpointId ? `&endpoint_id=${encodeURIComponent(endpointId)}` : '';
|
||||
const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}` + epQuery, { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
const p = await r.json();
|
||||
if (epoch !== _aiProgressEpoch) return;
|
||||
const st = p.stage || '';
|
||||
if (st === 'done' || st === 'error') { _setAiProgress(1, st === 'done' ? 'Done' : 'Failed'); return; }
|
||||
const frac = typeof p.progress === 'number' ? p.progress : 0;
|
||||
@@ -871,12 +885,13 @@ function _startAiProgress(requestId) {
|
||||
const verb = st === 'editing' ? 'Generating' : (st ? st[0].toUpperCase() + st.slice(1) : 'Working');
|
||||
_setAiProgress(frac, `${verb}…${stepTxt}${eta}`);
|
||||
_setAiCommandStatus(`${verb}…${stepTxt}${eta}`, 'running');
|
||||
} catch (_) {}
|
||||
} catch (_) {} finally { pending = false; }
|
||||
};
|
||||
_aiProgressTimer = setInterval(tick, 1000);
|
||||
tick();
|
||||
}
|
||||
function _stopAiProgress() {
|
||||
_aiProgressEpoch++;
|
||||
if (_aiProgressTimer) { clearInterval(_aiProgressTimer); _aiProgressTimer = null; }
|
||||
_setAiProgress(null);
|
||||
}
|
||||
@@ -1074,6 +1089,7 @@ const _QUICK_EDIT_TOOL_SUGGESTIONS = Object.entries(_TOOLS).map(([id, t]) => ({
|
||||
kind: 'Tool',
|
||||
icon: t.icon || '',
|
||||
toolId: id,
|
||||
runPrompt: id === 'rembg' ? 'remove background' : null,
|
||||
aliases: [id],
|
||||
}));
|
||||
const _QUICK_EDIT_CATALOG = [..._QUICK_EDIT_TOOL_SUGGESTIONS, ..._AI_COMMAND_SUGGESTIONS];
|
||||
@@ -1281,8 +1297,13 @@ function _wireAiCommandBox() {
|
||||
if (item.toolId) {
|
||||
_clickToolButton(item.toolId);
|
||||
_setAiCommandStatus(`${item.label} tool`, 'done');
|
||||
if (item.runPrompt) {
|
||||
input.value = item.runPrompt;
|
||||
syncClearButton();
|
||||
}
|
||||
hideSuggestions();
|
||||
input.focus();
|
||||
if (run && item.runPrompt) form.requestSubmit();
|
||||
return true;
|
||||
}
|
||||
input.value = item.insert;
|
||||
@@ -1471,6 +1492,24 @@ function _wireAiCommandBox() {
|
||||
// model (window.geEditorAI) just like the server would route it.
|
||||
const _executeQuickEditLocal = async (prompt) => {
|
||||
const p = prompt.toLowerCase();
|
||||
// Background-removal intent (shared with the routing guard so the regex
|
||||
// can never drift between the two call sites).
|
||||
if (_looksBackgroundRemoval(p)) {
|
||||
await _runQuickControlOp('ai.removeBackground', {});
|
||||
// Chain a tone step when the prompt also asks for one
|
||||
// ("remove the background and make it brighter", "...and warm it up").
|
||||
if (/\b(bright(?:er|en)?|lighten|exposure|well[-\s]*lit)\b/.test(p)) {
|
||||
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: 16, contrast: 4 } });
|
||||
} else if (/\b(dark(?:er|en)?|dimmer)\b/.test(p)) {
|
||||
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: -14, contrast: 4 } });
|
||||
} else if (/\b(warm(?:er)?)\b/.test(p)) {
|
||||
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: 18 } });
|
||||
} else if (/\b(cool(?:er)?)\b/.test(p)) {
|
||||
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: -18 } });
|
||||
}
|
||||
_setAiCommandStatus('Background removal finished.', 'done');
|
||||
return;
|
||||
}
|
||||
// A targeted recolour ("make only the red car blue", "recolour the rock to
|
||||
// teal") needs the model to repaint a specific object — never fake it with a
|
||||
// global colour tweak from the deterministic fast path.
|
||||
@@ -1933,24 +1972,6 @@ function _wireAiCommandBox() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Background-removal intent (shared with the routing guard so the regex
|
||||
// can never drift between the two call sites).
|
||||
if (_looksBackgroundRemoval(p)) {
|
||||
await _runQuickControlOp('ai.removeBackground', {});
|
||||
// Chain a tone step when the prompt also asks for one
|
||||
// ("remove the background and make it brighter", "...and warm it up").
|
||||
if (/\b(bright(?:er|en)?|lighten|exposure|well[-\s]*lit)\b/.test(p)) {
|
||||
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: 16, contrast: 4 } });
|
||||
} else if (/\b(dark(?:er|en)?|dimmer)\b/.test(p)) {
|
||||
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: -14, contrast: 4 } });
|
||||
} else if (/\b(warm(?:er)?)\b/.test(p)) {
|
||||
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: 18 } });
|
||||
} else if (/\b(cool(?:er)?)\b/.test(p)) {
|
||||
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: -18 } });
|
||||
}
|
||||
_setAiCommandStatus('Background removal finished.', 'done');
|
||||
return;
|
||||
}
|
||||
if (!/\b(text|title|caption|font|heading|subtitle)\b/.test(p)
|
||||
&& /\b(upscale|higher\s*res|increase\s*resolution|bigger|2x|4x)\b/.test(p)) {
|
||||
// Use the real upscale op (Real-ESRGAN, basic-upscale fallback). The
|
||||
@@ -2814,8 +2835,8 @@ function _wireAiCommandBox() {
|
||||
if (_looksBackgroundRemoval(prompt)) {
|
||||
_setAiCommandStatus('Removing the background…', 'running');
|
||||
try {
|
||||
const done = await _executeQuickEditLocal(prompt);
|
||||
if (done !== false) return { ok: true };
|
||||
await _executeQuickEditLocal(prompt);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (signal && signal.aborted) { _setAiCommandStatus('', ''); return { ok: false, cancelled: true }; }
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
@@ -2841,6 +2862,7 @@ function _wireAiCommandBox() {
|
||||
_setAiCommandStatus('Generated an image.', 'done');
|
||||
return { ok: true, image: res };
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return { ok: false, cancelled: true };
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
_setAiCommandStatus(_isGenOfflineMsg(msg) ? msg : `Could not generate an image: ${msg}`, 'error');
|
||||
return { ok: false, error: msg };
|
||||
@@ -3083,19 +3105,22 @@ function _wireAiCommandBox() {
|
||||
job.controller = controller;
|
||||
renderJobStack();
|
||||
_quickEditActiveJob = job;
|
||||
let result;
|
||||
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) {
|
||||
if (!job.cancelled) {
|
||||
job.status = 'error';
|
||||
job.detail = err?.message || 'AI edit failed';
|
||||
}
|
||||
} finally {
|
||||
_stopAiProgress();
|
||||
finishQuickEditJob(job, result);
|
||||
_setAiCommandStatus(job.cancelled ? '' : job.detail, job.cancelled ? '' : job.status);
|
||||
if (_quickEditActiveJob === job) _quickEditActiveJob = null;
|
||||
job.onChange = null;
|
||||
job.controller = null;
|
||||
if (job.cancelled) continue;
|
||||
if (job.status === 'running') job.status = 'done';
|
||||
renderJobStack();
|
||||
setTimeout(() => dismissJob(job), job.status === 'error' ? 3200 : 1600);
|
||||
}
|
||||
@@ -5304,9 +5329,14 @@ function _restoreState(snap) {
|
||||
// inside _buildEditor, so we touch the DOM here).
|
||||
const sizeLabel = document.getElementById('ge-canvas-size');
|
||||
if (sizeLabel) sizeLabel.textContent = `${state.imgWidth}×${state.imgHeight} px`;
|
||||
state.rembgLiveLayer = null;
|
||||
state.rembgLiveSnap = null;
|
||||
}
|
||||
|
||||
function undo() {
|
||||
// End sampling before restoring pixels; pointer-up must not replay the
|
||||
// unfinished stroke into the restored layer without a new history entry.
|
||||
if (state.drawing) _strokeTool.tryEnd();
|
||||
if (state.undoStack.length === 0) return;
|
||||
const target = state.undoStack.pop();
|
||||
const cur = _snapshotState();
|
||||
@@ -5759,7 +5789,20 @@ function _updateBrushCursor(e) {
|
||||
state.cursorEl.style.height = diameterY + 'px';
|
||||
}
|
||||
state.cursorEl.classList.toggle('ge-brush-cursor--pixel', pixelBrush);
|
||||
state.cursorEl.style.transform = `translate3d(${clientX - diameterX / 2}px, ${clientY - diameterY / 2}px, 0)`;
|
||||
// Updating an inline style on every move invalidates document-wide :has()
|
||||
// selectors. A paused animation keeps the same transform in the rendering
|
||||
// pipeline without mutating the cursor's style attribute on every frame.
|
||||
const cursorTransform = `translate3d(${clientX - diameterX / 2}px, ${clientY - diameterY / 2}px, 0)`;
|
||||
if (!state.cursorEl._positionAnimation || state.cursorEl._positionAnimation.playState === 'idle') {
|
||||
state.cursorEl._positionAnimation = state.cursorEl.animate(
|
||||
[{ transform: cursorTransform }, { transform: cursorTransform }],
|
||||
{ duration: 1, fill: 'both' },
|
||||
);
|
||||
state.cursorEl._positionAnimation.pause();
|
||||
state.cursorEl._positionAnimation.currentTime = 0;
|
||||
} else {
|
||||
state.cursorEl._positionAnimation.effect.setKeyframes([{ transform: cursorTransform }, { transform: cursorTransform }]);
|
||||
}
|
||||
if (state.cursorEl.style.display !== 'block') {
|
||||
state.cursorEl.style.display = 'block';
|
||||
}
|
||||
@@ -6522,6 +6565,11 @@ const _nudgeTransform = _transformSession.nudgeTransform;
|
||||
const _confirmTransform = _transformSession.confirmTransform;
|
||||
const _cancelTransform = _transformSession.cancelTransform;
|
||||
window.__galleryEditorHandleEscape = () => {
|
||||
const aiSettings = document.querySelector('.ge-ai-settings:not([hidden])');
|
||||
if (aiSettings) {
|
||||
aiSettings.querySelector('[data-ai-settings-close]')?.click();
|
||||
return true;
|
||||
}
|
||||
const sizePrompt = document.getElementById('ge-canvas-size-overlay');
|
||||
if (sizePrompt && getComputedStyle(sizePrompt).display !== 'none') {
|
||||
sizePrompt._cancelCanvasSize?.();
|
||||
@@ -8051,6 +8099,7 @@ async function _canMutateLayerPixels(layer, action = 'editing pixels') {
|
||||
return false;
|
||||
}
|
||||
if (!['placed', 'text', 'shape'].includes(layer.kind)) return true;
|
||||
if (action === 'cutting pixels') return _rasterizeLayerNow(layer);
|
||||
return _confirmRasterizeLayer(layer);
|
||||
}
|
||||
|
||||
@@ -8249,10 +8298,13 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
|
||||
// standard slim red-thumb styling instead of the bare browser
|
||||
// default. Value chip on the same line as the label.
|
||||
const isColor = p.type === 'color';
|
||||
const isSelect = p.type === 'select';
|
||||
rows += `
|
||||
<div class="ge-filter-row ge-eraser-row">
|
||||
<label>${p.label}${isColor ? '' : ` <span class="ge-filter-row-value" data-val-for="${p.key}">${p.value}${p.suffix || ''}</span>`}</label>
|
||||
${isColor
|
||||
<label>${p.label}${isColor || isSelect ? '' : ` <span class="ge-filter-row-value" data-val-for="${p.key}">${p.value}${p.suffix || ''}</span>`}</label>
|
||||
${isSelect
|
||||
? `<select data-key="${p.key}" class="ge-ctl-select" aria-label="${p.label}">${p.options.map(option => `<option value="${option.value}"${String(option.value) === String(p.value) ? ' selected' : ''}>${option.label}</option>`).join('')}</select>`
|
||||
: isColor
|
||||
? `<input type="color" data-key="${p.key}" value="${p.value}" aria-label="${p.label}" />`
|
||||
: `<input type="range" data-key="${p.key}" min="${p.min}" max="${p.max}" step="${p.step || 1}" value="${p.value}" />`}
|
||||
</div>
|
||||
@@ -8273,7 +8325,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
|
||||
for (const p of params) values[p.key] = p.value;
|
||||
// Initial preview render.
|
||||
try { onPreview(values); } catch {}
|
||||
overlay.querySelectorAll('input[data-key]').forEach(inp => {
|
||||
overlay.querySelectorAll('input[data-key], select[data-key]').forEach(inp => {
|
||||
inp.addEventListener('input', (e) => {
|
||||
if (!isCurrent()) { cleanup(null); return; }
|
||||
const k = e.target.dataset.key;
|
||||
@@ -8304,7 +8356,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
|
||||
e.preventDefault();
|
||||
cleanup(e.target?.dataset.action === 'cancel' ? null : values);
|
||||
} else if (e.key === 'Tab') {
|
||||
const fields = [...overlay.querySelectorAll('input, button')];
|
||||
const fields = [...overlay.querySelectorAll('input, select, button')];
|
||||
const index = fields.indexOf(document.activeElement);
|
||||
e.preventDefault();
|
||||
fields[(index + (e.shiftKey ? -1 : 1) + fields.length) % fields.length]?.focus();
|
||||
@@ -8316,7 +8368,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
|
||||
if (!overlay.isConnected || !isCurrent()) cleanup(null);
|
||||
});
|
||||
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="cancel"]').addEventListener('click', () => cleanup(null));
|
||||
// Click outside the modal (on the dim backdrop) = cancel.
|
||||
@@ -8482,6 +8534,7 @@ function _shadowOffsets(values = {}) {
|
||||
return {
|
||||
x: Math.round(Math.cos(radians) * distance),
|
||||
y: Math.round(-Math.sin(radians) * distance),
|
||||
inside: Number(values.inside) === 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8543,6 +8596,7 @@ async function _addRetainedEffect(type, presetName = null) {
|
||||
{ key: 'color', label: 'Color', type: 'color', value: '#000000' },
|
||||
{ key: 'opacity', label: 'Opacity', min: 0, max: 100, step: 1, value: 45, suffix: '%' },
|
||||
{ key: 'blur', label: 'Blur', min: 0, max: 100, step: 1, value: 12, suffix: 'px' },
|
||||
{ key: 'inside', label: 'Position', type: 'select', value: 0, options: [{ value: 0, label: 'Outside' }, { value: 1, label: 'Inside' }] },
|
||||
{ key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).distance, suffix: 'px' },
|
||||
{ key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).angle, suffix: '\u00b0' },
|
||||
];
|
||||
@@ -8756,6 +8810,7 @@ async function _editRetainedEffect(layer, effect) {
|
||||
{ key: 'color', label: 'Color', type: 'color', value: p.color || '#000000' },
|
||||
{ key: 'opacity', label: 'Opacity', min: 0, max: 100, step: 1, value: Math.round((Number(p.opacity) || 0) * 100), suffix: '%' },
|
||||
{ key: 'blur', label: 'Blur', min: 0, max: 100, step: 1, value: Number(p.blur) || 0, suffix: 'px' },
|
||||
{ key: 'inside', label: 'Position', type: 'select', value: p.inside ? 1 : 0, options: [{ value: 0, label: 'Outside' }, { value: 1, label: 'Inside' }] },
|
||||
{ key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance(p).distance, suffix: 'px' },
|
||||
{ key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance(p).angle, suffix: '\u00b0' },
|
||||
];
|
||||
@@ -9980,9 +10035,8 @@ function _buildEditor(container) {
|
||||
if (colorRow) colorRow.style.display = ['eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'liquify'].includes(toolId) ? 'none' : '';
|
||||
const colorLabel = colorRow?.querySelector('label');
|
||||
if (colorLabel) colorLabel.textContent = 'Color';
|
||||
// Per-tool stroke modifiers all live in the shared brush "More" menu so
|
||||
// every brush-family tool has the same compact strip as the Brush. Only
|
||||
// the group for the active tool is shown; the rest stay hidden.
|
||||
// Secondary modifiers stay in More; brush softness remains in the main
|
||||
// strip. Only the active tool's secondary group is shown.
|
||||
const strokeTool = (toolId === 'brush' || toolId === 'pixel' || toolId === 'dodge' || toolId === 'burn') ? 'brush'
|
||||
: toolId === 'eraser' ? 'eraser'
|
||||
: (toolId === 'clone' || toolId === 'heal') ? 'clone'
|
||||
@@ -9992,6 +10046,8 @@ function _buildEditor(container) {
|
||||
row.style.display = row.dataset.strokeTool === strokeTool ? '' : 'none';
|
||||
});
|
||||
const dodgeBurnRow = document.getElementById('ge-dodgeburn-intensity-row');
|
||||
const brushSoftnessRow = document.getElementById('ge-brush-softness-row');
|
||||
if (brushSoftnessRow) brushSoftnessRow.style.display = ['brush', 'dodge', 'burn'].includes(toolId) ? '' : 'none';
|
||||
// Intensity sits in the strip next to Size, so it must show for Dodge and
|
||||
// Burn only (and never for the other brush-family tools).
|
||||
if (dodgeBurnRow) dodgeBurnRow.style.display = (toolId === 'dodge' || toolId === 'burn') ? '' : 'none';
|
||||
@@ -10444,7 +10500,6 @@ function _buildEditor(container) {
|
||||
canvasArea.appendChild(state.transformOverlay);
|
||||
canvasArea.appendChild(_buildQuickMaskBar());
|
||||
canvasArea.appendChild(_buildAiCommandBox());
|
||||
_preflight = createPreflight({ canvasArea });
|
||||
_aiSettings = createAiSettings({ container });
|
||||
try { window.geEditorAiSettings = _aiSettings; } catch (_) {}
|
||||
// On phones the rulers eat scarce canvas width; default them off.
|
||||
@@ -10637,19 +10692,10 @@ function _buildEditor(container) {
|
||||
const initialStatusTool = document.getElementById('ge-status-tool');
|
||||
if (initialStatusTool) initialStatusTool.textContent = _TOOLS[state.tool]?.label || state.tool || '';
|
||||
}
|
||||
// Preflight checklist: greet the user on open (unless dismissed) and
|
||||
// expose a footer button to re-open it.
|
||||
// Keep model configuration in the AI settings dialog.
|
||||
try {
|
||||
const footer = container.querySelector('.ge-editor-footer');
|
||||
if (footer) {
|
||||
const setupBtn = document.createElement('button');
|
||||
setupBtn.type = 'button';
|
||||
setupBtn.id = 'ge-setup-btn';
|
||||
setupBtn.className = 'ge-setup-btn';
|
||||
setupBtn.title = 'Editor setup checklist';
|
||||
setupBtn.textContent = 'Setup';
|
||||
setupBtn.addEventListener('click', () => _preflight?.open());
|
||||
footer.appendChild(setupBtn);
|
||||
const aiBtn = document.createElement('button');
|
||||
aiBtn.type = 'button';
|
||||
aiBtn.id = 'ge-ai-settings-btn';
|
||||
@@ -10659,7 +10705,6 @@ function _buildEditor(container) {
|
||||
aiBtn.addEventListener('click', () => _aiSettings?.open());
|
||||
footer.appendChild(aiBtn);
|
||||
}
|
||||
_preflight?.maybeOpen();
|
||||
} catch (_) {}
|
||||
try { _initControlKit(container); } catch (_) {}
|
||||
let _lastLayoutDesktop = null;
|
||||
@@ -11779,6 +11824,14 @@ function _buildEditor(container) {
|
||||
// Keyboard shortcuts — full implementation in
|
||||
// editor/keyboard-shortcuts.js.
|
||||
wireKeyboardShortcuts({
|
||||
openLayerAdjustment: (type) => {
|
||||
const layer = activeLayer();
|
||||
if (!layer || !layer.canvas || layer.kind === 'adjustment') {
|
||||
uiModule?.showToast('Select an image layer first');
|
||||
return;
|
||||
}
|
||||
_openAdjPopup(layer, type, document.getElementById('ge-image-menu-btn'));
|
||||
},
|
||||
fillSelection: _doFillSelection,
|
||||
copyPixelsToClipboard: _copyPixelsToClipboard,
|
||||
toolbar, toolKeyMap: _toolKeyMap,
|
||||
@@ -12143,7 +12196,9 @@ function _buildEditor(container) {
|
||||
out.getContext('2d').drawImage(layer.canvas, off.x, off.y);
|
||||
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 image = flat.toDataURL('image/png').split(',')[1];
|
||||
const requestId = 'qe-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
||||
@@ -12154,7 +12209,7 @@ function _buildEditor(container) {
|
||||
} else {
|
||||
_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
|
||||
// model swaps, 502/504 gateways, dropped connections). Retry those a couple
|
||||
// of times with backoff so a hiccup doesn't silently leave the canvas
|
||||
@@ -12212,7 +12267,7 @@ function _buildEditor(container) {
|
||||
c.height = state.imgHeight;
|
||||
}
|
||||
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
|
||||
return { canvas: c, dataUrl: c.toDataURL('image/png') };
|
||||
return { canvas: c, dataUrl: c.toDataURL('image/png'), fullFrame: data.full_frame === true };
|
||||
} finally {
|
||||
if (_timer) clearTimeout(_timer);
|
||||
if (_aiEditAbort === _ctl) _aiEditAbort = null;
|
||||
@@ -12221,12 +12276,17 @@ function _buildEditor(container) {
|
||||
};
|
||||
const _placeResultLayer = (canvas, name, maskCanvas) => {
|
||||
_saveState(`AI: ${name}`);
|
||||
const layer = createLayer(name, state.imgWidth, state.imgHeight);
|
||||
layer.ctx.drawImage(canvas, 0, 0);
|
||||
const geometry = imageResultGeometry(canvas.width, canvas.height, state.imgWidth, state.imgHeight);
|
||||
const layer = createLayer(name, geometry.width, geometry.height);
|
||||
const offset = { x: geometry.offsetX, y: geometry.offsetY };
|
||||
state.layerOffsets.set(layer.id, offset);
|
||||
layer.ctx.imageSmoothingEnabled = true;
|
||||
layer.ctx.imageSmoothingQuality = 'high';
|
||||
layer.ctx.drawImage(canvas, geometry.drawX, geometry.drawY, geometry.drawWidth, geometry.drawHeight);
|
||||
if (maskCanvas) {
|
||||
layer.ctx.save();
|
||||
layer.ctx.globalCompositeOperation = 'destination-in';
|
||||
layer.ctx.drawImage(maskCanvas, 0, 0);
|
||||
layer.ctx.drawImage(maskCanvas, -offset.x, -offset.y);
|
||||
layer.ctx.restore();
|
||||
}
|
||||
state.layers.push(layer);
|
||||
@@ -12236,13 +12296,24 @@ function _buildEditor(container) {
|
||||
_renderLayerPanel();
|
||||
return layer.id;
|
||||
};
|
||||
const _generateImage = createImageGenerationAction({
|
||||
formatError: _generationErrorMessage,
|
||||
getDocument: () => ({
|
||||
key: state.editorOpen ? `${state.editorSessionToken}:${state.documentId}` : null,
|
||||
width: state.imgWidth, height: state.imgHeight, mask: state.wandMask,
|
||||
}),
|
||||
getSignal: () => _quickEditActiveJob?.controller?.signal,
|
||||
onProgress: seconds => _setAiCommandStatus(`Generating an image… ${seconds}s`, 'running'),
|
||||
placeResult: _placeResultLayer,
|
||||
});
|
||||
const _aiInpaintSelection = async (prompt) => {
|
||||
if (!state.wandMask) throw new Error('make a selection first');
|
||||
const layer = activeLayer() || _activeParentLayer();
|
||||
if (!layer) throw new Error('no layer');
|
||||
const mask = state.wandMask.toDataURL('image/png').split(',')[1];
|
||||
const { canvas } = await _aiEditCanvas('/api/image/inpaint', { mask, prompt: prompt || '', steps: 4 });
|
||||
return _placeResultLayer(canvas, 'Generative fill', state.wandMask);
|
||||
const selection = state.wandMask;
|
||||
const { canvas, fullFrame } = await _aiEditCanvas('/api/image/inpaint', { mask, prompt: prompt || '', steps: 4 });
|
||||
return _placeResultLayer(canvas, 'Generative fill', fullFrame ? null : selection);
|
||||
};
|
||||
// Replace the background/scene while keeping the subject: the server derives
|
||||
// the subject mask, repaints the inverse (new background), harmonises colour
|
||||
@@ -12535,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.setOpacity': { description: 'Set a layer opacity (0-1).', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState(`Change opacity of ${l.name}`); l.opacity = Math.max(0, Math.min(1, Number(p.opacity))); composite(); _renderLayerPanel(); return l.id; } },
|
||||
'layers.setBlend': { description: 'Set a layer blend mode.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState(`Change blend mode of ${l.name}`); l.blendMode = p.blendMode || p.mode || 'source-over'; composite(); _renderLayerPanel(); return l.id; } },
|
||||
'layers.placeImage': { description: 'Place an image (data URL) as a NEW layer, masked to the current selection when one exists; never overwrites the original. Instruction mode (mode:"instruction" or differenceMask:true) keeps untouched areas pixel-identical via a difference map. {dataUrl,name,masked,blend,opacity,x,y,w,h,mode,differenceMask,diffThreshold,diffGrow,diffFeather}.', run: (p) => new Promise((resolve, reject) => { if (!p || !p.dataUrl) { reject(new Error('dataUrl required')); return; } const img = new Image(); img.onload = () => { try { _saveState(`Place ${p.name || 'image'}`); const dw = state.imgWidth, dh = state.imgHeight; const useDiff = (p.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.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; } },
|
||||
@@ -12551,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.harmonize': { description: 'Harmonize the layer/selection lighting with the rest of the image; result is a NEW layer. {strength}.', run: async (p) => { const { canvas } = await _aiEditCanvas('/api/image/harmonize', { strength: Number(p && p.strength) || 0.4 }); return _placeResultLayer(canvas, 'Harmonized', state.wandMask || null); } },
|
||||
'ai.generativeUpscale': { description: 'Upscale the image 2x/4x; result is a NEW layer. Falls back to a high-quality canvas upscale labelled "basic upscale" when no Real-ESRGAN model is served. {scale}.', run: async (p) => { const scale = Number(p && p.scale) === 4 ? 4 : 2; try { const { canvas } = await _aiEditCanvas('/api/image/upscale-local', { scale }); return _placeResultLayer(canvas, `Upscaled ${scale}x`, null); } catch (e) { const src = flatten(); const c = document.createElement('canvas'); c.width = Math.max(1, Math.round(src.width * scale)); c.height = Math.max(1, Math.round(src.height * scale)); const cx = c.getContext('2d'); cx.imageSmoothingEnabled = true; cx.imageSmoothingQuality = 'high'; cx.drawImage(src, 0, 0, c.width, c.height); _saveState('Upscale (basic)'); _resizeCanvasDocument(state, c.width, c.height, { anchorX: 0, anchorY: 0 }); const layerId = _placeResultLayer(c, `Upscaled ${scale}x (basic upscale)`, null); return { layerId, scale, method: 'basic', reason: String(e && e.message || e) }; } } },
|
||||
'ai.generateImage': { description: 'Generate an image 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 }; } },
|
||||
'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; } },
|
||||
@@ -12796,6 +12867,10 @@ function _buildEditor(container) {
|
||||
endpoint: window.geEditorAgentEndpoint || '/api/editor/agent',
|
||||
getSession: () => agentBridgeSession,
|
||||
getModel: () => _getSelectedQuickEditModel(),
|
||||
getGenerationModel: () => {
|
||||
const value = document.getElementById('ge-ai-model')?.value || '';
|
||||
return value.includes('::') ? value.slice(value.indexOf('::') + 2) : '';
|
||||
},
|
||||
getAgentModel: () => _getSelectedQuickEditAgentModel(),
|
||||
});
|
||||
} catch (e) { console.error('[ge-editor] agent init failed', e); }
|
||||
@@ -13895,6 +13970,12 @@ export function openEditor(imageUrl, imageId, presetSize, displayName, draftId)
|
||||
return;
|
||||
}
|
||||
_finishBlank(size.w, size.h);
|
||||
}).catch(error => {
|
||||
console.error('[openEditor] New project failed:', error);
|
||||
closeEditor({ force: true });
|
||||
window.dispatchEvent(new CustomEvent('gallery-editor-setup-cancelled'));
|
||||
uiModule.showError(`Could not create project: ${error?.message || 'Canvas initialization failed'}`);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14049,8 +14130,6 @@ export function closeEditor(options = {}) {
|
||||
_selectionNudgeTimer = null;
|
||||
_selectionNudgeHistorySaved = false;
|
||||
_precisionGuides = null;
|
||||
try { _preflight?.destroy(); } catch {}
|
||||
_preflight = null;
|
||||
try { _aiSettings?.destroy(); } catch {}
|
||||
_aiSettings = null;
|
||||
try { delete window.geEditorAiSettings; } catch (_) { window.geEditorAiSettings = null; }
|
||||
@@ -14061,7 +14140,20 @@ export function closeEditor(options = {}) {
|
||||
const h = state.editorDocClickHandlers.pop();
|
||||
try { document.removeEventListener('click', h); } catch {}
|
||||
}
|
||||
if (state.cursorEl) { state.cursorEl.remove(); state.cursorEl = null; }
|
||||
if (_cursorRaf) { cancelAnimationFrame(_cursorRaf); _cursorRaf = null; }
|
||||
_pendingCursorEvent = null;
|
||||
_lastCursorRect = null;
|
||||
state._lastCursorDiameterX = null;
|
||||
state._lastCursorDiameterY = null;
|
||||
state._lastCursorBorderColor = null;
|
||||
if (state.cursorEl) {
|
||||
if (state.cursorEl._positionAnimation) {
|
||||
try { state.cursorEl._positionAnimation.cancel(); } catch {}
|
||||
state.cursorEl._positionAnimation = null;
|
||||
}
|
||||
state.cursorEl.remove();
|
||||
state.cursorEl = null;
|
||||
}
|
||||
try { document.getElementById('ge-cursor-layer')?.remove(); } catch {}
|
||||
// Tear down all floating popups + the dock so closing the editor
|
||||
// doesn't leave stale chips/panels behind on top of the gallery.
|
||||
|
||||
+4
-2
@@ -801,7 +801,8 @@ async function _sendRoundRobin(msg, box) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: [{
|
||||
role: 'user',
|
||||
content: `[${m._groupName || m.display}]: ${response}`
|
||||
content: `[${m._groupName || m.display}]: ${response}`,
|
||||
metadata: { group_peer: true, group_model: m._groupName || m.display, model: m.mid }
|
||||
}]}),
|
||||
});
|
||||
} catch (e) { console.warn('[group] sync failed:', e); }
|
||||
@@ -828,7 +829,8 @@ async function _syncAllResponses(holders) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: [{
|
||||
role: 'user',
|
||||
content: `[${model._groupName || model.display}]: ${response}`
|
||||
content: `[${model._groupName || model.display}]: ${response}`,
|
||||
metadata: { group_peer: true, group_model: model._groupName || model.display, model: model.mid }
|
||||
}]}),
|
||||
});
|
||||
} catch (e) { /* silent */ }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user