mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-01 10:42:23 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c8b196ba4 | ||
|
|
cee26bc522 | ||
|
|
0d25d49d15 | ||
|
|
bafb7949f8 | ||
|
|
3dd081c8d4 | ||
|
|
e33607fd4f | ||
|
|
1888839e06 | ||
|
|
1cb597ce12 | ||
|
|
54b734032c | ||
|
|
aea1317ade | ||
|
|
e79c81fbb8 |
@@ -76,7 +76,7 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
# Byte-compile sources — catches syntax errors without installing deps.
|
# Byte-compile sources — catches syntax errors without installing deps.
|
||||||
@@ -91,7 +91,7 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: "22.23.4"
|
node-version: "22.23.4"
|
||||||
- run: npm ci --ignore-scripts --no-audit --no-fund
|
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||||
@@ -151,7 +151,7 @@ jobs:
|
|||||||
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
@@ -168,7 +168,7 @@ jobs:
|
|||||||
|
|
||||||
# uv resolves the same versions as pip but installs ~5x faster, and its
|
# uv resolves the same versions as pip but installs ~5x faster, and its
|
||||||
# cache keeps each shard from re-downloading the whole requirement set.
|
# cache keeps each shard from re-downloading the whole requirement set.
|
||||||
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
with:
|
with:
|
||||||
version: "0.13.5"
|
version: "0.13.5"
|
||||||
@@ -181,7 +181,7 @@ jobs:
|
|||||||
uv pip install --system -r requirements.txt
|
uv pip install --system -r requirements.txt
|
||||||
uv pip install --system "playwright==1.62.0"
|
uv pip install --system "playwright==1.62.0"
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
with:
|
with:
|
||||||
node-version: "22.23.4"
|
node-version: "22.23.4"
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
build-mode: none
|
build-mode: none
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||||
with:
|
with:
|
||||||
category: "/language:${{ matrix.language }}"
|
category: "/language:${{ matrix.language }}"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Lint Dockerfile
|
- name: Lint Dockerfile
|
||||||
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
|
uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
|
||||||
with:
|
with:
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
# DL3008: pinning apt package versions is impractical on a -slim base
|
# DL3008: pinning apt package versions is impractical on a -slim base
|
||||||
|
|||||||
@@ -61,14 +61,14 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Buildx
|
- name: Set up Buildx
|
||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||||
with:
|
with:
|
||||||
driver: docker
|
driver: docker
|
||||||
|
|
||||||
# Build without pushing so a broken Dockerfile is caught here, and the
|
# Build without pushing so a broken Dockerfile is caught here, and the
|
||||||
# exact image we ship is what gets scanned.
|
# exact image we ship is what gets scanned.
|
||||||
- name: Build image
|
- name: Build image
|
||||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
@@ -107,12 +107,12 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Buildx
|
- name: Set up Buildx
|
||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||||
with:
|
with:
|
||||||
driver: docker
|
driver: docker
|
||||||
|
|
||||||
- name: Build image
|
- name: Build image
|
||||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
@@ -133,7 +133,7 @@ jobs:
|
|||||||
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
|
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
|
||||||
|
|
||||||
- name: Upload Trivy results
|
- name: Upload Trivy results
|
||||||
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||||
with:
|
with:
|
||||||
sarif_file: trivy-results.sarif
|
sarif_file: trivy-results.sarif
|
||||||
category: trivy-image
|
category: trivy-image
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
|
|||||||
@@ -47,4 +47,4 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Deploy to GitHub Pages
|
- name: Deploy to GitHub Pages
|
||||||
id: deployment
|
id: deployment
|
||||||
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
|
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||||
|
|||||||
@@ -55,16 +55,16 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||||
- name: Set up Buildx
|
- name: Set up Buildx
|
||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push by digest
|
- name: Build and push by digest
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
pull: true
|
pull: true
|
||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
digest="${{ steps.build.outputs.digest }}"
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: digest-${{ matrix.arch }}
|
name: digest-${{ matrix.arch }}
|
||||||
path: /tmp/digests/*
|
path: /tmp/digests/*
|
||||||
@@ -107,15 +107,15 @@ jobs:
|
|||||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||||
echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digest-*
|
pattern: digest-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
- name: Set up Buildx
|
- name: Set up Buildx
|
||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -62,9 +62,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
SEARXNG_TEST_IMAGE: ${{ steps.image.outputs.image }}
|
SEARXNG_TEST_IMAGE: ${{ steps.image.outputs.image }}
|
||||||
run: docker pull "$SEARXNG_TEST_IMAGE"
|
run: docker pull "$SEARXNG_TEST_IMAGE"
|
||||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||||
- name: Check unmodified upstream trust and actual entrypoint offline
|
- name: Check unmodified upstream trust and actual entrypoint offline
|
||||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/searxng/Dockerfile
|
file: docker/searxng/Dockerfile
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
|
|||||||
@@ -82,15 +82,13 @@ services:
|
|||||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||||
# Host workspace translation is opt-in. Keep the public compose file
|
# Host workspace translation is opt-in. Keep the public compose file
|
||||||
# user-neutral; configure these in a local .env or use the host-workspace
|
# user-neutral; configure these in a local .env or use the host-workspace
|
||||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||||
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
|
||||||
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
|
||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
@@ -167,7 +165,7 @@ services:
|
|||||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- searxng-data:/etc/searxng
|
- searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||||
|
|||||||
@@ -81,15 +81,13 @@ services:
|
|||||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||||
# Host workspace translation is opt-in. Keep the public compose file
|
# Host workspace translation is opt-in. Keep the public compose file
|
||||||
# user-neutral; configure these in a local .env or use the host-workspace
|
# user-neutral; configure these in a local .env or use the host-workspace
|
||||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||||
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
|
||||||
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
|
||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
@@ -170,7 +168,7 @@ services:
|
|||||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- searxng-data:/etc/searxng
|
- searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||||
|
|||||||
+2
-4
@@ -70,15 +70,13 @@ services:
|
|||||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||||
# Host workspace translation is opt-in. Keep the public compose file
|
# Host workspace translation is opt-in. Keep the public compose file
|
||||||
# user-neutral; configure these in a local .env or use the host-workspace
|
# user-neutral; configure these in a local .env or use the host-workspace
|
||||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||||
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
|
||||||
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
|
||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
@@ -148,7 +146,7 @@ services:
|
|||||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- searxng-data:/etc/searxng
|
- searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||||
|
|||||||
@@ -31,43 +31,21 @@ for spec in $SPECS; do
|
|||||||
name="${spec%%==*}"
|
name="${spec%%==*}"
|
||||||
ver="${spec##*==}"
|
ver="${spec##*==}"
|
||||||
# pip download builds metadata (and trips the same bug), so fetch the raw
|
# pip download builds metadata (and trips the same bug), so fetch the raw
|
||||||
# sdist URL from the PyPI JSON API instead. Use curl with retries to
|
# sdist URL from the PyPI JSON API instead.
|
||||||
# survive transient TLS / network failures that urllib cannot recover from.
|
url="$(python - "$name" "$ver" <<'PY'
|
||||||
metadata="${name}-${ver}.json"
|
import json, sys, urllib.request
|
||||||
curl --fail --silent --show-error --location \
|
name, ver = sys.argv[1], sys.argv[2]
|
||||||
--retry 8 \
|
data = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
|
||||||
--retry-delay 3 \
|
for f in data["urls"]:
|
||||||
--retry-max-time 120 \
|
if f["packagetype"] == "sdist":
|
||||||
--retry-all-errors \
|
print(f["url"]); break
|
||||||
"https://pypi.org/pypi/${name}/${ver}/json" \
|
|
||||||
-o "$metadata"
|
|
||||||
|
|
||||||
url="$(python - "$metadata" <<'PY'
|
|
||||||
import json, sys
|
|
||||||
|
|
||||||
with open(sys.argv[1], encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
for item in data["urls"]:
|
|
||||||
if item["packagetype"] == "sdist":
|
|
||||||
print(item["url"])
|
|
||||||
break
|
|
||||||
else:
|
else:
|
||||||
raise SystemExit("no sdist found")
|
sys.exit(f"no sdist found for {name}=={ver}")
|
||||||
PY
|
PY
|
||||||
)"
|
)"
|
||||||
rm -f "$metadata"
|
|
||||||
|
|
||||||
echo ">> fetching ${name} ${ver}: ${url}"
|
echo ">> fetching ${name} ${ver}: ${url}"
|
||||||
curl --fail --silent --show-error --location \
|
curl -fsSL "$url" -o "${name}.tar.gz"
|
||||||
--retry 8 \
|
|
||||||
--retry-delay 3 \
|
|
||||||
--retry-max-time 120 \
|
|
||||||
--retry-all-errors \
|
|
||||||
"$url" \
|
|
||||||
-o "${name}.tar.gz"
|
|
||||||
tar xzf "${name}.tar.gz"
|
tar xzf "${name}.tar.gz"
|
||||||
rm -f "${name}.tar.gz"
|
|
||||||
done
|
done
|
||||||
|
|
||||||
echo ">> patching get_version()"
|
echo ">> patching get_version()"
|
||||||
|
|||||||
+1
-17
@@ -1901,7 +1901,7 @@ def _ts_or_zero(value) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
|
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
|
||||||
"""Recover sess.model from the endpoint's visible cached and pinned models.
|
"""Re-populate sess.model from the matching endpoint's cached models.
|
||||||
|
|
||||||
Covers the window between endpoint setup and the first chat send: the
|
Covers the window between endpoint setup and the first chat send: the
|
||||||
picker showed a model in the dropdown but the session record never got
|
picker showed a model in the dropdown but the session record never got
|
||||||
@@ -1965,9 +1965,6 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
|
|||||||
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
|
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
|
||||||
except Exception:
|
except Exception:
|
||||||
visible = cached
|
visible = cached
|
||||||
if not is_chatgpt_subscription:
|
|
||||||
from src.endpoint_resolver import _endpoint_enabled_models
|
|
||||||
visible = _endpoint_enabled_models(ep)
|
|
||||||
if current_model and current_model in {str(item).strip() for item in visible}:
|
if current_model and current_model in {str(item).strip() for item in visible}:
|
||||||
return False
|
return False
|
||||||
if is_chatgpt_subscription:
|
if is_chatgpt_subscription:
|
||||||
@@ -3926,19 +3923,6 @@ def setup_chat_routes(
|
|||||||
if ctx.preprocessed.attachment_meta:
|
if ctx.preprocessed.attachment_meta:
|
||||||
yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n"
|
yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n"
|
||||||
|
|
||||||
# Give the page the saved user message's id. Only the reply's id
|
|
||||||
# was sent back, so a message sent in the open page had none and
|
|
||||||
# a later regenerate/edit of it could not cut the chat by id.
|
|
||||||
try:
|
|
||||||
_last_user_msg = next((m for m in reversed(getattr(sess, "history", None) or [])
|
|
||||||
if getattr(m, "role", None) == "user"), None)
|
|
||||||
_user_db_id = ((getattr(_last_user_msg, "metadata", None) or {}).get("_db_id")
|
|
||||||
if _last_user_msg is not None else None)
|
|
||||||
if _user_db_id:
|
|
||||||
yield f"data: {json.dumps({'type': 'user_message_saved', 'id': _user_db_id})}\n\n"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Announce any docs auto-created during preprocess (e.g. fillable
|
# Announce any docs auto-created during preprocess (e.g. fillable
|
||||||
# PDF → editable markdown) so the editor pane switches to them
|
# PDF → editable markdown) so the editor pane switches to them
|
||||||
# before the model starts streaming.
|
# before the model starts streaming.
|
||||||
|
|||||||
@@ -1491,20 +1491,6 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
|||||||
"Port is already in use.",
|
"Port is already in use.",
|
||||||
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
|
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
|
||||||
),
|
),
|
||||||
(
|
|
||||||
# PyPI torch/vLLM wheels now target CUDA 13, which needs driver 580+.
|
|
||||||
# torch: "The NVIDIA driver on your system is too old (found version 12080)";
|
|
||||||
# vLLM kernels: "CUDA driver version is insufficient for CUDA runtime version".
|
|
||||||
r"NVIDIA driver on your system is too old|CUDA driver version is insufficient",
|
|
||||||
"The installed PyTorch/vLLM build needs a newer NVIDIA driver than this server has.",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"label": "install builds for the driver's CUDA version, e.g. vLLM's +cu129 wheel from its GitHub release with uv pip install … --torch-backend cu129",
|
|
||||||
"op": "manual",
|
|
||||||
},
|
|
||||||
{"label": "or update the NVIDIA driver (CUDA 13 builds need driver 580 or newer)", "op": "manual"},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
|
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
|
||||||
"No GPUs are visible to the serve process.",
|
"No GPUs are visible to the serve process.",
|
||||||
|
|||||||
+172
-5
@@ -468,6 +468,175 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
task["payload"].pop("hf_token", None)
|
task["payload"].pop("hf_token", None)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
|
def _diagnose_serve_output(text: str) -> dict | None:
|
||||||
|
"""Server-side mirror of the Cookbook UI's common serve diagnoses.
|
||||||
|
|
||||||
|
The browser uses cookbook-diagnosis.js for clickable fixes. This gives
|
||||||
|
the agent/tool path the same structured signal so it can retry with an
|
||||||
|
adjusted command instead of guessing from raw tmux output.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
tail = text[-6000:]
|
||||||
|
patterns = [
|
||||||
|
(
|
||||||
|
r"No available memory for the cache blocks|Available KV cache memory:.*-",
|
||||||
|
"No GPU memory left for KV cache after loading model.",
|
||||||
|
[
|
||||||
|
{"label": "retry with GPU memory utilization 0.95", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.95"},
|
||||||
|
{"label": "retry with context 2048", "op": "replace", "flag": "--max-model-len", "value": "2048"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory|warming up sampler|max_num_seqs.*gpu_memory_utilization",
|
||||||
|
"GPU ran out of memory during startup or warmup.",
|
||||||
|
[
|
||||||
|
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
|
||||||
|
{"label": "retry with GPU memory utilization 0.80", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.80"},
|
||||||
|
{"label": "retry with --enforce-eager", "op": "append", "arg": "--enforce-eager"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"not divisib|must be divisible|attention heads.*divisible",
|
||||||
|
"Tensor parallel size is incompatible with the model.",
|
||||||
|
[
|
||||||
|
{"label": "retry with tensor parallel size 1", "op": "replace", "flag": "--tensor-parallel-size", "value": "1"},
|
||||||
|
{"label": "retry with tensor parallel size 2", "op": "replace", "flag": "--tensor-parallel-size", "value": "2"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context",
|
||||||
|
"Context length is too large for available GPU memory.",
|
||||||
|
[
|
||||||
|
{"label": "retry with context 8192", "op": "replace", "flag": "--max-model-len", "value": "8192"},
|
||||||
|
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"enable-auto-tool-choice requires --tool-call-parser",
|
||||||
|
"Auto tool choice requires an explicit tool call parser.",
|
||||||
|
[{"label": "retry with Hermes tool parser", "op": "append", "arg": "--tool-call-parser hermes"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"Please pass.*trust.remote.code=True|contains custom code which must be executed to correctly load|does not recognize this architecture|model type.*but Transformers does not",
|
||||||
|
"Model requires custom code or newer model support.",
|
||||||
|
[{"label": "retry with --trust-remote-code", "op": "append", "arg": "--trust-remote-code"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"Either a revision or a version must be specified|transformers\.integrations\.hub_kernels|kernels/layer",
|
||||||
|
"vLLM/Transformers kernel package mismatch.",
|
||||||
|
[{"label": "update vLLM, Transformers, and kernels on this server", "op": "dependency", "package": "vllm transformers kernels"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"Address already in use|bind.*address.*in use",
|
||||||
|
"Port is already in use.",
|
||||||
|
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
|
||||||
|
"No GPUs are visible to the serve process.",
|
||||||
|
[{"label": "clear Cookbook GPU selection or choose available GPUs", "op": "settings", "field": "gpus", "value": ""}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"Failed to infer device type|NVML Shared Library Not Found|No module named 'amdsmi'|platform is not available",
|
||||||
|
"vLLM could not find a supported GPU (CUDA or ROCm). "
|
||||||
|
"This machine may have integrated or unsupported graphics only.",
|
||||||
|
[
|
||||||
|
{"label": "switch to llama.cpp (CPU/Metal, works without a discrete GPU)", "op": "manual"},
|
||||||
|
{"label": "switch to Ollama (CPU/Metal, works without a discrete GPU)", "op": "manual"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"vllm.*command not found|No module named vllm|ERROR: vLLM is not installed",
|
||||||
|
"vLLM is not installed or not in PATH on this server.",
|
||||||
|
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|"
|
||||||
|
r"(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)[\s\S]*sgl_kernel|"
|
||||||
|
r"Could not load any common_ops library|"
|
||||||
|
r"Please ensure sgl_kernel is properly installed",
|
||||||
|
"SGLang native kernel/runtime is missing or mismatched on this server.",
|
||||||
|
[
|
||||||
|
{"label": "repair sglang-kernel in this Python environment", "op": "dependency", "package": "sglang-kernel"},
|
||||||
|
{"label": "if libnvrtc is still missing, install the matching CUDA/NVRTC runtime on this host", "op": "manual"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"sglang.*command not found|No module named sglang|SGLang is not installed",
|
||||||
|
"SGLang is not installed or not in PATH on this server.",
|
||||||
|
[{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"No module named ['\"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed",
|
||||||
|
"MLX LM is not installed on this server.",
|
||||||
|
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed",
|
||||||
|
"This image model uses a custom Diffusers pipeline that the launch environment does not know yet.",
|
||||||
|
[{"label": "update Diffusers image dependencies", "op": "dependency", "package": "diffusers transformers accelerate"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
|
||||||
|
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
|
||||||
|
[
|
||||||
|
{"label": "relaunch from the cached local Hugging Face snapshot path on this Mac", "op": "manual"},
|
||||||
|
{"label": "Odysseus now rewrites MLX repo-id launches to a cached snapshot when one exists", "op": "manual"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
# System build deps come BEFORE the generic llama.cpp catch-all
|
||||||
|
# so cmake / build-essential / git missing → a specific OS-package
|
||||||
|
# remediation instead of "install llama-cpp-python[server]" (which
|
||||||
|
# itself fails to compile when cmake is absent).
|
||||||
|
(
|
||||||
|
r"cmake: command not found|cmake.*not found.*[Cc]ould not",
|
||||||
|
"cmake is required to build llama.cpp from source but isn't installed on this server.",
|
||||||
|
[{"label": "install build deps for llama.cpp (apt: cmake build-essential git / pacman: cmake base-devel git / dnf: cmake gcc-c++ make git / brew: cmake git)", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"^(make|g\+\+|gcc): command not found|Could not find C\+\+ compiler",
|
||||||
|
"A C/C++ compiler (build-essential) is required to build llama.cpp from source.",
|
||||||
|
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"^git: command not found",
|
||||||
|
"git is required to clone the llama.cpp source tree.",
|
||||||
|
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'",
|
||||||
|
"llama.cpp / llama-cpp-python dependencies are missing.",
|
||||||
|
[{"label": "install llama.cpp dependencies or llama-cpp-python[server]", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"No GGUF found on this host|no \.gguf file|No GGUF file found",
|
||||||
|
"No GGUF file found for this model on this host. The llama.cpp backend needs a .gguf file.",
|
||||||
|
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"No module named 'torch'|No module named torch|No module named 'torchvision'|No module named torchvision|No module named 'diffusers'|No module named diffusers|No module named 'scipy'|No module named scipy|install scipy if you want to use beta sigmas|requires the Torchvision library",
|
||||||
|
"Diffusion serving requires PyTorch, Torchvision, Diffusers, Accelerate, and SciPy.",
|
||||||
|
[{"label": "install Diffusers image deps in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch] torchvision accelerate scipy python-multipart"}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
|
||||||
|
"Model access is gated or unauthorized.",
|
||||||
|
[{"label": "set HF token and request model access on HuggingFace", "op": "manual"}],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for pattern, message, suggestions in patterns:
|
||||||
|
if re.search(pattern, tail, re.I):
|
||||||
|
return {"message": message, "suggestions": suggestions}
|
||||||
|
if re.search(r"Traceback \(most recent call last\)", tail, re.I) and not re.search(
|
||||||
|
r"Application startup complete|GET /v1/|Uvicorn running on", tail, re.I
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"message": "Python traceback detected during serve startup.",
|
||||||
|
"suggestions": [{"label": "inspect traceback and retry with adjusted backend/settings", "op": "manual"}],
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
def _state_for_client(state):
|
def _state_for_client(state):
|
||||||
"""Return cookbook state without raw secrets for browser clients."""
|
"""Return cookbook state without raw secrets for browser clients."""
|
||||||
_strip_task_secrets(state)
|
_strip_task_secrets(state)
|
||||||
@@ -3761,7 +3930,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
return {"ok": False, "files": [], "error": f"HF API HTTP {resp.status_code}"}
|
return {"ok": False, "files": [], "error": f"HF API HTTP {resp.status_code}"}
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("HF GGUF file scan failed for %s", repo_id)
|
logger.exception("HF GGUF file scan failed for %s", repo)
|
||||||
return {"ok": False, "files": [], "error": "HF API request failed"}
|
return {"ok": False, "files": [], "error": "HF API request failed"}
|
||||||
files = [
|
files = [
|
||||||
str(s.get("rfilename") or "")
|
str(s.get("rfilename") or "")
|
||||||
@@ -4277,9 +4446,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
# fills the visible tail. Without this, output_tail ends up
|
# fills the visible tail. Without this, output_tail ends up
|
||||||
# as just "Locale: C / Ubuntu_Odysseus ❯" and the agent
|
# as just "Locale: C / Ubuntu_Odysseus ❯" and the agent
|
||||||
# can't diagnose the actual error.
|
# can't diagnose the actual error.
|
||||||
# -J joins lines tmux wrapped at the pane width, so an error
|
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-S", "-500")]
|
||||||
# split mid-word still matches the serve diagnosis patterns.
|
|
||||||
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-J", "-S", "-500")]
|
|
||||||
elif IS_WINDOWS:
|
elif IS_WINDOWS:
|
||||||
# LOCAL Windows task: launched as a detached process (no tmux).
|
# LOCAL Windows task: launched as a detached process (no tmux).
|
||||||
# Liveness comes from the <session>.pid file, output from the
|
# Liveness comes from the <session>.pid file, output from the
|
||||||
@@ -4288,7 +4455,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
capture_cmd = None
|
capture_cmd = None
|
||||||
else:
|
else:
|
||||||
check_cmd = ["tmux", "has-session", "-t", session_id]
|
check_cmd = ["tmux", "has-session", "-t", session_id]
|
||||||
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-J", "-S", "-500"]
|
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-500"]
|
||||||
|
|
||||||
local_win_task = (not remote) and IS_WINDOWS
|
local_win_task = (not remote) and IS_WINDOWS
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from core.database import SessionLocal, Document, DocumentVersion
|
|||||||
from core.database import Session as DbSession
|
from core.database import Session as DbSession
|
||||||
from src.auth_helpers import get_current_user, _auth_disabled
|
from src.auth_helpers import get_current_user, _auth_disabled
|
||||||
from src.constants import MAIL_ATTACHMENTS_DIR
|
from src.constants import MAIL_ATTACHMENTS_DIR
|
||||||
from src.session_search import _escape_like
|
|
||||||
from src.upload_handler import reserve_upload_references
|
from src.upload_handler import reserve_upload_references
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -572,10 +571,9 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
# match docs containing both words regardless of position/order.
|
# match docs containing both words regardless of position/order.
|
||||||
if search:
|
if search:
|
||||||
for tok in search.split():
|
for tok in search.split():
|
||||||
term = f"%{_escape_like(tok)}%"
|
term = f"%{tok}%"
|
||||||
q = q.filter(
|
q = q.filter(
|
||||||
Document.title.ilike(term, escape="\\")
|
Document.title.ilike(term) | Document.current_content.ilike(term)
|
||||||
| Document.current_content.ilike(term, escape="\\")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Language filter. "pdf" is a display language derived from the
|
# Language filter. "pdf" is a display language derived from the
|
||||||
@@ -814,13 +812,10 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
ext = _ext.get(doc.language or "text", ".txt")
|
ext = _ext.get(doc.language or "text", ".txt")
|
||||||
base = (doc.title or "document").strip() or "document"
|
base = (doc.title or "document").strip() or "document"
|
||||||
base = re.sub(r"[^\w\-. ]+", "", base)[:60].strip() or doc.id
|
base = re.sub(r"[^\w\-. ]+", "", base)[:60].strip() or doc.id
|
||||||
has_ext = base.lower().endswith(ext)
|
name = base if "." in base else base + ext
|
||||||
stem = base[:-len(ext)] if has_ext else base
|
|
||||||
suffix = base[-len(ext):] if has_ext else ext
|
|
||||||
name = stem + suffix
|
|
||||||
i = 1
|
i = 1
|
||||||
while name in used:
|
while name in used:
|
||||||
name = f"{stem}-{i}{suffix}"
|
name = f"{base}-{i}" + ("" if "." in base else ext)
|
||||||
i += 1
|
i += 1
|
||||||
used.add(name)
|
used.add(name)
|
||||||
content = doc.current_content or ""
|
content = doc.current_content or ""
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
|||||||
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
|
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
|
||||||
from core.database import Session as DbSession
|
from core.database import Session as DbSession
|
||||||
from src.auth_helpers import get_current_user, owner_filter, require_privilege
|
from src.auth_helpers import get_current_user, owner_filter, require_privilege
|
||||||
from src.session_search import escape_like_pattern
|
|
||||||
from src.upload_limits import (
|
from src.upload_limits import (
|
||||||
read_upload_limited,
|
read_upload_limited,
|
||||||
GALLERY_UPLOAD_MAX_BYTES,
|
GALLERY_UPLOAD_MAX_BYTES,
|
||||||
@@ -324,23 +323,6 @@ def _b64_bytes(value: str, *, label: str = "image") -> bytes:
|
|||||||
raise HTTPException(400, f"Invalid {label}") from exc
|
raise HTTPException(400, f"Invalid {label}") from exc
|
||||||
|
|
||||||
|
|
||||||
async def _json_object(request: Request) -> dict:
|
|
||||||
try:
|
|
||||||
data = await request.json()
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(400, "Invalid JSON body")
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise HTTPException(400, "JSON object body required")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
async def _image_ids(request: Request) -> list:
|
|
||||||
ids = (await _json_object(request)).get("image_ids", [])
|
|
||||||
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
|
|
||||||
raise HTTPException(400, "image_ids must be a list of image ids")
|
|
||||||
return ids
|
|
||||||
|
|
||||||
|
|
||||||
def _pil_image_to_b64(img, *, fmt: str = "PNG") -> str:
|
def _pil_image_to_b64(img, *, fmt: str = "PNG") -> str:
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
img.save(buf, format=fmt)
|
img.save(buf, format=fmt)
|
||||||
@@ -1221,18 +1203,15 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
)
|
)
|
||||||
q = _owner_filter(q, user)
|
q = _owner_filter(q, user)
|
||||||
|
|
||||||
# Per-term AND search: a single `%foo bar%` LIKE only matched the
|
# Search filter (prompt + tags + ai_tags)
|
||||||
# exact phrase, so "red car" missed "a car that is red".
|
|
||||||
# LIKE wildcards are escaped per token.
|
|
||||||
if search:
|
if search:
|
||||||
|
term = f"%{search}%"
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
for tok in search.split():
|
q = q.filter(or_(
|
||||||
term = f"%{escape_like_pattern(tok)}%"
|
GalleryImage.prompt.ilike(term),
|
||||||
q = q.filter(or_(
|
GalleryImage.tags.ilike(term),
|
||||||
GalleryImage.prompt.ilike(term, escape="\\"),
|
GalleryImage.ai_tags.ilike(term),
|
||||||
GalleryImage.tags.ilike(term, escape="\\"),
|
))
|
||||||
GalleryImage.ai_tags.ilike(term, escape="\\"),
|
|
||||||
))
|
|
||||||
|
|
||||||
# Tag filter. The UI stacks multiple tag pills by passing them
|
# Tag filter. The UI stacks multiple tag pills by passing them
|
||||||
# comma-separated — each tag adds a separate AND-filter so the
|
# comma-separated — each tag adds a separate AND-filter so the
|
||||||
@@ -1243,10 +1222,9 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
for one in (t.strip() for t in tag.split(",")):
|
for one in (t.strip() for t in tag.split(",")):
|
||||||
if not one:
|
if not one:
|
||||||
continue
|
continue
|
||||||
safe_tag = f"%{escape_like_pattern(one)}%"
|
|
||||||
q = q.filter(_or(
|
q = q.filter(_or(
|
||||||
GalleryImage.tags.ilike(safe_tag, escape="\\"),
|
GalleryImage.tags.ilike(f"%{one}%"),
|
||||||
GalleryImage.ai_tags.ilike(safe_tag, escape="\\"),
|
GalleryImage.ai_tags.ilike(f"%{one}%"),
|
||||||
))
|
))
|
||||||
|
|
||||||
# Model filter
|
# Model filter
|
||||||
@@ -3014,22 +2992,14 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
@router.put("/api/gallery/albums/{album_id}")
|
@router.put("/api/gallery/albums/{album_id}")
|
||||||
async def update_album(request: Request, album_id: str):
|
async def update_album(request: Request, album_id: str):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
data = await _json_object(request)
|
data = await request.json()
|
||||||
name = data.get("name")
|
|
||||||
if name is not None:
|
|
||||||
if not isinstance(name, str) or not name.strip():
|
|
||||||
raise HTTPException(400, "Album name required")
|
|
||||||
name = name.strip()
|
|
||||||
description = data.get("description")
|
|
||||||
if description is not None and not isinstance(description, str):
|
|
||||||
raise HTTPException(400, "Album description must be a string")
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
album = _get_or_404_album(db, album_id, user)
|
album = _get_or_404_album(db, album_id, user)
|
||||||
if name is not None:
|
if data.get("name") is not None:
|
||||||
album.name = name
|
album.name = data["name"]
|
||||||
if description is not None:
|
if data.get("description") is not None:
|
||||||
album.description = description
|
album.description = data["description"]
|
||||||
if data.get("cover_id") is not None:
|
if data.get("cover_id") is not None:
|
||||||
cover_id = data["cover_id"] or None
|
cover_id = data["cover_id"] or None
|
||||||
if cover_id:
|
if cover_id:
|
||||||
@@ -3059,7 +3029,8 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
@router.post("/api/gallery/albums/{album_id}/add")
|
@router.post("/api/gallery/albums/{album_id}/add")
|
||||||
async def add_to_album(request: Request, album_id: str):
|
async def add_to_album(request: Request, album_id: str):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
ids = await _image_ids(request)
|
data = await request.json()
|
||||||
|
ids = data.get("image_ids", [])
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
_get_or_404_album(db, album_id, user)
|
_get_or_404_album(db, album_id, user)
|
||||||
@@ -3076,7 +3047,8 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
@router.post("/api/gallery/albums/{album_id}/remove")
|
@router.post("/api/gallery/albums/{album_id}/remove")
|
||||||
async def remove_from_album(request: Request, album_id: str):
|
async def remove_from_album(request: Request, album_id: str):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
ids = await _image_ids(request)
|
data = await request.json()
|
||||||
|
ids = data.get("image_ids", [])
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
_get_or_404_album(db, album_id, user)
|
_get_or_404_album(db, album_id, user)
|
||||||
|
|||||||
@@ -825,23 +825,6 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
keep_count = body.get("keep_count", 0)
|
keep_count = body.get("keep_count", 0)
|
||||||
# A message id is exact; keep_count from the client counts only the
|
|
||||||
# messages loaded in the page. before_msg_id forks before that
|
|
||||||
# message, through_msg_id includes it.
|
|
||||||
before_msg_id = str(body.get("before_msg_id") or "").strip()
|
|
||||||
through_msg_id = str(body.get("through_msg_id") or "").strip()
|
|
||||||
if before_msg_id or through_msg_id:
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
all_db_messages = db.query(DbChatMessage).filter(
|
|
||||||
DbChatMessage.session_id == session_id
|
|
||||||
).order_by(DbChatMessage.timestamp).all()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
resolved = _keep_count_before_message(all_db_messages, before_msg_id or through_msg_id)
|
|
||||||
if resolved is None:
|
|
||||||
raise HTTPException(404, "Message not found")
|
|
||||||
keep_count = resolved + (0 if before_msg_id else 1)
|
|
||||||
|
|
||||||
# Get the source session. keep_count indexes into source.history,
|
# Get the source session. keep_count indexes into source.history,
|
||||||
# so this must go through get_session — reading the cache directly
|
# so this must go through get_session — reading the cache directly
|
||||||
|
|||||||
@@ -659,7 +659,7 @@ _NON_CHAT_CONTAINS = (
|
|||||||
"gliner", "riva", "-parse", "-embedqa", "-nemoretriever",
|
"gliner", "riva", "-parse", "-embedqa", "-nemoretriever",
|
||||||
"topic-control", "calibration",
|
"topic-control", "calibration",
|
||||||
"ai-synthetic-video", "cosmos-reason2",
|
"ai-synthetic-video", "cosmos-reason2",
|
||||||
"bge", "llama-guard", "-embed", "rerank",
|
"bge", "llama-guard",
|
||||||
)
|
)
|
||||||
_NON_CHAT_EXACT_PREFIXES = (
|
_NON_CHAT_EXACT_PREFIXES = (
|
||||||
"gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat)
|
"gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat)
|
||||||
@@ -2846,12 +2846,11 @@ def setup_model_routes(model_discovery):
|
|||||||
return {"endpoint_id": "", "endpoint_url": "", "model": ""}
|
return {"endpoint_id": "", "endpoint_url": "", "model": ""}
|
||||||
base = _normalize_base(ep.base_url)
|
base = _normalize_base(ep.base_url)
|
||||||
chat_url = build_chat_url(base)
|
chat_url = build_chat_url(base)
|
||||||
if model and not _is_chat_model(model):
|
|
||||||
model = ""
|
|
||||||
if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
|
if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
|
||||||
try:
|
try:
|
||||||
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
|
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
|
||||||
model = next((mid for mid in visible if _is_chat_model(mid)), "")
|
if visible:
|
||||||
|
model = visible[0]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model}
|
return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model}
|
||||||
|
|||||||
@@ -183,7 +183,6 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
|||||||
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
|
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
|
||||||
"""
|
"""
|
||||||
from src.endpoint_resolver import (
|
from src.endpoint_resolver import (
|
||||||
_endpoint_enabled_models,
|
|
||||||
build_chat_url,
|
build_chat_url,
|
||||||
build_headers,
|
build_headers,
|
||||||
resolve_endpoint_runtime as resolve_model_endpoint_runtime,
|
resolve_endpoint_runtime as resolve_model_endpoint_runtime,
|
||||||
@@ -198,7 +197,7 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
|||||||
ep_model = (model or "").strip()
|
ep_model = (model or "").strip()
|
||||||
if not ep_model:
|
if not ep_model:
|
||||||
try:
|
try:
|
||||||
models = _endpoint_enabled_models(ep)
|
models = json.loads(ep.cached_models) if ep.cached_models else []
|
||||||
if models:
|
if models:
|
||||||
ep_model = _first_chat_model(models)
|
ep_model = _first_chat_model(models)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -825,13 +824,23 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
if not ep_url or not ep_model:
|
if not ep_url or not ep_model:
|
||||||
# Last resort: this user's enabled endpoint, plus legacy shared rows.
|
# Last resort: this user's enabled endpoint, plus legacy shared rows.
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
|
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ep = _owned_enabled_endpoint(db, user)
|
ep = _owned_enabled_endpoint(db, user)
|
||||||
if ep:
|
if ep:
|
||||||
resolved = _resolve_endpoint_runtime(ep, owner=user)
|
base = normalize_base(ep.base_url)
|
||||||
if resolved:
|
fallback_url = build_chat_url(base)
|
||||||
_merge(*resolved)
|
fallback_headers = build_headers(ep.api_key, base)
|
||||||
|
fallback_model = ""
|
||||||
|
if ep.cached_models:
|
||||||
|
try:
|
||||||
|
models = json.loads(ep.cached_models)
|
||||||
|
if models:
|
||||||
|
fallback_model = _first_chat_model(models)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_merge(fallback_url, fallback_model, fallback_headers)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import httpx
|
|||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from services.memory.skill_format import slugify
|
|
||||||
from services.memory.skills import SkillsManager
|
from services.memory.skills import SkillsManager
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from services.skills import evaluation as _evaluation
|
from services.skills import evaluation as _evaluation
|
||||||
@@ -837,10 +836,6 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
updates = body.dict(exclude_none=True)
|
updates = body.dict(exclude_none=True)
|
||||||
if not updates:
|
if not updates:
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
if "name" in updates:
|
|
||||||
new_name = slugify(updates["name"] or match.get("name"))
|
|
||||||
if new_name != match.get("name") and skills_manager.has_skill(new_name, owner=user):
|
|
||||||
raise HTTPException(409, f"A skill named '{new_name}' already exists")
|
|
||||||
ok = skills_manager.update_skill(match.get("name"), updates, owner=user)
|
ok = skills_manager.update_skill(match.get("name"), updates, owner=user)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(404, "Skill not found")
|
raise HTTPException(404, "Skill not found")
|
||||||
|
|||||||
@@ -289,15 +289,15 @@ def _resolve_run_endpoint(db, task: ScheduledTask, run: TaskRun) -> str:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from core.database import ModelEndpoint
|
from core.database import ModelEndpoint
|
||||||
from src.auth_helpers import owner_filter
|
eps = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
||||||
from src.endpoint_resolver import _endpoint_enabled_models
|
|
||||||
|
|
||||||
eps = owner_filter(
|
|
||||||
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
|
|
||||||
ModelEndpoint, getattr(task, "owner", None),
|
|
||||||
).all()
|
|
||||||
for ep in eps:
|
for ep in eps:
|
||||||
if model in _endpoint_enabled_models(ep):
|
cached = []
|
||||||
|
if ep.cached_models:
|
||||||
|
try:
|
||||||
|
cached = json.loads(ep.cached_models) or []
|
||||||
|
except Exception:
|
||||||
|
cached = []
|
||||||
|
if model in cached:
|
||||||
return ep.base_url or ""
|
return ep.base_url or ""
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -130,15 +130,10 @@ def _parse_scalar(raw: str, *, string_only: bool = False) -> Any:
|
|||||||
def _split_top_level(s: str, sep: str) -> List[str]:
|
def _split_top_level(s: str, sep: str) -> List[str]:
|
||||||
"""Split `s` on `sep` ignoring separators inside [] or quotes."""
|
"""Split `s` on `sep` ignoring separators inside [] or quotes."""
|
||||||
out, buf, depth, quote = [], [], 0, None
|
out, buf, depth, quote = [], [], 0, None
|
||||||
escaped = False
|
|
||||||
for ch in s:
|
for ch in s:
|
||||||
if quote:
|
if quote:
|
||||||
buf.append(ch)
|
buf.append(ch)
|
||||||
if escaped:
|
if ch == quote:
|
||||||
escaped = False
|
|
||||||
elif ch == "\\" and quote == '"':
|
|
||||||
escaped = True
|
|
||||||
elif ch == quote:
|
|
||||||
quote = None
|
quote = None
|
||||||
continue
|
continue
|
||||||
if ch in ("'", '"'):
|
if ch in ("'", '"'):
|
||||||
|
|||||||
@@ -500,19 +500,6 @@ class SkillsManager:
|
|||||||
sk.path = self._skill_file(cat, nm)
|
sk.path = self._skill_file(cat, nm)
|
||||||
return sk.to_dict()
|
return sk.to_dict()
|
||||||
|
|
||||||
def has_skill(self, name: str, owner: Optional[str] = None) -> bool:
|
|
||||||
"""Whether `owner` already has a skill called `name`, in any category.
|
|
||||||
|
|
||||||
Update and delete find a skill by name within its owner, so two of
|
|
||||||
one owner's skills sharing a name would make them act on whichever
|
|
||||||
file is walked first.
|
|
||||||
"""
|
|
||||||
for path in self._iter_skill_files():
|
|
||||||
sk = self._read_skill(path)
|
|
||||||
if sk and sk.name == name and (sk.owner or "") == (owner or ""):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool:
|
def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool:
|
||||||
"""`skill_id` is the slug name. Allows updating any field plus
|
"""`skill_id` is the slug name. Allows updating any field plus
|
||||||
renames if `name` changes (file is moved on disk).
|
renames if `name` changes (file is moved on disk).
|
||||||
@@ -564,9 +551,6 @@ class SkillsManager:
|
|||||||
# Rename
|
# Rename
|
||||||
new_name = slugify(updates.get("name") or sk.name)
|
new_name = slugify(updates.get("name") or sk.name)
|
||||||
if new_name != sk.name:
|
if new_name != sk.name:
|
||||||
if self.has_skill(new_name, owner=owner):
|
|
||||||
logger.warning(f"Skill rename target name in use: {new_name}")
|
|
||||||
return False
|
|
||||||
sk.name = new_name
|
sk.name = new_name
|
||||||
|
|
||||||
# Write to potentially new path
|
# Write to potentially new path
|
||||||
|
|||||||
+11
-23
@@ -24193,14 +24193,11 @@ async def stream_agent_loop(
|
|||||||
budget_is_explicit,
|
budget_is_explicit,
|
||||||
hard_max=hard_max,
|
hard_max=hard_max,
|
||||||
)
|
)
|
||||||
if 0 < candidate_context <= 8192:
|
if candidate_context <= 8192:
|
||||||
# Small local servers often tokenize chat wrappers and tool
|
# Small local servers often tokenize chat wrappers and tool
|
||||||
# results much more generously than our rough estimator. Keep
|
# results much more generously than our rough estimator. Keep
|
||||||
# substantial headroom for those wrappers and generation so a
|
# substantial headroom for those wrappers and generation so a
|
||||||
# follow-up tool round cannot exceed the server's n_ctx.
|
# follow-up tool round cannot exceed the server's n_ctx.
|
||||||
# 0 means the window is unknown, which already gets the
|
|
||||||
# conservative default budget; clamping it as well cut every
|
|
||||||
# request to 1200 tokens and dropped open documents (#6678).
|
|
||||||
effective_budget = min(
|
effective_budget = min(
|
||||||
effective_budget,
|
effective_budget,
|
||||||
max(1200, int(candidate_context * 0.40)),
|
max(1200, int(candidate_context * 0.40)),
|
||||||
@@ -27836,25 +27833,16 @@ async def stream_agent_loop(
|
|||||||
if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text):
|
if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text):
|
||||||
_streamed_tool_markup += _delta_text
|
_streamed_tool_markup += _delta_text
|
||||||
if not _streamed_tool_markup_complete(_streamed_tool_markup):
|
if not _streamed_tool_markup_complete(_streamed_tool_markup):
|
||||||
if _streamed_tool_markup_starts(_streamed_tool_markup):
|
continue
|
||||||
continue
|
_visible_markup_tail = strip_tool_blocks(
|
||||||
# False alarm: a bare "<" (e.g. ``x < 2``) looked like
|
_streamed_tool_markup,
|
||||||
# a tag prefix, but the buffer can no longer become a
|
skip_fenced=True,
|
||||||
# tool tag. Release it as prose instead of holding the
|
).strip()
|
||||||
# rest of the answer forever.
|
_streamed_tool_markup = ""
|
||||||
_delta_text = _streamed_tool_markup
|
if not _visible_markup_tail:
|
||||||
_streamed_tool_markup = ""
|
continue
|
||||||
data["delta"] = _delta_text
|
_delta_text = _visible_markup_tail
|
||||||
else:
|
data["delta"] = _delta_text
|
||||||
_visible_markup_tail = strip_tool_blocks(
|
|
||||||
_streamed_tool_markup,
|
|
||||||
skip_fenced=True,
|
|
||||||
).strip()
|
|
||||||
_streamed_tool_markup = ""
|
|
||||||
if not _visible_markup_tail:
|
|
||||||
continue
|
|
||||||
_delta_text = _visible_markup_tail
|
|
||||||
data["delta"] = _delta_text
|
|
||||||
full_response += _delta_text
|
full_response += _delta_text
|
||||||
if not _suppress_unavailable_web_delta:
|
if not _suppress_unavailable_web_delta:
|
||||||
_qwen_buffered_route = bool(
|
_qwen_buffered_route = bool(
|
||||||
|
|||||||
+5
-37
@@ -10,7 +10,7 @@ import os
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from src.auth_helpers import owner_filter
|
from src.auth_helpers import owner_filter
|
||||||
@@ -1957,23 +1957,6 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
|
|||||||
return str(e), False
|
return str(e), False
|
||||||
|
|
||||||
|
|
||||||
def _daily_brief_utc_boundary(local_dt, tz=None):
|
|
||||||
"""Convert a local wall-clock boundary using the offset at that boundary."""
|
|
||||||
if local_dt.tzinfo is None and tz is not None:
|
|
||||||
local_dt = local_dt.replace(tzinfo=tz)
|
|
||||||
return local_dt.astimezone(timezone.utc).replace(tzinfo=None)
|
|
||||||
|
|
||||||
|
|
||||||
def _daily_brief_local_start(event, tz=None):
|
|
||||||
"""Keep date-only/floating rows local; convert only stored UTC instants."""
|
|
||||||
start = event.dtstart
|
|
||||||
if event.is_utc and not event.all_day:
|
|
||||||
if start.tzinfo is None:
|
|
||||||
start = start.replace(tzinfo=timezone.utc)
|
|
||||||
return start.astimezone(tz).replace(tzinfo=None)
|
|
||||||
return start
|
|
||||||
|
|
||||||
|
|
||||||
async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||||
"""Build a short morning digest: today's calendar events, unread email count
|
"""Build a short morning digest: today's calendar events, unread email count
|
||||||
+ top-N senders/subjects, active todos."""
|
+ top-N senders/subjects, active todos."""
|
||||||
@@ -1983,13 +1966,10 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
|
|
||||||
from core.database import SessionLocal, CalendarEvent, CalendarCal, Note
|
from core.database import SessionLocal, CalendarEvent, CalendarCal, Note
|
||||||
from routes.email_helpers import _imap_connect, _decode_header
|
from routes.email_helpers import _imap_connect, _decode_header
|
||||||
from sqlalchemy import and_, or_
|
|
||||||
|
|
||||||
# ----- Calendar: today's events -----
|
# ----- Calendar: today's events -----
|
||||||
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
tomorrow = today + _td(days=1)
|
tomorrow = today + _td(days=1)
|
||||||
utc_today = _daily_brief_utc_boundary(today)
|
|
||||||
utc_tomorrow = _daily_brief_utc_boundary(tomorrow)
|
|
||||||
# v2 review HIGH-12: gate the OR-null branch on single-user
|
# v2 review HIGH-12: gate the OR-null branch on single-user
|
||||||
# (unconfigured) deploys only. In a multi-user deploy, one
|
# (unconfigured) deploys only. In a multi-user deploy, one
|
||||||
# user's daily brief must not include another user's notes or
|
# user's daily brief must not include another user's notes or
|
||||||
@@ -2002,25 +1982,13 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ev_q = db.query(CalendarEvent).join(CalendarCal).filter(
|
ev_q = db.query(CalendarEvent).join(CalendarCal).filter(
|
||||||
or_(
|
CalendarEvent.dtstart < tomorrow,
|
||||||
and_(
|
CalendarEvent.dtend > today,
|
||||||
CalendarEvent.is_utc.is_(True),
|
|
||||||
CalendarEvent.all_day.is_not(True),
|
|
||||||
CalendarEvent.dtstart < utc_tomorrow,
|
|
||||||
CalendarEvent.dtend > utc_today,
|
|
||||||
),
|
|
||||||
and_(
|
|
||||||
or_(CalendarEvent.is_utc.is_not(True), CalendarEvent.all_day.is_(True)),
|
|
||||||
CalendarEvent.dtstart < tomorrow,
|
|
||||||
CalendarEvent.dtend > today,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
CalendarEvent.status != "cancelled",
|
CalendarEvent.status != "cancelled",
|
||||||
)
|
)
|
||||||
if owner:
|
if owner:
|
||||||
ev_q = owner_filter(ev_q, CalendarCal, owner, include_shared=_allow_null)
|
ev_q = owner_filter(ev_q, CalendarCal, owner, include_shared=_allow_null)
|
||||||
events = ev_q.all()
|
events = ev_q.order_by(CalendarEvent.dtstart).all()
|
||||||
events.sort(key=_daily_brief_local_start)
|
|
||||||
# ----- Notes: pinned + non-archived todos with at least one undone item -----
|
# ----- Notes: pinned + non-archived todos with at least one undone item -----
|
||||||
n_q = db.query(Note).filter(Note.archived == False) # noqa: E712
|
n_q = db.query(Note).filter(Note.archived == False) # noqa: E712
|
||||||
if owner:
|
if owner:
|
||||||
@@ -2090,7 +2058,7 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
if events:
|
if events:
|
||||||
plain.append("Calendar:")
|
plain.append("Calendar:")
|
||||||
for e in events:
|
for e in events:
|
||||||
t = _daily_brief_local_start(e).strftime("%H:%M") if not e.all_day else "all day"
|
t = e.dtstart.strftime("%H:%M") if not e.all_day else "all day"
|
||||||
loc = f" @ {e.location}" if e.location else ""
|
loc = f" @ {e.location}" if e.location else ""
|
||||||
plain.append(f" {t} {e.summary}{loc}")
|
plain.append(f" {t} {e.summary}{loc}")
|
||||||
plain.append("")
|
plain.append("")
|
||||||
|
|||||||
+1
-7
@@ -282,13 +282,7 @@ class ChatHandler:
|
|||||||
_sync_upload_vision_to_gallery(file_info, owner, vl_desc)
|
_sync_upload_vision_to_gallery(file_info, owner, vl_desc)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n"
|
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n{vl_desc}"
|
||||||
if vl_desc:
|
|
||||||
# A separate vision call wrote this, but the chat model sees
|
|
||||||
# one message and answers the prose as if the user wrote it
|
|
||||||
# (#5573). Label it below the marker line — the client strips
|
|
||||||
# the block by matching `[Image: <name>]\n`.
|
|
||||||
enhanced_message += f"[Image description — context only, not part of the user's message]:\n{vl_desc}"
|
|
||||||
# Surface the description to the client live so it renders as a
|
# Surface the description to the client live so it renders as a
|
||||||
# collapsible "image description" on the user bubble (not just
|
# collapsible "image description" on the user bubble (not just
|
||||||
# after a refresh that re-parses the stored message).
|
# after a refresh that re-parses the stored message).
|
||||||
|
|||||||
@@ -142,10 +142,7 @@ def _process_pdf(
|
|||||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||||
temp_img_path = tmp.name
|
temp_img_path = tmp.name
|
||||||
try:
|
try:
|
||||||
image = img.image
|
img.image.save(temp_img_path, "PNG") # pypdf -> PIL image
|
||||||
if image.mode == "CMYK":
|
|
||||||
image = image.convert("RGB")
|
|
||||||
image.save(temp_img_path, "PNG")
|
|
||||||
ocr_text = analyze_image_with_vl(temp_img_path, owner=owner)
|
ocr_text = analyze_image_with_vl(temp_img_path, owner=owner)
|
||||||
if ocr_text and "unavailable" not in ocr_text.lower():
|
if ocr_text and "unavailable" not in ocr_text.lower():
|
||||||
pdf_text += f"\n\n[Page {page_num + 1} image {img_index + 1} text]: {ocr_text}"
|
pdf_text += f"\n\n[Page {page_num + 1} image {img_index + 1} text]: {ocr_text}"
|
||||||
|
|||||||
+8
-63
@@ -76,23 +76,9 @@ _ERROR_DETAIL = {
|
|||||||
"auth_or_protocol_error": "authentication or protocol error",
|
"auth_or_protocol_error": "authentication or protocol error",
|
||||||
"no_models": "endpoint returned no models",
|
"no_models": "endpoint returned no models",
|
||||||
"no_host": "no host configured",
|
"no_host": "no host configured",
|
||||||
"config_source_error": "configuration source could not be read",
|
|
||||||
"error": "probe failed",
|
"error": "probe failed",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Config/inventory source behind each network subsystem. `_gather_inputs()`
|
|
||||||
# fails soft, so a source that could not be read hands its probe an empty
|
|
||||||
# inventory — indistinguishable from "nothing configured", which every probe
|
|
||||||
# answers with `disabled`. The collector therefore reports the source failure
|
|
||||||
# instead of running a probe whose verdict would be a lie.
|
|
||||||
CONFIG_SOURCE_ERROR = "config_source_error"
|
|
||||||
_SUBSYSTEM_SOURCE = {
|
|
||||||
"searxng": "settings",
|
|
||||||
"ntfy": "integrations",
|
|
||||||
"email": "accounts",
|
|
||||||
"providers": "endpoints",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _svc(name: str, status: str, detail: str, **meta: Any) -> Dict[str, Any]:
|
def _svc(name: str, status: str, detail: str, **meta: Any) -> Dict[str, Any]:
|
||||||
return {"name": name, "status": status, "detail": detail, "meta": dict(meta)}
|
return {"name": name, "status": status, "detail": detail, "meta": dict(meta)}
|
||||||
@@ -425,33 +411,26 @@ def _gather_inputs() -> Dict[str, Any]:
|
|||||||
"""Pull live config/account/endpoint lists from the app's data sources.
|
"""Pull live config/account/endpoint lists from the app's data sources.
|
||||||
|
|
||||||
Each lookup fails soft: a broken source yields an empty/neutral value so a
|
Each lookup fails soft: a broken source yields an empty/neutral value so a
|
||||||
single failure can't take down the whole health report. The failure is also
|
single failure can't take down the whole health report.
|
||||||
*recorded* under `failed` (source -> controlled category), because the empty
|
|
||||||
value alone cannot be told apart from a source that loaded and is genuinely
|
|
||||||
empty — and the probes read empty as `disabled`.
|
|
||||||
"""
|
"""
|
||||||
settings: Dict[str, Any] = {}
|
settings: Dict[str, Any] = {}
|
||||||
integrations: List[Dict[str, Any]] = []
|
integrations: List[Dict[str, Any]] = []
|
||||||
accounts: List[Dict[str, Any]] = []
|
accounts: List[Dict[str, Any]] = []
|
||||||
endpoints: List[Dict[str, Any]] = []
|
endpoints: List[Dict[str, Any]] = []
|
||||||
failed: Dict[str, str] = {}
|
|
||||||
try:
|
try:
|
||||||
from src.settings import load_settings
|
from src.settings import load_settings
|
||||||
settings = load_settings() or {}
|
settings = load_settings() or {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
failed["settings"] = _classify_error(e)
|
|
||||||
logger.debug(f"service_health: settings load failed: {e}")
|
logger.debug(f"service_health: settings load failed: {e}")
|
||||||
try:
|
try:
|
||||||
from src.integrations import load_integrations
|
from src.integrations import load_integrations
|
||||||
integrations = load_integrations() or []
|
integrations = load_integrations() or []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
failed["integrations"] = _classify_error(e)
|
|
||||||
logger.debug(f"service_health: integrations load failed: {e}")
|
logger.debug(f"service_health: integrations load failed: {e}")
|
||||||
try:
|
try:
|
||||||
from routes.email_helpers import _list_email_accounts
|
from routes.email_helpers import _list_email_accounts
|
||||||
accounts = _list_email_accounts() or []
|
accounts = _list_email_accounts() or []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
failed["accounts"] = _classify_error(e)
|
|
||||||
logger.debug(f"service_health: email accounts load failed: {e}")
|
logger.debug(f"service_health: email accounts load failed: {e}")
|
||||||
try:
|
try:
|
||||||
from core.database import SessionLocal, ModelEndpoint
|
from core.database import SessionLocal, ModelEndpoint
|
||||||
@@ -464,10 +443,9 @@ def _gather_inputs() -> Dict[str, Any]:
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
failed["endpoints"] = _classify_error(e)
|
|
||||||
logger.debug(f"service_health: endpoint load failed: {e}")
|
logger.debug(f"service_health: endpoint load failed: {e}")
|
||||||
return {"settings": settings, "integrations": integrations,
|
return {"settings": settings, "integrations": integrations,
|
||||||
"accounts": accounts, "endpoints": endpoints, "failed": failed}
|
"accounts": accounts, "endpoints": endpoints}
|
||||||
|
|
||||||
|
|
||||||
async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]:
|
async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]:
|
||||||
@@ -486,24 +464,6 @@ async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]:
|
|||||||
return _svc(name, DOWN, _detail_for(category), error=category)
|
return _svc(name, DOWN, _detail_for(category), error=category)
|
||||||
|
|
||||||
|
|
||||||
async def _source_unavailable(name: str, source: str,
|
|
||||||
category: str) -> Dict[str, Any]:
|
|
||||||
"""Controlled entry for a subsystem whose config source could not be read.
|
|
||||||
|
|
||||||
Deliberately not `disabled`: the configured state is *unknown*, not known to
|
|
||||||
be off. `degraded` rather than `down` because the failure is in reading the
|
|
||||||
inventory, not a probe that reached the subsystem and failed — but it still
|
|
||||||
carries non-zero severity, so a report containing one can never roll up to
|
|
||||||
`ok`. `meta` names the source and the controlled category of the underlying
|
|
||||||
exception; never its text.
|
|
||||||
"""
|
|
||||||
return _svc(name, DEGRADED,
|
|
||||||
f"Could not read configuration "
|
|
||||||
f"({_detail_for(CONFIG_SOURCE_ERROR)}).",
|
|
||||||
error=CONFIG_SOURCE_ERROR, source=source,
|
|
||||||
source_error=category)
|
|
||||||
|
|
||||||
|
|
||||||
async def collect_service_health(rag_manager: Any = None,
|
async def collect_service_health(rag_manager: Any = None,
|
||||||
memory_vector: Any = None) -> Dict[str, Any]:
|
memory_vector: Any = None) -> Dict[str, Any]:
|
||||||
"""Run every probe and return {overall, services, timestamp}.
|
"""Run every probe and return {overall, services, timestamp}.
|
||||||
@@ -512,10 +472,6 @@ async def collect_service_health(rag_manager: Any = None,
|
|||||||
four network subsystems run concurrently, each under `_SUBSYSTEM_DEADLINE`,
|
four network subsystems run concurrently, each under `_SUBSYSTEM_DEADLINE`,
|
||||||
with an overall `_AGGREGATE_DEADLINE` backstop. Per-item probes inside
|
with an overall `_AGGREGATE_DEADLINE` backstop. Per-item probes inside
|
||||||
providers/email are themselves bounded by `_FANOUT_BUDGET`.
|
providers/email are themselves bounded by `_FANOUT_BUDGET`.
|
||||||
|
|
||||||
A subsystem whose config/inventory source failed to load is reported via
|
|
||||||
`_source_unavailable()` instead of being probed with an empty inventory,
|
|
||||||
which every probe would otherwise report as `disabled`.
|
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -526,23 +482,12 @@ async def collect_service_health(rag_manager: Any = None,
|
|||||||
chroma = chromadb_health(rag_manager, memory_vector)
|
chroma = chromadb_health(rag_manager, memory_vector)
|
||||||
|
|
||||||
names = ["searxng", "ntfy", "email", "providers"]
|
names = ["searxng", "ntfy", "email", "providers"]
|
||||||
probes = {
|
coros = [
|
||||||
"searxng": (searxng_health, (settings,)),
|
_run_subsystem("searxng", searxng_health, settings),
|
||||||
"ntfy": (ntfy_health, (inputs["integrations"], settings)),
|
_run_subsystem("ntfy", ntfy_health, inputs["integrations"], settings),
|
||||||
"email": (email_health, (inputs["accounts"],)),
|
_run_subsystem("email", email_health, inputs["accounts"]),
|
||||||
"providers": (providers_health, (inputs["endpoints"],)),
|
_run_subsystem("providers", providers_health, inputs["endpoints"]),
|
||||||
}
|
]
|
||||||
# A subsystem whose inventory could not be read is reported as such. The
|
|
||||||
# others are still probed normally — one broken source degrades one entry.
|
|
||||||
failed = inputs.get("failed") or {}
|
|
||||||
coros = []
|
|
||||||
for name in names:
|
|
||||||
source = _SUBSYSTEM_SOURCE[name]
|
|
||||||
if source in failed:
|
|
||||||
coros.append(_source_unavailable(name, source, failed[source]))
|
|
||||||
else:
|
|
||||||
fn, args = probes[name]
|
|
||||||
coros.append(_run_subsystem(name, fn, *args))
|
|
||||||
try:
|
try:
|
||||||
results = await asyncio.wait_for(asyncio.gather(*coros),
|
results = await asyncio.wait_for(asyncio.gather(*coros),
|
||||||
timeout=_AGGREGATE_DEADLINE)
|
timeout=_AGGREGATE_DEADLINE)
|
||||||
|
|||||||
+5
-33
@@ -57,17 +57,8 @@ def _message_to_context(msg: DBChatMessage) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def escape_like_pattern(value: str) -> str:
|
|
||||||
"""Escape SQL LIKE wildcards (``%``, ``_``) and the escape char itself.
|
|
||||||
|
|
||||||
Public shared helper for building ``ilike(f"%{...}%", escape="\\\\")``
|
|
||||||
patterns from user input. Must escape backslash first.
|
|
||||||
"""
|
|
||||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
||||||
|
|
||||||
|
|
||||||
def _escape_like(value: str) -> str:
|
def _escape_like(value: str) -> str:
|
||||||
return escape_like_pattern(value)
|
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
|
|
||||||
def _snippet(content: str, query: str, radius: int = 60) -> str:
|
def _snippet(content: str, query: str, radius: int = 60) -> str:
|
||||||
@@ -76,34 +67,15 @@ def _snippet(content: str, query: str, radius: int = 60) -> str:
|
|||||||
if not query:
|
if not query:
|
||||||
return content[: radius * 2]
|
return content[: radius * 2]
|
||||||
|
|
||||||
match_start = -1
|
idx = content.lower().find(query.lower())
|
||||||
match_length = 0
|
if idx == -1:
|
||||||
lowered_content = content.lower()
|
|
||||||
for candidate in _query_terms(query):
|
|
||||||
idx = lowered_content.find(candidate.lower())
|
|
||||||
if idx != -1 and (match_start == -1 or idx < match_start):
|
|
||||||
match_start = idx
|
|
||||||
match_length = len(candidate)
|
|
||||||
|
|
||||||
if match_start == -1:
|
|
||||||
return content[: radius * 2]
|
return content[: radius * 2]
|
||||||
|
|
||||||
start = max(0, match_start - radius)
|
start = max(0, idx - radius)
|
||||||
end = min(len(content), match_start + match_length + radius)
|
end = min(len(content), idx + len(query) + radius)
|
||||||
return ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "")
|
return ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "")
|
||||||
|
|
||||||
|
|
||||||
def _query_terms(query: str) -> list[str]:
|
|
||||||
"""Return meaningful quoted phrases and tokens in user-entered order."""
|
|
||||||
terms: list[str] = []
|
|
||||||
for match in re.finditer(r'"([^"]+)"|[\w][\w._-]*', query, flags=re.UNICODE):
|
|
||||||
term = match.group(1) if match.group(1) is not None else match.group(0).strip("._-")
|
|
||||||
term = term.strip()
|
|
||||||
if term:
|
|
||||||
terms.append(term)
|
|
||||||
return terms
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_fts_query(query: str) -> str | None:
|
def _sanitize_fts_query(query: str) -> str | None:
|
||||||
"""Convert free text into a conservative FTS5 MATCH query.
|
"""Convert free text into a conservative FTS5 MATCH query.
|
||||||
|
|
||||||
|
|||||||
+50
-159
@@ -336,9 +336,6 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
let _sendInFlight = false; // covers the window from click → streaming start
|
let _sendInFlight = false; // covers the window from click → streaming start
|
||||||
let _displayOverride = null; // Override visible user bubble text (hides injected prompts)
|
let _displayOverride = null; // Override visible user bubble text (hides injected prompts)
|
||||||
let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop)
|
let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop)
|
||||||
// The user bubble a regenerate keeps on screen. Its resubmitted message is
|
|
||||||
// a new DB row, so the saved id is attached here.
|
|
||||||
let _regenKeptUserBubble = null;
|
|
||||||
let _contextHeaderSeq = 0;
|
let _contextHeaderSeq = 0;
|
||||||
let _contextHeaderData = null;
|
let _contextHeaderData = null;
|
||||||
let _contextHeaderBound = false;
|
let _contextHeaderBound = false;
|
||||||
@@ -4998,15 +4995,6 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
refreshChatContextHeader('metrics');
|
refreshChatContextHeader('metrics');
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (json.type === 'user_message_saved') {
|
|
||||||
// The user bubble gets its DB id too, so regenerating or
|
|
||||||
// editing it later cuts the chat by id, not page position.
|
|
||||||
if (!_isBg && json.id) {
|
|
||||||
if (_userMsgEl) _userMsgEl.dataset.dbId = json.id;
|
|
||||||
else if (_regenKeptUserBubble) _regenKeptUserBubble.dataset.dbId = json.id;
|
|
||||||
_regenKeptUserBubble = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if (json.type === 'message_saved') {
|
} else if (json.type === 'message_saved') {
|
||||||
// Wire the persisted DB id onto the just-streamed bubble so it
|
// Wire the persisted DB id onto the just-streamed bubble so it
|
||||||
// can be edited/deleted immediately, without reloading the chat.
|
// can be edited/deleted immediately, without reloading the chat.
|
||||||
@@ -7248,22 +7236,6 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
/**
|
/**
|
||||||
* Edit a user message: show an input, truncate to before it, resubmit the edited text.
|
* Edit a user message: show an input, truncate to before it, resubmit the edited text.
|
||||||
*/
|
*/
|
||||||
// Recover attached file IDs from rendered user bubbles, including older
|
|
||||||
// image previews that were rendered before data-file-id was stamped.
|
|
||||||
function _userMessageAttachmentIds(msgElement) {
|
|
||||||
if (!msgElement) return [];
|
|
||||||
const els = msgElement.querySelectorAll('[data-file-id]');
|
|
||||||
const ids = Array.from(els).map(el => el.dataset.fileId).filter(Boolean);
|
|
||||||
if (!ids.length) {
|
|
||||||
const images = msgElement.querySelectorAll('.attach-image-preview img, .attach-card img');
|
|
||||||
for (const image of images) {
|
|
||||||
const match = (image.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
|
|
||||||
if (match && match[1] && !ids.includes(match[1])) ids.push(match[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function editUserMessage(userMsgElement) {
|
export async function editUserMessage(userMsgElement) {
|
||||||
const box = document.getElementById('chat-history');
|
const box = document.getElementById('chat-history');
|
||||||
const allMsgs = Array.from(box.querySelectorAll('.msg'));
|
const allMsgs = Array.from(box.querySelectorAll('.msg'));
|
||||||
@@ -7311,49 +7283,23 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const newText = editor.value.trim();
|
const newText = editor.value.trim();
|
||||||
if (!newText) return;
|
if (!newText) return;
|
||||||
const editAttachmentIds = _userMessageAttachmentIds(userMsgElement);
|
|
||||||
|
|
||||||
const sessionId = sessionModule.getCurrentSessionId();
|
const sessionId = sessionModule.getCurrentSessionId();
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
// Everything after the original reply to this message would also be
|
|
||||||
// destroyed. Cancelling leaves the editor open with the draft intact.
|
|
||||||
const extraCount = allMsgs.length - msgIndex - 2;
|
|
||||||
const choice = await _confirmTruncateIfDestructive(extraCount, 'edit');
|
|
||||||
if (!choice) return;
|
|
||||||
if (choice === 'fork') {
|
|
||||||
// Branch just before this user turn and send the edit there; this
|
|
||||||
// chat keeps the original message and everything after it.
|
|
||||||
try {
|
|
||||||
await _forkAndSend(msgIndex, userMsgElement.dataset.dbId || '', newText, editAttachmentIds);
|
|
||||||
bodyEl.innerHTML = originalHTML;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Fork failed:', err);
|
|
||||||
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keepCount = msgIndex;
|
const keepCount = msgIndex;
|
||||||
// Cut at the message's database id: msgIndex counts only the messages
|
|
||||||
// loaded in the page, so it is too small when older history has not
|
|
||||||
// been scrolled in yet.
|
|
||||||
const editBeforeId = userMsgElement.dataset.dbId || '';
|
|
||||||
try {
|
try {
|
||||||
const editTruncate = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(editBeforeId ? { before_msg_id: editBeforeId } : { keep_count: keepCount })
|
body: JSON.stringify({ keep_count: keepCount })
|
||||||
});
|
});
|
||||||
if (!editTruncate.ok) throw new Error('Server error ' + editTruncate.status);
|
|
||||||
|
|
||||||
// Remove DOM elements from msgIndex onward
|
// Remove DOM elements from msgIndex onward
|
||||||
for (let i = allMsgs.length - 1; i >= msgIndex; i--) {
|
for (let i = allMsgs.length - 1; i >= msgIndex; i--) {
|
||||||
allMsgs[i].remove();
|
allMsgs[i].remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The edited user turn still refers to its original attachments.
|
|
||||||
_pendingRegenAttachments = editAttachmentIds;
|
|
||||||
// Submit the edited text
|
// Submit the edited text
|
||||||
const messageInput = uiModule.el('message');
|
const messageInput = uiModule.el('message');
|
||||||
messageInput.value = newText;
|
messageInput.value = newText;
|
||||||
@@ -7377,59 +7323,6 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regenerate, resend and edit permanently delete (server-side truncate)
|
|
||||||
// everything after the point clicked. Ask first unless nothing comes after
|
|
||||||
// it (extraCount 0), offering a fork that leaves this chat untouched.
|
|
||||||
// Resolves to 'proceed', 'fork', or null (cancelled).
|
|
||||||
const _TRUNCATE_ACTIONS = {
|
|
||||||
regenerate: { lead: 'Regenerating here', confirmText: 'Delete and regenerate' },
|
|
||||||
resend: { lead: 'Resending this message', confirmText: 'Delete and resend' },
|
|
||||||
edit: { lead: 'Sending this edit', confirmText: 'Delete and send' },
|
|
||||||
};
|
|
||||||
async function _confirmTruncateIfDestructive(extraCount, action = 'regenerate') {
|
|
||||||
if (extraCount <= 0) return 'proceed';
|
|
||||||
const { lead, confirmText } = _TRUNCATE_ACTIONS[action] || _TRUNCATE_ACTIONS.regenerate;
|
|
||||||
const result = await uiModule.styledConfirm(
|
|
||||||
`${lead} will also permanently delete the ${extraCount} ` +
|
|
||||||
`message${extraCount === 1 ? '' : 's'} after it. This can't be undone. ` +
|
|
||||||
`Fork from here keeps this chat as it is and continues in a new one instead.`,
|
|
||||||
{ confirmText, alternateText: 'Fork from here', cancelText: 'Cancel', danger: true }
|
|
||||||
);
|
|
||||||
if (result === 'alternate') return 'fork';
|
|
||||||
return result ? 'proceed' : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy the first `keepCount` messages of the current chat into a new chat
|
|
||||||
// and switch to it. Returns the new session id; throws on failure so callers
|
|
||||||
// that replay an action in the fork can stop before sending anything.
|
|
||||||
// Fork by message id when the element has one (before it, or through it);
|
|
||||||
// keepCount is only a fallback, since it counts loaded messages only.
|
|
||||||
async function _forkSessionAt(keepCount, beforeMsgId = '', throughMsgId = '') {
|
|
||||||
const sessionId = sessionModule.getCurrentSessionId();
|
|
||||||
if (!sessionId) throw new Error('No active chat');
|
|
||||||
const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ keep_count: keepCount,
|
|
||||||
...(beforeMsgId ? { before_msg_id: beforeMsgId } : throughMsgId ? { through_msg_id: throughMsgId } : {}) }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
const data = await res.json();
|
|
||||||
await sessionModule.loadSessions();
|
|
||||||
await sessionModule.selectSession(data.id);
|
|
||||||
if (uiModule) uiModule.showToast(`Forked → ${data.name}`);
|
|
||||||
return data.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fork before a user message and send `text` there; this chat is untouched.
|
|
||||||
// `attachments` (if given) are re-sent only once the fork has succeeded.
|
|
||||||
async function _forkAndSend(keepCount, beforeMsgId, text, attachments) {
|
|
||||||
await _forkSessionAt(keepCount, beforeMsgId);
|
|
||||||
if (attachments !== undefined) _pendingRegenAttachments = attachments;
|
|
||||||
uiModule.el('message').value = text;
|
|
||||||
document.querySelector('.send-btn')?.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resend a user message. By default this replaces from the selected user turn
|
* Resend a user message. By default this replaces from the selected user turn
|
||||||
* so failed attempts do not remain in model context or SFT traces. Callers can
|
* so failed attempts do not remain in model context or SFT traces. Callers can
|
||||||
@@ -7451,7 +7344,18 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
let text = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
|
let text = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
|
||||||
text = text.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
|
text = text.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
|
||||||
|
|
||||||
const _ids = _userMessageAttachmentIds(userMsgElement);
|
// 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Rescue: legacy bubbles may have stored the filename as the message
|
// Rescue: legacy bubbles may have stored the filename as the message
|
||||||
// content (artifact of earlier broken resends). Don't re-send that as
|
// content (artifact of earlier broken resends). Don't re-send that as
|
||||||
@@ -7474,18 +7378,6 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (replaceFromHere) {
|
if (replaceFromHere) {
|
||||||
// Everything strictly after the immediate AI reply to this user
|
|
||||||
// message would also be destroyed — that's the part a click on
|
|
||||||
// Resend / "Regenerate message" doesn't expect to lose.
|
|
||||||
const extraCount = allMsgs.length - msgIndex - 2;
|
|
||||||
const choice = await _confirmTruncateIfDestructive(extraCount, 'resend');
|
|
||||||
if (!choice) return;
|
|
||||||
if (choice === 'fork') {
|
|
||||||
// Branch just before this user turn and send it again there; this
|
|
||||||
// chat keeps every message.
|
|
||||||
await _forkAndSend(msgIndex, userMsgElement.dataset.dbId || '', text, _ids);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Resend/regenerate trims history to this point before resubmitting so
|
// Resend/regenerate trims history to this point before resubmitting so
|
||||||
// the replacement request is the only copy the backend sees.
|
// the replacement request is the only copy the backend sees.
|
||||||
const keepCount = msgIndex;
|
const keepCount = msgIndex;
|
||||||
@@ -7550,14 +7442,25 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything strictly after the AI reply being regenerated would also
|
// Collect any file_ids attached to the original user message so the
|
||||||
// be destroyed — that's the part a click on "Regenerate from here"
|
// regenerated send re-uses them. Without this the AI is regenerated on
|
||||||
// doesn't expect to lose.
|
// text alone — photos (and the user-edited OCR text cached server-side
|
||||||
const extraCount = allMsgs.length - aiIndex - 1;
|
// under that file_id) would be silently dropped.
|
||||||
const choice = await _confirmTruncateIfDestructive(extraCount, 'regenerate');
|
const _attachEls = userMsgEl ? userMsgEl.querySelectorAll('[data-file-id]') : [];
|
||||||
if (!choice) return;
|
let _regenIds = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
|
||||||
|
// Fallback for bubbles rendered before the data-file-id stamp landed:
|
||||||
const _regenIds = _userMessageAttachmentIds(userMsgEl);
|
// 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;
|
||||||
|
|
||||||
// Rescue: earlier-version regens (before the dataset.raw fix) stored the
|
// Rescue: earlier-version regens (before the dataset.raw fix) stored the
|
||||||
// photo's filename as the user-message content. On a follow-up regen,
|
// photo's filename as the user-message content. On a follow-up regen,
|
||||||
@@ -7566,7 +7469,7 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
// an image file". If userText is just a bare image filename and we have
|
// an image file". If userText is just a bare image filename and we have
|
||||||
// attachments, drop it so the OCR text (or the image bytes for vision
|
// attachments, drop it so the OCR text (or the image bytes for vision
|
||||||
// models) is what the model actually sees.
|
// models) is what the model actually sees.
|
||||||
if (userText && _regenIds.length &&
|
if (userText && _pendingRegenAttachments.length &&
|
||||||
/^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(userText.trim())) {
|
/^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(userText.trim())) {
|
||||||
userText = '';
|
userText = '';
|
||||||
}
|
}
|
||||||
@@ -7574,7 +7477,7 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
// A photo-only message has empty user text — regen must still proceed,
|
// A photo-only message has empty user text — regen must still proceed,
|
||||||
// because the attachments themselves are the message. Bail only if there
|
// because the attachments themselves are the message. Bail only if there
|
||||||
// is no text AND no attachments to send.
|
// is no text AND no attachments to send.
|
||||||
if (!userText && !_regenIds.length) {
|
if (!userText && !_pendingRegenAttachments.length) {
|
||||||
if (uiModule) uiModule.showError('Nothing to regenerate — the user message has no text and no attachments');
|
if (uiModule) uiModule.showError('Nothing to regenerate — the user message has no text and no attachments');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -7582,20 +7485,6 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
const sessionId = sessionModule.getCurrentSessionId();
|
const sessionId = sessionModule.getCurrentSessionId();
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
if (choice === 'fork') {
|
|
||||||
// Branch just before the user turn and send it again there, so the new
|
|
||||||
// chat gets a fresh reply to the same message; this chat keeps every
|
|
||||||
// message. No variants or hidden bubble: the fork starts without them.
|
|
||||||
try {
|
|
||||||
await _forkAndSend(userIndex, (userMsgEl && userMsgEl.dataset.dbId) || '', userText, _regenIds);
|
|
||||||
} catch (err) {
|
|
||||||
if (_pendingRegenAttachments === _regenIds) _pendingRegenAttachments = null;
|
|
||||||
console.error('Fork failed:', err);
|
|
||||||
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save current response as a variant
|
// Save current response as a variant
|
||||||
const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || '';
|
const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || '';
|
||||||
const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || '';
|
const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || '';
|
||||||
@@ -7607,19 +7496,13 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
}
|
}
|
||||||
|
|
||||||
const keepCount = userIndex;
|
const keepCount = userIndex;
|
||||||
// Same as resend: cut at the user message's database id, because
|
|
||||||
// userIndex counts only the messages loaded in the page.
|
|
||||||
const regenBeforeId = (userMsgEl && userMsgEl.dataset.dbId) || '';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const regenTruncate = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(regenBeforeId ? { before_msg_id: regenBeforeId } : { keep_count: keepCount })
|
body: JSON.stringify({ keep_count: keepCount })
|
||||||
});
|
});
|
||||||
// A refused cut (e.g. a stale id) must not stack a second reply.
|
|
||||||
if (!regenTruncate.ok) throw new Error('Server error ' + regenTruncate.status);
|
|
||||||
_pendingRegenAttachments = _regenIds;
|
|
||||||
|
|
||||||
// Keep the original user bubble, but remove every rendered trace after
|
// Keep the original user bubble, but remove every rendered trace after
|
||||||
// it, including agent-thread tool history between the user and AI bubble.
|
// it, including agent-thread tool history between the user and AI bubble.
|
||||||
@@ -7636,14 +7519,12 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
_pendingVariantLabel = 'regen';
|
_pendingVariantLabel = 'regen';
|
||||||
|
|
||||||
_hideUserBubble = true;
|
_hideUserBubble = true;
|
||||||
_regenKeptUserBubble = userMsgEl;
|
|
||||||
const messageInput = uiModule.el('message');
|
const messageInput = uiModule.el('message');
|
||||||
messageInput.value = userText;
|
messageInput.value = userText;
|
||||||
const submitBtn = document.querySelector('.send-btn');
|
const submitBtn = document.querySelector('.send-btn');
|
||||||
if (submitBtn) submitBtn.click();
|
if (submitBtn) submitBtn.click();
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (_pendingRegenAttachments === _regenIds) _pendingRegenAttachments = null;
|
|
||||||
console.error('Regenerate failed:', err);
|
console.error('Regenerate failed:', err);
|
||||||
if (uiModule) uiModule.showError('Regenerate failed: ' + err.message);
|
if (uiModule) uiModule.showError('Regenerate failed: ' + err.message);
|
||||||
}
|
}
|
||||||
@@ -7655,8 +7536,6 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
// File-ids carried over from the original user message during a regen, so
|
// File-ids carried over from the original user message during a regen, so
|
||||||
// photos / OCR overrides survive into the new send. Consumed once.
|
// photos / OCR overrides survive into the new send. Consumed once.
|
||||||
let _pendingRegenAttachments = null;
|
let _pendingRegenAttachments = null;
|
||||||
// Read-only view for tests.
|
|
||||||
export function _pendingRegenAttachmentIds() { return _pendingRegenAttachments; }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called after streaming completes to attach variant navigation if this was a regen.
|
* Called after streaming completes to attach variant navigation if this was a regen.
|
||||||
@@ -7796,8 +7675,20 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
const sessionId = sessionModule.getCurrentSessionId();
|
const sessionId = sessionModule.getCurrentSessionId();
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
const keepCount = aiIndex + 1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _forkSessionAt(aiIndex + 1, '', aiMsgElement.dataset.dbId || '');
|
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}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fork failed:', err);
|
console.error('Fork failed:', err);
|
||||||
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
|
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import unittest
|
|||||||
|
|
||||||
|
|
||||||
path = Path(__file__).resolve().parents[1] / "routes/chat_routes.py"
|
path = Path(__file__).resolve().parents[1] / "routes/chat_routes.py"
|
||||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
tree = ast.parse(path.read_text())
|
||||||
node = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "_AgentRenderState")
|
node = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "_AgentRenderState")
|
||||||
namespace = {}
|
namespace = {}
|
||||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), "exec"), namespace)
|
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), "exec"), namespace)
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
"""An unknown context window must not be budgeted like a tiny local server (#6678).
|
|
||||||
|
|
||||||
``budget_context_for_model`` returns 0 when the endpoint does not prove its
|
|
||||||
window, and ``compute_input_token_budget`` answers 0 with the conservative
|
|
||||||
default budget. The agent loop's small-server clamp (``<= 8192``) also caught
|
|
||||||
0 and cut the request to 1200 tokens, so an open document or earlier context
|
|
||||||
of a few thousand tokens was trimmed away before the model saw it.
|
|
||||||
"""
|
|
||||||
import inspect
|
|
||||||
import json
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
DONE = 'data: [DONE]\n\n'
|
|
||||||
MARKER = 'NIGHTJAR-7'
|
|
||||||
# ~3000 estimated tokens: over the old 1200 clamp, under the 6000 default.
|
|
||||||
CONTEXT = ''.join(
|
|
||||||
f'## Project-{n}\nOwner: team {n}. Status: active. Notes: routine maintenance '
|
|
||||||
'and quarterly review scheduled for every workstream in this registry.\n\n'
|
|
||||||
for n in range(60)
|
|
||||||
) + f'## Project-Alpha\nAlias: {MARKER}\n'
|
|
||||||
|
|
||||||
|
|
||||||
def _event(payload):
|
|
||||||
return 'data: ' + json.dumps(payload) + '\n\n'
|
|
||||||
|
|
||||||
|
|
||||||
async def _first_request(monkeypatch, tmp_path, context_window):
|
|
||||||
import src.agent_loop as al
|
|
||||||
from tests.test_agent_runtime_context import _patch_fake_skills
|
|
||||||
_patch_fake_skills(monkeypatch)
|
|
||||||
monkeypatch.setattr('src.tool_index.get_tool_index', lambda: None)
|
|
||||||
monkeypatch.setattr('src.model_context.budget_context_for_model',
|
|
||||||
lambda *args, **kwargs: context_window)
|
|
||||||
seen = []
|
|
||||||
|
|
||||||
async def provider(candidates, messages, **kwargs):
|
|
||||||
# Like llm_core: the per-candidate factory shapes and trims the request.
|
|
||||||
url, model, headers = candidates[0][:3]
|
|
||||||
request = kwargs['candidate_request_factory'](0, url, model, headers)
|
|
||||||
if inspect.isawaitable(request):
|
|
||||||
request = await request
|
|
||||||
seen.append((request or {}).get('messages', messages))
|
|
||||||
yield _event({'delta': 'ok'})
|
|
||||||
yield DONE
|
|
||||||
|
|
||||||
monkeypatch.setattr(al, 'stream_llm_with_fallback', provider)
|
|
||||||
history = [
|
|
||||||
{'role': 'user', 'content': 'Keep this registry for later:\n\n' + CONTEXT},
|
|
||||||
{'role': 'assistant', 'content': 'Saved.'},
|
|
||||||
{'role': 'user', 'content': 'What is the alias of Project-Alpha?'},
|
|
||||||
]
|
|
||||||
async for _ in al.stream_agent_loop('http://127.0.0.1:9/v1', 'model', history,
|
|
||||||
max_rounds=1, workspace=str(tmp_path)):
|
|
||||||
pass
|
|
||||||
assert seen, 'the provider was never called'
|
|
||||||
return json.dumps(seen[0])
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unknown_context_window_keeps_earlier_context(monkeypatch, tmp_path):
|
|
||||||
request = await _first_request(monkeypatch, tmp_path, 0)
|
|
||||||
assert MARKER in request
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_known_small_window_is_still_clamped(monkeypatch, tmp_path):
|
|
||||||
# The clamp exists for small local servers whose window is known (n_ctx
|
|
||||||
# 2048 here); it must keep protecting them.
|
|
||||||
request = await _first_request(monkeypatch, tmp_path, 2048)
|
|
||||||
assert MARKER not in request
|
|
||||||
@@ -3,8 +3,8 @@ from tests.helpers.stylesheets import app_css
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
CHAT = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
|
CHAT = (ROOT / "static/js/chat.js").read_text()
|
||||||
SESSIONS = (ROOT / "static/js/sessions.js").read_text(encoding="utf-8")
|
SESSIONS = (ROOT / "static/js/sessions.js").read_text()
|
||||||
CSS = app_css()
|
CSS = app_css()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
CHAT = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
|
CHAT = (ROOT / "static/js/chat.js").read_text()
|
||||||
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text()
|
||||||
|
|
||||||
|
|
||||||
def test_processing_indicator_shows_live_ttft_timer():
|
def test_processing_indicator_shows_live_ttft_timer():
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
"""Every variable a shipped compose file forwards must carry a default.
|
|
||||||
|
|
||||||
A bare ``${VAR}`` makes ``docker compose`` print "variable is not set" on every
|
|
||||||
command for a default ``.env`` (#6619), which greets fresh installs with a
|
|
||||||
warning that looks like a misconfiguration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
COMPOSE_FILES = sorted(ROOT.glob("docker-compose*.yml")) + sorted((ROOT / "docker").glob("*.yml"))
|
|
||||||
|
|
||||||
# ${VAR} with no ``:-``/``-``/``:?``/``?`` modifier.
|
|
||||||
BARE_VAR = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("path", COMPOSE_FILES, ids=lambda p: p.relative_to(ROOT).as_posix())
|
|
||||||
def test_compose_variables_have_defaults(path):
|
|
||||||
bare = [
|
|
||||||
f"{path.name}:{lineno}: ${{{name}}}"
|
|
||||||
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
|
|
||||||
if not line.lstrip().startswith("#")
|
|
||||||
for name in BARE_VAR.findall(line)
|
|
||||||
]
|
|
||||||
assert not bare, "compose variables without a default:\n" + "\n".join(bare)
|
|
||||||
@@ -70,63 +70,3 @@ def test_no_gguf_found_pattern():
|
|||||||
result = _diagnose_serve_output(out)
|
result = _diagnose_serve_output(out)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert "GGUF" in result["message"]
|
assert "GGUF" in result["message"]
|
||||||
|
|
||||||
|
|
||||||
def test_cookbook_routes_uses_shared_diagnosis_helper():
|
|
||||||
# /api/cookbook/tasks/status must use the maintained helper. A nested copy in
|
|
||||||
# cookbook_routes.py shadowed the import and silently drifted (missing the
|
|
||||||
# ModelOpt, mflux and MLX bridge diagnoses).
|
|
||||||
import ast
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
source = (Path(__file__).resolve().parents[1] / "routes" / "cookbook_routes.py").read_text(encoding="utf-8")
|
|
||||||
local_defs = [
|
|
||||||
node.lineno
|
|
||||||
for node in ast.walk(ast.parse(source))
|
|
||||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "_diagnose_serve_output"
|
|
||||||
]
|
|
||||||
assert local_defs == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_modelopt_lm_head_checkpoint_returns_diagnosis():
|
|
||||||
out = "ValueError: There is no module or parameter named 'lm_head.input_scale' in Qwen3ForCausalLM"
|
|
||||||
result = _diagnose_serve_output(out)
|
|
||||||
assert result is not None
|
|
||||||
assert "ModelOpt" in result["message"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_nvidia_driver_too_old_for_torch_returns_diagnosis():
|
|
||||||
# vLLM 0.31 from PyPI ships torch cu130; on driver 570 (CUDA 12.8) the engine dies here.
|
|
||||||
out = (
|
|
||||||
"(EngineCore pid=1) ERROR [core.py:1433] self.driver_worker.init_device()\n"
|
|
||||||
"(EngineCore pid=1) ERROR [core.py:1433] RuntimeError: The NVIDIA driver on your system is too old "
|
|
||||||
"(found version 12080). Please update your GPU driver by downloading and installing a new version\n"
|
|
||||||
"Traceback (most recent call last):\n"
|
|
||||||
"RuntimeError: Engine core initialization failed. See root cause above. Failed core proc(s): {}\n"
|
|
||||||
)
|
|
||||||
result = _diagnose_serve_output(out)
|
|
||||||
assert result is not None
|
|
||||||
assert "driver" in result["message"].lower()
|
|
||||||
assert any("cu129" in s["label"] for s in result["suggestions"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_cuda_runtime_newer_than_driver_returns_diagnosis():
|
|
||||||
# torch switched to cu128 but the vLLM wheel's own kernels still need CUDA 13.
|
|
||||||
out = (
|
|
||||||
"RuntimeError: get_cuda_view_from_cpu_tensor, /workspace/csrc/libtorch_stable/cuda_view.cu:38, "
|
|
||||||
"cudaHostGetDevicePointer failed: CUDA driver version is insufficient for CUDA runtime version\n"
|
|
||||||
"Traceback (most recent call last):\n"
|
|
||||||
)
|
|
||||||
result = _diagnose_serve_output(out)
|
|
||||||
assert result is not None
|
|
||||||
assert "driver" in result["message"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_tasks_status_capture_joins_wrapped_tmux_lines():
|
|
||||||
# tmux wraps at the pane width (often mid-word), which hid errors like
|
|
||||||
# "insufficie|nt for CUDA runtime version" from the diagnosis patterns.
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
source = (Path(__file__).resolve().parents[1] / "routes" / "cookbook_routes.py").read_text(encoding="utf-8")
|
|
||||||
assert '_remote_tmux_command("capture-pane", "-t", session_id, "-p", "-J", "-S", "-500")' in source
|
|
||||||
assert '["tmux", "capture-pane", "-t", session_id, "-p", "-J", "-S", "-500"]' in source
|
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import pytest
|
|
||||||
import httpx
|
|
||||||
from unittest.mock import patch, AsyncMock
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from routes.cookbook_routes import setup_cookbook_routes
|
|
||||||
from src.auth_helpers import require_user
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
router = setup_cookbook_routes()
|
|
||||||
app.include_router(router)
|
|
||||||
app.dependency_overrides[require_user] = lambda: "test_user"
|
|
||||||
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
def test_hf_gguf_files_success():
|
|
||||||
mock_resp = httpx.Response(
|
|
||||||
200,
|
|
||||||
json={
|
|
||||||
"siblings": [
|
|
||||||
{"rfilename": "model-q4_k_m.gguf"},
|
|
||||||
{"rfilename": "model-q8_0.gguf"},
|
|
||||||
{"rfilename": "README.md"},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
request=httpx.Request("GET", "https://huggingface.co/api/models/TheBloke/Llama-2-7B-GGUF"),
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=mock_resp):
|
|
||||||
response = client.get("/api/cookbook/hf-gguf-files?repo_id=TheBloke/Llama-2-7B-GGUF")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["ok"] is True
|
|
||||||
assert data["repo_id"] == "TheBloke/Llama-2-7B-GGUF"
|
|
||||||
assert data["files"] == ["model-q4_k_m.gguf", "model-q8_0.gguf"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_hf_gguf_files_http_error():
|
|
||||||
mock_resp = httpx.Response(
|
|
||||||
404,
|
|
||||||
json={"error": "Model not found"},
|
|
||||||
request=httpx.Request("GET", "https://huggingface.co/api/models/invalid/repo"),
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=mock_resp):
|
|
||||||
response = client.get("/api/cookbook/hf-gguf-files?repo_id=invalid/repo")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["ok"] is False
|
|
||||||
assert "HF API HTTP 404" in data["error"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_hf_gguf_files_exception_handling():
|
|
||||||
with patch("httpx.AsyncClient.get", new_callable=AsyncMock, side_effect=httpx.ConnectError("Network unreachable")):
|
|
||||||
response = client.get("/api/cookbook/hf-gguf-files?repo_id=TheBloke/Llama-2-7B-GGUF")
|
|
||||||
# Should gracefully return JSON error payload, not raise NameError / 500
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["ok"] is False
|
|
||||||
assert data["error"] == "HF API request failed"
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
"""Daily brief must query and display each calendar storage kind in local time."""
|
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
from sqlalchemy.pool import StaticPool
|
|
||||||
|
|
||||||
from core import database
|
|
||||||
from src import builtin_actions
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def system_timezone(name):
|
|
||||||
previous = os.environ.get('TZ')
|
|
||||||
os.environ['TZ'] = name
|
|
||||||
time.tzset()
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
if previous is None:
|
|
||||||
os.environ.pop('TZ', None)
|
|
||||||
else:
|
|
||||||
os.environ['TZ'] = previous
|
|
||||||
time.tzset()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not hasattr(time, 'tzset'), reason='requires changing the process local timezone')
|
|
||||||
@pytest.mark.parametrize('zone', ['Europe/Berlin', 'America/Los_Angeles'])
|
|
||||||
def test_daily_brief_selects_local_day_and_orders_mixed_storage(monkeypatch, zone):
|
|
||||||
engine = create_engine('sqlite://', poolclass=StaticPool)
|
|
||||||
for model in [database.CalendarCal, database.CalendarEvent, database.Note]:
|
|
||||||
model.__table__.create(engine)
|
|
||||||
sessions = sessionmaker(bind=engine)
|
|
||||||
monkeypatch.setattr(database, 'SessionLocal', sessions)
|
|
||||||
from core.auth import AuthManager
|
|
||||||
monkeypatch.setattr(AuthManager, 'is_configured', property(lambda self: True))
|
|
||||||
from routes import email_helpers
|
|
||||||
def no_email(*args):
|
|
||||||
raise RuntimeError('No email account in this calendar-only test')
|
|
||||||
monkeypatch.setattr(email_helpers, '_imap_connect', no_email)
|
|
||||||
try:
|
|
||||||
with system_timezone(zone):
|
|
||||||
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
def utc(value):
|
|
||||||
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
|
||||||
with sessions() as db:
|
|
||||||
for owner in ['alice', 'bob', None]:
|
|
||||||
db.add(database.CalendarCal(id=owner or 'shared', owner=owner, name=owner or 'shared'))
|
|
||||||
db.flush()
|
|
||||||
def add(uid, start, end, *, utc_row=False, all_day=False, owner='alice', status='confirmed'):
|
|
||||||
db.add(database.CalendarEvent(uid=uid, calendar_id=owner or 'shared', summary=uid,
|
|
||||||
dtstart=utc(start) if utc_row and not all_day else start,
|
|
||||||
dtend=utc(end) if utc_row and not all_day else end,
|
|
||||||
is_utc=utc_row, all_day=all_day, status=status))
|
|
||||||
add('early', today+timedelta(minutes=30), today+timedelta(hours=1), utc_row=True)
|
|
||||||
add('utc-ten', today+timedelta(hours=10), today+timedelta(hours=11), utc_row=True)
|
|
||||||
add('local-nine', today+timedelta(hours=9), today+timedelta(hours=10))
|
|
||||||
add('all-day', today, today+timedelta(days=1), utc_row=True, all_day=True)
|
|
||||||
add('next-day', today+timedelta(days=1,minutes=30), today+timedelta(days=1,hours=1), utc_row=True)
|
|
||||||
add('ended', today-timedelta(hours=1), today, utc_row=True)
|
|
||||||
add('cancelled', today+timedelta(hours=12), today+timedelta(hours=13), utc_row=True, status='cancelled')
|
|
||||||
add('foreign', today+timedelta(hours=12), today+timedelta(hours=13), utc_row=True, owner='bob')
|
|
||||||
add('unowned', today+timedelta(hours=12), today+timedelta(hours=13), owner=None)
|
|
||||||
db.commit()
|
|
||||||
text, success = asyncio.run(builtin_actions.action_daily_brief('alice'))
|
|
||||||
assert success, text
|
|
||||||
lines = ['all day all-day', '00:30 early', '09:00 local-nine', '10:00 utc-ten']
|
|
||||||
assert all(line in text for line in lines), text
|
|
||||||
assert [text.index(line) for line in lines] == sorted(text.index(line) for line in lines), text
|
|
||||||
for excluded in ['next-day', 'ended', 'cancelled', 'foreign', 'unowned']:
|
|
||||||
assert excluded not in text, text
|
|
||||||
finally:
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('day, hours', [('2026-03-29', 23), ('2026-10-25', 25)])
|
|
||||||
def test_utc_day_window_uses_offset_at_each_boundary(day, hours):
|
|
||||||
start = datetime.fromisoformat(day)
|
|
||||||
zone = ZoneInfo('Europe/Berlin')
|
|
||||||
begin = builtin_actions._daily_brief_utc_boundary(start, zone)
|
|
||||||
end = builtin_actions._daily_brief_utc_boundary(start+timedelta(days=1), zone)
|
|
||||||
assert end-begin == timedelta(hours=hours)
|
|
||||||
if hasattr(time, 'tzset'):
|
|
||||||
with system_timezone('Europe/Berlin'):
|
|
||||||
assert builtin_actions._daily_brief_utc_boundary(start) == begin
|
|
||||||
assert builtin_actions._daily_brief_utc_boundary(start+timedelta(days=1)) == end
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('utc_row, all_day, expected', [
|
|
||||||
(True, False, datetime(2026, 6, 1, 10)),
|
|
||||||
(False, False, datetime(2026, 6, 1, 8)),
|
|
||||||
(True, True, datetime(2026, 6, 1, 8)),
|
|
||||||
])
|
|
||||||
def test_local_start_preserves_floating_and_date_only_values(utc_row, all_day, expected):
|
|
||||||
event = SimpleNamespace(dtstart=datetime(2026,6,1,8), is_utc=utc_row, all_day=all_day)
|
|
||||||
assert builtin_actions._daily_brief_local_start(event, ZoneInfo('Europe/Berlin')) == expected
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
"""Issue #2137: never preselect an embedding or other non-chat model."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from core.database import ModelEndpoint
|
|
||||||
from routes import model_routes, prefs_routes
|
|
||||||
from tests.helpers.database import disposable_database
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def defaults_api(monkeypatch, tmp_path):
|
|
||||||
defaults = {}
|
|
||||||
with disposable_database(tmp_path) as factory:
|
|
||||||
monkeypatch.setattr(model_routes, "SessionLocal", factory)
|
|
||||||
monkeypatch.setattr(model_routes, "_load_settings", lambda: {})
|
|
||||||
monkeypatch.setattr(prefs_routes, "_load_for_user", lambda owner: defaults)
|
|
||||||
app = FastAPI()
|
|
||||||
app.state.auth_manager = SimpleNamespace(is_admin=lambda owner: False)
|
|
||||||
|
|
||||||
@app.middleware("http")
|
|
||||||
async def owner(request, call_next):
|
|
||||||
request.state.current_user = "alice"
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
app.include_router(model_routes.setup_model_routes(model_discovery=None))
|
|
||||||
with TestClient(app) as client:
|
|
||||||
yield client, factory, defaults
|
|
||||||
|
|
||||||
|
|
||||||
def _endpoint(factory, *, cached=(), pinned=(), hidden=(), owner="alice", endpoint_id="ep"):
|
|
||||||
with factory() as db:
|
|
||||||
db.add(ModelEndpoint(
|
|
||||||
id=endpoint_id, name="Fixture", base_url="http://model.invalid/v1", owner=owner,
|
|
||||||
is_enabled=True, cached_models=json.dumps(cached),
|
|
||||||
pinned_models=json.dumps(pinned), hidden_models=json.dumps(hidden),
|
|
||||||
))
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("non_chat", [
|
|
||||||
"text-embedding-3-small", "nomic-embed-text:latest", "jina-reranker-v2-base",
|
|
||||||
"tts-1", "whisper-1", "gpt-image-1",
|
|
||||||
])
|
|
||||||
def test_automatic_default_skips_non_chat_models(defaults_api, non_chat):
|
|
||||||
client, factory, _ = defaults_api
|
|
||||||
_endpoint(factory, cached=[non_chat, "chat-model"])
|
|
||||||
assert client.get("/api/default-chat").json()["model"] == "chat-model"
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_chat_saved_default_falls_back_to_visible_pinned_chat(defaults_api):
|
|
||||||
client, factory, defaults = defaults_api
|
|
||||||
_endpoint(factory, cached=["text-embedding-3-small", "hidden-chat"],
|
|
||||||
pinned=["jina-reranker-v2-base", "pinned-chat"], hidden=["hidden-chat"])
|
|
||||||
defaults.update(default_endpoint_id="ep", default_model="text-embedding-3-small")
|
|
||||||
assert client.get("/api/default-chat").json()["model"] == "pinned-chat"
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_chat_candidate_leaves_model_empty(defaults_api):
|
|
||||||
client, factory, defaults = defaults_api
|
|
||||||
_endpoint(factory, cached=["text-embedding-3-small", "hidden-chat"],
|
|
||||||
pinned=["jina-reranker-v2-base"], hidden=["hidden-chat"])
|
|
||||||
defaults.update(default_endpoint_id="ep", default_model="text-embedding-3-small")
|
|
||||||
result = client.get("/api/default-chat").json()
|
|
||||||
assert result == {"endpoint_id": "ep", "endpoint_url": "http://model.invalid/v1/chat/completions", "model": ""}
|
|
||||||
|
|
||||||
|
|
||||||
def test_valid_explicit_model_survives_stale_catalog(defaults_api):
|
|
||||||
client, factory, defaults = defaults_api
|
|
||||||
_endpoint(factory, cached=["text-embedding-3-small", "other-chat"])
|
|
||||||
defaults.update(default_endpoint_id="ep", default_model="my-custom-chat")
|
|
||||||
assert client.get("/api/default-chat").json()["model"] == "my-custom-chat"
|
|
||||||
|
|
||||||
|
|
||||||
def test_model_filter_preserves_owner_scope(defaults_api):
|
|
||||||
client, factory, defaults = defaults_api
|
|
||||||
_endpoint(factory, owner="bob", endpoint_id="bobs-endpoint", cached=["bobs-chat"])
|
|
||||||
_endpoint(factory, cached=["text-embedding-3-small", "alices-chat"])
|
|
||||||
defaults.update(default_endpoint_id="bobs-endpoint")
|
|
||||||
result = client.get("/api/default-chat").json()
|
|
||||||
assert result["endpoint_id"] == "ep"
|
|
||||||
assert result["model"] == "alices-chat"
|
|
||||||
@@ -67,14 +67,6 @@ def test_compose_files_forward_companion_base_url():
|
|||||||
assert "COMPANION_BASE_URL" in _compose_env_names(path), path.name
|
assert "COMPANION_BASE_URL" in _compose_env_names(path), path.name
|
||||||
|
|
||||||
|
|
||||||
def test_compose_files_forward_first_token_timeout():
|
|
||||||
# The local first-event watchdog (src/llm_core.py) reads this from the
|
|
||||||
# process environment, so a Docker install can only reach it when the
|
|
||||||
# compose files forward it from the host .env.
|
|
||||||
for path in COMPOSE_FILES:
|
|
||||||
assert "ODYSSEUS_FIRST_TOKEN_TIMEOUT" in _compose_env_names(path), path.name
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_compose_files_do_not_mount_host_docker_socket():
|
def test_default_compose_files_do_not_mount_host_docker_socket():
|
||||||
for path in COMPOSE_FILES:
|
for path in COMPOSE_FILES:
|
||||||
text = path.read_text(encoding="utf-8")
|
text = path.read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
"""ZIP exports keep language extensions and place collision suffixes before them."""
|
|
||||||
import io
|
|
||||||
import zipfile
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from core.database import Base, Document
|
|
||||||
from routes.document import document_routes
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def exports(tmp_path, monkeypatch):
|
|
||||||
engine = create_engine(f"sqlite:///{tmp_path / 'documents.db'}", connect_args={"check_same_thread": False})
|
|
||||||
Base.metadata.create_all(engine)
|
|
||||||
factory = sessionmaker(bind=engine)
|
|
||||||
monkeypatch.setattr(document_routes, "SessionLocal", factory)
|
|
||||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
@app.middleware("http")
|
|
||||||
async def fixture_user(request: Request, call_next):
|
|
||||||
request.state.current_user = "alice"
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
app.include_router(document_routes.setup_document_routes(None))
|
|
||||||
|
|
||||||
def add(doc_id, title, language, content="body\n", owner="alice"):
|
|
||||||
with factory() as db:
|
|
||||||
db.add(Document(id=doc_id, title=title, language=language,
|
|
||||||
current_content=content, owner=owner, is_active=True))
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
with TestClient(app) as client:
|
|
||||||
yield client, add
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("title,language,filename", [
|
|
||||||
("config.local", "python", "config.local.py"),
|
|
||||||
("report.v2", "markdown", "report.v2.md"),
|
|
||||||
("data.2024", "json", "data.2024.json"),
|
|
||||||
("module.ts", "typescript", "module.ts"),
|
|
||||||
("SCRIPT.PY", "python", "SCRIPT.PY"),
|
|
||||||
("résumé.v2", "text", "résumé.v2.txt"),
|
|
||||||
("unknown.v2", "unknown", "unknown.v2.txt"),
|
|
||||||
("draft", None, "draft.txt"),
|
|
||||||
("project/report.v2", "markdown", "projectreport.v2.md"),
|
|
||||||
])
|
|
||||||
def test_export_uses_the_language_extension_even_for_dotted_titles(exports, title, language, filename):
|
|
||||||
client, add = exports
|
|
||||||
body = "Original text: α\n"
|
|
||||||
add("doc", title, language, body)
|
|
||||||
response = client.post("/api/documents/export-zip", json={"ids": ["doc"]})
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.headers["content-type"] == "application/zip"
|
|
||||||
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
|
||||||
assert archive.namelist() == [filename]
|
|
||||||
assert archive.read(filename).decode() == body
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("title", ["config.local", "config.local.py"])
|
|
||||||
def test_duplicate_names_keep_the_extension_at_the_end(exports, title):
|
|
||||||
client, add = exports
|
|
||||||
for i in range(3):
|
|
||||||
add(str(i), title, "python", f"body {i}")
|
|
||||||
response = client.post("/api/documents/export-zip", json={"ids": ["0", "1", "2"]})
|
|
||||||
assert response.status_code == 200
|
|
||||||
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
|
||||||
assert set(archive.namelist()) == {"config.local.py", "config.local-1.py", "config.local-2.py"}
|
|
||||||
assert {archive.read(name).decode() for name in archive.namelist()} == {"body 0", "body 1", "body 2"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_added_extension_collides_safely_with_an_existing_extension(exports):
|
|
||||||
client, add = exports
|
|
||||||
add("a", "report.v2", "markdown", "first")
|
|
||||||
add("b", "report.v2.md", "markdown", "second")
|
|
||||||
response = client.post("/api/documents/export-zip", json={"ids": ["a", "b"]})
|
|
||||||
assert response.status_code == 200
|
|
||||||
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
|
||||||
assert set(archive.namelist()) == {"report.v2.md", "report.v2-1.md"}
|
|
||||||
assert {archive.read(name) for name in archive.namelist()} == {b"first", b"second"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_foreign_documents_are_still_excluded_and_email_body_is_preserved(exports):
|
|
||||||
client, add = exports
|
|
||||||
add("owned", "reply.v2", "email", "Subject: Hello\n---\nBody")
|
|
||||||
add("foreign", "private", "python", "secret", owner="bob")
|
|
||||||
response = client.post("/api/documents/export-zip", json={"ids": ["owned", "foreign"]})
|
|
||||||
assert response.status_code == 200
|
|
||||||
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
|
||||||
assert archive.namelist() == ["reply.v2.eml"]
|
|
||||||
assert archive.read("reply.v2.eml") == b"Subject: Hello\r\n\r\nBody"
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
"""Literal punctuation in library queries must not become SQL wildcards."""
|
|
||||||
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from core.database import Document
|
|
||||||
from routes.document import document_routes
|
|
||||||
from tests.helpers.database import disposable_database
|
|
||||||
|
|
||||||
|
|
||||||
CASES = [
|
|
||||||
("100%", "100% complete", "1000 complete"),
|
|
||||||
("a_b", "a_b", "axb"),
|
|
||||||
("%", "price 10%", "price 100"),
|
|
||||||
("_", "a_b", "axb"),
|
|
||||||
(r"path\notes", r"path\notes", "pathnotes"),
|
|
||||||
(r"path\_tag", r"path\_tag", r"path\Xtag"),
|
|
||||||
("50%_done", "50%_done", "500Xdone"),
|
|
||||||
("can't", "can't stop", "cannot stop"),
|
|
||||||
("mixed case", "Case ordered Mixed", "Mixed without"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("column", ["title", "current_content"])
|
|
||||||
@pytest.mark.parametrize("query,literal,lookalike", CASES)
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_library_search_matches_literal_tokens(tmp_path, monkeypatch, column, query, literal, lookalike):
|
|
||||||
with disposable_database(tmp_path) as factory:
|
|
||||||
monkeypatch.setattr(document_routes, "SessionLocal", factory)
|
|
||||||
with factory() as db:
|
|
||||||
for doc_id, text, owner, active, archived in [
|
|
||||||
("literal", literal, "alice", True, False),
|
|
||||||
("lookalike", lookalike, "alice", True, False),
|
|
||||||
("foreign", literal, "bob", True, False),
|
|
||||||
("inactive", literal, "alice", False, False),
|
|
||||||
("archived", literal, "alice", True, True),
|
|
||||||
]:
|
|
||||||
fields = {"title": "Document", "current_content": "Body", column: text}
|
|
||||||
db.add(Document(id=doc_id, owner=owner, is_active=active, archived=archived,
|
|
||||||
language="text", version_count=1, **fields))
|
|
||||||
db.commit()
|
|
||||||
router = document_routes.setup_document_routes(MagicMock())
|
|
||||||
endpoint = next(r.endpoint for r in router.routes if r.path == "/api/documents/library")
|
|
||||||
request = SimpleNamespace(state=SimpleNamespace(current_user="alice"))
|
|
||||||
result = await endpoint(request, search=query, language=None, sort="recent", offset=0, limit=20, archived=False)
|
|
||||||
assert result["total"] == 1
|
|
||||||
assert [row["id"] for row in result["documents"]] == ["literal"]
|
|
||||||
archived = await endpoint(request, search=query, language=None, sort="recent", offset=0, limit=20, archived=True)
|
|
||||||
assert [row["id"] for row in archived["documents"]] == ["archived"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_literal_search_keeps_cross_field_and_pagination_behavior(tmp_path, monkeypatch):
|
|
||||||
with disposable_database(tmp_path) as factory:
|
|
||||||
monkeypatch.setattr(document_routes, "SessionLocal", factory)
|
|
||||||
with factory() as db:
|
|
||||||
for doc_id, title, content in [
|
|
||||||
("first", "100% ready", "done"),
|
|
||||||
("second", "done", "100% ready"),
|
|
||||||
("lookalike", "1000 ready", "done"),
|
|
||||||
("missing-term", "100% ready", "waiting"),
|
|
||||||
]:
|
|
||||||
db.add(Document(id=doc_id, owner="alice", title=title, current_content=content,
|
|
||||||
is_active=True, archived=False, language="text", version_count=1))
|
|
||||||
db.commit()
|
|
||||||
router = document_routes.setup_document_routes(MagicMock())
|
|
||||||
endpoint = next(r.endpoint for r in router.routes if r.path == "/api/documents/library")
|
|
||||||
request = SimpleNamespace(state=SimpleNamespace(current_user="alice"))
|
|
||||||
results = [await endpoint(request, search="100% done", language="text", sort="alpha",
|
|
||||||
offset=offset, limit=1, archived=False) for offset in [0, 1]]
|
|
||||||
assert all(result["total"] == 2 for result in results)
|
|
||||||
assert {row["id"] for result in results for row in result["documents"]} == {"first", "second"}
|
|
||||||
@@ -41,8 +41,7 @@ def test_claim_is_fsynced_to_disk_before_returning(tmp_path, target, monkeypatch
|
|||||||
on_disk = records(log.path)
|
on_disk = records(log.path)
|
||||||
assert [r["type"] for r in on_disk] == ["claim"]
|
assert [r["type"] for r in on_disk] == ["claim"]
|
||||||
assert fx.EffectClaim.from_dict(on_disk[0]["record"]) == made
|
assert fx.EffectClaim.from_dict(on_disk[0]["record"]) == made
|
||||||
if os.name != "nt": # Windows has no POSIX permission bits: os.open's 0o600 is not reflected in st_mode
|
assert oct(log.path.stat().st_mode & 0o777) == "0o600"
|
||||||
assert oct(log.path.stat().st_mode & 0o777) == "0o600"
|
|
||||||
|
|
||||||
|
|
||||||
def test_claim_persistence_failure_raises_and_records_nothing(tmp_path, target):
|
def test_claim_persistence_failure_raises_and_records_nothing(tmp_path, target):
|
||||||
|
|||||||
@@ -90,42 +90,3 @@ def test_fork_does_not_corrupt_source_message_metadata(monkeypatch):
|
|||||||
# ...and the source session's _db_id values are untouched.
|
# ...and the source session's _db_id values are untouched.
|
||||||
assert source.history[0].metadata["_db_id"] == "src-0"
|
assert source.history[0].metadata["_db_id"] == "src-0"
|
||||||
assert source.history[1].metadata["_db_id"] == "src-1"
|
assert source.history[1].metadata["_db_id"] == "src-1"
|
||||||
|
|
||||||
|
|
||||||
def _fork_with(monkeypatch, body, db_ids):
|
|
||||||
"""Fork a 4-message source whose DB rows have the given ids."""
|
|
||||||
monkeypatch.setattr(mod, "_verify_session_owner", lambda *a, **k: None)
|
|
||||||
source = _FakeSession(name="Long chat")
|
|
||||||
for i, role in enumerate(["user", "assistant", "user", "assistant"]):
|
|
||||||
source.history.append(ChatMessage(role=role, content=f"m{i}", metadata={"_db_id": db_ids[i]}))
|
|
||||||
manager = _FakeSessionManager(source)
|
|
||||||
rows = [SimpleNamespace(id=i) for i in db_ids]
|
|
||||||
|
|
||||||
class _Query:
|
|
||||||
def filter(self, *a): return self
|
|
||||||
def order_by(self, *a): return self
|
|
||||||
def all(self): return rows
|
|
||||||
|
|
||||||
class _DB:
|
|
||||||
def query(self, *a): return _Query()
|
|
||||||
def close(self): pass
|
|
||||||
|
|
||||||
monkeypatch.setattr(mod, "SessionLocal", lambda: _DB())
|
|
||||||
router = mod.setup_history_routes(manager)
|
|
||||||
|
|
||||||
class _Req:
|
|
||||||
async def json(self): return body
|
|
||||||
|
|
||||||
asyncio.run(_fork_handler(router)(_Req(), "src-id"))
|
|
||||||
return [m.content for m in manager.created.history]
|
|
||||||
|
|
||||||
|
|
||||||
def test_fork_before_a_message_id_ignores_a_stale_keep_count(monkeypatch):
|
|
||||||
copied = _fork_with(monkeypatch, {"before_msg_id": "c", "keep_count": 0}, ["a", "b", "c", "d"])
|
|
||||||
assert copied == ["m0", "m1"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_fork_through_a_message_id_includes_it(monkeypatch):
|
|
||||||
copied = _fork_with(monkeypatch, {"through_msg_id": "b", "keep_count": 0}, ["a", "b", "c", "d"])
|
|
||||||
assert copied == ["m0", "m1"]
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
import routes.gallery_routes as gallery
|
|
||||||
|
|
||||||
|
|
||||||
def route(path, method):
|
|
||||||
return next(r for r in gallery.setup_gallery_routes().routes if r.path == path and method in r.methods)
|
|
||||||
|
|
||||||
|
|
||||||
def call(path, method, monkeypatch, body):
|
|
||||||
monkeypatch.setattr(gallery, 'get_current_user', lambda request: 'alice')
|
|
||||||
|
|
||||||
class Request:
|
|
||||||
async def json(self):
|
|
||||||
return body
|
|
||||||
|
|
||||||
return asyncio.run(route(path, method).endpoint(Request(), 'album-1'))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('body', [
|
|
||||||
{'name': ''},
|
|
||||||
{'name': ' '},
|
|
||||||
{'name': 123},
|
|
||||||
{'name': ['x']},
|
|
||||||
{'description': {'a': 1}},
|
|
||||||
[1],
|
|
||||||
])
|
|
||||||
def test_album_update_rejects_bad_input(body, monkeypatch):
|
|
||||||
with pytest.raises(HTTPException) as exc:
|
|
||||||
call('/api/gallery/albums/{album_id}', 'PUT', monkeypatch, body)
|
|
||||||
assert exc.value.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('op', ['add', 'remove'])
|
|
||||||
@pytest.mark.parametrize('body', [
|
|
||||||
{'image_ids': 'abc'},
|
|
||||||
{'image_ids': 5},
|
|
||||||
{'image_ids': None},
|
|
||||||
{'image_ids': [{'a': 1}]},
|
|
||||||
{'image_ids': ['ok', 1]},
|
|
||||||
[1],
|
|
||||||
])
|
|
||||||
def test_album_image_ids_must_be_a_list_of_strings(op, body, monkeypatch):
|
|
||||||
with pytest.raises(HTTPException) as exc:
|
|
||||||
call(f'/api/gallery/albums/{{album_id}}/{op}', 'POST', monkeypatch, body)
|
|
||||||
assert exc.value.status_code == 400
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
"""Regression for #2123 (gallery part): library search must match multi-word
|
|
||||||
queries in any order and treat % / _ literally.
|
|
||||||
|
|
||||||
Old code built a single ``%{search}%`` LIKE over prompt/tags/ai_tags, so
|
|
||||||
"red car" missed "a car that is red", and a literal "%" matched every row.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
from sqlalchemy.pool import NullPool
|
|
||||||
|
|
||||||
import core.database as cdb
|
|
||||||
from core.database import GalleryImage
|
|
||||||
import routes.gallery_routes as gallery_routes
|
|
||||||
|
|
||||||
|
|
||||||
def _client_with_gallery(monkeypatch, tmp_path):
|
|
||||||
engine = create_engine(
|
|
||||||
f"sqlite:///{tmp_path / 'gallery_search.db'}",
|
|
||||||
connect_args={"check_same_thread": False},
|
|
||||||
poolclass=NullPool,
|
|
||||||
)
|
|
||||||
cdb.Base.metadata.create_all(engine)
|
|
||||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
|
||||||
monkeypatch.setattr(gallery_routes, "SessionLocal", session_factory)
|
|
||||||
|
|
||||||
db = session_factory()
|
|
||||||
try:
|
|
||||||
db.add_all(
|
|
||||||
[
|
|
||||||
GalleryImage(
|
|
||||||
id="img-red-car",
|
|
||||||
filename=f"{uuid.uuid4().hex}.png",
|
|
||||||
prompt="a car that is red",
|
|
||||||
model="model-a",
|
|
||||||
tags="",
|
|
||||||
ai_tags="",
|
|
||||||
is_active=True,
|
|
||||||
file_size=10,
|
|
||||||
),
|
|
||||||
GalleryImage(
|
|
||||||
id="img-boat",
|
|
||||||
filename=f"{uuid.uuid4().hex}.png",
|
|
||||||
prompt="blue boat on the lake",
|
|
||||||
model="model-a",
|
|
||||||
tags="",
|
|
||||||
ai_tags="",
|
|
||||||
is_active=True,
|
|
||||||
file_size=10,
|
|
||||||
),
|
|
||||||
GalleryImage(
|
|
||||||
id="img-percent",
|
|
||||||
filename=f"{uuid.uuid4().hex}.png",
|
|
||||||
prompt="100% cotton shirt",
|
|
||||||
model="model-a",
|
|
||||||
tags="sale_2024",
|
|
||||||
ai_tags="",
|
|
||||||
is_active=True,
|
|
||||||
file_size=10,
|
|
||||||
),
|
|
||||||
GalleryImage(
|
|
||||||
id="img-backslash",
|
|
||||||
filename=f"{uuid.uuid4().hex}.png",
|
|
||||||
prompt=r"C:\photos\sunset",
|
|
||||||
model="model-a",
|
|
||||||
tags="",
|
|
||||||
ai_tags="",
|
|
||||||
is_active=True,
|
|
||||||
file_size=10,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
app.include_router(gallery_routes.setup_gallery_routes())
|
|
||||||
return TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
def test_gallery_search_matches_words_in_any_order(monkeypatch, tmp_path):
|
|
||||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
|
||||||
client = _client_with_gallery(monkeypatch, tmp_path)
|
|
||||||
|
|
||||||
body = client.get("/api/gallery/library", params={"search": "red car"}).json()
|
|
||||||
ids = {item["id"] for item in body["items"]}
|
|
||||||
assert "img-red-car" in ids
|
|
||||||
assert "img-boat" not in ids
|
|
||||||
|
|
||||||
|
|
||||||
def test_gallery_search_escapes_percent_wildcard(monkeypatch, tmp_path):
|
|
||||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
|
||||||
client = _client_with_gallery(monkeypatch, tmp_path)
|
|
||||||
|
|
||||||
body = client.get("/api/gallery/library", params={"search": "%"}).json()
|
|
||||||
ids = {item["id"] for item in body["items"]}
|
|
||||||
assert ids == {"img-percent"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_gallery_search_escapes_underscore_wildcard(monkeypatch, tmp_path):
|
|
||||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
|
||||||
client = _client_with_gallery(monkeypatch, tmp_path)
|
|
||||||
|
|
||||||
# Only img-percent has a literal underscore (in tags "sale_2024").
|
|
||||||
# Unescaped, "_" matches any single char so every row matches.
|
|
||||||
body = client.get("/api/gallery/library", params={"search": "_"}).json()
|
|
||||||
ids = {item["id"] for item in body["items"]}
|
|
||||||
assert ids == {"img-percent"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_gallery_search_escapes_backslash(monkeypatch, tmp_path):
|
|
||||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
|
||||||
client = _client_with_gallery(monkeypatch, tmp_path)
|
|
||||||
|
|
||||||
# Only img-backslash has a literal backslash. The escape char itself
|
|
||||||
# must survive escaping instead of escaping the surrounding wildcards.
|
|
||||||
body = client.get("/api/gallery/library", params={"search": "\\"}).json()
|
|
||||||
ids = {item["id"] for item in body["items"]}
|
|
||||||
assert ids == {"img-backslash"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_gallery_tag_filter_escapes_wildcards(monkeypatch, tmp_path):
|
|
||||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
|
||||||
client = _client_with_gallery(monkeypatch, tmp_path)
|
|
||||||
|
|
||||||
# Unescaped, "%" / "_" match every row; escaped, only literal matches.
|
|
||||||
assert client.get("/api/gallery/library", params={"tag": "%"}).json()["items"] == []
|
|
||||||
|
|
||||||
body = client.get("/api/gallery/library", params={"tag": "_"}).json()
|
|
||||||
assert {item["id"] for item in body["items"]} == {"img-percent"}
|
|
||||||
@@ -4,11 +4,11 @@ from tests.helpers.stylesheets import app_css
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
CHAT = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
|
CHAT = (ROOT / "static/js/chat.js").read_text()
|
||||||
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text()
|
||||||
STYLE = app_css()
|
STYLE = app_css()
|
||||||
APP = (ROOT / "static/app.js").read_text(encoding="utf-8")
|
APP = (ROOT / "static/app.js").read_text()
|
||||||
INDEX = (ROOT / "static/index.html").read_text(encoding="utf-8")
|
INDEX = (ROOT / "static/index.html").read_text()
|
||||||
|
|
||||||
|
|
||||||
def test_live_interruption_controls_have_visible_resume_labels():
|
def test_live_interruption_controls_have_visible_resume_labels():
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
"""Actual embedded PDF images must reach vision as a decodable PNG."""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image
|
|
||||||
from pypdf import PdfReader, PdfWriter
|
|
||||||
from pypdf.generic import DecodedStreamObject, DictionaryObject, NameObject, NumberObject
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src import document_processor as dp
|
|
||||||
|
|
||||||
|
|
||||||
def make_pdf(tmp_path, mode):
|
|
||||||
colors = {"CMYK": (0, 255, 255, 0), "RGB": (255, 0, 0), "L": 127}
|
|
||||||
path = tmp_path / (mode + ".pdf")
|
|
||||||
image = Image.new(mode, (128, 96), colors[mode])
|
|
||||||
if mode == "CMYK":
|
|
||||||
# A raw DeviceCMYK XObject avoids JPEG/Adobe inversion differences in
|
|
||||||
# Pillow-generated PDFs; the extracted color samples are unambiguous.
|
|
||||||
writer = PdfWriter()
|
|
||||||
page = writer.add_blank_page(width=128, height=96)
|
|
||||||
stream = DecodedStreamObject()
|
|
||||||
stream.set_data(image.tobytes())
|
|
||||||
stream.update({NameObject(key): value for key, value in {
|
|
||||||
"/Type": NameObject("/XObject"), "/Subtype": NameObject("/Image"),
|
|
||||||
"/ColorSpace": NameObject("/DeviceCMYK"), "/BitsPerComponent": NumberObject(8),
|
|
||||||
"/Width": NumberObject(128), "/Height": NumberObject(96),
|
|
||||||
}.items()})
|
|
||||||
page[NameObject("/Resources")] = DictionaryObject({NameObject("/XObject"):
|
|
||||||
DictionaryObject({NameObject("/Im0"): writer._add_object(stream)})})
|
|
||||||
content = DecodedStreamObject()
|
|
||||||
content.set_data(b"q 128 0 0 96 0 0 cm /Im0 Do Q")
|
|
||||||
page[NameObject("/Contents")] = writer._add_object(content)
|
|
||||||
writer.write(path)
|
|
||||||
else:
|
|
||||||
image.save(path, "PDF")
|
|
||||||
assert PdfReader(path).pages[0].images[0].image.mode == mode
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("mode,expected", [("CMYK", "RGB"), ("RGB", "RGB"), ("L", "L")])
|
|
||||||
def test_pdf_image_reaches_vision_with_owner_and_png_pixels(tmp_path, monkeypatch, mode, expected):
|
|
||||||
pdf = make_pdf(tmp_path, mode)
|
|
||||||
calls = []
|
|
||||||
def analyze(path, owner=None):
|
|
||||||
with Image.open(path) as image:
|
|
||||||
image.load()
|
|
||||||
assert image.format == "PNG" and image.mode == expected
|
|
||||||
assert image.size == (128, 96)
|
|
||||||
if mode == "CMYK":
|
|
||||||
red, green, blue = image.getpixel((64, 48))
|
|
||||||
assert red > 250 and green < 5 and blue < 5
|
|
||||||
calls.append((Path(path), owner))
|
|
||||||
return "Synthetic OCR result"
|
|
||||||
monkeypatch.setattr(dp, "analyze_image_with_vl", analyze)
|
|
||||||
output = dp._process_pdf(str(pdf), owner="alice")
|
|
||||||
assert "[Page 1 image 1 text]: Synthetic OCR result" in output
|
|
||||||
assert len(calls) == 1 and calls[0][1] == "alice"
|
|
||||||
assert not calls[0][0].exists(), "Temporary PNG must be removed after analysis"
|
|
||||||
assert PdfReader(pdf).pages[0].images[0].image.mode == mode, "Source PDF is unchanged"
|
|
||||||
|
|
||||||
|
|
||||||
def test_mixed_pdf_keeps_images_on_both_pages(tmp_path, monkeypatch):
|
|
||||||
writer = PdfWriter()
|
|
||||||
for mode in ("CMYK", "RGB"):
|
|
||||||
writer.append(str(make_pdf(tmp_path, mode)))
|
|
||||||
pdf = tmp_path / "mixed.pdf"
|
|
||||||
writer.write(pdf)
|
|
||||||
calls = []
|
|
||||||
def analyze(path, owner=None):
|
|
||||||
with Image.open(path) as image:
|
|
||||||
image.load()
|
|
||||||
calls.append(image.mode)
|
|
||||||
return "Image content"
|
|
||||||
monkeypatch.setattr(dp, "analyze_image_with_vl", analyze)
|
|
||||||
output = dp._process_pdf(str(pdf), owner="alice")
|
|
||||||
assert calls == ["RGB", "RGB"]
|
|
||||||
assert "Page 1 image 1 text" in output and "Page 2 image 1 text" in output
|
|
||||||
|
|
||||||
|
|
||||||
def test_text_only_extraction_does_not_enable_vision(tmp_path, monkeypatch):
|
|
||||||
pdf = make_pdf(tmp_path, "CMYK")
|
|
||||||
def unexpected(*args, **kwargs):
|
|
||||||
pytest.fail("Text-only extraction must not call vision")
|
|
||||||
monkeypatch.setattr(dp, "analyze_image_with_vl", unexpected)
|
|
||||||
assert "no readable content" in dp.extract_local_document(str(pdf), owner="alice")
|
|
||||||
|
|
||||||
|
|
||||||
def test_vision_failure_still_removes_converted_png(tmp_path, monkeypatch):
|
|
||||||
pdf = make_pdf(tmp_path, "CMYK")
|
|
||||||
paths = []
|
|
||||||
def unavailable(path, owner=None):
|
|
||||||
paths.append(Path(path))
|
|
||||||
with Image.open(path) as image:
|
|
||||||
assert image.mode == "RGB" and image.format == "PNG"
|
|
||||||
raise RuntimeError("Synthetic unavailable vision endpoint")
|
|
||||||
monkeypatch.setattr(dp, "analyze_image_with_vl", unavailable)
|
|
||||||
assert "no readable content" in dp._process_pdf(str(pdf), owner="alice")
|
|
||||||
assert len(paths) == 1 and not paths[0].exists()
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
"""Route defaults must use manually pinned IDs without widening ownership."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
import core.database as cdb
|
|
||||||
import routes.chat_routes as chat
|
|
||||||
from routes.task.task_routes import _resolve_run_endpoint
|
|
||||||
from routes.research.research_routes import _resolve_endpoint_runtime
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def database(monkeypatch):
|
|
||||||
engine = create_engine("sqlite:///:memory:")
|
|
||||||
cdb.Base.metadata.create_all(engine)
|
|
||||||
factory = sessionmaker(bind=engine)
|
|
||||||
monkeypatch.setattr(chat, "SessionLocal", factory)
|
|
||||||
with factory() as db:
|
|
||||||
yield db
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
def endpoint(db, eid="owned", *, owner="alice", cached=(), pinned=("pinned-chat",), hidden=()):
|
|
||||||
row = cdb.ModelEndpoint(
|
|
||||||
id=eid, name=eid, owner=owner, is_enabled=True,
|
|
||||||
base_url=f"https://{eid}.example.test/v1", api_key=None,
|
|
||||||
cached_models=json.dumps(cached), pinned_models=json.dumps(pinned),
|
|
||||||
hidden_models=json.dumps(hidden),
|
|
||||||
)
|
|
||||||
db.add(row)
|
|
||||||
db.commit()
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("cached", [(), ("hidden-chat",)])
|
|
||||||
def test_empty_session_recovers_a_visible_pinned_model(database, cached):
|
|
||||||
ep = endpoint(database, cached=cached, hidden=("hidden-chat",))
|
|
||||||
database.add(cdb.Session(id="chat", name="Chat", owner="alice", model="", endpoint_url=ep.base_url, endpoint_id=ep.id))
|
|
||||||
database.commit()
|
|
||||||
session = SimpleNamespace(model="", endpoint_url=ep.base_url, endpoint_id=ep.id)
|
|
||||||
|
|
||||||
assert chat._recover_empty_session_model(session, "chat", "alice") is True
|
|
||||||
assert session.model == "pinned-chat"
|
|
||||||
database.expire_all()
|
|
||||||
assert database.get(cdb.Session, "chat").model == "pinned-chat"
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_session_does_not_recover_a_hidden_pin(database):
|
|
||||||
ep = endpoint(database, hidden=("pinned-chat",))
|
|
||||||
session = SimpleNamespace(model="", endpoint_url=ep.base_url, endpoint_id=ep.id)
|
|
||||||
assert chat._recover_empty_session_model(session, "missing", "alice") is False
|
|
||||||
assert session.model == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_subscription_recovery_still_requires_its_live_catalog(database):
|
|
||||||
ep = endpoint(database)
|
|
||||||
ep.base_url = "https://chatgpt.com/backend-api/codex"
|
|
||||||
database.commit()
|
|
||||||
session = SimpleNamespace(model="", endpoint_url=ep.base_url, endpoint_id=ep.id)
|
|
||||||
assert chat._recover_empty_session_model(session, "missing", "alice") is False
|
|
||||||
assert session.model == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_task_run_reopens_against_its_owners_pinned_endpoint(database):
|
|
||||||
endpoint(database, "foreign", owner="bob", cached=("pinned-chat",))
|
|
||||||
own = endpoint(database)
|
|
||||||
task = SimpleNamespace(owner="alice", endpoint_url="", session_id=None, model="pinned-chat")
|
|
||||||
assert _resolve_run_endpoint(database, task, SimpleNamespace(model="pinned-chat")) == own.base_url
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("owner", ["alice", None])
|
|
||||||
def test_task_run_can_find_visible_pins_on_owned_or_shared_endpoints(database, owner):
|
|
||||||
ep = endpoint(database, owner=owner)
|
|
||||||
task = SimpleNamespace(owner="alice", endpoint_url="", session_id=None, model="pinned-chat")
|
|
||||||
assert _resolve_run_endpoint(database, task, SimpleNamespace(model=None)) == ep.base_url
|
|
||||||
|
|
||||||
|
|
||||||
def test_task_run_does_not_autoselect_a_hidden_pin(database):
|
|
||||||
endpoint(database, hidden=("pinned-chat",))
|
|
||||||
task = SimpleNamespace(owner="alice", endpoint_url="", session_id=None, model="pinned-chat")
|
|
||||||
assert _resolve_run_endpoint(database, task, SimpleNamespace(model="pinned-chat")) == ""
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("cached", [(), ("hidden-chat",)])
|
|
||||||
def test_research_default_uses_the_visible_pinned_id(database, cached):
|
|
||||||
ep = endpoint(database, cached=cached, hidden=("hidden-chat",))
|
|
||||||
assert _resolve_endpoint_runtime(ep, owner="alice") == (
|
|
||||||
ep.base_url + "/chat/completions", "pinned-chat", {},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_research_default_does_not_autoselect_a_hidden_pin(database):
|
|
||||||
ep = endpoint(database, hidden=("pinned-chat",))
|
|
||||||
assert _resolve_endpoint_runtime(ep, owner="alice") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_research_explicit_model_selection_is_preserved(database):
|
|
||||||
ep = endpoint(database, cached=("cached-chat",))
|
|
||||||
assert _resolve_endpoint_runtime(ep, owner="alice", model="explicit-chat")[1] == "explicit-chat"
|
|
||||||
|
|
||||||
|
|
||||||
def test_research_cached_default_keeps_priority_over_pins(database):
|
|
||||||
ep = endpoint(database, cached=("cached-chat",))
|
|
||||||
assert _resolve_endpoint_runtime(ep, owner="alice")[1] == "cached-chat"
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Execution outcomes must reach the model, not just the tool-event UI."""
|
"""Execution outcomes must reach the model, not just the tool-event UI."""
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -8,7 +7,6 @@ from src.agent_tools.subprocess_tools import BashTool
|
|||||||
from src.clean_agent_preview import preview_tool_result_text
|
from src.clean_agent_preview import preview_tool_result_text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX-only: enforcing containment (bubblewrap/process group) needed to run native bash; Windows windows_tree gives wall-clock only")
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_failed_shell_retains_exit_status_and_both_streams_for_followup(tmp_path):
|
async def test_failed_shell_retains_exit_status_and_both_streams_for_followup(tmp_path):
|
||||||
from tests.process_resource_helpers import launch_authority
|
from tests.process_resource_helpers import launch_authority
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
P2-2 invariant: external bridge execution != local containment.
|
P2-2 invariant: external bridge execution != local containment.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -181,7 +180,6 @@ async def test_external_record_does_not_grant_authority(tmp_path):
|
|||||||
await containment.run(grant, "id")
|
await containment.run(grant, "id")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX-only: enforcing containment (bubblewrap/process group) needed to run native bash; Windows windows_tree gives wall-clock only")
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_native_local_bash_python_behavior_unchanged(tmp_path, monkeypatch):
|
async def test_native_local_bash_python_behavior_unchanged(tmp_path, monkeypatch):
|
||||||
"""4. Native local Bash/Python behavior is unchanged."""
|
"""4. Native local Bash/Python behavior is unchanged."""
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
CHAT = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
|
CHAT = (ROOT / "static/js/chat.js").read_text()
|
||||||
|
|
||||||
|
|
||||||
def test_queued_stack_is_consumed_as_one_ordered_message():
|
def test_queued_stack_is_consumed_as_one_ordered_message():
|
||||||
|
|||||||
@@ -1,541 +0,0 @@
|
|||||||
"""Executable regression for the "regenerate" data-loss warning.
|
|
||||||
|
|
||||||
`regenerateFrom` (the per-message regenerate footer button), `resendUserMessage`
|
|
||||||
(footer Resend and the vision editor's "Regenerate message" button) and
|
|
||||||
`editUserMessage` (sending an edited user message) all permanently delete
|
|
||||||
every message after the point clicked via POST /api/session/{id}/truncate —
|
|
||||||
a real, unrecoverable server-side delete. Using any of them on anything but
|
|
||||||
the very last exchange used to do this with no warning at all; a mis-click
|
|
||||||
could silently erase an entire conversation. This drives the real chat.js
|
|
||||||
functions under Node, confirming:
|
|
||||||
|
|
||||||
1. Regenerating the *last* exchange (nothing after it) never prompts and
|
|
||||||
truncates directly — the everyday case must not gain friction.
|
|
||||||
2. Regenerating anything earlier prompts first, with the correct count of
|
|
||||||
messages that would be destroyed in the message text.
|
|
||||||
3. Cancelling the prompt aborts before any network call — nothing is
|
|
||||||
deleted.
|
|
||||||
4. Confirming the prompt proceeds with the correct `keep_count`.
|
|
||||||
5. Choosing "Fork from here" deletes nothing: it forks the chat just
|
|
||||||
before the user turn and replays the action (same text, or the edited
|
|
||||||
text) in the new chat instead.
|
|
||||||
|
|
||||||
All three entry points are exercised the same way since they wrap the same
|
|
||||||
destructive operation.
|
|
||||||
|
|
||||||
Unlike test_pr6020_browser_review_regressions.py's `_chat_smoke_source`
|
|
||||||
(which appends extra code to a copy of chat.js's own source — fine for
|
|
||||||
defining an extra exported function, since ES module static imports are
|
|
||||||
hoisted and evaluated before any top-level code regardless of where in the
|
|
||||||
file it appears), this harness needs its global stubs (document, fetch,
|
|
||||||
etc.) in place *before* chat.js's own import tree evaluates, since some of
|
|
||||||
those transitive dependencies touch browser globals at their own module top
|
|
||||||
level. So this loads chat.js via a real dynamic `import()` of its actual
|
|
||||||
file, in a small wrapper script that sets the stubs up first as plain
|
|
||||||
synchronous statements — dynamic `import()` runs in program order, unlike a
|
|
||||||
static `import` declaration.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parents[1]
|
|
||||||
_JS_DIR = _REPO / "static" / "js"
|
|
||||||
_HAS_NODE = shutil.which("node") is not None
|
|
||||||
|
|
||||||
|
|
||||||
def _versioned_js_uri(entry_js: Path, target_name: str) -> str:
|
|
||||||
"""file:// URI for target_name with the same cache-busting query string
|
|
||||||
entry_js's own import of it uses. ES modules cache by full URL, so
|
|
||||||
importing 'ui.js' here while chat.js imports 'ui.js?v=X' yields two module
|
|
||||||
instances, and patching uiMod here would never reach chat.js."""
|
|
||||||
src = entry_js.read_text(encoding="utf-8")
|
|
||||||
m = re.search(rf"from ['\"]\./{re.escape(target_name)}(\?[^'\"]*)?['\"]", src)
|
|
||||||
suffix = (m.group(1) or "") if m else ""
|
|
||||||
return (entry_js.parent / target_name).resolve().as_uri() + suffix
|
|
||||||
|
|
||||||
|
|
||||||
def _run_node(source: str) -> dict:
|
|
||||||
proc = subprocess.run(
|
|
||||||
["node", "--input-type=module"],
|
|
||||||
input=source,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
cwd=_REPO,
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
assert proc.returncode == 0, proc.stderr
|
|
||||||
return json.loads(proc.stdout.strip().splitlines()[-1])
|
|
||||||
|
|
||||||
|
|
||||||
_HARNESS_PREAMBLE = """
|
|
||||||
globalThis.window = globalThis;
|
|
||||||
Object.defineProperty(globalThis, "navigator", { value: { platform: "" }, writable: true, configurable: true });
|
|
||||||
globalThis.addEventListener = () => {};
|
|
||||||
globalThis.removeEventListener = () => {};
|
|
||||||
globalThis.dispatchEvent = () => {};
|
|
||||||
globalThis.requestAnimationFrame = () => 0;
|
|
||||||
globalThis.cancelAnimationFrame = () => {};
|
|
||||||
globalThis.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} };
|
|
||||||
globalThis.location = {};
|
|
||||||
globalThis.history = {};
|
|
||||||
globalThis.MutationObserver = class { observe() {} };
|
|
||||||
globalThis.CustomEvent = class {};
|
|
||||||
globalThis.Storage = class {};
|
|
||||||
|
|
||||||
const fetchCalls = [];
|
|
||||||
globalThis.fetch = async (url, opts) => {
|
|
||||||
fetchCalls.push({ url, body: opts && opts.body ? JSON.parse(opts.body) : null });
|
|
||||||
const data = url.includes('/fork') ? { id: 'forked-session', name: 'Forked chat' } : {};
|
|
||||||
// A test can make the server refuse the cut (e.g. a stale message id).
|
|
||||||
const ok = !(url.includes('/truncate') && globalThis.__refuseTruncate);
|
|
||||||
return { ok, status: ok ? 200 : 404, json: async () => data, text: async () => '', headers: { get() { return null; } } };
|
|
||||||
};
|
|
||||||
|
|
||||||
class Element {
|
|
||||||
constructor() {
|
|
||||||
this.children = [];
|
|
||||||
this.classList = { add() {}, remove() {}, toggle() {}, contains: () => false };
|
|
||||||
this.style = { setProperty() {} };
|
|
||||||
this.dataset = {};
|
|
||||||
this._listeners = {};
|
|
||||||
this._html = '';
|
|
||||||
}
|
|
||||||
get innerHTML() { return this._html; }
|
|
||||||
set innerHTML(v) { this._html = v; this.children = []; }
|
|
||||||
querySelector() { return null; }
|
|
||||||
querySelectorAll() { return []; }
|
|
||||||
appendChild(child) { this.children.push(child); return child; }
|
|
||||||
addEventListener(type, fn) { (this._listeners[type] ||= []).push(fn); }
|
|
||||||
removeEventListener() {}
|
|
||||||
focus() {}
|
|
||||||
// Fire a listener and wait for it — the edit Send handler is async.
|
|
||||||
fire(type) {
|
|
||||||
const ev = { stopPropagation() {}, preventDefault() {} };
|
|
||||||
return Promise.all((this._listeners[type] || []).map(fn => fn(ev)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class HTMLInputElement extends Element {
|
|
||||||
get value() { return this._value || ''; }
|
|
||||||
set value(v) { this._value = v; }
|
|
||||||
}
|
|
||||||
globalThis.HTMLInputElement = HTMLInputElement;
|
|
||||||
|
|
||||||
// A minimal .msg-list DOM: `msgs` is an ordered array of
|
|
||||||
// { role: 'user'|'ai' } describing one conversation. Each entry
|
|
||||||
// becomes a fake message element good enough for regenerateFrom /
|
|
||||||
// resendUserMessage's own traversal (classList.contains, .dataset,
|
|
||||||
// querySelector('.body'), querySelectorAll('[data-file-id]')).
|
|
||||||
function buildMsgEls(msgs) {
|
|
||||||
return msgs.map((m, i) => {
|
|
||||||
const body = new Element();
|
|
||||||
body.textContent = m === 'user' ? `question ${i}` : `answer ${i}`;
|
|
||||||
body.innerHTML = body.textContent;
|
|
||||||
return {
|
|
||||||
classList: { contains: (c) => c === (m === 'user' ? 'msg-user' : 'msg-ai') },
|
|
||||||
dataset: { raw: m === 'user' ? `question ${i}` : '' },
|
|
||||||
querySelector: (sel) => sel === '.body' ? body : null,
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
nextSibling: null,
|
|
||||||
remove() {},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendClicks = [];
|
|
||||||
const messageInputEl = new HTMLInputElement();
|
|
||||||
const sendBtnEl = { click: () => sendClicks.push(messageInputEl.value) };
|
|
||||||
|
|
||||||
globalThis.document = {
|
|
||||||
body: new Element(),
|
|
||||||
head: new Element(),
|
|
||||||
documentElement: new Element(),
|
|
||||||
getElementById(id) {
|
|
||||||
if (id === 'message') return messageInputEl;
|
|
||||||
if (id === 'chat-history') return this._box;
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
querySelector(sel) { return sel === '.send-btn' ? sendBtnEl : null; },
|
|
||||||
querySelectorAll() { return []; },
|
|
||||||
createElement(tag) { return tag === 'input' ? new HTMLInputElement() : new Element(); },
|
|
||||||
createTextNode(text) { return { textContent: text }; },
|
|
||||||
addEventListener() {},
|
|
||||||
removeEventListener() {},
|
|
||||||
};
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _scenario_script(func_call_js: str, roles: list, target_index: int, confirm_resolution, then_js: str = "", db_ids: bool = False, refuse_truncate: bool = False, file_ids: dict | None = None) -> str:
|
|
||||||
# Globals are stubbed above as plain synchronous statements *before* the
|
|
||||||
# dynamic import()s below run — unlike a static `import` declaration,
|
|
||||||
# dynamic import() executes in program order, so chat.js's transitive
|
|
||||||
# dependency tree only evaluates once the stubs are already in place.
|
|
||||||
script = _HARNESS_PREAMBLE
|
|
||||||
script += f"""
|
|
||||||
const chat = await import({json.dumps((_JS_DIR / "chat.js").resolve().as_uri())});
|
|
||||||
const {{ default: uiMod }} = await import({json.dumps(_versioned_js_uri(_JS_DIR / "chat.js", "ui.js"))});
|
|
||||||
const {{ default: sessionMod }} = await import({json.dumps(_versioned_js_uri(_JS_DIR / "chat.js", "sessions.js"))});
|
|
||||||
|
|
||||||
let currentSession = 'test-session';
|
|
||||||
const selectedSessions = [];
|
|
||||||
sessionMod.getCurrentSessionId = () => currentSession;
|
|
||||||
sessionMod.loadSessions = async () => {{}};
|
|
||||||
sessionMod.selectSession = async (id) => {{ selectedSessions.push(id); currentSession = id; }};
|
|
||||||
|
|
||||||
const confirmCalls = [];
|
|
||||||
uiMod.styledConfirm = async (message, opts) => {{
|
|
||||||
confirmCalls.push({{ message, opts }});
|
|
||||||
return {json.dumps(confirm_resolution)};
|
|
||||||
}};
|
|
||||||
uiMod.showError = () => {{}};
|
|
||||||
uiMod.showToast = () => {{}};
|
|
||||||
|
|
||||||
globalThis.__refuseTruncate = {json.dumps(refuse_truncate)};
|
|
||||||
const msgs = buildMsgEls({json.dumps(roles)});
|
|
||||||
// Persisted messages carry their database id; the page may have loaded
|
|
||||||
// only the newest of them, so ids (not positions) identify a message.
|
|
||||||
if ({json.dumps(db_ids)}) msgs.forEach((m, i) => {{ m.dataset.dbId = `db-${{i + 40}}`; }});
|
|
||||||
// Attach uploaded files ({{ index: file_id }}) the way rendered bubbles do.
|
|
||||||
for (const [i, fileId] of Object.entries({json.dumps(file_ids or {})})) {{
|
|
||||||
msgs[i].querySelectorAll = (sel) => sel === '[data-file-id]' ? [{{ dataset: {{ fileId }} }}] : [];
|
|
||||||
}}
|
|
||||||
// Record the attachments queued for each send as the button is clicked.
|
|
||||||
const sentAttachments = [];
|
|
||||||
sendBtnEl.click = () => {{ sendClicks.push(messageInputEl.value); sentAttachments.push(chat._pendingRegenAttachmentIds()); }};
|
|
||||||
const box = {{ querySelectorAll: () => msgs, _isBox: true }};
|
|
||||||
globalThis.document._box = box;
|
|
||||||
const target = msgs[{target_index}];
|
|
||||||
|
|
||||||
await {func_call_js};
|
|
||||||
{then_js}
|
|
||||||
|
|
||||||
const forkCall = fetchCalls.find(c => c.url.includes('/fork'));
|
|
||||||
console.log(JSON.stringify({{
|
|
||||||
confirmShown: confirmCalls.length > 0,
|
|
||||||
confirmMessage: confirmCalls.length ? confirmCalls[0].message : null,
|
|
||||||
confirmOpts: confirmCalls.length ? confirmCalls[0].opts : null,
|
|
||||||
truncateCalled: fetchCalls.some(c => c.url.includes('/truncate')),
|
|
||||||
truncateKeepCount: (fetchCalls.find(c => c.url.includes('/truncate')) || {{}}).body?.keep_count ?? null,
|
|
||||||
forkCalled: Boolean(forkCall),
|
|
||||||
forkUrl: forkCall ? forkCall.url : null,
|
|
||||||
forkKeepCount: forkCall ? forkCall.body.keep_count : null,
|
|
||||||
forkBody: forkCall ? forkCall.body : null,
|
|
||||||
truncateBody: (fetchCalls.find(c => c.url.includes('/truncate')) || {{}}).body || null,
|
|
||||||
selectedSessions,
|
|
||||||
sendClicks,
|
|
||||||
sentAttachments,
|
|
||||||
pendingAttachments: chat._pendingRegenAttachmentIds(),
|
|
||||||
}}));
|
|
||||||
process.exit(0);
|
|
||||||
"""
|
|
||||||
return script
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# regenerateFrom (main chat footer "Regenerate from here" button)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_regenerate_from_last_message_skips_prompt_and_truncates():
|
|
||||||
roles = ["user", "ai"] # target (index 1) IS the last message
|
|
||||||
script = _scenario_script("chat.regenerateFrom(target)", roles, 1, confirm_resolution=True)
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["confirmShown"] is False
|
|
||||||
assert result["truncateCalled"] is True
|
|
||||||
assert result["truncateKeepCount"] == 0 # keep everything before the user turn (index 0)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_regenerate_from_earlier_message_prompts_with_correct_count():
|
|
||||||
roles = ["user", "ai", "user", "ai", "user", "ai"] # target (index 1) has 4 messages after it
|
|
||||||
script = _scenario_script("chat.regenerateFrom(target)", roles, 1, confirm_resolution=True)
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["confirmShown"] is True
|
|
||||||
assert "4 messages" in result["confirmMessage"]
|
|
||||||
assert result["truncateCalled"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_regenerate_from_earlier_message_cancel_aborts_without_truncating():
|
|
||||||
roles = ["user", "ai", "user", "ai"]
|
|
||||||
script = _scenario_script("chat.regenerateFrom(target)", roles, 1, confirm_resolution=False)
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["confirmShown"] is True
|
|
||||||
assert result["truncateCalled"] is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# resendUserMessage(..., { replaceFromHere: true }) (vision editor "Regenerate message")
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_resend_replace_from_here_last_pair_skips_prompt():
|
|
||||||
roles = ["user", "ai"] # target (index 0) is the user turn of the last pair
|
|
||||||
script = _scenario_script(
|
|
||||||
"chat.resendUserMessage(target, { replaceFromHere: true })", roles, 0, confirm_resolution=True,
|
|
||||||
)
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["confirmShown"] is False
|
|
||||||
assert result["truncateCalled"] is True
|
|
||||||
assert result["truncateKeepCount"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_resend_replace_from_here_earlier_pair_prompts_with_correct_count():
|
|
||||||
roles = ["user", "ai", "user", "ai", "user", "ai"] # target (index 0) has 4 messages after its own pair
|
|
||||||
script = _scenario_script(
|
|
||||||
"chat.resendUserMessage(target, { replaceFromHere: true })", roles, 0, confirm_resolution=True,
|
|
||||||
)
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["confirmShown"] is True
|
|
||||||
assert "4 messages" in result["confirmMessage"]
|
|
||||||
assert result["truncateCalled"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_resend_replace_from_here_cancel_aborts_without_truncating():
|
|
||||||
roles = ["user", "ai", "user", "ai"]
|
|
||||||
script = _scenario_script(
|
|
||||||
"chat.resendUserMessage(target, { replaceFromHere: true })", roles, 0, confirm_resolution=False,
|
|
||||||
)
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["confirmShown"] is True
|
|
||||||
assert result["truncateCalled"] is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# resendUserMessage(el) with no options (footer "Resend message")
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# `dev` made plain resend replace-by-default (see
|
|
||||||
# test_resend_message_nondestructive.py): no options means replaceFromHere,
|
|
||||||
# so the footer Resend button now truncates everything after the clicked
|
|
||||||
# turn too, and gets the same warning. Append-only resend must be explicit.
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_resend_default_earlier_pair_prompts_with_correct_count():
|
|
||||||
roles = ["user", "ai", "user", "ai", "user", "ai"]
|
|
||||||
script = _scenario_script("chat.resendUserMessage(target)", roles, 0, confirm_resolution=True)
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["confirmShown"] is True
|
|
||||||
assert "4 messages" in result["confirmMessage"]
|
|
||||||
assert result["truncateCalled"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_resend_append_only_never_truncates_or_prompts():
|
|
||||||
roles = ["user", "ai", "user", "ai"]
|
|
||||||
script = _scenario_script("chat.resendUserMessage(target, { append: true })", roles, 0, confirm_resolution=True)
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["confirmShown"] is False
|
|
||||||
assert result["truncateCalled"] is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# editUserMessage (the pencil edit button, then Send)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The edit Send handler is wired up inside editUserMessage, so these open the
|
|
||||||
# editor, type into it, and click the editor's own Send button.
|
|
||||||
|
|
||||||
_EDIT_AND_SEND = """
|
|
||||||
const _body = target.querySelector('.body');
|
|
||||||
const [_editor, _btnRow] = _body.children;
|
|
||||||
_editor.value = 'edited question';
|
|
||||||
await _btnRow.children[0].fire('click');
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_edit_last_message_skips_prompt_and_truncates():
|
|
||||||
roles = ["user", "ai"]
|
|
||||||
script = _scenario_script("chat.editUserMessage(target)", roles, 0, True, _EDIT_AND_SEND)
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["confirmShown"] is False
|
|
||||||
assert result["truncateCalled"] is True
|
|
||||||
assert result["truncateKeepCount"] == 0
|
|
||||||
assert result["sendClicks"] == ["edited question"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_edit_earlier_message_prompts_with_correct_count():
|
|
||||||
roles = ["user", "ai", "user", "ai", "user", "ai"] # target (index 2) has 2 messages after its own pair
|
|
||||||
script = _scenario_script("chat.editUserMessage(target)", roles, 2, True, _EDIT_AND_SEND)
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["confirmShown"] is True
|
|
||||||
assert "2 messages" in result["confirmMessage"]
|
|
||||||
assert result["confirmOpts"]["confirmText"] == "Delete and send"
|
|
||||||
assert result["truncateCalled"] is True
|
|
||||||
assert result["truncateKeepCount"] == 2
|
|
||||||
assert result["sendClicks"] == ["edited question"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_edit_earlier_message_cancel_aborts_without_truncating_or_sending():
|
|
||||||
roles = ["user", "ai", "user", "ai"]
|
|
||||||
script = _scenario_script("chat.editUserMessage(target)", roles, 0, False, _EDIT_AND_SEND)
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["confirmShown"] is True
|
|
||||||
assert result["truncateCalled"] is False
|
|
||||||
assert result["sendClicks"] == []
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# "Fork from here" — the prompt's alternate choice
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# styledConfirm resolves to 'alternate' for its third button. Forking must
|
|
||||||
# not truncate anything; it forks just before the user turn (so the new chat
|
|
||||||
# ends where the action starts), switches to the fork, and replays there.
|
|
||||||
|
|
||||||
def _assert_forked_instead(result, keep_count, sent_text):
|
|
||||||
assert result["confirmShown"] is True
|
|
||||||
assert result["confirmOpts"]["alternateText"] == "Fork from here"
|
|
||||||
assert result["truncateCalled"] is False
|
|
||||||
assert result["forkCalled"] is True
|
|
||||||
assert "/api/session/test-session/fork" in result["forkUrl"]
|
|
||||||
assert result["forkKeepCount"] == keep_count
|
|
||||||
assert result["selectedSessions"] == ["forked-session"]
|
|
||||||
assert result["sendClicks"] == [sent_text]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_regenerate_fork_choice_forks_before_user_turn_and_resends():
|
|
||||||
roles = ["user", "ai", "user", "ai", "user", "ai"]
|
|
||||||
script = _scenario_script("chat.regenerateFrom(target)", roles, 3, "alternate")
|
|
||||||
_assert_forked_instead(_run_node(script), keep_count=2, sent_text="question 2")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_resend_fork_choice_forks_before_user_turn_and_resends():
|
|
||||||
roles = ["user", "ai", "user", "ai", "user", "ai"]
|
|
||||||
script = _scenario_script("chat.resendUserMessage(target)", roles, 2, "alternate")
|
|
||||||
_assert_forked_instead(_run_node(script), keep_count=2, sent_text="question 2")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_edit_fork_choice_forks_before_user_turn_and_sends_edit():
|
|
||||||
roles = ["user", "ai", "user", "ai", "user", "ai"]
|
|
||||||
script = _scenario_script("chat.editUserMessage(target)", roles, 2, "alternate", _EDIT_AND_SEND)
|
|
||||||
_assert_forked_instead(_run_node(script), keep_count=2, sent_text="edited question")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_regenerate_fork_choice_keeps_the_user_message_attachments():
|
|
||||||
roles = ["user", "ai", "user", "ai", "user", "ai"]
|
|
||||||
script = _scenario_script("chat.regenerateFrom(target)", roles, 3, "alternate", file_ids={2: "file-abc"})
|
|
||||||
result = _run_node(script)
|
|
||||||
_assert_forked_instead(result, keep_count=2, sent_text="question 2")
|
|
||||||
assert result["sentAttachments"] == [["file-abc"]]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_resend_fork_choice_keeps_the_user_message_attachments():
|
|
||||||
roles = ["user", "ai", "user", "ai", "user", "ai"]
|
|
||||||
script = _scenario_script("chat.resendUserMessage(target)", roles, 2, "alternate", file_ids={2: "file-abc"})
|
|
||||||
result = _run_node(script)
|
|
||||||
_assert_forked_instead(result, keep_count=2, sent_text="question 2")
|
|
||||||
assert result["sentAttachments"] == [["file-abc"]]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Cutting by message id
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# A long chat loads only its newest messages until the user scrolls up, so a
|
|
||||||
# position counted in the page is smaller than the real one. Truncating by
|
|
||||||
# that position deleted real messages above the clicked one. Every action
|
|
||||||
# sends the clicked message's database id when it has one.
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_regenerate_truncates_before_the_user_message_by_id():
|
|
||||||
roles = ["user", "ai", "user", "ai"]
|
|
||||||
script = _scenario_script("chat.regenerateFrom(target)", roles, 3, True, db_ids=True)
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["truncateBody"] == {"before_msg_id": "db-42"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_resend_truncates_before_the_user_message_by_id():
|
|
||||||
roles = ["user", "ai", "user", "ai"]
|
|
||||||
script = _scenario_script("chat.resendUserMessage(target)", roles, 2, True, db_ids=True)
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["truncateBody"] == {"before_msg_id": "db-42"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_edit_truncates_before_the_user_message_by_id():
|
|
||||||
roles = ["user", "ai", "user", "ai"]
|
|
||||||
script = _scenario_script("chat.editUserMessage(target)", roles, 2, True, _EDIT_AND_SEND, db_ids=True)
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["truncateBody"] == {"before_msg_id": "db-42"}
|
|
||||||
assert result["sendClicks"] == ["edited question"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_fork_choice_forks_before_the_user_message_by_id():
|
|
||||||
roles = ["user", "ai", "user", "ai", "user", "ai"]
|
|
||||||
script = _scenario_script("chat.regenerateFrom(target)", roles, 3, "alternate", db_ids=True)
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["truncateCalled"] is False
|
|
||||||
assert result["forkBody"]["before_msg_id"] == "db-42"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_fork_conversation_forks_through_the_reply_by_id():
|
|
||||||
roles = ["user", "ai", "user", "ai"]
|
|
||||||
script = _scenario_script("chat.forkFrom(target)", roles, 1, True, db_ids=True)
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["forkBody"]["through_msg_id"] == "db-41"
|
|
||||||
|
|
||||||
|
|
||||||
# A refused cut (the server can't find the message id, e.g. one a previous
|
|
||||||
# regenerate already deleted) must stop the action. Sending anyway stacked a
|
|
||||||
# second reply under the same message.
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_regenerate_stops_when_the_server_refuses_the_cut():
|
|
||||||
roles = ["user", "ai"]
|
|
||||||
script = _scenario_script("chat.regenerateFrom(target)", roles, 1, True, db_ids=True, refuse_truncate=True)
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["truncateCalled"] is True
|
|
||||||
assert result["sendClicks"] == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_edit_stops_when_the_server_refuses_the_cut():
|
|
||||||
roles = ["user", "ai"]
|
|
||||||
script = _scenario_script("chat.editUserMessage(target)", roles, 0, True, _EDIT_AND_SEND, db_ids=True, refuse_truncate=True)
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["truncateCalled"] is True
|
|
||||||
assert result["sendClicks"] == []
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_edit_fork_choice_keeps_original_attachments():
|
|
||||||
roles = ["user", "ai", "user", "ai", "user", "ai"]
|
|
||||||
script = _scenario_script("chat.editUserMessage(target)", roles, 2, "alternate", _EDIT_AND_SEND, file_ids={2: "file-abc"})
|
|
||||||
result = _run_node(script)
|
|
||||||
_assert_forked_instead(result, keep_count=2, sent_text="edited question")
|
|
||||||
assert result["sentAttachments"] == [["file-abc"]]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_edit_destructive_resend_keeps_original_attachments():
|
|
||||||
roles = ["user", "ai", "user", "ai"]
|
|
||||||
script = _scenario_script("chat.editUserMessage(target)", roles, 2, True, _EDIT_AND_SEND, file_ids={2: "file-abc"})
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["truncateCalled"] is True
|
|
||||||
assert result["sentAttachments"] == [["file-abc"]]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_refused_regenerate_does_not_leak_queued_attachments():
|
|
||||||
roles = ["user", "ai"]
|
|
||||||
script = _scenario_script("chat.regenerateFrom(target)", roles, 1, True, db_ids=True,
|
|
||||||
refuse_truncate=True, file_ids={0: "file-abc"})
|
|
||||||
result = _run_node(script)
|
|
||||||
assert result["truncateCalled"] is True
|
|
||||||
assert result["sendClicks"] == []
|
|
||||||
assert result["pendingAttachments"] is None
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -1203,7 +1202,7 @@ async def test_write_file_inline_json_args(monkeypatch):
|
|||||||
for b in blocks:
|
for b in blocks:
|
||||||
await _execute_without_run_context(execute_tool_block, b, owner="admin", workspace="/tmp")
|
await _execute_without_run_context(execute_tool_block, b, owner="admin", workspace="/tmp")
|
||||||
|
|
||||||
assert captured.get("path") in ("/tmp/wf.txt", os.path.abspath("/tmp/wf.txt")), (
|
assert captured.get("path") == "/tmp/wf.txt", (
|
||||||
f"write_file did not decode inline JSON args; got path {captured.get('path')!r}"
|
f"write_file did not decode inline JSON args; got path {captured.get('path')!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -137,170 +137,3 @@ def test_collect_aggregate_deadline_yields_controlled_result(monkeypatch):
|
|||||||
net = [s for s in out["services"] if s["name"] != "chromadb"]
|
net = [s for s in out["services"] if s["name"] != "chromadb"]
|
||||||
assert all(s["status"] == sh.DOWN and s["meta"].get("error") == "timeout"
|
assert all(s["status"] == sh.DOWN and s["meta"].get("error") == "timeout"
|
||||||
for s in net)
|
for s in net)
|
||||||
|
|
||||||
|
|
||||||
# ── Discovery failure is not "disabled" (#6154) ──
|
|
||||||
#
|
|
||||||
# `_gather_inputs()` fails soft to an empty inventory, and every probe reads an
|
|
||||||
# empty inventory as `disabled`. Without a record of *why* it is empty, a broken
|
|
||||||
# integrations file / email store / endpoint DB is reported as an intentionally
|
|
||||||
# unconfigured subsystem — and, with the rest disabled, `overall: "ok"`.
|
|
||||||
|
|
||||||
def _boom(*_a, **_k):
|
|
||||||
raise RuntimeError("secret-bearing detail: postgres://u:p@db/x")
|
|
||||||
|
|
||||||
|
|
||||||
def test_gather_inputs_records_each_failed_source(monkeypatch):
|
|
||||||
import core.database
|
|
||||||
import routes.email_helpers
|
|
||||||
import src.integrations
|
|
||||||
import src.settings
|
|
||||||
|
|
||||||
monkeypatch.setattr(src.settings, "load_settings", lambda: {"a": 1})
|
|
||||||
monkeypatch.setattr(src.integrations, "load_integrations", _boom)
|
|
||||||
monkeypatch.setattr(routes.email_helpers, "_list_email_accounts", _boom)
|
|
||||||
monkeypatch.setattr(core.database, "SessionLocal", _boom)
|
|
||||||
|
|
||||||
out = sh._gather_inputs()
|
|
||||||
assert out["settings"] == {"a": 1}
|
|
||||||
assert set(out["failed"]) == {"integrations", "accounts", "endpoints"}
|
|
||||||
# Controlled categories only — never the exception text.
|
|
||||||
assert set(out["failed"].values()) <= set(sh._ERROR_DETAIL)
|
|
||||||
assert "postgres" not in repr(out)
|
|
||||||
|
|
||||||
|
|
||||||
def test_gather_inputs_reports_no_failures_when_sources_load(monkeypatch):
|
|
||||||
import core.database
|
|
||||||
import routes.email_helpers
|
|
||||||
import src.integrations
|
|
||||||
import src.settings
|
|
||||||
|
|
||||||
class _DB:
|
|
||||||
def query(self, *_a):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def filter(self, *_a):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def all(self):
|
|
||||||
return []
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
monkeypatch.setattr(src.settings, "load_settings", lambda: {})
|
|
||||||
monkeypatch.setattr(src.integrations, "load_integrations", lambda: [])
|
|
||||||
monkeypatch.setattr(routes.email_helpers, "_list_email_accounts", lambda: [])
|
|
||||||
monkeypatch.setattr(core.database, "SessionLocal", lambda: _DB())
|
|
||||||
|
|
||||||
assert sh._gather_inputs()["failed"] == {}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("source,service", [
|
|
||||||
("settings", "searxng"),
|
|
||||||
("integrations", "ntfy"),
|
|
||||||
("accounts", "email"),
|
|
||||||
("endpoints", "providers"),
|
|
||||||
])
|
|
||||||
def test_failed_source_is_not_reported_as_disabled(monkeypatch, source, service):
|
|
||||||
import asyncio
|
|
||||||
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
|
|
||||||
"settings": {"search_provider": "disabled"},
|
|
||||||
"integrations": [], "accounts": [], "endpoints": [],
|
|
||||||
"failed": {source: "network_error"},
|
|
||||||
})
|
|
||||||
out = asyncio.run(sh.collect_service_health(None, None))
|
|
||||||
entry = next(s for s in out["services"] if s["name"] == service)
|
|
||||||
assert entry["status"] != sh.DISABLED
|
|
||||||
assert entry["meta"]["error"] == sh.CONFIG_SOURCE_ERROR
|
|
||||||
assert entry["meta"]["source"] == source
|
|
||||||
assert entry["meta"]["source_error"] == "network_error"
|
|
||||||
# A failed source must reach the aggregate verdict.
|
|
||||||
assert out["overall"] != sh.OK
|
|
||||||
assert set(out) == {"overall", "services", "timestamp"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_all_sources_failing_cannot_report_overall_ok(monkeypatch):
|
|
||||||
# The exact shape from the report: settings loads and says search is off,
|
|
||||||
# the other three sources raise. Before the fix this was overall "ok" with
|
|
||||||
# five "disabled" entries.
|
|
||||||
import asyncio
|
|
||||||
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
|
|
||||||
"settings": {"search_provider": "disabled"},
|
|
||||||
"integrations": [], "accounts": [], "endpoints": [],
|
|
||||||
"failed": {"integrations": "error", "accounts": "error",
|
|
||||||
"endpoints": "error"},
|
|
||||||
})
|
|
||||||
out = asyncio.run(sh.collect_service_health(None, None))
|
|
||||||
by_name = {s["name"]: s for s in out["services"]}
|
|
||||||
assert out["overall"] == sh.DEGRADED
|
|
||||||
# Loaded-and-genuinely-empty still reads as disabled.
|
|
||||||
assert by_name["searxng"]["status"] == sh.DISABLED
|
|
||||||
for name in ("ntfy", "email", "providers"):
|
|
||||||
assert by_name[name]["status"] == sh.DEGRADED
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_inventory_without_failure_stays_disabled(monkeypatch):
|
|
||||||
import asyncio
|
|
||||||
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
|
|
||||||
"settings": {"search_provider": "disabled"},
|
|
||||||
"integrations": [], "accounts": [], "endpoints": [], "failed": {},
|
|
||||||
})
|
|
||||||
out = asyncio.run(sh.collect_service_health(_Store(True), _Store(True)))
|
|
||||||
assert out["overall"] == sh.OK
|
|
||||||
assert all(s["status"] == sh.DISABLED
|
|
||||||
for s in out["services"] if s["name"] != "chromadb")
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_failed_source_still_probes_the_others(monkeypatch):
|
|
||||||
# A broken endpoint DB must not stop ntfy/email from being probed.
|
|
||||||
import asyncio
|
|
||||||
probed = []
|
|
||||||
|
|
||||||
def _probe(name):
|
|
||||||
def _fn(*_a, **_k):
|
|
||||||
probed.append(name)
|
|
||||||
return sh._svc(name, sh.OK, "probed")
|
|
||||||
return _fn
|
|
||||||
|
|
||||||
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
|
|
||||||
"settings": {}, "integrations": [], "accounts": [], "endpoints": [],
|
|
||||||
"failed": {"endpoints": "timeout"},
|
|
||||||
})
|
|
||||||
monkeypatch.setattr(sh, "searxng_health", _probe("searxng"))
|
|
||||||
monkeypatch.setattr(sh, "ntfy_health", _probe("ntfy"))
|
|
||||||
monkeypatch.setattr(sh, "email_health", _probe("email"))
|
|
||||||
monkeypatch.setattr(sh, "providers_health", _probe("providers"))
|
|
||||||
|
|
||||||
out = asyncio.run(sh.collect_service_health(None, None))
|
|
||||||
assert sorted(probed) == ["email", "ntfy", "searxng"]
|
|
||||||
assert "providers" not in probed
|
|
||||||
by_name = {s["name"]: s for s in out["services"]}
|
|
||||||
assert by_name["providers"]["status"] == sh.DEGRADED
|
|
||||||
assert by_name["ntfy"]["status"] == sh.OK
|
|
||||||
|
|
||||||
|
|
||||||
def test_source_failure_entry_carries_no_raw_detail(monkeypatch):
|
|
||||||
import asyncio
|
|
||||||
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
|
|
||||||
"settings": {}, "integrations": [], "accounts": [], "endpoints": [],
|
|
||||||
"failed": {"accounts": "auth_or_protocol_error"},
|
|
||||||
})
|
|
||||||
out = asyncio.run(sh.collect_service_health(None, None))
|
|
||||||
entry = next(s for s in out["services"] if s["name"] == "email")
|
|
||||||
assert entry["detail"] == (
|
|
||||||
"Could not read configuration (configuration source could not be read).")
|
|
||||||
assert set(entry["meta"]) == {"error", "source", "source_error"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_collect_tolerates_inputs_without_a_failed_key(monkeypatch):
|
|
||||||
# `_gather_inputs()` is monkeypatched in a number of existing tests (and by
|
|
||||||
# anything calling the collector with a stub); a payload predating `failed`
|
|
||||||
# must still probe normally rather than raise.
|
|
||||||
import asyncio
|
|
||||||
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
|
|
||||||
"settings": {"search_provider": "disabled"},
|
|
||||||
"integrations": [], "accounts": [], "endpoints": [],
|
|
||||||
})
|
|
||||||
out = asyncio.run(sh.collect_service_health(_Store(True), _Store(True)))
|
|
||||||
assert out["overall"] == sh.OK
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
from core.database import Base
|
from core.database import Base
|
||||||
from core.database import ChatMessage as DbChatMessage
|
from core.database import ChatMessage as DbChatMessage
|
||||||
from core.database import Session as DbSession
|
from core.database import Session as DbSession
|
||||||
from src.session_search import SessionSearchResult, _snippet, search_session_messages
|
from src.session_search import SessionSearchResult, search_session_messages
|
||||||
|
|
||||||
|
|
||||||
def _db(with_fts=True):
|
def _db(with_fts=True):
|
||||||
@@ -61,47 +61,6 @@ def _has_fts(db):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_snippet_locates_quoted_phrase_instead_of_raw_query():
|
|
||||||
content = "A" * 100 + "The session search result is useful." + "Z" * 100
|
|
||||||
|
|
||||||
snippet = _snippet(content, '"session search"', radius=20)
|
|
||||||
|
|
||||||
assert "session search" in snippet
|
|
||||||
assert snippet.startswith("...")
|
|
||||||
assert snippet.endswith("...")
|
|
||||||
|
|
||||||
|
|
||||||
def test_snippet_locates_punctuation_bearing_term_case_insensitively():
|
|
||||||
content = "A" * 100 + "Use FOO_BAR.py for routing." + "Z" * 100
|
|
||||||
|
|
||||||
snippet = _snippet(content, "foo_bar.py!", radius=20)
|
|
||||||
|
|
||||||
assert "FOO_BAR.py" in snippet
|
|
||||||
|
|
||||||
|
|
||||||
def test_snippet_uses_first_meaningful_matching_term():
|
|
||||||
content = "A" * 100 + "second appears here; first appears later." + "Z" * 100
|
|
||||||
|
|
||||||
snippet = _snippet(content, "first second", radius=12)
|
|
||||||
|
|
||||||
assert "second appears" in snippet
|
|
||||||
assert "first appears" not in snippet
|
|
||||||
|
|
||||||
|
|
||||||
def test_snippet_falls_back_to_content_start_when_no_term_matches():
|
|
||||||
content = "Beginning of the transcript, followed by more detail."
|
|
||||||
|
|
||||||
assert _snippet(content, '"missing phrase"', radius=10) == content[:20]
|
|
||||||
|
|
||||||
|
|
||||||
def test_snippet_preserves_boundary_markers_and_truncation():
|
|
||||||
content = "A" * 50 + "target" + "Z" * 50
|
|
||||||
|
|
||||||
snippet = _snippet(content, "target", radius=5)
|
|
||||||
|
|
||||||
assert snippet == "...AAAAAtargetZZZZZ..."
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_search_uses_fts_and_returns_context():
|
def test_session_search_uses_fts_and_returns_context():
|
||||||
db = _db(with_fts=True)
|
db = _db(with_fts=True)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
"""Inline frontmatter lists must preserve quoted values across save/load cycles."""
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
|
|
||||||
# Load the dependency-free formatter without starting the services package.
|
|
||||||
_PATH = Path(__file__).resolve().parents[1] / "services" / "memory" / "skill_format.py"
|
|
||||||
_SPEC = importlib.util.spec_from_file_location("_skill_list_format_under_test", _PATH)
|
|
||||||
_FORMAT = importlib.util.module_from_spec(_SPEC)
|
|
||||||
sys.modules[_SPEC.name] = _FORMAT
|
|
||||||
_SPEC.loader.exec_module(_FORMAT)
|
|
||||||
|
|
||||||
|
|
||||||
class FrontmatterListEscapesTests(unittest.TestCase):
|
|
||||||
def test_skill_tags_survive_repeated_save_load_cycles(self):
|
|
||||||
tags = ['review "release, notes', 'use "search", then summarize', 'publish']
|
|
||||||
skill = _FORMAT.Skill(name="release-review", tags=tags)
|
|
||||||
for _ in range(5):
|
|
||||||
skill = _FORMAT.Skill.from_markdown(skill.to_markdown())
|
|
||||||
self.assertEqual(skill.tags, tags)
|
|
||||||
|
|
||||||
def test_escaped_quotes_preserve_each_list_field(self):
|
|
||||||
for field in ("tags", "platforms", "requires_toolsets", "fallback_for_toolsets"):
|
|
||||||
with self.subTest(field=field):
|
|
||||||
values = ['review "release, notes', 'publish']
|
|
||||||
skill = _FORMAT.Skill(name="release-review", **{field: values})
|
|
||||||
restored = _FORMAT.Skill.from_markdown(skill.to_markdown())
|
|
||||||
self.assertEqual(getattr(restored, field), values)
|
|
||||||
|
|
||||||
def test_backslash_parity_does_not_hide_quotes_or_list_separators(self):
|
|
||||||
for count in range(1, 5):
|
|
||||||
with self.subTest(backslashes=count):
|
|
||||||
values = ['review ' + '\\' * count + '"release, notes', 'path, ' + '\\' * count, 'publish']
|
|
||||||
text = _FORMAT.emit_frontmatter({"tags": values})
|
|
||||||
parsed, _ = _FORMAT.parse_frontmatter(f"---\n{text}\n---\n")
|
|
||||||
self.assertEqual(parsed["tags"], values)
|
|
||||||
|
|
||||||
def test_single_quoted_backslashes_remain_literal(self):
|
|
||||||
text = "---\ntags: ['C:\\', next]\n---\n"
|
|
||||||
parsed, _ = _FORMAT.parse_frontmatter(text)
|
|
||||||
self.assertEqual(parsed["tags"], ["C:\\", "next"])
|
|
||||||
|
|
||||||
def test_nested_lists_preserve_quoted_brackets_and_commas(self):
|
|
||||||
values = [['review "], notes', 'publish'], 'archive']
|
|
||||||
text = _FORMAT.emit_frontmatter({"tags": values})
|
|
||||||
parsed, _ = _FORMAT.parse_frontmatter(f"---\n{text}\n---\n")
|
|
||||||
self.assertEqual(parsed["tags"], values)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
"""Renaming a skill onto a name the owner already uses must be refused.
|
|
||||||
|
|
||||||
``update_skill`` only checked whether the target directory existed. A
|
|
||||||
skill in another category with the wanted name lives in a different
|
|
||||||
directory, so the rename went through and the owner ended up with two
|
|
||||||
skills sharing one name. Update and delete look skills up by name, so the
|
|
||||||
next DELETE removed whichever of the two ``os.walk`` reached first, which
|
|
||||||
was often the one the user did not pick.
|
|
||||||
"""
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from routes.skills_routes import SkillUpdateRequest, setup_skills_routes
|
|
||||||
from services.memory.skills import SkillsManager
|
|
||||||
|
|
||||||
from tests.test_skills_routes_owner_update import _request, _route_handler, _write_skill_md
|
|
||||||
|
|
||||||
|
|
||||||
def _two_skills(tmp_path, owner="alice"):
|
|
||||||
mgr = SkillsManager(str(tmp_path))
|
|
||||||
mgr.add_skill(name="deploy-app", description="Deploy the web app", category="ops",
|
|
||||||
source="user", procedure=["ship it"], owner=owner)
|
|
||||||
mgr.add_skill(name="draft-notes", description="Draft meeting notes", category="writing",
|
|
||||||
source="user", procedure=["write"], owner=owner)
|
|
||||||
return mgr
|
|
||||||
|
|
||||||
|
|
||||||
def _names(mgr, owner="alice"):
|
|
||||||
return sorted((s["name"], s["category"]) for s in mgr.load(owner=owner))
|
|
||||||
|
|
||||||
|
|
||||||
def test_rename_onto_a_name_in_another_category_is_refused(tmp_path):
|
|
||||||
mgr = _two_skills(tmp_path)
|
|
||||||
|
|
||||||
assert mgr.update_skill("draft-notes", {"name": "deploy-app"}, owner="alice") is False
|
|
||||||
assert _names(mgr) == [("deploy-app", "ops"), ("draft-notes", "writing")]
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_after_refused_rename_removes_only_the_named_skill(tmp_path):
|
|
||||||
mgr = _two_skills(tmp_path)
|
|
||||||
mgr.update_skill("draft-notes", {"name": "deploy-app"}, owner="alice")
|
|
||||||
|
|
||||||
assert mgr.delete_skill("draft-notes", owner="alice") is True
|
|
||||||
assert _names(mgr) == [("deploy-app", "ops")]
|
|
||||||
|
|
||||||
|
|
||||||
def test_rename_to_a_free_name_still_moves_the_skill(tmp_path):
|
|
||||||
mgr = _two_skills(tmp_path)
|
|
||||||
|
|
||||||
assert mgr.update_skill("draft-notes", {"name": "meeting-notes"}, owner="alice") is True
|
|
||||||
assert _names(mgr) == [("deploy-app", "ops"), ("meeting-notes", "writing")]
|
|
||||||
|
|
||||||
|
|
||||||
def test_another_owners_skill_does_not_block_the_rename(tmp_path):
|
|
||||||
skills_root = tmp_path / "skills"
|
|
||||||
_write_skill_md(skills_root, "bob-cat", "caveman-mode", "bob")
|
|
||||||
_write_skill_md(skills_root, "alice-cat", "terse-mode", "alice")
|
|
||||||
mgr = SkillsManager(str(tmp_path))
|
|
||||||
|
|
||||||
assert mgr.update_skill("terse-mode", {"name": "caveman-mode"}, owner="alice") is True
|
|
||||||
assert [s["name"] for s in mgr.load(owner="alice")] == ["caveman-mode"]
|
|
||||||
assert [s["name"] for s in mgr.load(owner="bob")] == ["caveman-mode"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_keeping_the_same_name_is_not_a_conflict(tmp_path):
|
|
||||||
mgr = _two_skills(tmp_path)
|
|
||||||
|
|
||||||
assert mgr.update_skill("draft-notes", {"name": "Draft Notes", "status": "published"},
|
|
||||||
owner="alice") is True
|
|
||||||
assert [s["status"] for s in mgr.load(owner="alice") if s["name"] == "draft-notes"] == ["published"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_put_route_answers_409_for_a_taken_name(tmp_path):
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
mgr = _two_skills(tmp_path)
|
|
||||||
update_route = _route_handler(setup_skills_routes(mgr), "/api/skills/{skill_id}", "PUT")
|
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc:
|
|
||||||
await update_route(_request("alice"), "draft-notes", SkillUpdateRequest(name="deploy-app"))
|
|
||||||
|
|
||||||
assert exc.value.status_code == 409
|
|
||||||
assert _names(mgr) == [("deploy-app", "ops"), ("draft-notes", "writing")]
|
|
||||||
@@ -27,102 +27,3 @@ def test_dsml_tool_markup_strips_to_empty_visible_text():
|
|||||||
def test_chunk_split_tool_call_prefix_is_buffered_and_not_persisted():
|
def test_chunk_split_tool_call_prefix_is_buffered_and_not_persisted():
|
||||||
assert _streamed_tool_markup_starts('I will search<tool_cal')
|
assert _streamed_tool_markup_starts('I will search<tool_cal')
|
||||||
assert _strip_incomplete_tool_markup_tail('I will search<tool_cal') == 'I will search'
|
assert _strip_incomplete_tool_markup_tail('I will search<tool_cal') == 'I will search'
|
||||||
|
|
||||||
|
|
||||||
def test_bare_less_than_and_comparison_operators_false_alarm_recognition():
|
|
||||||
# A bare "<" starts buffering because it could be a tool-call tag prefix like "<tool_call>"
|
|
||||||
assert _streamed_tool_markup_starts(' <')
|
|
||||||
assert _streamed_tool_markup_starts('x <')
|
|
||||||
assert _streamed_tool_markup_starts('<tool_call')
|
|
||||||
assert _streamed_tool_markup_starts('<invoke')
|
|
||||||
|
|
||||||
# Once subsequent characters arrive and cannot become a tool-call tag, false alarm is detected
|
|
||||||
assert not _streamed_tool_markup_starts(' < 2')
|
|
||||||
assert not _streamed_tool_markup_starts('x < 2')
|
|
||||||
assert not _streamed_tool_markup_starts('len(items) < 2')
|
|
||||||
assert not _streamed_tool_markup_starts(' < 2:')
|
|
||||||
assert not _streamed_tool_markup_starts('x <= y')
|
|
||||||
assert not _streamed_tool_markup_starts('a < b')
|
|
||||||
|
|
||||||
|
|
||||||
async def test_stream_agent_loop_does_not_truncate_at_less_than_operator(monkeypatch):
|
|
||||||
import json
|
|
||||||
import src.agent_loop as al
|
|
||||||
|
|
||||||
async def fake_stream(*args, **kwargs):
|
|
||||||
for d in ["def is_short(items):\n", " return len(items)", " <", " 2\n"]:
|
|
||||||
yield "data: " + json.dumps({"delta": d}) + "\n\n"
|
|
||||||
yield "data: [DONE]\n\n"
|
|
||||||
|
|
||||||
monkeypatch.setattr(al, "get_setting", lambda k, d=None: d, raising=False)
|
|
||||||
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
|
|
||||||
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
|
|
||||||
monkeypatch.setattr(al, "stream_llm_with_fallback", fake_stream, raising=False)
|
|
||||||
|
|
||||||
deltas = []
|
|
||||||
async for chunk in al.stream_agent_loop(
|
|
||||||
"http://local.test/v1",
|
|
||||||
"qwen3.6-35b-a3b-heretic",
|
|
||||||
[{"role": "user", "content": "Write python function"}],
|
|
||||||
max_rounds=1,
|
|
||||||
owner="test_user",
|
|
||||||
):
|
|
||||||
for line in chunk.split("\n"):
|
|
||||||
line = line.strip()
|
|
||||||
if line.startswith("data: ") and line[6:] != "[DONE]":
|
|
||||||
try:
|
|
||||||
payload = json.loads(line[6:])
|
|
||||||
if "delta" in payload:
|
|
||||||
deltas.append(payload["delta"])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
full_text = "".join(deltas)
|
|
||||||
assert full_text == "def is_short(items):\n return len(items) < 2\n"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_stream_agent_loop_still_hides_real_tool_markup(monkeypatch):
|
|
||||||
import json
|
|
||||||
import src.agent_loop as al
|
|
||||||
|
|
||||||
async def fake_stream(*args, **kwargs):
|
|
||||||
chunks = [
|
|
||||||
"Sure.",
|
|
||||||
" <",
|
|
||||||
"tool",
|
|
||||||
"_call",
|
|
||||||
'>{"name":"host_shell","arguments":{"command":"pwd"}}',
|
|
||||||
"</",
|
|
||||||
"tool",
|
|
||||||
"_call",
|
|
||||||
">",
|
|
||||||
" done",
|
|
||||||
]
|
|
||||||
for c in chunks:
|
|
||||||
yield "data: " + json.dumps({"delta": c}) + "\n\n"
|
|
||||||
yield "data: [DONE]\n\n"
|
|
||||||
|
|
||||||
monkeypatch.setattr(al, "get_setting", lambda k, d=None: d, raising=False)
|
|
||||||
monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False)
|
|
||||||
monkeypatch.setattr(al, "estimate_tokens", lambda *a, **k: 10, raising=False)
|
|
||||||
monkeypatch.setattr(al, "stream_llm_with_fallback", fake_stream, raising=False)
|
|
||||||
|
|
||||||
deltas = []
|
|
||||||
async for chunk in al.stream_agent_loop(
|
|
||||||
"http://local.test/v1",
|
|
||||||
"qwen3.6-35b-a3b-heretic",
|
|
||||||
[{"role": "user", "content": "run command"}],
|
|
||||||
max_rounds=1,
|
|
||||||
owner="test_user",
|
|
||||||
):
|
|
||||||
for line in chunk.split("\n"):
|
|
||||||
line = line.strip()
|
|
||||||
if line.startswith("data: ") and line[6:] != "[DONE]":
|
|
||||||
try:
|
|
||||||
payload = json.loads(line[6:])
|
|
||||||
if "delta" in payload:
|
|
||||||
deltas.append(payload["delta"])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
assert "".join(deltas) == "Sure. done"
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
CHAT = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
|
CHAT = (ROOT / "static/js/chat.js").read_text()
|
||||||
|
|
||||||
|
|
||||||
def _visibility_handler() -> str:
|
def _visibility_handler() -> str:
|
||||||
|
|||||||
@@ -28,11 +28,6 @@ def standalone_dispatch_authority(monkeypatch):
|
|||||||
server_authorized_executor(tool_execution.execute_tool_block))
|
server_authorized_executor(tool_execution.execute_tool_block))
|
||||||
|
|
||||||
|
|
||||||
def _native(path: str) -> str:
|
|
||||||
"""POSIX-style literal -> native separators (the policy splits on os.sep)."""
|
|
||||||
return path.replace("/", os.sep)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_block(tool_type, content):
|
def _make_block(tool_type, content):
|
||||||
return SimpleNamespace(tool_type=tool_type, content=content)
|
return SimpleNamespace(tool_type=tool_type, content=content)
|
||||||
|
|
||||||
@@ -41,27 +36,27 @@ def _make_block(tool_type, content):
|
|||||||
|
|
||||||
def test_sensitive_ssh_dir():
|
def test_sensitive_ssh_dir():
|
||||||
from src.tool_execution import _is_sensitive_path
|
from src.tool_execution import _is_sensitive_path
|
||||||
assert _is_sensitive_path(_native("/home/user/.ssh/authorized_keys"))
|
assert _is_sensitive_path("/home/user/.ssh/authorized_keys")
|
||||||
assert _is_sensitive_path(_native(os.path.expanduser("~") + "/.ssh/config"))
|
assert _is_sensitive_path(os.path.expanduser("~") + "/.ssh/config")
|
||||||
|
|
||||||
|
|
||||||
def test_sensitive_gnupg_dir():
|
def test_sensitive_gnupg_dir():
|
||||||
from src.tool_execution import _is_sensitive_path
|
from src.tool_execution import _is_sensitive_path
|
||||||
assert _is_sensitive_path(_native("/home/user/.gnupg/pubring.kbx"))
|
assert _is_sensitive_path("/home/user/.gnupg/pubring.kbx")
|
||||||
|
|
||||||
|
|
||||||
def test_sensitive_shell_rc():
|
def test_sensitive_shell_rc():
|
||||||
from src.tool_execution import _is_sensitive_path
|
from src.tool_execution import _is_sensitive_path
|
||||||
assert _is_sensitive_path(_native("/home/user/.bashrc"))
|
assert _is_sensitive_path("/home/user/.bashrc")
|
||||||
assert _is_sensitive_path(_native("/home/user/.zshrc"))
|
assert _is_sensitive_path("/home/user/.zshrc")
|
||||||
assert _is_sensitive_path(_native("/home/user/.profile"))
|
assert _is_sensitive_path("/home/user/.profile")
|
||||||
|
|
||||||
|
|
||||||
def test_sensitive_key_filenames():
|
def test_sensitive_key_filenames():
|
||||||
from src.tool_execution import _is_sensitive_path
|
from src.tool_execution import _is_sensitive_path
|
||||||
assert _is_sensitive_path(_native("/tmp/id_rsa"))
|
assert _is_sensitive_path("/tmp/id_rsa")
|
||||||
assert _is_sensitive_path(_native("/tmp/id_ed25519"))
|
assert _is_sensitive_path("/tmp/id_ed25519")
|
||||||
assert _is_sensitive_path(_native("/tmp/authorized_keys"))
|
assert _is_sensitive_path("/tmp/authorized_keys")
|
||||||
|
|
||||||
|
|
||||||
def test_non_sensitive_path():
|
def test_non_sensitive_path():
|
||||||
@@ -191,11 +186,9 @@ def test_allows_agent_workspace(tmp_path):
|
|||||||
os.unlink(target)
|
os.unlink(target)
|
||||||
|
|
||||||
|
|
||||||
def test_allows_tmp(tmp_path, monkeypatch):
|
def test_allows_tmp(tmp_path):
|
||||||
"""Paths under /tmp (or its realpath) must resolve cleanly."""
|
"""Paths under /tmp (or its realpath) must resolve cleanly."""
|
||||||
from src.tool_execution import _resolve_tool_path
|
from src.tool_execution import _resolve_tool_path
|
||||||
# Windows has no /tmp; its temp root is only allowed via $TMPDIR.
|
|
||||||
monkeypatch.setenv("TMPDIR", str(tmp_path))
|
|
||||||
f = tmp_path / "confinement-test.txt"
|
f = tmp_path / "confinement-test.txt"
|
||||||
f.write_text("ok")
|
f.write_text("ok")
|
||||||
resolved = _resolve_tool_path(str(f))
|
resolved = _resolve_tool_path(str(f))
|
||||||
@@ -365,4 +358,4 @@ async def test_write_file_dispatch_blocks_cron(monkeypatch):
|
|||||||
def test_application_secrets_are_sensitive_paths(filename):
|
def test_application_secrets_are_sensitive_paths(filename):
|
||||||
from src.tool_execution import _is_sensitive_path
|
from src.tool_execution import _is_sensitive_path
|
||||||
|
|
||||||
assert _is_sensitive_path(_native(f"/tmp/odysseus-data/{filename}"))
|
assert _is_sensitive_path(f"/tmp/odysseus-data/{filename}")
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
"""Issue #5573 — the vision model's description must not read as the user's own words.
|
|
||||||
|
|
||||||
For a text-only chat model the description comes from a separate vision call and
|
|
||||||
is appended to the user's message. Unlabelled, the chat model answers the prose
|
|
||||||
instead of the image — reviewing the user's "writing" back to them.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
DESCRIPTION = "A chocolate cake, plated with a mirror glaze and gold leaf."
|
|
||||||
QUESTION = "What am I looking at?"
|
|
||||||
|
|
||||||
|
|
||||||
class _UploadHandler:
|
|
||||||
def __init__(self, file_info):
|
|
||||||
self._file_info = file_info
|
|
||||||
|
|
||||||
def resolve_upload(self, att_id, owner=None):
|
|
||||||
return self._file_info if att_id == self._file_info["id"] else None
|
|
||||||
|
|
||||||
def is_image_file(self, *_args, **_kwargs):
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _no_vision_call(*_args, **_kwargs):
|
|
||||||
raise AssertionError("the vision model must not be called on this path")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def vision_env(monkeypatch, tmp_path):
|
|
||||||
"""Text-only chat model with one image attachment, no DB and no real vision call.
|
|
||||||
|
|
||||||
Other tests drop src.chat_handler from sys.modules, so resolve it here rather
|
|
||||||
than at import time — otherwise the patches land on a different module object
|
|
||||||
than the one under test.
|
|
||||||
"""
|
|
||||||
module = importlib.import_module("src.chat_handler")
|
|
||||||
monkeypatch.setattr(module, "UPLOAD_DIR", str(tmp_path))
|
|
||||||
monkeypatch.setattr(module, "model_supports_vision", lambda *a, **k: False)
|
|
||||||
monkeypatch.setattr(module, "_sync_upload_vision_to_gallery", lambda *a, **k: None)
|
|
||||||
monkeypatch.setattr(module, "build_user_content", lambda text, *a, **k: text)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
importlib.import_module("src.settings"), "get_setting", lambda key, default=None: default
|
|
||||||
)
|
|
||||||
return SimpleNamespace(module=module, tmp_path=tmp_path, file_info={
|
|
||||||
"id": "img-1", "name": "cake.jpg", "path": str(tmp_path / "cake.jpg"),
|
|
||||||
"mime": "image/jpeg", "size": 1, "hash": "h",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
async def _preprocess(env):
|
|
||||||
handler = env.module.ChatHandler(
|
|
||||||
session_manager=None, memory_manager=None, chat_processor=None,
|
|
||||||
research_handler=None, preset_manager=None,
|
|
||||||
upload_handler=_UploadHandler(env.file_info),
|
|
||||||
)
|
|
||||||
sess = SimpleNamespace(model="chat-model", endpoint_url="", owner="user", id="s1")
|
|
||||||
enhanced, *_ = await handler.preprocess_message(
|
|
||||||
QUESTION, ["img-1"], sess, auto_opened_docs=[],
|
|
||||||
)
|
|
||||||
return enhanced
|
|
||||||
|
|
||||||
|
|
||||||
def _describe(env, monkeypatch, text):
|
|
||||||
monkeypatch.setattr(env.module, "analyze_image_with_vl_result",
|
|
||||||
lambda *a, **k: {"text": text, "model": "vl-model"})
|
|
||||||
|
|
||||||
|
|
||||||
def _label_above(enhanced, description):
|
|
||||||
"""The line the description is introduced by."""
|
|
||||||
return enhanced.split(description)[0].rstrip("\n").rsplit("\n", 1)[-1]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_description_is_marked_as_not_the_users_message(vision_env, monkeypatch):
|
|
||||||
_describe(vision_env, monkeypatch, DESCRIPTION)
|
|
||||||
enhanced = await _preprocess(vision_env)
|
|
||||||
|
|
||||||
assert QUESTION in enhanced, "the user's own words must survive untouched"
|
|
||||||
label = _label_above(enhanced, DESCRIPTION)
|
|
||||||
assert label.startswith("[") and "user" in label.lower(), (
|
|
||||||
"the description is appended as bare prose, so the chat model reads it as "
|
|
||||||
f"the user's own writing. Got:\n{enhanced}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cached_description_is_labelled_too(vision_env, monkeypatch):
|
|
||||||
"""The cache holds an auto description or a hand-corrected one, and the label
|
|
||||||
has to be true either way — it is a description of the image regardless."""
|
|
||||||
cache = vision_env.tmp_path / ".vision"
|
|
||||||
cache.mkdir()
|
|
||||||
(cache / "img-1.txt").write_text(DESCRIPTION, encoding="utf-8")
|
|
||||||
monkeypatch.setattr(vision_env.module, "analyze_image_with_vl_result", _no_vision_call)
|
|
||||||
|
|
||||||
enhanced = await _preprocess(vision_env)
|
|
||||||
|
|
||||||
label = _label_above(enhanced, DESCRIPTION)
|
|
||||||
assert label.startswith("[") and "user" in label.lower(), (
|
|
||||||
f"a cached description is injected with no label. Got:\n{enhanced}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_client_side_image_marker_is_preserved(vision_env, monkeypatch):
|
|
||||||
"""chatRenderer.js and sessions.js strip the block by matching `[Image: <name>]\\n`.
|
|
||||||
|
|
||||||
Renaming that marker leaves the raw description rendered in the user's bubble.
|
|
||||||
"""
|
|
||||||
_describe(vision_env, monkeypatch, DESCRIPTION)
|
|
||||||
assert "[Image: cake.jpg]\n" in await _preprocess(vision_env)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_no_dangling_label_when_the_vision_model_returns_nothing(vision_env, monkeypatch):
|
|
||||||
_describe(vision_env, monkeypatch, "")
|
|
||||||
enhanced = await _preprocess(vision_env)
|
|
||||||
assert enhanced.endswith("[Image: cake.jpg]\n"), (
|
|
||||||
"an empty description must leave the bare marker, trailing newline included "
|
|
||||||
f"— the client's strip regex needs it. Got:\n{enhanced!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_vision_capable_model_path_is_unchanged(vision_env, monkeypatch):
|
|
||||||
monkeypatch.setattr(vision_env.module, "model_supports_vision", lambda *a, **k: True)
|
|
||||||
monkeypatch.setattr(vision_env.module, "analyze_image_with_vl_result", _no_vision_call)
|
|
||||||
|
|
||||||
assert await _preprocess(vision_env) == f"{QUESTION}\n\n[Image attached: cake.jpg]"
|
|
||||||
@@ -5,7 +5,7 @@ from bs4 import BeautifulSoup
|
|||||||
from src.visual_report import generate_visual_report
|
from src.visual_report import generate_visual_report
|
||||||
|
|
||||||
|
|
||||||
SOURCE = (Path(__file__).resolve().parents[1] / "src/visual_report.py").read_text(encoding="utf-8")
|
SOURCE = (Path(__file__).resolve().parents[1] / "src/visual_report.py").read_text()
|
||||||
|
|
||||||
|
|
||||||
def test_export_bookmark_is_anchored_and_expands_on_demand():
|
def test_export_bookmark_is_anchored_and_expands_on_demand():
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text()
|
||||||
CHAT = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
|
CHAT = (ROOT / "static/js/chat.js").read_text()
|
||||||
|
|
||||||
|
|
||||||
def test_saved_web_search_tool_icon_has_no_action_target():
|
def test_saved_web_search_tool_icon_has_no_action_target():
|
||||||
|
|||||||
Reference in New Issue
Block a user