mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-17 23:46:47 +00:00
Compare commits
26 Commits
v3.2.0-clo
...
jamison/ti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e69f66705 | ||
|
|
66c361bd37 | ||
|
|
01cbea8c4b | ||
|
|
2dc2b0da84 | ||
|
|
4b58c9cda6 | ||
|
|
7eb945f060 | ||
|
|
e29f948f29 | ||
|
|
7a18b896aa | ||
|
|
53e00c7989 | ||
|
|
50df53727a | ||
|
|
e629574580 | ||
|
|
8d539cdf3f | ||
|
|
52524cbe57 | ||
|
|
c64def6a9e | ||
|
|
2628fe1b93 | ||
|
|
96bf344f9c | ||
|
|
b92d3a307d | ||
|
|
c55207eeba | ||
|
|
2de56cd65f | ||
|
|
92bc13f920 | ||
|
|
3ddcf101bf | ||
|
|
9f764ee55f | ||
|
|
4d059b5e0f | ||
|
|
57e78cf4c9 | ||
|
|
48e74ad3ef | ||
|
|
ca10520190 |
50
.github/workflows/deployment.yml
vendored
50
.github/workflows/deployment.yml
vendored
@@ -462,7 +462,7 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
@@ -472,7 +472,7 @@ jobs:
|
||||
|
||||
- name: Build and push AMD64
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # ratchet:docker/build-push-action@v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # ratchet:docker/build-push-action@v7
|
||||
with:
|
||||
context: ./web
|
||||
file: ./web/Dockerfile
|
||||
@@ -536,7 +536,7 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
@@ -546,7 +546,7 @@ jobs:
|
||||
|
||||
- name: Build and push ARM64
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # ratchet:docker/build-push-action@v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # ratchet:docker/build-push-action@v7
|
||||
with:
|
||||
context: ./web
|
||||
file: ./web/Dockerfile
|
||||
@@ -597,7 +597,7 @@ jobs:
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
@@ -676,7 +676,7 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
@@ -686,7 +686,7 @@ jobs:
|
||||
|
||||
- name: Build and push AMD64
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # ratchet:docker/build-push-action@v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # ratchet:docker/build-push-action@v7
|
||||
with:
|
||||
context: ./web
|
||||
file: ./web/Dockerfile
|
||||
@@ -761,7 +761,7 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
@@ -771,7 +771,7 @@ jobs:
|
||||
|
||||
- name: Build and push ARM64
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # ratchet:docker/build-push-action@v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # ratchet:docker/build-push-action@v7
|
||||
with:
|
||||
context: ./web
|
||||
file: ./web/Dockerfile
|
||||
@@ -833,7 +833,7 @@ jobs:
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
@@ -908,7 +908,7 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
@@ -918,7 +918,7 @@ jobs:
|
||||
|
||||
- name: Build and push AMD64
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # ratchet:docker/build-push-action@v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # ratchet:docker/build-push-action@v7
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
@@ -981,7 +981,7 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
@@ -991,7 +991,7 @@ jobs:
|
||||
|
||||
- name: Build and push ARM64
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # ratchet:docker/build-push-action@v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # ratchet:docker/build-push-action@v7
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
@@ -1041,7 +1041,7 @@ jobs:
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
@@ -1119,7 +1119,7 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
@@ -1129,7 +1129,7 @@ jobs:
|
||||
|
||||
- name: Build and push AMD64
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # ratchet:docker/build-push-action@v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # ratchet:docker/build-push-action@v7
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
@@ -1192,7 +1192,7 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
@@ -1202,7 +1202,7 @@ jobs:
|
||||
|
||||
- name: Build and push ARM64
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # ratchet:docker/build-push-action@v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # ratchet:docker/build-push-action@v7
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
@@ -1253,7 +1253,7 @@ jobs:
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
@@ -1329,7 +1329,7 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
with:
|
||||
buildkitd-flags: ${{ vars.DOCKER_DEBUG == 'true' && '--debug' || '' }}
|
||||
|
||||
@@ -1341,7 +1341,7 @@ jobs:
|
||||
|
||||
- name: Build and push AMD64
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # ratchet:docker/build-push-action@v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # ratchet:docker/build-push-action@v7
|
||||
env:
|
||||
DEBUG: ${{ vars.DOCKER_DEBUG == 'true' && 1 || 0 }}
|
||||
with:
|
||||
@@ -1409,7 +1409,7 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
with:
|
||||
buildkitd-flags: ${{ vars.DOCKER_DEBUG == 'true' && '--debug' || '' }}
|
||||
|
||||
@@ -1421,7 +1421,7 @@ jobs:
|
||||
|
||||
- name: Build and push ARM64
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # ratchet:docker/build-push-action@v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # ratchet:docker/build-push-action@v7
|
||||
env:
|
||||
DEBUG: ${{ vars.DOCKER_DEBUG == 'true' && 1 || 0 }}
|
||||
with:
|
||||
@@ -1475,7 +1475,7 @@ jobs:
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
|
||||
2
.github/workflows/docker-tag-beta.yml
vendored
2
.github/workflows/docker-tag-beta.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
|
||||
2
.github/workflows/docker-tag-latest.yml
vendored
2
.github/workflows/docker-tag-latest.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
|
||||
10
.github/workflows/pr-integration-tests.yml
vendored
10
.github/workflows/pr-integration-tests.yml
vendored
@@ -115,7 +115,7 @@ jobs:
|
||||
echo "cache-suffix=${CACHE_SUFFIX}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
# needed for pulling Vespa, Redis, Postgres, and Minio images
|
||||
# otherwise, we hit the "Unauthenticated users" limit
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push Backend Docker image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # ratchet:docker/build-push-action@v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # ratchet:docker/build-push-action@v7
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
echo "cache-suffix=${CACHE_SUFFIX}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
# needed for pulling Vespa, Redis, Postgres, and Minio images
|
||||
# otherwise, we hit the "Unauthenticated users" limit
|
||||
@@ -187,7 +187,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push Model Server Docker image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # ratchet:docker/build-push-action@v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # ratchet:docker/build-push-action@v7
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile.model_server
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
# needed for pulling openapitools/openapi-generator-cli
|
||||
# otherwise, we hit the "Unauthenticated users" limit
|
||||
|
||||
12
.github/workflows/pr-playwright-tests.yml
vendored
12
.github/workflows/pr-playwright-tests.yml
vendored
@@ -94,7 +94,7 @@ jobs:
|
||||
echo "cache-suffix=${CACHE_SUFFIX}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
# needed for pulling external images otherwise, we hit the "Unauthenticated users" limit
|
||||
# https://docs.docker.com/docker-hub/usage/
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push Web Docker image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # ratchet:docker/build-push-action@v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # ratchet:docker/build-push-action@v7
|
||||
with:
|
||||
context: ./web
|
||||
file: ./web/Dockerfile
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
echo "cache-suffix=${CACHE_SUFFIX}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
# needed for pulling external images otherwise, we hit the "Unauthenticated users" limit
|
||||
# https://docs.docker.com/docker-hub/usage/
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push Backend Docker image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # ratchet:docker/build-push-action@v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # ratchet:docker/build-push-action@v7
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
@@ -216,7 +216,7 @@ jobs:
|
||||
echo "cache-suffix=${CACHE_SUFFIX}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
# needed for pulling external images otherwise, we hit the "Unauthenticated users" limit
|
||||
# https://docs.docker.com/docker-hub/usage/
|
||||
@@ -227,7 +227,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push Model Server Docker image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # ratchet:docker/build-push-action@v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # ratchet:docker/build-push-action@v7
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile.model_server
|
||||
|
||||
2
.github/workflows/pr-python-model-tests.yml
vendored
2
.github/workflows/pr-python-model-tests.yml
vendored
@@ -69,7 +69,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and load
|
||||
uses: docker/bake-action@82490499d2e5613fcead7e128237ef0b0ea210f7 # ratchet:docker/bake-action@v7.0.0
|
||||
|
||||
10
.github/workflows/sandbox-deployment.yml
vendored
10
.github/workflows/sandbox-deployment.yml
vendored
@@ -132,7 +132,7 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
|
||||
- name: Build and push AMD64
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # ratchet:docker/build-push-action@v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # ratchet:docker/build-push-action@v7
|
||||
with:
|
||||
context: ./backend/onyx/server/features/build/sandbox/kubernetes/docker
|
||||
file: ./backend/onyx/server/features/build/sandbox/kubernetes/docker/Dockerfile
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
|
||||
- name: Build and push ARM64
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # ratchet:docker/build-push-action@v6
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # ratchet:docker/build-push-action@v7
|
||||
with:
|
||||
context: ./backend/onyx/server/features/build/sandbox/kubernetes/docker
|
||||
file: ./backend/onyx/server/features/build/sandbox/kubernetes/docker/Dockerfile
|
||||
@@ -258,7 +258,7 @@ jobs:
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # ratchet:docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
|
||||
@@ -826,6 +826,12 @@ def translate_history_to_llm_format(
|
||||
base64_data = img_file.to_base64()
|
||||
image_url = f"data:{image_type};base64,{base64_data}"
|
||||
|
||||
content_parts.append(
|
||||
TextContentPart(
|
||||
type="text",
|
||||
text=f"[attached image — file_id: {img_file.file_id}]",
|
||||
)
|
||||
)
|
||||
image_part = ImageContentPart(
|
||||
type="image_url",
|
||||
image_url=ImageUrlDetail(
|
||||
|
||||
@@ -93,6 +93,7 @@ from onyx.llm.factory import get_llm_for_persona
|
||||
from onyx.llm.factory import get_llm_token_counter
|
||||
from onyx.llm.interfaces import LLM
|
||||
from onyx.llm.interfaces import LLMUserIdentity
|
||||
from onyx.llm.multi_llm import LLMTimeoutError
|
||||
from onyx.llm.override_models import LLMOverride
|
||||
from onyx.llm.request_context import reset_llm_mock_response
|
||||
from onyx.llm.request_context import set_llm_mock_response
|
||||
@@ -1166,6 +1167,32 @@ def _run_models(
|
||||
else:
|
||||
if item is _MODEL_DONE:
|
||||
models_remaining -= 1
|
||||
elif isinstance(item, LLMTimeoutError):
|
||||
model_llm = setup.llms[model_idx]
|
||||
error_msg = (
|
||||
"The LLM took too long to respond. "
|
||||
"If you're running a local model, try increasing the "
|
||||
"LLM_SOCKET_READ_TIMEOUT environment variable "
|
||||
"(current default: 120 seconds)."
|
||||
)
|
||||
stack_trace = "".join(
|
||||
traceback.format_exception(type(item), item, item.__traceback__)
|
||||
)
|
||||
if model_llm.config.api_key and len(model_llm.config.api_key) > 2:
|
||||
stack_trace = stack_trace.replace(
|
||||
model_llm.config.api_key, "[REDACTED_API_KEY]"
|
||||
)
|
||||
yield StreamingError(
|
||||
error=error_msg,
|
||||
stack_trace=stack_trace,
|
||||
error_code="CONNECTION_ERROR",
|
||||
is_retryable=True,
|
||||
details={
|
||||
"model": model_llm.config.model_name,
|
||||
"provider": model_llm.config.model_provider,
|
||||
"model_index": model_idx,
|
||||
},
|
||||
)
|
||||
elif isinstance(item, Exception):
|
||||
# Yield a tagged error for this model but keep the other models running.
|
||||
# Do NOT decrement models_remaining — _run_model's finally always posts
|
||||
|
||||
@@ -843,6 +843,29 @@ MAX_FILE_SIZE_BYTES = int(
|
||||
os.environ.get("MAX_FILE_SIZE_BYTES") or 2 * 1024 * 1024 * 1024
|
||||
) # 2GB in bytes
|
||||
|
||||
# Maximum embedded images allowed in a single file. PDFs (and other formats)
|
||||
# with thousands of embedded images can OOM the user-file-processing worker
|
||||
# because every image is decoded with PIL and then sent to the vision LLM.
|
||||
# Enforced both at upload time (rejects the file) and during extraction
|
||||
# (defense-in-depth: caps the number of images materialized).
|
||||
#
|
||||
# Clamped to >= 0; a negative env value would turn upload validation into
|
||||
# always-fail and extraction into always-stop, which is never desired. 0
|
||||
# disables image extraction entirely, which is a valid (if aggressive) setting.
|
||||
MAX_EMBEDDED_IMAGES_PER_FILE = max(
|
||||
0, int(os.environ.get("MAX_EMBEDDED_IMAGES_PER_FILE") or 500)
|
||||
)
|
||||
|
||||
# Maximum embedded images allowed across all files in a single upload batch.
|
||||
# Protects against the scenario where a user uploads many files that each
|
||||
# fall under MAX_EMBEDDED_IMAGES_PER_FILE but aggregate to enough work
|
||||
# (serial-ish celery fan-out plus per-image vision-LLM calls) to OOM the
|
||||
# worker under concurrency or run up surprise latency/cost. Also clamped
|
||||
# to >= 0.
|
||||
MAX_EMBEDDED_IMAGES_PER_UPLOAD = max(
|
||||
0, int(os.environ.get("MAX_EMBEDDED_IMAGES_PER_UPLOAD") or 1000)
|
||||
)
|
||||
|
||||
# Use document summary for contextual rag
|
||||
USE_DOCUMENT_SUMMARY = os.environ.get("USE_DOCUMENT_SUMMARY", "true").lower() == "true"
|
||||
# Use chunk summary for contextual rag
|
||||
|
||||
@@ -3,6 +3,7 @@ from collections.abc import Callable
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Any
|
||||
from typing import TypeVar
|
||||
from urllib.parse import urljoin
|
||||
@@ -10,7 +11,6 @@ from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from dateutil.parser import parse
|
||||
from dateutil.parser import ParserError
|
||||
|
||||
from onyx.configs.app_configs import CONNECTOR_LOCALHOST_OVERRIDE
|
||||
from onyx.configs.constants import DocumentSource
|
||||
@@ -56,18 +56,16 @@ def time_str_to_utc(datetime_str: str) -> datetime:
|
||||
if fixed not in candidates:
|
||||
candidates.append(fixed)
|
||||
|
||||
last_exception: Exception | None = None
|
||||
for candidate in candidates:
|
||||
try:
|
||||
dt = parse(candidate)
|
||||
return datetime_to_utc(dt)
|
||||
except (ValueError, ParserError) as exc:
|
||||
last_exception = exc
|
||||
# dateutil is the primary; the stdlib RFC 2822 parser is a fallback for
|
||||
# inputs dateutil rejects (e.g. headers concatenated without a CRLF —
|
||||
# TZ may be dropped, datetime_to_utc then assumes UTC).
|
||||
for parser in (parse, parsedate_to_datetime):
|
||||
for candidate in candidates:
|
||||
try:
|
||||
return datetime_to_utc(parser(candidate))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
continue
|
||||
|
||||
if last_exception is not None:
|
||||
raise last_exception
|
||||
|
||||
# Fallback in case parsing failed without raising (should not happen)
|
||||
raise ValueError(f"Unable to parse datetime string: {datetime_str}")
|
||||
|
||||
|
||||
|
||||
@@ -253,7 +253,17 @@ def thread_to_document(
|
||||
|
||||
updated_at_datetime = None
|
||||
if updated_at:
|
||||
updated_at_datetime = time_str_to_utc(updated_at)
|
||||
try:
|
||||
updated_at_datetime = time_str_to_utc(updated_at)
|
||||
except (ValueError, OverflowError) as e:
|
||||
# Old mailboxes contain RFC-violating Date headers. Drop the
|
||||
# timestamp instead of aborting the indexing run.
|
||||
logger.warning(
|
||||
"Skipping unparseable Gmail Date header on thread %s: %r (%s)",
|
||||
full_thread.get("id"),
|
||||
updated_at,
|
||||
e,
|
||||
)
|
||||
|
||||
id = full_thread.get("id")
|
||||
if not id:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import copy
|
||||
import time
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
@@ -8,27 +9,58 @@ from typing import Any
|
||||
from typing import cast
|
||||
|
||||
import requests
|
||||
from pydantic import BaseModel
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
|
||||
from onyx.configs.app_configs import CONTINUE_ON_CONNECTOR_FAILURE
|
||||
from onyx.configs.app_configs import GONG_CONNECTOR_START_TIME
|
||||
from onyx.configs.app_configs import INDEX_BATCH_SIZE
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.interfaces import GenerateDocumentsOutput
|
||||
from onyx.connectors.interfaces import LoadConnector
|
||||
from onyx.connectors.interfaces import PollConnector
|
||||
from onyx.connectors.interfaces import CheckpointedConnector
|
||||
from onyx.connectors.interfaces import CheckpointOutput
|
||||
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
|
||||
from onyx.connectors.models import ConnectorCheckpoint
|
||||
from onyx.connectors.models import ConnectorFailure
|
||||
from onyx.connectors.models import ConnectorMissingCredentialError
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import HierarchyNode
|
||||
from onyx.connectors.models import DocumentFailure
|
||||
from onyx.connectors.models import TextSection
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
class GongConnector(LoadConnector, PollConnector):
|
||||
class GongConnectorCheckpoint(ConnectorCheckpoint):
|
||||
# Resolved workspace IDs to iterate through.
|
||||
# None means "not yet resolved" — first checkpoint call resolves them.
|
||||
# Inner None means "no workspace filter" (fetch all).
|
||||
workspace_ids: list[str | None] | None = None
|
||||
# Index into workspace_ids for current workspace
|
||||
workspace_index: int = 0
|
||||
# Gong API cursor for current workspace's transcript pagination
|
||||
cursor: str | None = None
|
||||
# Cached time range — computed once, reused across checkpoint calls
|
||||
time_range: tuple[str, str] | None = None
|
||||
|
||||
|
||||
class _TranscriptPage(BaseModel):
|
||||
"""One page of transcripts from /v2/calls/transcript."""
|
||||
|
||||
transcripts: list[dict[str, Any]]
|
||||
next_cursor: str | None = None
|
||||
|
||||
|
||||
class _CursorExpiredError(Exception):
|
||||
"""Raised when Gong rejects a pagination cursor as expired.
|
||||
|
||||
Gong pagination cursors TTL is ~1 hour from the first request in a
|
||||
pagination sequence, not from the last cursor fetch. Since checkpointed
|
||||
connector runs can pause between invocations, a resumed run may encounter
|
||||
an expired cursor and must restart the current workspace from scratch.
|
||||
See https://visioneers.gong.io/integrations-77/pagination-cursor-expires-after-1-hours-even-for-a-new-cursor-1382
|
||||
"""
|
||||
|
||||
|
||||
class GongConnector(CheckpointedConnector[GongConnectorCheckpoint]):
|
||||
BASE_URL = "https://api.gong.io"
|
||||
MAX_CALL_DETAILS_ATTEMPTS = 6
|
||||
CALL_DETAILS_DELAY = 30 # in seconds
|
||||
@@ -38,13 +70,9 @@ class GongConnector(LoadConnector, PollConnector):
|
||||
def __init__(
|
||||
self,
|
||||
workspaces: list[str] | None = None,
|
||||
batch_size: int = INDEX_BATCH_SIZE,
|
||||
continue_on_fail: bool = CONTINUE_ON_CONNECTOR_FAILURE,
|
||||
hide_user_info: bool = False,
|
||||
) -> None:
|
||||
self.workspaces = workspaces
|
||||
self.batch_size: int = batch_size
|
||||
self.continue_on_fail = continue_on_fail
|
||||
self.auth_token_basic: str | None = None
|
||||
self.hide_user_info = hide_user_info
|
||||
self._last_request_time: float = 0.0
|
||||
@@ -98,67 +126,50 @@ class GongConnector(LoadConnector, PollConnector):
|
||||
# Then the user input is treated as the name
|
||||
return {**id_id_map, **name_id_map}
|
||||
|
||||
def _get_transcript_batches(
|
||||
self, start_datetime: str | None = None, end_datetime: str | None = None
|
||||
) -> Generator[list[dict[str, Any]], None, None]:
|
||||
body: dict[str, dict] = {"filter": {}}
|
||||
def _fetch_transcript_page(
|
||||
self,
|
||||
start_datetime: str | None,
|
||||
end_datetime: str | None,
|
||||
workspace_id: str | None,
|
||||
cursor: str | None,
|
||||
) -> _TranscriptPage:
|
||||
"""Fetch one page of transcripts from the Gong API.
|
||||
|
||||
Raises _CursorExpiredError if Gong reports the pagination cursor
|
||||
expired (TTL is ~1 hour from first request in the pagination sequence).
|
||||
"""
|
||||
body: dict[str, Any] = {"filter": {}}
|
||||
if start_datetime:
|
||||
body["filter"]["fromDateTime"] = start_datetime
|
||||
if end_datetime:
|
||||
body["filter"]["toDateTime"] = end_datetime
|
||||
if workspace_id:
|
||||
body["filter"]["workspaceId"] = workspace_id
|
||||
if cursor:
|
||||
body["cursor"] = cursor
|
||||
|
||||
# The batch_ids in the previous method appears to be batches of call_ids to process
|
||||
# In this method, we will retrieve transcripts for them in batches.
|
||||
transcripts: list[dict[str, Any]] = []
|
||||
workspace_list = self.workspaces or [None]
|
||||
workspace_map = self._get_workspace_id_map() if self.workspaces else {}
|
||||
response = self._throttled_request(
|
||||
"POST", GongConnector.make_url("/v2/calls/transcript"), json=body
|
||||
)
|
||||
# If no calls in the range, return empty
|
||||
if response.status_code == 404:
|
||||
return _TranscriptPage(transcripts=[])
|
||||
|
||||
for workspace in workspace_list:
|
||||
if workspace:
|
||||
logger.info(f"Updating Gong workspace: {workspace}")
|
||||
workspace_id = workspace_map.get(workspace)
|
||||
if not workspace_id:
|
||||
logger.error(f"Invalid Gong workspace: {workspace}")
|
||||
if not self.continue_on_fail:
|
||||
raise ValueError(f"Invalid workspace: {workspace}")
|
||||
continue
|
||||
body["filter"]["workspaceId"] = workspace_id
|
||||
else:
|
||||
if "workspaceId" in body["filter"]:
|
||||
del body["filter"]["workspaceId"]
|
||||
if not response.ok:
|
||||
# Cursor expiration comes back as a 4xx with this error message —
|
||||
# detect it before raise_for_status so callers can restart the workspace.
|
||||
if cursor and "cursor has expired" in response.text.lower():
|
||||
raise _CursorExpiredError(response.text)
|
||||
logger.error(f"Error fetching transcripts: {response.text}")
|
||||
response.raise_for_status()
|
||||
|
||||
while True:
|
||||
response = self._throttled_request(
|
||||
"POST", GongConnector.make_url("/v2/calls/transcript"), json=body
|
||||
)
|
||||
# If no calls in the range, just break out
|
||||
if response.status_code == 404:
|
||||
break
|
||||
data = response.json()
|
||||
return _TranscriptPage(
|
||||
transcripts=data.get("callTranscripts", []),
|
||||
next_cursor=data.get("records", {}).get("cursor"),
|
||||
)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except Exception:
|
||||
logger.error(f"Error fetching transcripts: {response.text}")
|
||||
raise
|
||||
|
||||
data = response.json()
|
||||
call_transcripts = data.get("callTranscripts", [])
|
||||
transcripts.extend(call_transcripts)
|
||||
|
||||
while len(transcripts) >= self.batch_size:
|
||||
yield transcripts[: self.batch_size]
|
||||
transcripts = transcripts[self.batch_size :]
|
||||
|
||||
cursor = data.get("records", {}).get("cursor")
|
||||
if cursor:
|
||||
body["cursor"] = cursor
|
||||
else:
|
||||
break
|
||||
|
||||
if transcripts:
|
||||
yield transcripts
|
||||
|
||||
def _get_call_details_by_ids(self, call_ids: list[str]) -> dict:
|
||||
def _get_call_details_by_ids(self, call_ids: list[str]) -> dict[str, Any]:
|
||||
body = {
|
||||
"filter": {"callIds": call_ids},
|
||||
"contentSelector": {"exposedFields": {"parties": True}},
|
||||
@@ -176,6 +187,50 @@ class GongConnector(LoadConnector, PollConnector):
|
||||
|
||||
return call_to_metadata
|
||||
|
||||
def _fetch_call_details_with_retry(self, call_ids: list[str]) -> dict[str, Any]:
|
||||
"""Fetch call details with retry for the Gong API race condition.
|
||||
|
||||
The Gong API has a known race where transcript call IDs don't immediately
|
||||
appear in /v2/calls/extensive. Retries with exponential backoff, only
|
||||
re-requesting the missing IDs on each attempt.
|
||||
"""
|
||||
call_details_map = self._get_call_details_by_ids(call_ids)
|
||||
if set(call_ids) == set(call_details_map.keys()):
|
||||
return call_details_map
|
||||
|
||||
for attempt in range(2, self.MAX_CALL_DETAILS_ATTEMPTS + 1):
|
||||
missing_ids = list(set(call_ids) - set(call_details_map.keys()))
|
||||
logger.warning(
|
||||
f"_get_call_details_by_ids is missing call id's: current_attempt={attempt - 1} missing_call_ids={missing_ids}"
|
||||
)
|
||||
|
||||
wait_seconds = self.CALL_DETAILS_DELAY * pow(2, attempt - 2)
|
||||
logger.warning(
|
||||
f"_get_call_details_by_ids waiting to retry: "
|
||||
f"wait={wait_seconds}s "
|
||||
f"current_attempt={attempt - 1} "
|
||||
f"next_attempt={attempt} "
|
||||
f"max_attempts={self.MAX_CALL_DETAILS_ATTEMPTS}"
|
||||
)
|
||||
time.sleep(wait_seconds)
|
||||
|
||||
# Only re-fetch the missing IDs, merge into existing results
|
||||
new_details = self._get_call_details_by_ids(missing_ids)
|
||||
call_details_map.update(new_details)
|
||||
|
||||
if set(call_ids) == set(call_details_map.keys()):
|
||||
return call_details_map
|
||||
|
||||
missing_ids = list(set(call_ids) - set(call_details_map.keys()))
|
||||
logger.error(
|
||||
f"Giving up on missing call id's after "
|
||||
f"{self.MAX_CALL_DETAILS_ATTEMPTS} attempts: "
|
||||
f"missing_call_ids={missing_ids} — "
|
||||
f"proceeding with {len(call_details_map)} of "
|
||||
f"{len(call_ids)} calls"
|
||||
)
|
||||
return call_details_map
|
||||
|
||||
@staticmethod
|
||||
def _parse_parties(parties: list[dict]) -> dict[str, str]:
|
||||
id_mapping = {}
|
||||
@@ -196,186 +251,46 @@ class GongConnector(LoadConnector, PollConnector):
|
||||
|
||||
return id_mapping
|
||||
|
||||
def _fetch_calls(
|
||||
self, start_datetime: str | None = None, end_datetime: str | None = None
|
||||
) -> GenerateDocumentsOutput:
|
||||
num_calls = 0
|
||||
def _resolve_workspace_ids(self) -> list[str | None]:
|
||||
"""Resolve configured workspace names/IDs to actual workspace IDs.
|
||||
|
||||
for transcript_batch in self._get_transcript_batches(
|
||||
start_datetime, end_datetime
|
||||
):
|
||||
doc_batch: list[Document | HierarchyNode] = []
|
||||
Returns a list of workspace IDs. If no workspaces are configured,
|
||||
returns [None] to indicate "fetch all workspaces".
|
||||
|
||||
transcript_call_ids = cast(
|
||||
list[str],
|
||||
[t.get("callId") for t in transcript_batch if t.get("callId")],
|
||||
Raises ValueError if workspaces are configured but none resolve —
|
||||
we never silently widen scope to "fetch all" on misconfiguration,
|
||||
because that could ingest an entire Gong account by mistake.
|
||||
"""
|
||||
if not self.workspaces:
|
||||
return [None]
|
||||
|
||||
workspace_map = self._get_workspace_id_map()
|
||||
resolved: list[str | None] = []
|
||||
for workspace in self.workspaces:
|
||||
workspace_id = workspace_map.get(workspace)
|
||||
if not workspace_id:
|
||||
logger.error(f"Invalid Gong workspace: {workspace}")
|
||||
continue
|
||||
resolved.append(workspace_id)
|
||||
|
||||
if not resolved:
|
||||
raise ValueError(
|
||||
f"No valid Gong workspaces found — check workspace names/IDs in connector config. Configured: {self.workspaces}"
|
||||
)
|
||||
|
||||
call_details_map: dict[str, Any] = {}
|
||||
return resolved
|
||||
|
||||
# There's a likely race condition in the API where a transcript will have a
|
||||
# call id but the call to v2/calls/extensive will not return all of the id's
|
||||
# retry with exponential backoff has been observed to mitigate this
|
||||
# in ~2 minutes. After max attempts, proceed with whatever we have —
|
||||
# the per-call loop below will skip missing IDs gracefully.
|
||||
current_attempt = 0
|
||||
while True:
|
||||
current_attempt += 1
|
||||
call_details_map = self._get_call_details_by_ids(transcript_call_ids)
|
||||
if set(transcript_call_ids) == set(call_details_map.keys()):
|
||||
# we got all the id's we were expecting ... break and continue
|
||||
break
|
||||
|
||||
# we are missing some id's. Log and retry with exponential backoff
|
||||
missing_call_ids = set(transcript_call_ids) - set(
|
||||
call_details_map.keys()
|
||||
)
|
||||
logger.warning(
|
||||
f"_get_call_details_by_ids is missing call id's: "
|
||||
f"current_attempt={current_attempt} "
|
||||
f"missing_call_ids={missing_call_ids}"
|
||||
)
|
||||
if current_attempt >= self.MAX_CALL_DETAILS_ATTEMPTS:
|
||||
logger.error(
|
||||
f"Giving up on missing call id's after "
|
||||
f"{self.MAX_CALL_DETAILS_ATTEMPTS} attempts: "
|
||||
f"missing_call_ids={missing_call_ids} — "
|
||||
f"proceeding with {len(call_details_map)} of "
|
||||
f"{len(transcript_call_ids)} calls"
|
||||
)
|
||||
break
|
||||
|
||||
wait_seconds = self.CALL_DETAILS_DELAY * pow(2, current_attempt - 1)
|
||||
logger.warning(
|
||||
f"_get_call_details_by_ids waiting to retry: "
|
||||
f"wait={wait_seconds}s "
|
||||
f"current_attempt={current_attempt} "
|
||||
f"next_attempt={current_attempt + 1} "
|
||||
f"max_attempts={self.MAX_CALL_DETAILS_ATTEMPTS}"
|
||||
)
|
||||
time.sleep(wait_seconds)
|
||||
|
||||
# now we can iterate per call/transcript
|
||||
for transcript in transcript_batch:
|
||||
call_id = transcript.get("callId")
|
||||
|
||||
if not call_id or call_id not in call_details_map:
|
||||
# NOTE(rkuo): seeing odd behavior where call_ids from the transcript
|
||||
# don't have call details. adding error debugging logs to trace.
|
||||
logger.error(
|
||||
f"Couldn't get call information for Call ID: {call_id}"
|
||||
)
|
||||
if call_id:
|
||||
logger.error(
|
||||
f"Call debug info: call_id={call_id} "
|
||||
f"call_ids={transcript_call_ids} "
|
||||
f"call_details_map={call_details_map.keys()}"
|
||||
)
|
||||
if not self.continue_on_fail:
|
||||
raise RuntimeError(
|
||||
f"Couldn't get call information for Call ID: {call_id}"
|
||||
)
|
||||
continue
|
||||
|
||||
call_details = call_details_map[call_id]
|
||||
call_metadata = call_details["metaData"]
|
||||
|
||||
call_time_str = call_metadata["started"]
|
||||
call_title = call_metadata["title"]
|
||||
logger.info(
|
||||
f"{num_calls + 1}: Indexing Gong call id {call_id} from {call_time_str.split('T', 1)[0]}: {call_title}"
|
||||
)
|
||||
|
||||
call_parties = cast(list[dict] | None, call_details.get("parties"))
|
||||
if call_parties is None:
|
||||
logger.error(f"Couldn't get parties for Call ID: {call_id}")
|
||||
call_parties = []
|
||||
|
||||
id_to_name_map = self._parse_parties(call_parties)
|
||||
|
||||
# Keeping a separate dict here in case the parties info is incomplete
|
||||
speaker_to_name: dict[str, str] = {}
|
||||
|
||||
transcript_text = ""
|
||||
call_purpose = call_metadata["purpose"]
|
||||
if call_purpose:
|
||||
transcript_text += f"Call Description: {call_purpose}\n\n"
|
||||
|
||||
contents = transcript["transcript"]
|
||||
for segment in contents:
|
||||
speaker_id = segment.get("speakerId", "")
|
||||
if speaker_id not in speaker_to_name:
|
||||
if self.hide_user_info:
|
||||
speaker_to_name[speaker_id] = (
|
||||
f"User {len(speaker_to_name) + 1}"
|
||||
)
|
||||
else:
|
||||
speaker_to_name[speaker_id] = id_to_name_map.get(
|
||||
speaker_id, "Unknown"
|
||||
)
|
||||
|
||||
speaker_name = speaker_to_name[speaker_id]
|
||||
|
||||
sentences = segment.get("sentences", {})
|
||||
monolog = " ".join(
|
||||
[sentence.get("text", "") for sentence in sentences]
|
||||
)
|
||||
transcript_text += f"{speaker_name}: {monolog}\n\n"
|
||||
|
||||
metadata = {}
|
||||
if call_metadata.get("system"):
|
||||
metadata["client"] = call_metadata.get("system")
|
||||
# TODO calls have a clientUniqueId field, can pull that in later
|
||||
|
||||
doc_batch.append(
|
||||
Document(
|
||||
id=call_id,
|
||||
sections=[
|
||||
TextSection(link=call_metadata["url"], text=transcript_text)
|
||||
],
|
||||
source=DocumentSource.GONG,
|
||||
# Should not ever be Untitled as a call cannot be made without a Title
|
||||
semantic_identifier=call_title or "Untitled",
|
||||
doc_updated_at=datetime.fromisoformat(call_time_str).astimezone(
|
||||
timezone.utc
|
||||
),
|
||||
metadata={"client": call_metadata.get("system")},
|
||||
)
|
||||
)
|
||||
|
||||
num_calls += 1
|
||||
|
||||
yield doc_batch
|
||||
|
||||
logger.info(f"_fetch_calls finished: num_calls={num_calls}")
|
||||
|
||||
def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None:
|
||||
combined = (
|
||||
f"{credentials['gong_access_key']}:{credentials['gong_access_key_secret']}"
|
||||
)
|
||||
self.auth_token_basic = base64.b64encode(combined.encode("utf-8")).decode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
if self.auth_token_basic is None:
|
||||
raise ConnectorMissingCredentialError("Gong")
|
||||
|
||||
self._session.headers.update(
|
||||
{"Authorization": f"Basic {self.auth_token_basic}"}
|
||||
)
|
||||
return None
|
||||
|
||||
def load_from_state(self) -> GenerateDocumentsOutput:
|
||||
return self._fetch_calls()
|
||||
|
||||
def poll_source(
|
||||
self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch
|
||||
) -> GenerateDocumentsOutput:
|
||||
@staticmethod
|
||||
def _compute_time_range(
|
||||
start: SecondsSinceUnixEpoch,
|
||||
end: SecondsSinceUnixEpoch,
|
||||
) -> tuple[str, str]:
|
||||
"""Compute the start/end datetime strings for the Gong API filter,
|
||||
applying GONG_CONNECTOR_START_TIME and the 1-day offset."""
|
||||
end_datetime = datetime.fromtimestamp(end, tz=timezone.utc)
|
||||
|
||||
# if this env variable is set, don't start from a timestamp before the specified
|
||||
# start time
|
||||
# TODO: remove this once this is globally available
|
||||
if GONG_CONNECTOR_START_TIME:
|
||||
special_start_datetime = datetime.fromisoformat(GONG_CONNECTOR_START_TIME)
|
||||
special_start_datetime = special_start_datetime.replace(tzinfo=timezone.utc)
|
||||
@@ -394,11 +309,186 @@ class GongConnector(LoadConnector, PollConnector):
|
||||
# so adding a 1 day buffer and fetching by default till current time
|
||||
start_one_day_offset = start_datetime - timedelta(days=1)
|
||||
start_time = start_one_day_offset.isoformat()
|
||||
end_time = end_datetime.isoformat()
|
||||
|
||||
end_time = datetime.fromtimestamp(end, tz=timezone.utc).isoformat()
|
||||
return start_time, end_time
|
||||
|
||||
logger.info(f"Fetching Gong calls between {start_time} and {end_time}")
|
||||
return self._fetch_calls(start_time, end_time)
|
||||
def _process_transcripts(
|
||||
self,
|
||||
transcripts: list[dict[str, Any]],
|
||||
) -> Generator[Document | ConnectorFailure, None, None]:
|
||||
"""Process a batch of transcripts into Documents or ConnectorFailures."""
|
||||
transcript_call_ids = cast(
|
||||
list[str],
|
||||
[t.get("callId") for t in transcripts if t.get("callId")],
|
||||
)
|
||||
|
||||
call_details_map = self._fetch_call_details_with_retry(transcript_call_ids)
|
||||
|
||||
for transcript in transcripts:
|
||||
call_id = transcript.get("callId")
|
||||
|
||||
if not call_id or call_id not in call_details_map:
|
||||
logger.error(f"Couldn't get call information for Call ID: {call_id}")
|
||||
if call_id:
|
||||
logger.error(
|
||||
f"Call debug info: call_id={call_id} "
|
||||
f"call_ids={transcript_call_ids} "
|
||||
f"call_details_map={call_details_map.keys()}"
|
||||
)
|
||||
yield ConnectorFailure(
|
||||
failed_document=DocumentFailure(
|
||||
document_id=call_id or "unknown",
|
||||
),
|
||||
failure_message=f"Couldn't get call information for Call ID: {call_id}",
|
||||
)
|
||||
continue
|
||||
|
||||
call_details = call_details_map[call_id]
|
||||
call_metadata = call_details["metaData"]
|
||||
|
||||
call_time_str = call_metadata["started"]
|
||||
call_title = call_metadata["title"]
|
||||
logger.info(
|
||||
f"Indexing Gong call id {call_id} from {call_time_str.split('T', 1)[0]}: {call_title}"
|
||||
)
|
||||
|
||||
call_parties = cast(list[dict] | None, call_details.get("parties"))
|
||||
if call_parties is None:
|
||||
logger.error(f"Couldn't get parties for Call ID: {call_id}")
|
||||
call_parties = []
|
||||
|
||||
id_to_name_map = self._parse_parties(call_parties)
|
||||
|
||||
speaker_to_name: dict[str, str] = {}
|
||||
|
||||
transcript_text = ""
|
||||
call_purpose = call_metadata["purpose"]
|
||||
if call_purpose:
|
||||
transcript_text += f"Call Description: {call_purpose}\n\n"
|
||||
|
||||
contents = transcript["transcript"]
|
||||
for segment in contents:
|
||||
speaker_id = segment.get("speakerId", "")
|
||||
if speaker_id not in speaker_to_name:
|
||||
if self.hide_user_info:
|
||||
speaker_to_name[speaker_id] = f"User {len(speaker_to_name) + 1}"
|
||||
else:
|
||||
speaker_to_name[speaker_id] = id_to_name_map.get(
|
||||
speaker_id, "Unknown"
|
||||
)
|
||||
|
||||
speaker_name = speaker_to_name[speaker_id]
|
||||
|
||||
sentences = segment.get("sentences", {})
|
||||
monolog = " ".join([sentence.get("text", "") for sentence in sentences])
|
||||
transcript_text += f"{speaker_name}: {monolog}\n\n"
|
||||
|
||||
yield Document(
|
||||
id=call_id,
|
||||
sections=[TextSection(link=call_metadata["url"], text=transcript_text)],
|
||||
source=DocumentSource.GONG,
|
||||
semantic_identifier=call_title or "Untitled",
|
||||
doc_updated_at=datetime.fromisoformat(call_time_str).astimezone(
|
||||
timezone.utc
|
||||
),
|
||||
metadata={"client": call_metadata.get("system")},
|
||||
)
|
||||
|
||||
def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None:
|
||||
combined = (
|
||||
f"{credentials['gong_access_key']}:{credentials['gong_access_key_secret']}"
|
||||
)
|
||||
self.auth_token_basic = base64.b64encode(combined.encode("utf-8")).decode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
if self.auth_token_basic is None:
|
||||
raise ConnectorMissingCredentialError("Gong")
|
||||
|
||||
self._session.headers.update(
|
||||
{"Authorization": f"Basic {self.auth_token_basic}"}
|
||||
)
|
||||
return None
|
||||
|
||||
def build_dummy_checkpoint(self) -> GongConnectorCheckpoint:
|
||||
return GongConnectorCheckpoint(has_more=True)
|
||||
|
||||
def validate_checkpoint_json(self, checkpoint_json: str) -> GongConnectorCheckpoint:
|
||||
return GongConnectorCheckpoint.model_validate_json(checkpoint_json)
|
||||
|
||||
def load_from_checkpoint(
|
||||
self,
|
||||
start: SecondsSinceUnixEpoch,
|
||||
end: SecondsSinceUnixEpoch,
|
||||
checkpoint: GongConnectorCheckpoint,
|
||||
) -> CheckpointOutput[GongConnectorCheckpoint]:
|
||||
checkpoint = copy.deepcopy(checkpoint)
|
||||
|
||||
# Step 1: Resolve workspace IDs on first call
|
||||
if checkpoint.workspace_ids is None:
|
||||
checkpoint.workspace_ids = self._resolve_workspace_ids()
|
||||
checkpoint.time_range = self._compute_time_range(start, end)
|
||||
checkpoint.has_more = True
|
||||
return checkpoint
|
||||
|
||||
workspace_ids = checkpoint.workspace_ids
|
||||
|
||||
# If we've exhausted all workspaces, we're done
|
||||
if checkpoint.workspace_index >= len(workspace_ids):
|
||||
checkpoint.has_more = False
|
||||
return checkpoint
|
||||
|
||||
# Use cached time range, falling back to computation if not cached
|
||||
start_time, end_time = checkpoint.time_range or self._compute_time_range(
|
||||
start, end
|
||||
)
|
||||
logger.info(
|
||||
f"Fetching Gong calls between {start_time} and {end_time} "
|
||||
f"(workspace {checkpoint.workspace_index + 1}/{len(workspace_ids)})"
|
||||
)
|
||||
|
||||
workspace_id = workspace_ids[checkpoint.workspace_index]
|
||||
|
||||
# Step 2: Fetch one page of transcripts
|
||||
try:
|
||||
page = self._fetch_transcript_page(
|
||||
start_datetime=start_time,
|
||||
end_datetime=end_time,
|
||||
workspace_id=workspace_id,
|
||||
cursor=checkpoint.cursor,
|
||||
)
|
||||
except _CursorExpiredError:
|
||||
# Gong cursors TTL ~1h from first request in the sequence. If the
|
||||
# checkpoint paused long enough for the cursor to expire, restart
|
||||
# the current workspace from the beginning of the time range.
|
||||
# Document upserts are idempotent (keyed by call_id) so
|
||||
# reprocessing is safe.
|
||||
logger.warning(
|
||||
f"Gong pagination cursor expired for workspace "
|
||||
f"{checkpoint.workspace_index + 1}/{len(workspace_ids)}; "
|
||||
f"restarting workspace from beginning of time range."
|
||||
)
|
||||
checkpoint.cursor = None
|
||||
checkpoint.has_more = True
|
||||
return checkpoint
|
||||
|
||||
# Step 3: Process transcripts into documents
|
||||
if page.transcripts:
|
||||
yield from self._process_transcripts(page.transcripts)
|
||||
|
||||
# Step 4: Update checkpoint state
|
||||
if page.next_cursor:
|
||||
# More pages in this workspace
|
||||
checkpoint.cursor = page.next_cursor
|
||||
checkpoint.has_more = True
|
||||
else:
|
||||
# This workspace is exhausted — advance to next
|
||||
checkpoint.workspace_index += 1
|
||||
checkpoint.cursor = None
|
||||
checkpoint.has_more = checkpoint.workspace_index < len(workspace_ids)
|
||||
|
||||
return checkpoint
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -412,5 +502,13 @@ if __name__ == "__main__":
|
||||
}
|
||||
)
|
||||
|
||||
latest_docs = connector.load_from_state()
|
||||
print(next(latest_docs))
|
||||
checkpoint = connector.build_dummy_checkpoint()
|
||||
while checkpoint.has_more:
|
||||
doc_generator = connector.load_from_checkpoint(0, time.time(), checkpoint)
|
||||
try:
|
||||
while True:
|
||||
item = next(doc_generator)
|
||||
print(item)
|
||||
except StopIteration as e:
|
||||
checkpoint = e.value
|
||||
print(f"Checkpoint: {checkpoint}")
|
||||
|
||||
@@ -502,6 +502,9 @@ class GoogleDriveConnector(
|
||||
files: list[RetrievedDriveFile],
|
||||
seen_hierarchy_node_raw_ids: ThreadSafeSet[str],
|
||||
fully_walked_hierarchy_node_raw_ids: ThreadSafeSet[str],
|
||||
failed_folder_ids_by_email: (
|
||||
ThreadSafeDict[str, ThreadSafeSet[str]] | None
|
||||
) = None,
|
||||
permission_sync_context: PermissionSyncContext | None = None,
|
||||
add_prefix: bool = False,
|
||||
) -> list[HierarchyNode]:
|
||||
@@ -525,6 +528,9 @@ class GoogleDriveConnector(
|
||||
seen_hierarchy_node_raw_ids: Set of already-yielded node IDs (modified in place)
|
||||
fully_walked_hierarchy_node_raw_ids: Set of node IDs where the walk to root
|
||||
succeeded (modified in place)
|
||||
failed_folder_ids_by_email: Map of email → folder IDs where that email
|
||||
previously confirmed no accessible parent. Skips the API call if the same
|
||||
(folder, email) is encountered again (modified in place).
|
||||
permission_sync_context: If provided, permissions will be fetched for hierarchy nodes.
|
||||
Contains google_domain and primary_admin_email needed for permission syncing.
|
||||
add_prefix: When True, prefix group IDs with source type (for indexing path).
|
||||
@@ -569,7 +575,7 @@ class GoogleDriveConnector(
|
||||
|
||||
# Fetch folder metadata
|
||||
folder = self._get_folder_metadata(
|
||||
current_id, file.user_email, field_type
|
||||
current_id, file.user_email, field_type, failed_folder_ids_by_email
|
||||
)
|
||||
if not folder:
|
||||
# Can't access this folder - stop climbing
|
||||
@@ -653,7 +659,13 @@ class GoogleDriveConnector(
|
||||
return new_nodes
|
||||
|
||||
def _get_folder_metadata(
|
||||
self, folder_id: str, retriever_email: str, field_type: DriveFileFieldType
|
||||
self,
|
||||
folder_id: str,
|
||||
retriever_email: str,
|
||||
field_type: DriveFileFieldType,
|
||||
failed_folder_ids_by_email: (
|
||||
ThreadSafeDict[str, ThreadSafeSet[str]] | None
|
||||
) = None,
|
||||
) -> GoogleDriveFileType | None:
|
||||
"""
|
||||
Fetch metadata for a folder by ID.
|
||||
@@ -667,6 +679,17 @@ class GoogleDriveConnector(
|
||||
|
||||
# Use a set to deduplicate if retriever_email == primary_admin_email
|
||||
for email in {retriever_email, self.primary_admin_email}:
|
||||
failed_ids = (
|
||||
failed_folder_ids_by_email.get(email)
|
||||
if failed_folder_ids_by_email
|
||||
else None
|
||||
)
|
||||
if failed_ids and folder_id in failed_ids:
|
||||
logger.debug(
|
||||
f"Skipping folder {folder_id} using {email} (previously confirmed no parents)"
|
||||
)
|
||||
continue
|
||||
|
||||
service = get_drive_service(self.creds, email)
|
||||
folder = get_folder_metadata(service, folder_id, field_type)
|
||||
|
||||
@@ -682,6 +705,10 @@ class GoogleDriveConnector(
|
||||
|
||||
# Folder has no parents - could be a root OR user lacks access to parent
|
||||
# Keep this as a fallback but try admin to see if they can see parents
|
||||
if failed_folder_ids_by_email is not None:
|
||||
failed_folder_ids_by_email.setdefault(email, ThreadSafeSet()).add(
|
||||
folder_id
|
||||
)
|
||||
if best_folder is None:
|
||||
best_folder = folder
|
||||
logger.debug(
|
||||
@@ -1090,6 +1117,13 @@ class GoogleDriveConnector(
|
||||
]
|
||||
yield from parallel_yield(user_retrieval_gens, max_workers=MAX_DRIVE_WORKERS)
|
||||
|
||||
# Free per-user cache entries now that this batch is done.
|
||||
# Skip the admin email — it is shared across all user batches and must
|
||||
# persist for the duration of the run.
|
||||
for email in non_completed_org_emails:
|
||||
if email != self.primary_admin_email:
|
||||
checkpoint.failed_folder_ids_by_email.pop(email, None)
|
||||
|
||||
# if there are more emails to process, don't mark as complete
|
||||
if not email_batch_takes_us_to_completion:
|
||||
return
|
||||
@@ -1546,6 +1580,7 @@ class GoogleDriveConnector(
|
||||
files=files_batch,
|
||||
seen_hierarchy_node_raw_ids=checkpoint.seen_hierarchy_node_raw_ids,
|
||||
fully_walked_hierarchy_node_raw_ids=checkpoint.fully_walked_hierarchy_node_raw_ids,
|
||||
failed_folder_ids_by_email=checkpoint.failed_folder_ids_by_email,
|
||||
permission_sync_context=permission_sync_context,
|
||||
add_prefix=True,
|
||||
)
|
||||
@@ -1782,6 +1817,7 @@ class GoogleDriveConnector(
|
||||
files=files_batch,
|
||||
seen_hierarchy_node_raw_ids=checkpoint.seen_hierarchy_node_raw_ids,
|
||||
fully_walked_hierarchy_node_raw_ids=checkpoint.fully_walked_hierarchy_node_raw_ids,
|
||||
failed_folder_ids_by_email=checkpoint.failed_folder_ids_by_email,
|
||||
permission_sync_context=permission_sync_context,
|
||||
)
|
||||
|
||||
|
||||
@@ -167,6 +167,13 @@ class GoogleDriveCheckpoint(ConnectorCheckpoint):
|
||||
default_factory=ThreadSafeSet
|
||||
)
|
||||
|
||||
# Maps email → set of IDs of folders where that email confirmed no accessible parent.
|
||||
# Avoids redundant API calls when the same (folder, email) pair is
|
||||
# encountered again within the same retrieval run.
|
||||
failed_folder_ids_by_email: ThreadSafeDict[str, ThreadSafeSet[str]] = Field(
|
||||
default_factory=ThreadSafeDict
|
||||
)
|
||||
|
||||
@field_serializer("completion_map")
|
||||
def serialize_completion_map(
|
||||
self, completion_map: ThreadSafeDict[str, StageCompletion], _info: Any
|
||||
@@ -211,3 +218,25 @@ class GoogleDriveCheckpoint(ConnectorCheckpoint):
|
||||
if isinstance(v, list):
|
||||
return ThreadSafeSet(set(v)) # ty: ignore[invalid-return-type]
|
||||
return ThreadSafeSet()
|
||||
|
||||
@field_serializer("failed_folder_ids_by_email")
|
||||
def serialize_failed_folder_ids_by_email(
|
||||
self,
|
||||
failed_folder_ids_by_email: ThreadSafeDict[str, ThreadSafeSet[str]],
|
||||
_info: Any,
|
||||
) -> dict[str, set[str]]:
|
||||
return {
|
||||
k: inner.copy() for k, inner in failed_folder_ids_by_email.copy().items()
|
||||
}
|
||||
|
||||
@field_validator("failed_folder_ids_by_email", mode="before")
|
||||
def validate_failed_folder_ids_by_email(
|
||||
cls, v: Any
|
||||
) -> ThreadSafeDict[str, ThreadSafeSet[str]]:
|
||||
if isinstance(v, ThreadSafeDict):
|
||||
return v
|
||||
if isinstance(v, dict):
|
||||
return ThreadSafeDict(
|
||||
{k: ThreadSafeSet(set(vals)) for k, vals in v.items()}
|
||||
)
|
||||
return ThreadSafeDict()
|
||||
|
||||
@@ -81,9 +81,7 @@ class ZulipConnector(LoadConnector, PollConnector):
|
||||
# zuliprc file. This reverts them back to newlines.
|
||||
contents_spaces_to_newlines = contents.replace(" ", "\n")
|
||||
# create a temporary zuliprc file
|
||||
tempdir = tempfile.tempdir
|
||||
if tempdir is None:
|
||||
raise Exception("Could not determine tempfile directory")
|
||||
tempdir = tempfile.gettempdir()
|
||||
config_file = os.path.join(tempdir, f"zuliprc-{self.realm_name}")
|
||||
with open(config_file, "w") as f:
|
||||
f.write(contents_spaces_to_newlines)
|
||||
|
||||
@@ -23,6 +23,7 @@ import openpyxl
|
||||
from openpyxl.worksheet.worksheet import Worksheet
|
||||
from PIL import Image
|
||||
|
||||
from onyx.configs.app_configs import MAX_EMBEDDED_IMAGES_PER_FILE
|
||||
from onyx.configs.constants import ONYX_METADATA_FILENAME
|
||||
from onyx.configs.llm_configs import get_image_extraction_and_analysis_enabled
|
||||
from onyx.file_processing.file_types import OnyxFileExtensions
|
||||
@@ -191,6 +192,56 @@ def read_text_file(
|
||||
return file_content_raw, metadata
|
||||
|
||||
|
||||
def count_pdf_embedded_images(file: IO[Any], cap: int) -> int:
|
||||
"""Return the number of embedded images in a PDF, short-circuiting at cap+1.
|
||||
|
||||
Used to reject PDFs whose image count would OOM the user-file-processing
|
||||
worker during indexing. Returns a value > cap as a sentinel once the count
|
||||
exceeds the cap, so callers do not iterate thousands of image objects just
|
||||
to report a number. Returns 0 if the PDF cannot be parsed.
|
||||
|
||||
Owner-password-only PDFs (permission restrictions but no open password) are
|
||||
counted normally — they decrypt with an empty string. Truly password-locked
|
||||
PDFs are skipped (return 0) since we can't inspect them; the caller should
|
||||
ensure the password-protected check runs first.
|
||||
|
||||
Always restores the file pointer to its original position before returning.
|
||||
"""
|
||||
from pypdf import PdfReader
|
||||
|
||||
try:
|
||||
start_pos = file.tell()
|
||||
except Exception:
|
||||
start_pos = None
|
||||
try:
|
||||
if start_pos is not None:
|
||||
file.seek(0)
|
||||
reader = PdfReader(file)
|
||||
if reader.is_encrypted:
|
||||
# Try empty password first (owner-password-only PDFs); give up if that fails.
|
||||
try:
|
||||
if reader.decrypt("") == 0:
|
||||
return 0
|
||||
except Exception:
|
||||
return 0
|
||||
count = 0
|
||||
for page in reader.pages:
|
||||
for _ in page.images:
|
||||
count += 1
|
||||
if count > cap:
|
||||
return count
|
||||
return count
|
||||
except Exception:
|
||||
logger.warning("Failed to count embedded images in PDF", exc_info=True)
|
||||
return 0
|
||||
finally:
|
||||
if start_pos is not None:
|
||||
try:
|
||||
file.seek(start_pos)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def pdf_to_text(file: IO[Any], pdf_pass: str | None = None) -> str:
|
||||
"""
|
||||
Extract text from a PDF. For embedded images, a more complex approach is needed.
|
||||
@@ -254,8 +305,27 @@ def read_pdf_file(
|
||||
)
|
||||
|
||||
if extract_images:
|
||||
image_cap = MAX_EMBEDDED_IMAGES_PER_FILE
|
||||
images_processed = 0
|
||||
cap_reached = False
|
||||
for page_num, page in enumerate(pdf_reader.pages):
|
||||
if cap_reached:
|
||||
break
|
||||
for image_file_object in page.images:
|
||||
if images_processed >= image_cap:
|
||||
# Defense-in-depth backstop. Upload-time validation
|
||||
# should have rejected files exceeding the cap, but
|
||||
# we also break here so a single oversized file can
|
||||
# never pin a worker.
|
||||
logger.warning(
|
||||
"PDF embedded image cap reached (%d). "
|
||||
"Skipping remaining images on page %d and beyond.",
|
||||
image_cap,
|
||||
page_num + 1,
|
||||
)
|
||||
cap_reached = True
|
||||
break
|
||||
|
||||
image = Image.open(io.BytesIO(image_file_object.data))
|
||||
img_byte_arr = io.BytesIO()
|
||||
image.save(img_byte_arr, format=image.format)
|
||||
@@ -268,6 +338,7 @@ def read_pdf_file(
|
||||
image_callback(img_bytes, image_name)
|
||||
else:
|
||||
extracted_images.append((img_bytes, image_name))
|
||||
images_processed += 1
|
||||
|
||||
return text, metadata, extracted_images
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
from collections import Counter
|
||||
from datetime import date
|
||||
from itertools import zip_longest
|
||||
|
||||
from dateutil.parser import parse as parse_dt
|
||||
from pydantic import BaseModel
|
||||
from pydantic import ConfigDict
|
||||
from pydantic import Field
|
||||
|
||||
from onyx.utils.csv_utils import ParsedRow
|
||||
|
||||
|
||||
CATEGORICAL_DISTINCT_THRESHOLD = 20
|
||||
ID_NAME_TOKENS = {"id", "uuid", "uid", "guid", "key"}
|
||||
|
||||
|
||||
class SheetAnalysis(BaseModel):
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
row_count: int
|
||||
num_cols: int
|
||||
numeric_cols: list[int] = Field(default_factory=list)
|
||||
categorical_cols: list[int] = Field(default_factory=list)
|
||||
numeric_values: dict[int, list[float]] = Field(default_factory=dict)
|
||||
categorical_counts: dict[int, Counter[str]] = Field(default_factory=dict)
|
||||
id_col: int | None = None
|
||||
date_min: date | None = None
|
||||
date_max: date | None = None
|
||||
|
||||
@property
|
||||
def categorical_values(self) -> dict[int, list[str]]:
|
||||
return {ci: list(c.keys()) for ci, c in self.categorical_counts.items()}
|
||||
|
||||
|
||||
def analyze_sheet(headers: list[str], parsed_rows: list[ParsedRow]) -> SheetAnalysis:
|
||||
a = SheetAnalysis(row_count=len(parsed_rows), num_cols=len(headers))
|
||||
columns = zip_longest(*(pr.row for pr in parsed_rows), fillvalue="")
|
||||
for idx, (header, raw_values) in enumerate(zip(headers, columns)):
|
||||
values = [v.strip() for v in raw_values if v.strip()]
|
||||
if not values:
|
||||
continue
|
||||
|
||||
# Identifier: id-named column whose values are all unique. Detected
|
||||
# before classification so a numeric `id` column still gets flagged.
|
||||
distinct = set(values)
|
||||
if a.id_col is None and len(distinct) == len(values) and _is_id_name(header):
|
||||
a.id_col = idx
|
||||
|
||||
# Numeric: every value parses as a number.
|
||||
nums = _try_all_numeric(values)
|
||||
if nums is not None:
|
||||
a.numeric_cols.append(idx)
|
||||
a.numeric_values[idx] = nums
|
||||
continue
|
||||
|
||||
# Date: every value parses as a date — fold into the sheet-wide range.
|
||||
dates = _try_all_dates(values)
|
||||
if dates:
|
||||
dmin = min(dates)
|
||||
dmax = max(dates)
|
||||
a.date_min = dmin if a.date_min is None else min(a.date_min, dmin)
|
||||
a.date_max = dmax if a.date_max is None else max(a.date_max, dmax)
|
||||
continue
|
||||
|
||||
# Categorical: low-cardinality column — keep counts for samples + top values.
|
||||
if len(distinct) <= max(CATEGORICAL_DISTINCT_THRESHOLD, len(values) // 2):
|
||||
a.categorical_cols.append(idx)
|
||||
a.categorical_counts[idx] = Counter(values)
|
||||
return a
|
||||
|
||||
|
||||
def _try_all_numeric(values: list[str]) -> list[float] | None:
|
||||
parsed: list[float] = []
|
||||
for v in values:
|
||||
n = _parse_num(v)
|
||||
if n is None:
|
||||
return None
|
||||
parsed.append(n)
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_num(value: str) -> float | None:
|
||||
try:
|
||||
return float(value.replace(",", ""))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _try_all_dates(values: list[str]) -> list[date] | None:
|
||||
parsed: list[date] = []
|
||||
for v in values:
|
||||
d = _try_date(v)
|
||||
if d is None:
|
||||
return None
|
||||
parsed.append(d)
|
||||
return parsed
|
||||
|
||||
|
||||
def _try_date(value: str) -> date | None:
|
||||
if len(value) < 4 or not any(c in value for c in "-/T"):
|
||||
return None
|
||||
try:
|
||||
return parse_dt(value).date()
|
||||
except (ValueError, OverflowError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _is_id_name(name: str) -> bool:
|
||||
lowered = name.lower().strip().replace("-", "_")
|
||||
return lowered in ID_NAME_TOKENS or any(
|
||||
lowered.endswith(f"_{t}") for t in ID_NAME_TOKENS
|
||||
)
|
||||
@@ -1,51 +1,29 @@
|
||||
"""Per-section sheet descriptor chunk builder."""
|
||||
|
||||
from datetime import date
|
||||
from itertools import zip_longest
|
||||
|
||||
from dateutil.parser import parse as parse_dt
|
||||
from pydantic import BaseModel
|
||||
from pydantic import Field
|
||||
|
||||
from onyx.connectors.models import Section
|
||||
from onyx.indexing.chunking.tabular_section_chunker.analysis import SheetAnalysis
|
||||
from onyx.indexing.chunking.tabular_section_chunker.util import label
|
||||
from onyx.indexing.chunking.tabular_section_chunker.util import pack_lines
|
||||
from onyx.natural_language_processing.utils import BaseTokenizer
|
||||
from onyx.natural_language_processing.utils import count_tokens
|
||||
from onyx.utils.csv_utils import parse_csv_string
|
||||
from onyx.utils.csv_utils import ParsedRow
|
||||
from onyx.utils.csv_utils import read_csv_header
|
||||
|
||||
|
||||
MAX_NUMERIC_COLS = 12
|
||||
MAX_CATEGORICAL_COLS = 6
|
||||
MAX_CATEGORICAL_WITH_SAMPLES = 4
|
||||
MAX_DISTINCT_SAMPLES = 8
|
||||
CATEGORICAL_DISTINCT_THRESHOLD = 20
|
||||
ID_NAME_TOKENS = {"id", "uuid", "uid", "guid", "key"}
|
||||
|
||||
|
||||
class SheetAnalysis(BaseModel):
|
||||
row_count: int
|
||||
num_cols: int
|
||||
numeric_cols: list[int] = Field(default_factory=list)
|
||||
categorical_cols: list[int] = Field(default_factory=list)
|
||||
categorical_values: dict[int, list[str]] = Field(default_factory=dict)
|
||||
id_col: int | None = None
|
||||
date_min: date | None = None
|
||||
date_max: date | None = None
|
||||
|
||||
|
||||
def build_sheet_descriptor_chunks(
|
||||
section: Section,
|
||||
headers: list[str],
|
||||
analysis: SheetAnalysis,
|
||||
heading: str,
|
||||
tokenizer: BaseTokenizer,
|
||||
max_tokens: int,
|
||||
) -> list[str]:
|
||||
"""Build sheet descriptor chunk(s) from a parsed CSV section.
|
||||
"""Build sheet descriptor chunk(s) from a pre-parsed sheet.
|
||||
|
||||
Output (lines joined by "\\n"; lines that overflow ``max_tokens`` on
|
||||
their own are skipped; ``section.heading`` is prepended to every
|
||||
emitted chunk so retrieval keeps sheet context after a split):
|
||||
their own are skipped; ``heading`` is prepended to every emitted
|
||||
chunk so retrieval keeps sheet context after a split):
|
||||
|
||||
{section.heading} # optional
|
||||
{heading} # optional
|
||||
Sheet overview.
|
||||
This sheet has {N} rows and {M} columns.
|
||||
Columns: {col1}, {col2}, ...
|
||||
@@ -55,25 +33,21 @@ def build_sheet_descriptor_chunks(
|
||||
Identifier column: {col}. # optional
|
||||
Values seen in {col}: {v1}, {v2}, ... # optional, repeated
|
||||
"""
|
||||
text = section.text or ""
|
||||
parsed_rows = list(parse_csv_string(text))
|
||||
headers = parsed_rows[0].header if parsed_rows else read_csv_header(text)
|
||||
if not headers:
|
||||
return []
|
||||
|
||||
a = _analyze(headers, parsed_rows)
|
||||
lines = [
|
||||
_overview_line(a),
|
||||
_overview_line(analysis),
|
||||
_columns_line(headers),
|
||||
_time_range_line(a),
|
||||
_numeric_cols_line(headers, a),
|
||||
_categorical_cols_line(headers, a),
|
||||
_id_col_line(headers, a),
|
||||
_values_seen_line(headers, a),
|
||||
_time_range_line(analysis),
|
||||
_numeric_cols_line(headers, analysis),
|
||||
_categorical_cols_line(headers, analysis),
|
||||
_id_col_line(headers, analysis),
|
||||
_values_seen_line(headers, analysis),
|
||||
]
|
||||
return _pack_lines(
|
||||
return pack_lines(
|
||||
[line for line in lines if line],
|
||||
prefix=section.heading or "",
|
||||
prefix=heading,
|
||||
tokenizer=tokenizer,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
@@ -87,7 +61,7 @@ def _overview_line(a: SheetAnalysis) -> str:
|
||||
|
||||
|
||||
def _columns_line(headers: list[str]) -> str:
|
||||
return "Columns: " + ", ".join(_label(h) for h in headers)
|
||||
return "Columns: " + ", ".join(label(h) for h in headers)
|
||||
|
||||
|
||||
def _time_range_line(a: SheetAnalysis) -> str:
|
||||
@@ -99,7 +73,7 @@ def _time_range_line(a: SheetAnalysis) -> str:
|
||||
def _numeric_cols_line(headers: list[str], a: SheetAnalysis) -> str:
|
||||
if not a.numeric_cols:
|
||||
return ""
|
||||
names = ", ".join(_label(headers[i]) for i in a.numeric_cols[:MAX_NUMERIC_COLS])
|
||||
names = ", ".join(label(headers[i]) for i in a.numeric_cols[:MAX_NUMERIC_COLS])
|
||||
return f"Numeric columns (aggregatable by sum, average, min, max): {names}"
|
||||
|
||||
|
||||
@@ -107,7 +81,7 @@ def _categorical_cols_line(headers: list[str], a: SheetAnalysis) -> str:
|
||||
if not a.categorical_cols:
|
||||
return ""
|
||||
names = ", ".join(
|
||||
_label(headers[i]) for i in a.categorical_cols[:MAX_CATEGORICAL_COLS]
|
||||
label(headers[i]) for i in a.categorical_cols[:MAX_CATEGORICAL_COLS]
|
||||
)
|
||||
return f"Categorical columns (groupable, can be counted by value): {names}"
|
||||
|
||||
@@ -115,7 +89,7 @@ def _categorical_cols_line(headers: list[str], a: SheetAnalysis) -> str:
|
||||
def _id_col_line(headers: list[str], a: SheetAnalysis) -> str:
|
||||
if a.id_col is None:
|
||||
return ""
|
||||
return f"Identifier column: {_label(headers[a.id_col])}."
|
||||
return f"Identifier column: {label(headers[a.id_col])}."
|
||||
|
||||
|
||||
def _values_seen_line(headers: list[str], a: SheetAnalysis) -> str:
|
||||
@@ -123,106 +97,5 @@ def _values_seen_line(headers: list[str], a: SheetAnalysis) -> str:
|
||||
for ci in a.categorical_cols[:MAX_CATEGORICAL_WITH_SAMPLES]:
|
||||
sample = sorted(a.categorical_values.get(ci, []))[:MAX_DISTINCT_SAMPLES]
|
||||
if sample:
|
||||
rows.append(f"Values seen in {_label(headers[ci])}: " + ", ".join(sample))
|
||||
rows.append(f"Values seen in {label(headers[ci])}: " + ", ".join(sample))
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def _label(name: str) -> str:
|
||||
return f"{name} ({name.replace('_', ' ')})" if "_" in name else name
|
||||
|
||||
|
||||
def _is_numeric(value: str) -> bool:
|
||||
try:
|
||||
float(value.replace(",", ""))
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _try_date(value: str) -> date | None:
|
||||
if len(value) < 4 or not any(c in value for c in "-/T"):
|
||||
return None
|
||||
try:
|
||||
return parse_dt(value).date()
|
||||
except (ValueError, OverflowError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _is_id_name(name: str) -> bool:
|
||||
lowered = name.lower().strip().replace("-", "_")
|
||||
return lowered in ID_NAME_TOKENS or any(
|
||||
lowered.endswith(f"_{t}") for t in ID_NAME_TOKENS
|
||||
)
|
||||
|
||||
|
||||
def _analyze(headers: list[str], parsed_rows: list[ParsedRow]) -> SheetAnalysis:
|
||||
a = SheetAnalysis(row_count=len(parsed_rows), num_cols=len(headers))
|
||||
columns = zip_longest(*(pr.row for pr in parsed_rows), fillvalue="")
|
||||
for idx, (header, raw_values) in enumerate(zip(headers, columns)):
|
||||
# Pull the column's non-empty values; skip if the column is blank.
|
||||
values = [v.strip() for v in raw_values if v.strip()]
|
||||
if not values:
|
||||
continue
|
||||
|
||||
# Identifier: id-named column whose values are all unique. Detected
|
||||
# before classification so a numeric `id` column still gets flagged.
|
||||
distinct = set(values)
|
||||
if a.id_col is None and len(distinct) == len(values) and _is_id_name(header):
|
||||
a.id_col = idx
|
||||
|
||||
# Numeric: every value parses as a number.
|
||||
if all(_is_numeric(v) for v in values):
|
||||
a.numeric_cols.append(idx)
|
||||
continue
|
||||
|
||||
# Date: every value parses as a date — fold into the sheet-wide range.
|
||||
dates = [_try_date(v) for v in values]
|
||||
if all(d is not None for d in dates):
|
||||
dmin = min(filter(None, dates))
|
||||
dmax = max(filter(None, dates))
|
||||
a.date_min = dmin if a.date_min is None else min(a.date_min, dmin)
|
||||
a.date_max = dmax if a.date_max is None else max(a.date_max, dmax)
|
||||
continue
|
||||
|
||||
# Categorical: low-cardinality column — keep distinct values for samples.
|
||||
if len(distinct) <= max(CATEGORICAL_DISTINCT_THRESHOLD, len(values) // 2):
|
||||
a.categorical_cols.append(idx)
|
||||
a.categorical_values[idx] = list(distinct)
|
||||
return a
|
||||
|
||||
|
||||
def _pack_lines(
|
||||
lines: list[str],
|
||||
prefix: str,
|
||||
tokenizer: BaseTokenizer,
|
||||
max_tokens: int,
|
||||
) -> list[str]:
|
||||
"""Greedily pack lines into chunks ≤ max_tokens. Lines that on
|
||||
their own exceed max_tokens (after accounting for the prefix) are
|
||||
skipped. ``prefix`` is prepended to every emitted chunk."""
|
||||
prefix_tokens = count_tokens(prefix, tokenizer) + 1 if prefix else 0
|
||||
budget = max_tokens - prefix_tokens
|
||||
|
||||
chunks: list[str] = []
|
||||
current: list[str] = []
|
||||
current_tokens = 0
|
||||
for line in lines:
|
||||
line_tokens = count_tokens(line, tokenizer)
|
||||
if line_tokens > budget:
|
||||
continue
|
||||
sep = 1 if current else 0
|
||||
if current_tokens + sep + line_tokens > budget:
|
||||
chunks.append(_join_with_prefix(current, prefix))
|
||||
current = [line]
|
||||
current_tokens = line_tokens
|
||||
else:
|
||||
current.append(line)
|
||||
current_tokens += sep + line_tokens
|
||||
if current:
|
||||
chunks.append(_join_with_prefix(current, prefix))
|
||||
return chunks
|
||||
|
||||
|
||||
def _join_with_prefix(lines: list[str], prefix: str) -> str:
|
||||
body = "\n".join(lines)
|
||||
return f"{prefix}\n{body}" if prefix else body
|
||||
|
||||
@@ -7,14 +7,19 @@ from onyx.indexing.chunking.section_chunker import AccumulatorState
|
||||
from onyx.indexing.chunking.section_chunker import ChunkPayload
|
||||
from onyx.indexing.chunking.section_chunker import SectionChunker
|
||||
from onyx.indexing.chunking.section_chunker import SectionChunkerOutput
|
||||
from onyx.indexing.chunking.tabular_section_chunker.analysis import analyze_sheet
|
||||
from onyx.indexing.chunking.tabular_section_chunker.sheet_descriptor import (
|
||||
build_sheet_descriptor_chunks,
|
||||
)
|
||||
from onyx.indexing.chunking.tabular_section_chunker.total_descriptor import (
|
||||
build_total_descriptor_chunks,
|
||||
)
|
||||
from onyx.natural_language_processing.utils import BaseTokenizer
|
||||
from onyx.natural_language_processing.utils import count_tokens
|
||||
from onyx.natural_language_processing.utils import split_text_by_tokens
|
||||
from onyx.utils.csv_utils import parse_csv_string
|
||||
from onyx.utils.csv_utils import ParsedRow
|
||||
from onyx.utils.csv_utils import read_csv_header
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
@@ -230,24 +235,38 @@ class TabularChunker(SectionChunker):
|
||||
) -> SectionChunkerOutput:
|
||||
payloads = accumulator.flush_to_list()
|
||||
|
||||
parsed_rows = list(parse_csv_string(section.text or ""))
|
||||
sheet_header = section.heading or ""
|
||||
text = section.text or ""
|
||||
parsed_rows = list(parse_csv_string(text))
|
||||
headers = parsed_rows[0].header if parsed_rows else read_csv_header(text)
|
||||
heading = section.heading or ""
|
||||
|
||||
chunk_texts: list[str] = []
|
||||
if parsed_rows:
|
||||
chunk_texts.extend(
|
||||
parse_to_chunks(
|
||||
rows=parsed_rows,
|
||||
sheet_header=sheet_header,
|
||||
sheet_header=heading,
|
||||
tokenizer=self.tokenizer,
|
||||
max_tokens=content_token_limit,
|
||||
)
|
||||
)
|
||||
|
||||
if not self.ignore_metadata_chunks:
|
||||
if not self.ignore_metadata_chunks and headers:
|
||||
analysis = analyze_sheet(headers, parsed_rows)
|
||||
chunk_texts.extend(
|
||||
build_sheet_descriptor_chunks(
|
||||
section=section,
|
||||
headers=headers,
|
||||
analysis=analysis,
|
||||
heading=heading,
|
||||
tokenizer=self.tokenizer,
|
||||
max_tokens=content_token_limit,
|
||||
)
|
||||
)
|
||||
chunk_texts.extend(
|
||||
build_total_descriptor_chunks(
|
||||
headers=headers,
|
||||
analysis=analysis,
|
||||
heading=heading,
|
||||
tokenizer=self.tokenizer,
|
||||
max_tokens=content_token_limit,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
from collections import Counter
|
||||
|
||||
from onyx.indexing.chunking.tabular_section_chunker.analysis import SheetAnalysis
|
||||
from onyx.indexing.chunking.tabular_section_chunker.util import label
|
||||
from onyx.indexing.chunking.tabular_section_chunker.util import pack_lines
|
||||
from onyx.natural_language_processing.utils import BaseTokenizer
|
||||
|
||||
|
||||
TOTALS_HEADER = (
|
||||
"Totals and overall aggregates across all rows. This sheet can answer "
|
||||
"whole-dataset questions about total, overall, grand total, sum across "
|
||||
"all, average, combined, mean, minimum, maximum, and count of values."
|
||||
)
|
||||
|
||||
|
||||
def build_total_descriptor_chunks(
|
||||
headers: list[str],
|
||||
analysis: SheetAnalysis,
|
||||
heading: str,
|
||||
tokenizer: BaseTokenizer,
|
||||
max_tokens: int,
|
||||
) -> list[str]:
|
||||
if analysis.row_count == 0:
|
||||
return []
|
||||
|
||||
lines: list[str] = []
|
||||
for idx in analysis.numeric_cols:
|
||||
lines.append(_numeric_totals_line(headers[idx], analysis.numeric_values[idx]))
|
||||
for idx in analysis.categorical_cols:
|
||||
line = _categorical_top_line(headers[idx], analysis.categorical_counts[idx])
|
||||
if line:
|
||||
lines.append(line)
|
||||
|
||||
# No meaningful information - leave early
|
||||
if not lines:
|
||||
return []
|
||||
|
||||
lines.append(f"Total row count: {analysis.row_count}.")
|
||||
|
||||
prefix = (f"{heading}\n" if heading else "") + TOTALS_HEADER
|
||||
return pack_lines(
|
||||
lines=lines,
|
||||
prefix=prefix,
|
||||
tokenizer=tokenizer,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
|
||||
def _numeric_totals_line(name: str, values: list[float]) -> str:
|
||||
total = sum(values)
|
||||
avg = total / len(values)
|
||||
return (
|
||||
f"Column {label(name)}: total (sum across all rows) = {_fmt(total)}, "
|
||||
f"average = {_fmt(avg)}, minimum = {_fmt(min(values))}, "
|
||||
f"maximum = {_fmt(max(values))}, count = {len(values)}."
|
||||
)
|
||||
|
||||
|
||||
def _categorical_top_line(name: str, counts: Counter[str]) -> str:
|
||||
top = counts.most_common(1)
|
||||
if not top:
|
||||
return ""
|
||||
val, n = top[0]
|
||||
return f"Column {label(name)} most frequent value: {val} ({n} occurrences)."
|
||||
|
||||
|
||||
def _fmt(num: float) -> str:
|
||||
if abs(num) < 1e15 and num == int(num):
|
||||
return str(int(num))
|
||||
return f"{num:.6g}"
|
||||
@@ -0,0 +1,48 @@
|
||||
from onyx.natural_language_processing.utils import BaseTokenizer
|
||||
from onyx.natural_language_processing.utils import count_tokens
|
||||
|
||||
|
||||
def label(name: str) -> str:
|
||||
"""Render a column name with a space-substituted friendly alias in
|
||||
parens for underscored headers so retrieval matches either surface
|
||||
form (e.g. ``MTTR_hours`` → ``MTTR_hours (MTTR hours)``)."""
|
||||
return f"{name} ({name.replace('_', ' ')})" if "_" in name else name
|
||||
|
||||
|
||||
def pack_lines(
|
||||
lines: list[str],
|
||||
prefix: str,
|
||||
tokenizer: BaseTokenizer,
|
||||
max_tokens: int,
|
||||
) -> list[str]:
|
||||
"""Greedily pack ``lines`` into chunks ≤ ``max_tokens``, prepending
|
||||
``prefix`` (verbatim) to every emitted chunk. Lines whose own token
|
||||
count exceeds the post-prefix budget are skipped. Callers assemble
|
||||
the full prefix (heading, header text, etc.) before calling.
|
||||
"""
|
||||
prefix_tokens = count_tokens(prefix, tokenizer) + 1 if prefix else 0
|
||||
budget = max_tokens - prefix_tokens
|
||||
|
||||
chunks: list[str] = []
|
||||
current: list[str] = []
|
||||
current_tokens = 0
|
||||
for line in lines:
|
||||
line_tokens = count_tokens(line, tokenizer)
|
||||
if line_tokens > budget:
|
||||
continue
|
||||
sep = 1 if current else 0
|
||||
if current_tokens + sep + line_tokens > budget:
|
||||
chunks.append(_join_with_prefix(current, prefix))
|
||||
current = [line]
|
||||
current_tokens = line_tokens
|
||||
else:
|
||||
current.append(line)
|
||||
current_tokens += sep + line_tokens
|
||||
if current:
|
||||
chunks.append(_join_with_prefix(current, prefix))
|
||||
return chunks
|
||||
|
||||
|
||||
def _join_with_prefix(lines: list[str], prefix: str) -> str:
|
||||
body = "\n".join(lines)
|
||||
return f"{prefix}\n{body}" if prefix else body
|
||||
@@ -1516,6 +1516,10 @@
|
||||
"display_name": "Claude Opus 4.6",
|
||||
"model_vendor": "anthropic"
|
||||
},
|
||||
"claude-opus-4-7": {
|
||||
"display_name": "Claude Opus 4.7",
|
||||
"model_vendor": "anthropic"
|
||||
},
|
||||
"claude-opus-4-5-20251101": {
|
||||
"display_name": "Claude Opus 4.5",
|
||||
"model_vendor": "anthropic",
|
||||
|
||||
@@ -46,6 +46,15 @@ ANTHROPIC_REASONING_EFFORT_BUDGET: dict[ReasoningEffort, int] = {
|
||||
ReasoningEffort.HIGH: 4096,
|
||||
}
|
||||
|
||||
# Newer Anthropic models (Claude Opus 4.7+) use adaptive thinking with
|
||||
# output_config.effort instead of thinking.type.enabled + budget_tokens.
|
||||
ANTHROPIC_ADAPTIVE_REASONING_EFFORT: dict[ReasoningEffort, str] = {
|
||||
ReasoningEffort.AUTO: "medium",
|
||||
ReasoningEffort.LOW: "low",
|
||||
ReasoningEffort.MEDIUM: "medium",
|
||||
ReasoningEffort.HIGH: "high",
|
||||
}
|
||||
|
||||
|
||||
# Content part structures for multimodal messages
|
||||
# The classes in this mirror the OpenAI Chat Completions message types and work well with routers like LiteLLM
|
||||
|
||||
@@ -23,6 +23,7 @@ from onyx.llm.interfaces import ToolChoiceOptions
|
||||
from onyx.llm.model_response import ModelResponse
|
||||
from onyx.llm.model_response import ModelResponseStream
|
||||
from onyx.llm.model_response import Usage
|
||||
from onyx.llm.models import ANTHROPIC_ADAPTIVE_REASONING_EFFORT
|
||||
from onyx.llm.models import ANTHROPIC_REASONING_EFFORT_BUDGET
|
||||
from onyx.llm.models import OPENAI_REASONING_EFFORT
|
||||
from onyx.llm.request_context import get_llm_mock_response
|
||||
@@ -67,8 +68,13 @@ STANDARD_MAX_TOKENS_KWARG = "max_completion_tokens"
|
||||
_VERTEX_ANTHROPIC_MODELS_REJECTING_OUTPUT_CONFIG = (
|
||||
"claude-opus-4-5",
|
||||
"claude-opus-4-6",
|
||||
"claude-opus-4-7",
|
||||
)
|
||||
|
||||
# Anthropic models that require the adaptive thinking API (thinking.type.adaptive
|
||||
# + output_config.effort) instead of the legacy thinking.type.enabled + budget_tokens.
|
||||
_ANTHROPIC_ADAPTIVE_THINKING_MODELS = ("claude-opus-4-7",)
|
||||
|
||||
|
||||
class LLMTimeoutError(Exception):
|
||||
"""
|
||||
@@ -230,6 +236,14 @@ def _is_vertex_model_rejecting_output_config(model_name: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _anthropic_uses_adaptive_thinking(model_name: str) -> bool:
|
||||
normalized_model_name = model_name.lower()
|
||||
return any(
|
||||
adaptive_model in normalized_model_name
|
||||
for adaptive_model in _ANTHROPIC_ADAPTIVE_THINKING_MODELS
|
||||
)
|
||||
|
||||
|
||||
class LitellmLLM(LLM):
|
||||
"""Uses Litellm library to allow easy configuration to use a multitude of LLMs
|
||||
See https://python.langchain.com/docs/integrations/chat/litellm"""
|
||||
@@ -509,10 +523,6 @@ class LitellmLLM(LLM):
|
||||
}
|
||||
|
||||
elif is_claude_model:
|
||||
budget_tokens: int | None = ANTHROPIC_REASONING_EFFORT_BUDGET.get(
|
||||
reasoning_effort
|
||||
)
|
||||
|
||||
# Anthropic requires every assistant message with tool_use
|
||||
# blocks to start with a thinking block that carries a
|
||||
# cryptographic signature. We don't preserve those blocks
|
||||
@@ -520,24 +530,35 @@ class LitellmLLM(LLM):
|
||||
# contains tool-calling assistant messages. LiteLLM's
|
||||
# modify_params workaround doesn't cover all providers
|
||||
# (notably Bedrock).
|
||||
can_enable_thinking = (
|
||||
budget_tokens is not None
|
||||
and not _prompt_contains_tool_call_history(prompt)
|
||||
)
|
||||
has_tool_call_history = _prompt_contains_tool_call_history(prompt)
|
||||
|
||||
if can_enable_thinking:
|
||||
assert budget_tokens is not None # mypy
|
||||
if max_tokens is not None:
|
||||
# Anthropic has a weird rule where max token has to be at least as much as budget tokens if set
|
||||
# and the minimum budget tokens is 1024
|
||||
# Will note that overwriting a developer set max tokens is not ideal but is the best we can do for now
|
||||
# It is better to allow the LLM to output more reasoning tokens even if it results in a fairly small tool
|
||||
# call as compared to reducing the budget for reasoning.
|
||||
max_tokens = max(budget_tokens + 1, max_tokens)
|
||||
optional_kwargs["thinking"] = {
|
||||
"type": "enabled",
|
||||
"budget_tokens": budget_tokens,
|
||||
}
|
||||
if _anthropic_uses_adaptive_thinking(self.config.model_name):
|
||||
# Newer Anthropic models (Claude Opus 4.7+) reject
|
||||
# thinking.type.enabled — they require the adaptive
|
||||
# thinking config with output_config.effort.
|
||||
if not has_tool_call_history:
|
||||
optional_kwargs["thinking"] = {"type": "adaptive"}
|
||||
optional_kwargs["output_config"] = {
|
||||
"effort": ANTHROPIC_ADAPTIVE_REASONING_EFFORT[
|
||||
reasoning_effort
|
||||
],
|
||||
}
|
||||
else:
|
||||
budget_tokens: int | None = ANTHROPIC_REASONING_EFFORT_BUDGET.get(
|
||||
reasoning_effort
|
||||
)
|
||||
if budget_tokens is not None and not has_tool_call_history:
|
||||
if max_tokens is not None:
|
||||
# Anthropic has a weird rule where max token has to be at least as much as budget tokens if set
|
||||
# and the minimum budget tokens is 1024
|
||||
# Will note that overwriting a developer set max tokens is not ideal but is the best we can do for now
|
||||
# It is better to allow the LLM to output more reasoning tokens even if it results in a fairly small tool
|
||||
# call as compared to reducing the budget for reasoning.
|
||||
max_tokens = max(budget_tokens + 1, max_tokens)
|
||||
optional_kwargs["thinking"] = {
|
||||
"type": "enabled",
|
||||
"budget_tokens": budget_tokens,
|
||||
}
|
||||
|
||||
# LiteLLM just does some mapping like this anyway but is incomplete for Anthropic
|
||||
optional_kwargs.pop("reasoning_effort", None)
|
||||
|
||||
@@ -290,7 +290,11 @@ def litellm_exception_to_error_msg(
|
||||
error_code = "BUDGET_EXCEEDED"
|
||||
is_retryable = False
|
||||
elif isinstance(core_exception, Timeout):
|
||||
error_msg = "Request timed out: The operation took too long to complete. Please try again."
|
||||
error_msg = (
|
||||
"The LLM took too long to respond. "
|
||||
"If you're running a local model, try increasing the "
|
||||
"LLM_SOCKET_READ_TIMEOUT environment variable (current default: 120 seconds)."
|
||||
)
|
||||
error_code = "CONNECTION_ERROR"
|
||||
is_retryable = True
|
||||
elif isinstance(core_exception, APIError):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.1",
|
||||
"updated_at": "2026-03-05T00:00:00Z",
|
||||
"version": "1.2",
|
||||
"updated_at": "2026-04-16T00:00:00Z",
|
||||
"providers": {
|
||||
"openai": {
|
||||
"default_model": { "name": "gpt-5.4" },
|
||||
@@ -10,8 +10,12 @@
|
||||
]
|
||||
},
|
||||
"anthropic": {
|
||||
"default_model": "claude-opus-4-6",
|
||||
"default_model": "claude-opus-4-7",
|
||||
"additional_visible_models": [
|
||||
{
|
||||
"name": "claude-opus-4-7",
|
||||
"display_name": "Claude Opus 4.7"
|
||||
},
|
||||
{
|
||||
"name": "claude-opus-4-6",
|
||||
"display_name": "Claude Opus 4.6"
|
||||
|
||||
@@ -65,8 +65,9 @@ IMPORTANT: each call to this tool is independent. Variables from previous calls
|
||||
GENERATE_IMAGE_GUIDANCE = """
|
||||
## generate_image
|
||||
NEVER use generate_image unless the user specifically requests an image.
|
||||
For edits/variations of a previously generated image, pass `reference_image_file_ids` with
|
||||
the `file_id` values returned by earlier `generate_image` tool results.
|
||||
To edit, restyle, or vary an existing image, pass its file_id in `reference_image_file_ids`. \
|
||||
File IDs come from `[attached image — file_id: <id>]` tags on user-attached images or from prior `generate_image` tool results — never invent one. \
|
||||
Leave `reference_image_file_ids` unset for a fresh generation.
|
||||
""".lstrip()
|
||||
|
||||
MEMORY_GUIDANCE = """
|
||||
|
||||
@@ -40,6 +40,8 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.permissions import require_permission
|
||||
from onyx.background.celery.versioned_apps.client import app as celery_app
|
||||
from onyx.configs.app_configs import MAX_EMBEDDED_IMAGES_PER_FILE
|
||||
from onyx.configs.app_configs import MAX_EMBEDDED_IMAGES_PER_UPLOAD
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.configs.constants import OnyxCeleryQueues
|
||||
from onyx.configs.constants import OnyxCeleryTask
|
||||
@@ -51,6 +53,9 @@ from onyx.db.enums import ConnectorCredentialPairStatus
|
||||
from onyx.db.enums import Permission
|
||||
from onyx.db.models import User
|
||||
from onyx.document_index.interfaces import DocumentMetadata
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.file_processing.extract_file_text import count_pdf_embedded_images
|
||||
from onyx.server.features.build.configs import USER_LIBRARY_MAX_FILE_SIZE_BYTES
|
||||
from onyx.server.features.build.configs import USER_LIBRARY_MAX_FILES_PER_UPLOAD
|
||||
from onyx.server.features.build.configs import USER_LIBRARY_MAX_TOTAL_SIZE_BYTES
|
||||
@@ -128,6 +133,49 @@ class DeleteFileResponse(BaseModel):
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _looks_like_pdf(filename: str, content_type: str | None) -> bool:
|
||||
"""True if either the filename or the content-type indicates a PDF.
|
||||
|
||||
Client-supplied ``content_type`` can be spoofed (e.g. a PDF uploaded with
|
||||
``Content-Type: application/octet-stream``), so we also fall back to
|
||||
extension-based detection via ``mimetypes.guess_type`` on the filename.
|
||||
"""
|
||||
if content_type == "application/pdf":
|
||||
return True
|
||||
guessed, _ = mimetypes.guess_type(filename)
|
||||
return guessed == "application/pdf"
|
||||
|
||||
|
||||
def _check_pdf_image_caps(
|
||||
filename: str, content: bytes, content_type: str | None, batch_total: int
|
||||
) -> int:
|
||||
"""Enforce per-file and per-batch embedded-image caps for PDFs.
|
||||
|
||||
Returns the number of embedded images in this file (0 for non-PDFs) so
|
||||
callers can update their running batch total. Raises OnyxError(INVALID_INPUT)
|
||||
if either cap is exceeded.
|
||||
"""
|
||||
if not _looks_like_pdf(filename, content_type):
|
||||
return 0
|
||||
file_cap = MAX_EMBEDDED_IMAGES_PER_FILE
|
||||
batch_cap = MAX_EMBEDDED_IMAGES_PER_UPLOAD
|
||||
# Short-circuit at the larger cap so we get a useful count for both checks.
|
||||
count = count_pdf_embedded_images(BytesIO(content), max(file_cap, batch_cap))
|
||||
if count > file_cap:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INVALID_INPUT,
|
||||
f"PDF '{filename}' contains too many embedded images "
|
||||
f"(more than {file_cap}). Try splitting the document into smaller files.",
|
||||
)
|
||||
if batch_total + count > batch_cap:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INVALID_INPUT,
|
||||
f"Upload would exceed the {batch_cap}-image limit across all "
|
||||
f"files in this batch. Try uploading fewer image-heavy files at once.",
|
||||
)
|
||||
return count
|
||||
|
||||
|
||||
def _sanitize_path(path: str) -> str:
|
||||
"""Sanitize a file path, removing traversal attempts and normalizing.
|
||||
|
||||
@@ -356,6 +404,7 @@ async def upload_files(
|
||||
|
||||
uploaded_entries: list[LibraryEntryResponse] = []
|
||||
total_size = 0
|
||||
batch_image_total = 0
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Sanitize the base path
|
||||
@@ -375,6 +424,14 @@ async def upload_files(
|
||||
detail=f"File '{file.filename}' exceeds maximum size of {USER_LIBRARY_MAX_FILE_SIZE_BYTES // (1024 * 1024)}MB",
|
||||
)
|
||||
|
||||
# Reject PDFs with an unreasonable per-file or per-batch image count
|
||||
batch_image_total += _check_pdf_image_caps(
|
||||
filename=file.filename or "unnamed",
|
||||
content=content,
|
||||
content_type=file.content_type,
|
||||
batch_total=batch_image_total,
|
||||
)
|
||||
|
||||
# Validate cumulative storage (existing + this upload batch)
|
||||
total_size += file_size
|
||||
if existing_usage + total_size > USER_LIBRARY_MAX_TOTAL_SIZE_BYTES:
|
||||
@@ -473,6 +530,7 @@ async def upload_zip(
|
||||
|
||||
uploaded_entries: list[LibraryEntryResponse] = []
|
||||
total_size = 0
|
||||
batch_image_total = 0
|
||||
|
||||
# Extract zip contents into a subfolder named after the zip file
|
||||
zip_name = api_sanitize_filename(file.filename or "upload")
|
||||
@@ -511,6 +569,36 @@ async def upload_zip(
|
||||
logger.warning(f"Skipping '{zip_info.filename}' - exceeds max size")
|
||||
continue
|
||||
|
||||
# Skip PDFs that would trip the per-file or per-batch image
|
||||
# cap (would OOM the user-file-processing worker). Matches
|
||||
# /upload behavior but uses skip-and-warn to stay consistent
|
||||
# with the zip path's handling of oversized files.
|
||||
zip_file_name = zip_info.filename.split("/")[-1]
|
||||
zip_content_type, _ = mimetypes.guess_type(zip_file_name)
|
||||
if zip_content_type == "application/pdf":
|
||||
image_count = count_pdf_embedded_images(
|
||||
BytesIO(file_content),
|
||||
max(
|
||||
MAX_EMBEDDED_IMAGES_PER_FILE,
|
||||
MAX_EMBEDDED_IMAGES_PER_UPLOAD,
|
||||
),
|
||||
)
|
||||
if image_count > MAX_EMBEDDED_IMAGES_PER_FILE:
|
||||
logger.warning(
|
||||
"Skipping '%s' - exceeds %d per-file embedded-image cap",
|
||||
zip_info.filename,
|
||||
MAX_EMBEDDED_IMAGES_PER_FILE,
|
||||
)
|
||||
continue
|
||||
if batch_image_total + image_count > MAX_EMBEDDED_IMAGES_PER_UPLOAD:
|
||||
logger.warning(
|
||||
"Skipping '%s' - would exceed %d per-batch embedded-image cap",
|
||||
zip_info.filename,
|
||||
MAX_EMBEDDED_IMAGES_PER_UPLOAD,
|
||||
)
|
||||
continue
|
||||
batch_image_total += image_count
|
||||
|
||||
total_size += file_size
|
||||
|
||||
# Validate cumulative storage
|
||||
|
||||
@@ -9,7 +9,10 @@ from pydantic import ConfigDict
|
||||
from pydantic import Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.configs.app_configs import MAX_EMBEDDED_IMAGES_PER_FILE
|
||||
from onyx.configs.app_configs import MAX_EMBEDDED_IMAGES_PER_UPLOAD
|
||||
from onyx.db.llm import fetch_default_llm_model
|
||||
from onyx.file_processing.extract_file_text import count_pdf_embedded_images
|
||||
from onyx.file_processing.extract_file_text import extract_file_text
|
||||
from onyx.file_processing.extract_file_text import get_file_ext
|
||||
from onyx.file_processing.file_types import OnyxFileExtensions
|
||||
@@ -190,6 +193,11 @@ def categorize_uploaded_files(
|
||||
token_threshold_k * 1000 if token_threshold_k else None
|
||||
) # 0 → None = no limit
|
||||
|
||||
# Running total of embedded images across PDFs in this batch. Once the
|
||||
# aggregate cap is reached, subsequent PDFs in the same upload are
|
||||
# rejected even if they'd individually fit under MAX_EMBEDDED_IMAGES_PER_FILE.
|
||||
batch_image_total = 0
|
||||
|
||||
for upload in files:
|
||||
try:
|
||||
filename = get_safe_filename(upload)
|
||||
@@ -252,6 +260,47 @@ def categorize_uploaded_files(
|
||||
)
|
||||
continue
|
||||
|
||||
# Reject PDFs with an unreasonable number of embedded images
|
||||
# (either per-file or accumulated across this upload batch).
|
||||
# A PDF with thousands of embedded images can OOM the
|
||||
# user-file-processing celery worker because every image is
|
||||
# decoded with PIL and then sent to the vision LLM.
|
||||
if extension == ".pdf":
|
||||
file_cap = MAX_EMBEDDED_IMAGES_PER_FILE
|
||||
batch_cap = MAX_EMBEDDED_IMAGES_PER_UPLOAD
|
||||
# Use the larger of the two caps as the short-circuit
|
||||
# threshold so we get a useful count for both checks.
|
||||
# count_pdf_embedded_images restores the stream position.
|
||||
count = count_pdf_embedded_images(
|
||||
upload.file, max(file_cap, batch_cap)
|
||||
)
|
||||
if count > file_cap:
|
||||
results.rejected.append(
|
||||
RejectedFile(
|
||||
filename=filename,
|
||||
reason=(
|
||||
f"PDF contains too many embedded images "
|
||||
f"(more than {file_cap}). Try splitting "
|
||||
f"the document into smaller files."
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
if batch_image_total + count > batch_cap:
|
||||
results.rejected.append(
|
||||
RejectedFile(
|
||||
filename=filename,
|
||||
reason=(
|
||||
f"Upload would exceed the "
|
||||
f"{batch_cap}-image limit across all "
|
||||
f"files in this batch. Try uploading "
|
||||
f"fewer image-heavy files at once."
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
batch_image_total += count
|
||||
|
||||
text_content = extract_file_text(
|
||||
file=upload.file,
|
||||
file_name=filename,
|
||||
|
||||
@@ -208,12 +208,6 @@ class PythonToolOverrideKwargs(BaseModel):
|
||||
chat_files: list[ChatFile] = []
|
||||
|
||||
|
||||
class ImageGenerationToolOverrideKwargs(BaseModel):
|
||||
"""Override kwargs for image generation tool calls."""
|
||||
|
||||
recent_generated_image_file_ids: list[str] = []
|
||||
|
||||
|
||||
class SearchToolRunContext(BaseModel):
|
||||
emitter: Emitter
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ from onyx.server.query_and_chat.streaming_models import ImageGenerationToolHeart
|
||||
from onyx.server.query_and_chat.streaming_models import ImageGenerationToolStart
|
||||
from onyx.server.query_and_chat.streaming_models import Packet
|
||||
from onyx.tools.interface import Tool
|
||||
from onyx.tools.models import ImageGenerationToolOverrideKwargs
|
||||
from onyx.tools.models import ToolCallException
|
||||
from onyx.tools.models import ToolExecutionException
|
||||
from onyx.tools.models import ToolResponse
|
||||
@@ -48,7 +47,7 @@ PROMPT_FIELD = "prompt"
|
||||
REFERENCE_IMAGE_FILE_IDS_FIELD = "reference_image_file_ids"
|
||||
|
||||
|
||||
class ImageGenerationTool(Tool[ImageGenerationToolOverrideKwargs | None]):
|
||||
class ImageGenerationTool(Tool[None]):
|
||||
NAME = "generate_image"
|
||||
DESCRIPTION = "Generate an image based on a prompt. Do not use unless the user specifically requests an image."
|
||||
DISPLAY_NAME = "Image Generation"
|
||||
@@ -142,8 +141,11 @@ class ImageGenerationTool(Tool[ImageGenerationToolOverrideKwargs | None]):
|
||||
REFERENCE_IMAGE_FILE_IDS_FIELD: {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"Optional image file IDs to use as reference context for edits/variations. "
|
||||
"Use the file_id values returned by previous generate_image calls."
|
||||
"Optional file_ids of existing images to edit or use as reference;"
|
||||
" the first is the primary edit source."
|
||||
" Get file_ids from `[attached image — file_id: <id>]` tags on"
|
||||
" user-attached images or from prior generate_image tool responses."
|
||||
" Omit for a fresh, unrelated generation."
|
||||
),
|
||||
"items": {
|
||||
"type": "string",
|
||||
@@ -254,41 +256,31 @@ class ImageGenerationTool(Tool[ImageGenerationToolOverrideKwargs | None]):
|
||||
def _resolve_reference_image_file_ids(
|
||||
self,
|
||||
llm_kwargs: dict[str, Any],
|
||||
override_kwargs: ImageGenerationToolOverrideKwargs | None,
|
||||
) -> list[str]:
|
||||
raw_reference_ids = llm_kwargs.get(REFERENCE_IMAGE_FILE_IDS_FIELD)
|
||||
if raw_reference_ids is not None:
|
||||
if not isinstance(raw_reference_ids, list) or not all(
|
||||
isinstance(file_id, str) for file_id in raw_reference_ids
|
||||
):
|
||||
raise ToolCallException(
|
||||
message=(
|
||||
f"Invalid {REFERENCE_IMAGE_FILE_IDS_FIELD}: expected array of strings, got {type(raw_reference_ids)}"
|
||||
),
|
||||
llm_facing_message=(
|
||||
f"The '{REFERENCE_IMAGE_FILE_IDS_FIELD}' field must be an array of file_id strings."
|
||||
),
|
||||
)
|
||||
reference_image_file_ids = [
|
||||
file_id.strip() for file_id in raw_reference_ids if file_id.strip()
|
||||
]
|
||||
elif (
|
||||
override_kwargs
|
||||
and override_kwargs.recent_generated_image_file_ids
|
||||
and self.img_provider.supports_reference_images
|
||||
):
|
||||
# If no explicit reference was provided, default to the most recently generated image.
|
||||
reference_image_file_ids = [
|
||||
override_kwargs.recent_generated_image_file_ids[-1]
|
||||
]
|
||||
else:
|
||||
reference_image_file_ids = []
|
||||
if raw_reference_ids is None:
|
||||
# No references requested — plain generation.
|
||||
return []
|
||||
|
||||
# Deduplicate while preserving order.
|
||||
if not isinstance(raw_reference_ids, list) or not all(
|
||||
isinstance(file_id, str) for file_id in raw_reference_ids
|
||||
):
|
||||
raise ToolCallException(
|
||||
message=(
|
||||
f"Invalid {REFERENCE_IMAGE_FILE_IDS_FIELD}: expected array of strings, got {type(raw_reference_ids)}"
|
||||
),
|
||||
llm_facing_message=(
|
||||
f"The '{REFERENCE_IMAGE_FILE_IDS_FIELD}' field must be an array of file_id strings."
|
||||
),
|
||||
)
|
||||
|
||||
# Deduplicate while preserving order (first occurrence wins, so the
|
||||
# LLM's intended "primary edit source" stays at index 0).
|
||||
deduped_reference_image_ids: list[str] = []
|
||||
seen_ids: set[str] = set()
|
||||
for file_id in reference_image_file_ids:
|
||||
if file_id in seen_ids:
|
||||
for file_id in raw_reference_ids:
|
||||
file_id = file_id.strip()
|
||||
if not file_id or file_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(file_id)
|
||||
deduped_reference_image_ids.append(file_id)
|
||||
@@ -302,14 +294,14 @@ class ImageGenerationTool(Tool[ImageGenerationToolOverrideKwargs | None]):
|
||||
f"Reference images requested but provider '{self.provider}' does not support image-editing context."
|
||||
),
|
||||
llm_facing_message=(
|
||||
"This image provider does not support editing from previous image context. "
|
||||
"This image provider does not support editing from existing images. "
|
||||
"Try text-only generation, or switch to a provider/model that supports image edits."
|
||||
),
|
||||
)
|
||||
|
||||
max_reference_images = self.img_provider.max_reference_images
|
||||
if max_reference_images > 0:
|
||||
return deduped_reference_image_ids[-max_reference_images:]
|
||||
return deduped_reference_image_ids[:max_reference_images]
|
||||
return deduped_reference_image_ids
|
||||
|
||||
def _load_reference_images(
|
||||
@@ -358,7 +350,7 @@ class ImageGenerationTool(Tool[ImageGenerationToolOverrideKwargs | None]):
|
||||
def run(
|
||||
self,
|
||||
placement: Placement,
|
||||
override_kwargs: ImageGenerationToolOverrideKwargs | None = None,
|
||||
override_kwargs: None = None, # noqa: ARG002
|
||||
**llm_kwargs: Any,
|
||||
) -> ToolResponse:
|
||||
if PROMPT_FIELD not in llm_kwargs:
|
||||
@@ -373,7 +365,6 @@ class ImageGenerationTool(Tool[ImageGenerationToolOverrideKwargs | None]):
|
||||
shape = ImageShape(llm_kwargs.get("shape", ImageShape.SQUARE.value))
|
||||
reference_image_file_ids = self._resolve_reference_image_file_ids(
|
||||
llm_kwargs=llm_kwargs,
|
||||
override_kwargs=override_kwargs,
|
||||
)
|
||||
reference_images = self._load_reference_images(reference_image_file_ids)
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
@@ -14,7 +13,6 @@ from onyx.server.query_and_chat.streaming_models import SectionEnd
|
||||
from onyx.tools.interface import Tool
|
||||
from onyx.tools.models import ChatFile
|
||||
from onyx.tools.models import ChatMinimalTextMessage
|
||||
from onyx.tools.models import ImageGenerationToolOverrideKwargs
|
||||
from onyx.tools.models import OpenURLToolOverrideKwargs
|
||||
from onyx.tools.models import ParallelToolCallResponse
|
||||
from onyx.tools.models import PythonToolOverrideKwargs
|
||||
@@ -24,9 +22,6 @@ from onyx.tools.models import ToolCallKickoff
|
||||
from onyx.tools.models import ToolExecutionException
|
||||
from onyx.tools.models import ToolResponse
|
||||
from onyx.tools.models import WebSearchToolOverrideKwargs
|
||||
from onyx.tools.tool_implementations.images.image_generation_tool import (
|
||||
ImageGenerationTool,
|
||||
)
|
||||
from onyx.tools.tool_implementations.memory.memory_tool import MemoryTool
|
||||
from onyx.tools.tool_implementations.memory.memory_tool import MemoryToolOverrideKwargs
|
||||
from onyx.tools.tool_implementations.open_url.open_url_tool import OpenURLTool
|
||||
@@ -110,63 +105,6 @@ def _merge_tool_calls(tool_calls: list[ToolCallKickoff]) -> list[ToolCallKickoff
|
||||
return merged_calls
|
||||
|
||||
|
||||
def _extract_image_file_ids_from_tool_response_message(
|
||||
message: str,
|
||||
) -> list[str]:
|
||||
try:
|
||||
parsed_message = json.loads(message)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
parsed_items: list[Any] = (
|
||||
parsed_message if isinstance(parsed_message, list) else [parsed_message]
|
||||
)
|
||||
file_ids: list[str] = []
|
||||
for item in parsed_items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
file_id = item.get("file_id")
|
||||
if isinstance(file_id, str):
|
||||
file_ids.append(file_id)
|
||||
|
||||
return file_ids
|
||||
|
||||
|
||||
def _extract_recent_generated_image_file_ids(
|
||||
message_history: list[ChatMessageSimple],
|
||||
) -> list[str]:
|
||||
tool_name_by_tool_call_id: dict[str, str] = {}
|
||||
recent_image_file_ids: list[str] = []
|
||||
seen_file_ids: set[str] = set()
|
||||
|
||||
for message in message_history:
|
||||
if message.message_type == MessageType.ASSISTANT and message.tool_calls:
|
||||
for tool_call in message.tool_calls:
|
||||
tool_name_by_tool_call_id[tool_call.tool_call_id] = tool_call.tool_name
|
||||
continue
|
||||
|
||||
if (
|
||||
message.message_type != MessageType.TOOL_CALL_RESPONSE
|
||||
or not message.tool_call_id
|
||||
):
|
||||
continue
|
||||
|
||||
tool_name = tool_name_by_tool_call_id.get(message.tool_call_id)
|
||||
if tool_name != ImageGenerationTool.NAME:
|
||||
continue
|
||||
|
||||
for file_id in _extract_image_file_ids_from_tool_response_message(
|
||||
message.message
|
||||
):
|
||||
if file_id in seen_file_ids:
|
||||
continue
|
||||
seen_file_ids.add(file_id)
|
||||
recent_image_file_ids.append(file_id)
|
||||
|
||||
return recent_image_file_ids
|
||||
|
||||
|
||||
def _safe_run_single_tool(
|
||||
tool: Tool,
|
||||
tool_call: ToolCallKickoff,
|
||||
@@ -386,9 +324,6 @@ def run_tool_calls(
|
||||
url_to_citation: dict[str, int] = {
|
||||
url: citation_num for citation_num, url in citation_mapping.items()
|
||||
}
|
||||
recent_generated_image_file_ids = _extract_recent_generated_image_file_ids(
|
||||
message_history
|
||||
)
|
||||
|
||||
# Prepare all tool calls with their override_kwargs
|
||||
# Each tool gets a unique starting citation number to avoid conflicts when running in parallel
|
||||
@@ -405,7 +340,6 @@ def run_tool_calls(
|
||||
| WebSearchToolOverrideKwargs
|
||||
| OpenURLToolOverrideKwargs
|
||||
| PythonToolOverrideKwargs
|
||||
| ImageGenerationToolOverrideKwargs
|
||||
| MemoryToolOverrideKwargs
|
||||
| None
|
||||
) = None
|
||||
@@ -454,10 +388,6 @@ def run_tool_calls(
|
||||
override_kwargs = PythonToolOverrideKwargs(
|
||||
chat_files=chat_files or [],
|
||||
)
|
||||
elif isinstance(tool, ImageGenerationTool):
|
||||
override_kwargs = ImageGenerationToolOverrideKwargs(
|
||||
recent_generated_image_file_ids=recent_generated_image_file_ids
|
||||
)
|
||||
elif isinstance(tool, MemoryTool):
|
||||
override_kwargs = MemoryToolOverrideKwargs(
|
||||
user_name=(
|
||||
|
||||
@@ -34,6 +34,7 @@ R = TypeVar("R")
|
||||
KT = TypeVar("KT") # Key type
|
||||
VT = TypeVar("VT") # Value type
|
||||
_T = TypeVar("_T") # Default type
|
||||
_MISSING: object = object()
|
||||
|
||||
|
||||
class ThreadSafeDict(MutableMapping[KT, VT]):
|
||||
@@ -117,10 +118,10 @@ class ThreadSafeDict(MutableMapping[KT, VT]):
|
||||
with self.lock:
|
||||
return self._dict.get(key, default)
|
||||
|
||||
def pop(self, key: KT, default: Any = None) -> Any:
|
||||
def pop(self, key: KT, default: Any = _MISSING) -> Any:
|
||||
"""Remove and return a value with optional default, atomically."""
|
||||
with self.lock:
|
||||
if default is None:
|
||||
if default is _MISSING:
|
||||
return self._dict.pop(key)
|
||||
return self._dict.pop(key, default)
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ attrs==25.4.0
|
||||
# jsonschema
|
||||
# referencing
|
||||
# zeep
|
||||
authlib==1.6.9
|
||||
authlib==1.6.11
|
||||
# via fastmcp
|
||||
azure-cognitiveservices-speech==1.38.0
|
||||
babel==2.17.0
|
||||
@@ -214,7 +214,9 @@ distro==1.9.0
|
||||
dnspython==2.8.0
|
||||
# via email-validator
|
||||
docstring-parser==0.17.0
|
||||
# via cyclopts
|
||||
# via
|
||||
# cyclopts
|
||||
# google-cloud-aiplatform
|
||||
docutils==0.22.3
|
||||
# via rich-rst
|
||||
dropbox==12.0.2
|
||||
@@ -270,7 +272,13 @@ gitdb==4.0.12
|
||||
gitpython==3.1.45
|
||||
# via braintrust
|
||||
google-api-core==2.28.1
|
||||
# via google-api-python-client
|
||||
# via
|
||||
# google-api-python-client
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-core
|
||||
# google-cloud-resource-manager
|
||||
# google-cloud-storage
|
||||
google-api-python-client==2.86.0
|
||||
google-auth==2.48.0
|
||||
# via
|
||||
@@ -278,21 +286,61 @@ google-auth==2.48.0
|
||||
# google-api-python-client
|
||||
# google-auth-httplib2
|
||||
# google-auth-oauthlib
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-core
|
||||
# google-cloud-resource-manager
|
||||
# google-cloud-storage
|
||||
# google-genai
|
||||
# kubernetes
|
||||
google-auth-httplib2==0.1.0
|
||||
# via google-api-python-client
|
||||
google-auth-oauthlib==1.0.0
|
||||
google-cloud-aiplatform==1.133.0
|
||||
# via litellm
|
||||
google-cloud-bigquery==3.41.0
|
||||
# via google-cloud-aiplatform
|
||||
google-cloud-core==2.5.1
|
||||
# via
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
google-cloud-resource-manager==1.17.0
|
||||
# via google-cloud-aiplatform
|
||||
google-cloud-storage==3.10.1
|
||||
# via google-cloud-aiplatform
|
||||
google-crc32c==1.8.0
|
||||
# via
|
||||
# google-cloud-storage
|
||||
# google-resumable-media
|
||||
google-genai==1.52.0
|
||||
# via onyx
|
||||
# via
|
||||
# google-cloud-aiplatform
|
||||
# onyx
|
||||
google-resumable-media==2.8.2
|
||||
# via
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
googleapis-common-protos==1.72.0
|
||||
# via
|
||||
# google-api-core
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
# opentelemetry-exporter-otlp-proto-http
|
||||
greenlet==3.2.4
|
||||
# via
|
||||
# playwright
|
||||
# sqlalchemy
|
||||
grpc-google-iam-v1==0.14.4
|
||||
# via google-cloud-resource-manager
|
||||
grpcio==1.80.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-resource-manager
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
grpcio-status==1.80.0
|
||||
# via google-api-core
|
||||
h11==0.16.0
|
||||
# via
|
||||
# httpcore
|
||||
@@ -443,7 +491,7 @@ magika==0.6.3
|
||||
# via markitdown
|
||||
makefun==1.16.0
|
||||
# via fastapi-users
|
||||
mako==1.2.4
|
||||
mako==1.3.11
|
||||
# via alembic
|
||||
mammoth==1.11.0
|
||||
# via markitdown
|
||||
@@ -559,6 +607,8 @@ packaging==24.2
|
||||
# dask
|
||||
# distributed
|
||||
# fastmcp
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# huggingface-hub
|
||||
# jira
|
||||
# kombu
|
||||
@@ -605,12 +655,19 @@ propcache==0.4.1
|
||||
# aiohttp
|
||||
# yarl
|
||||
proto-plus==1.26.1
|
||||
# via google-api-core
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-resource-manager
|
||||
protobuf==6.33.5
|
||||
# via
|
||||
# ddtrace
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-resource-manager
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
# onnxruntime
|
||||
# opentelemetry-proto
|
||||
# proto-plus
|
||||
@@ -643,6 +700,7 @@ pydantic==2.11.7
|
||||
# exa-py
|
||||
# fastapi
|
||||
# fastmcp
|
||||
# google-cloud-aiplatform
|
||||
# google-genai
|
||||
# langchain-core
|
||||
# langfuse
|
||||
@@ -679,7 +737,7 @@ pynacl==1.6.2
|
||||
pypandoc-binary==1.16.2
|
||||
pyparsing==3.2.5
|
||||
# via httplib2
|
||||
pypdf==6.10.0
|
||||
pypdf==6.10.2
|
||||
# via unstructured-client
|
||||
pyperclip==1.11.0
|
||||
# via fastmcp
|
||||
@@ -701,6 +759,7 @@ python-dateutil==2.8.2
|
||||
# botocore
|
||||
# celery
|
||||
# dateparser
|
||||
# google-cloud-bigquery
|
||||
# htmldate
|
||||
# hubspot-api-client
|
||||
# kubernetes
|
||||
@@ -779,6 +838,8 @@ requests==2.33.0
|
||||
# dropbox
|
||||
# exa-py
|
||||
# google-api-core
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
# google-genai
|
||||
# hubspot-api-client
|
||||
# jira
|
||||
@@ -951,7 +1012,9 @@ typing-extensions==4.15.0
|
||||
# exa-py
|
||||
# exceptiongroup
|
||||
# fastapi
|
||||
# google-cloud-aiplatform
|
||||
# google-genai
|
||||
# grpcio
|
||||
# huggingface-hub
|
||||
# jira
|
||||
# langchain-core
|
||||
|
||||
@@ -114,6 +114,8 @@ distlib==0.4.0
|
||||
# via virtualenv
|
||||
distro==1.9.0
|
||||
# via openai
|
||||
docstring-parser==0.17.0
|
||||
# via google-cloud-aiplatform
|
||||
durationpy==0.10
|
||||
# via kubernetes
|
||||
execnet==2.1.2
|
||||
@@ -141,14 +143,65 @@ frozenlist==1.8.0
|
||||
# aiosignal
|
||||
fsspec==2025.10.0
|
||||
# via huggingface-hub
|
||||
google-api-core==2.28.1
|
||||
# via
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-core
|
||||
# google-cloud-resource-manager
|
||||
# google-cloud-storage
|
||||
google-auth==2.48.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-core
|
||||
# google-cloud-resource-manager
|
||||
# google-cloud-storage
|
||||
# google-genai
|
||||
# kubernetes
|
||||
google-cloud-aiplatform==1.133.0
|
||||
# via litellm
|
||||
google-cloud-bigquery==3.41.0
|
||||
# via google-cloud-aiplatform
|
||||
google-cloud-core==2.5.1
|
||||
# via
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
google-cloud-resource-manager==1.17.0
|
||||
# via google-cloud-aiplatform
|
||||
google-cloud-storage==3.10.1
|
||||
# via google-cloud-aiplatform
|
||||
google-crc32c==1.8.0
|
||||
# via
|
||||
# google-cloud-storage
|
||||
# google-resumable-media
|
||||
google-genai==1.52.0
|
||||
# via onyx
|
||||
# via
|
||||
# google-cloud-aiplatform
|
||||
# onyx
|
||||
google-resumable-media==2.8.2
|
||||
# via
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
googleapis-common-protos==1.72.0
|
||||
# via
|
||||
# google-api-core
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
greenlet==3.2.4 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'
|
||||
# via sqlalchemy
|
||||
grpc-google-iam-v1==0.14.4
|
||||
# via google-cloud-resource-manager
|
||||
grpcio==1.80.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-resource-manager
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
grpcio-status==1.80.0
|
||||
# via google-api-core
|
||||
h11==0.16.0
|
||||
# via
|
||||
# httpcore
|
||||
@@ -218,7 +271,7 @@ kubernetes==31.0.0
|
||||
# via onyx
|
||||
litellm==1.81.6
|
||||
# via onyx
|
||||
mako==1.2.4
|
||||
mako==1.3.11
|
||||
# via alembic
|
||||
manygo==0.2.0
|
||||
markdown-it-py==4.0.0
|
||||
@@ -267,6 +320,8 @@ openapi-generator-cli==7.17.0
|
||||
packaging==24.2
|
||||
# via
|
||||
# black
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# hatchling
|
||||
# huggingface-hub
|
||||
# ipykernel
|
||||
@@ -307,6 +362,20 @@ propcache==0.4.1
|
||||
# via
|
||||
# aiohttp
|
||||
# yarl
|
||||
proto-plus==1.26.1
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-resource-manager
|
||||
protobuf==6.33.5
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-resource-manager
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
# proto-plus
|
||||
psutil==7.1.3
|
||||
# via ipykernel
|
||||
ptyprocess==0.7.0 ; sys_platform != 'emscripten' and sys_platform != 'win32'
|
||||
@@ -328,6 +397,7 @@ pydantic==2.11.7
|
||||
# agent-client-protocol
|
||||
# cohere
|
||||
# fastapi
|
||||
# google-cloud-aiplatform
|
||||
# google-genai
|
||||
# litellm
|
||||
# mcp
|
||||
@@ -364,6 +434,7 @@ python-dateutil==2.8.2
|
||||
# via
|
||||
# aiobotocore
|
||||
# botocore
|
||||
# google-cloud-bigquery
|
||||
# jupyter-client
|
||||
# kubernetes
|
||||
# matplotlib
|
||||
@@ -398,6 +469,9 @@ reorder-python-imports-black==3.14.0
|
||||
requests==2.33.0
|
||||
# via
|
||||
# cohere
|
||||
# google-api-core
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
# google-genai
|
||||
# kubernetes
|
||||
# requests-oauthlib
|
||||
@@ -498,7 +572,9 @@ typing-extensions==4.15.0
|
||||
# celery-types
|
||||
# cohere
|
||||
# fastapi
|
||||
# google-cloud-aiplatform
|
||||
# google-genai
|
||||
# grpcio
|
||||
# huggingface-hub
|
||||
# ipython
|
||||
# mcp
|
||||
|
||||
@@ -87,6 +87,8 @@ discord-py==2.4.0
|
||||
# via onyx
|
||||
distro==1.9.0
|
||||
# via openai
|
||||
docstring-parser==0.17.0
|
||||
# via google-cloud-aiplatform
|
||||
durationpy==0.10
|
||||
# via kubernetes
|
||||
fastapi==0.133.1
|
||||
@@ -103,12 +105,63 @@ frozenlist==1.8.0
|
||||
# aiosignal
|
||||
fsspec==2025.10.0
|
||||
# via huggingface-hub
|
||||
google-api-core==2.28.1
|
||||
# via
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-core
|
||||
# google-cloud-resource-manager
|
||||
# google-cloud-storage
|
||||
google-auth==2.48.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-core
|
||||
# google-cloud-resource-manager
|
||||
# google-cloud-storage
|
||||
# google-genai
|
||||
# kubernetes
|
||||
google-cloud-aiplatform==1.133.0
|
||||
# via litellm
|
||||
google-cloud-bigquery==3.41.0
|
||||
# via google-cloud-aiplatform
|
||||
google-cloud-core==2.5.1
|
||||
# via
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
google-cloud-resource-manager==1.17.0
|
||||
# via google-cloud-aiplatform
|
||||
google-cloud-storage==3.10.1
|
||||
# via google-cloud-aiplatform
|
||||
google-crc32c==1.8.0
|
||||
# via
|
||||
# google-cloud-storage
|
||||
# google-resumable-media
|
||||
google-genai==1.52.0
|
||||
# via onyx
|
||||
# via
|
||||
# google-cloud-aiplatform
|
||||
# onyx
|
||||
google-resumable-media==2.8.2
|
||||
# via
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
googleapis-common-protos==1.72.0
|
||||
# via
|
||||
# google-api-core
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
grpc-google-iam-v1==0.14.4
|
||||
# via google-cloud-resource-manager
|
||||
grpcio==1.80.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-resource-manager
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
grpcio-status==1.80.0
|
||||
# via google-api-core
|
||||
h11==0.16.0
|
||||
# via
|
||||
# httpcore
|
||||
@@ -184,7 +237,10 @@ openai==2.14.0
|
||||
# litellm
|
||||
# onyx
|
||||
packaging==24.2
|
||||
# via huggingface-hub
|
||||
# via
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# huggingface-hub
|
||||
parameterized==0.9.0
|
||||
# via cohere
|
||||
posthog==3.7.4
|
||||
@@ -198,6 +254,20 @@ propcache==0.4.1
|
||||
# via
|
||||
# aiohttp
|
||||
# yarl
|
||||
proto-plus==1.26.1
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-resource-manager
|
||||
protobuf==6.33.5
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-resource-manager
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
# proto-plus
|
||||
py==1.11.0
|
||||
# via retry
|
||||
pyasn1==0.6.3
|
||||
@@ -213,6 +283,7 @@ pydantic==2.11.7
|
||||
# agent-client-protocol
|
||||
# cohere
|
||||
# fastapi
|
||||
# google-cloud-aiplatform
|
||||
# google-genai
|
||||
# litellm
|
||||
# mcp
|
||||
@@ -231,6 +302,7 @@ python-dateutil==2.8.2
|
||||
# via
|
||||
# aiobotocore
|
||||
# botocore
|
||||
# google-cloud-bigquery
|
||||
# kubernetes
|
||||
# posthog
|
||||
python-dotenv==1.1.1
|
||||
@@ -254,6 +326,9 @@ regex==2025.11.3
|
||||
requests==2.33.0
|
||||
# via
|
||||
# cohere
|
||||
# google-api-core
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
# google-genai
|
||||
# kubernetes
|
||||
# posthog
|
||||
@@ -318,7 +393,9 @@ typing-extensions==4.15.0
|
||||
# anyio
|
||||
# cohere
|
||||
# fastapi
|
||||
# google-cloud-aiplatform
|
||||
# google-genai
|
||||
# grpcio
|
||||
# huggingface-hub
|
||||
# mcp
|
||||
# openai
|
||||
|
||||
@@ -102,6 +102,8 @@ discord-py==2.4.0
|
||||
# via onyx
|
||||
distro==1.9.0
|
||||
# via openai
|
||||
docstring-parser==0.17.0
|
||||
# via google-cloud-aiplatform
|
||||
durationpy==0.10
|
||||
# via kubernetes
|
||||
einops==0.8.1
|
||||
@@ -125,12 +127,63 @@ fsspec==2025.10.0
|
||||
# via
|
||||
# huggingface-hub
|
||||
# torch
|
||||
google-api-core==2.28.1
|
||||
# via
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-core
|
||||
# google-cloud-resource-manager
|
||||
# google-cloud-storage
|
||||
google-auth==2.48.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-core
|
||||
# google-cloud-resource-manager
|
||||
# google-cloud-storage
|
||||
# google-genai
|
||||
# kubernetes
|
||||
google-cloud-aiplatform==1.133.0
|
||||
# via litellm
|
||||
google-cloud-bigquery==3.41.0
|
||||
# via google-cloud-aiplatform
|
||||
google-cloud-core==2.5.1
|
||||
# via
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
google-cloud-resource-manager==1.17.0
|
||||
# via google-cloud-aiplatform
|
||||
google-cloud-storage==3.10.1
|
||||
# via google-cloud-aiplatform
|
||||
google-crc32c==1.8.0
|
||||
# via
|
||||
# google-cloud-storage
|
||||
# google-resumable-media
|
||||
google-genai==1.52.0
|
||||
# via onyx
|
||||
# via
|
||||
# google-cloud-aiplatform
|
||||
# onyx
|
||||
google-resumable-media==2.8.2
|
||||
# via
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
googleapis-common-protos==1.72.0
|
||||
# via
|
||||
# google-api-core
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
grpc-google-iam-v1==0.14.4
|
||||
# via google-cloud-resource-manager
|
||||
grpcio==1.80.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-resource-manager
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
grpcio-status==1.80.0
|
||||
# via google-api-core
|
||||
h11==0.16.0
|
||||
# via
|
||||
# httpcore
|
||||
@@ -265,6 +318,8 @@ openai==2.14.0
|
||||
packaging==24.2
|
||||
# via
|
||||
# accelerate
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# huggingface-hub
|
||||
# kombu
|
||||
# transformers
|
||||
@@ -282,6 +337,20 @@ propcache==0.4.1
|
||||
# via
|
||||
# aiohttp
|
||||
# yarl
|
||||
proto-plus==1.26.1
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-resource-manager
|
||||
protobuf==6.33.5
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-resource-manager
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
# proto-plus
|
||||
psutil==7.1.3
|
||||
# via accelerate
|
||||
py==1.11.0
|
||||
@@ -299,6 +368,7 @@ pydantic==2.11.7
|
||||
# agent-client-protocol
|
||||
# cohere
|
||||
# fastapi
|
||||
# google-cloud-aiplatform
|
||||
# google-genai
|
||||
# litellm
|
||||
# mcp
|
||||
@@ -318,6 +388,7 @@ python-dateutil==2.8.2
|
||||
# aiobotocore
|
||||
# botocore
|
||||
# celery
|
||||
# google-cloud-bigquery
|
||||
# kubernetes
|
||||
python-dotenv==1.1.1
|
||||
# via
|
||||
@@ -344,6 +415,9 @@ regex==2025.11.3
|
||||
requests==2.33.0
|
||||
# via
|
||||
# cohere
|
||||
# google-api-core
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
# google-genai
|
||||
# kubernetes
|
||||
# requests-oauthlib
|
||||
@@ -437,7 +511,9 @@ typing-extensions==4.15.0
|
||||
# anyio
|
||||
# cohere
|
||||
# fastapi
|
||||
# google-cloud-aiplatform
|
||||
# google-genai
|
||||
# grpcio
|
||||
# huggingface-hub
|
||||
# mcp
|
||||
# openai
|
||||
|
||||
@@ -7,7 +7,6 @@ import pytest
|
||||
|
||||
from onyx.connectors.gong.connector import GongConnector
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import HierarchyNode
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -32,18 +31,20 @@ def test_gong_basic(
|
||||
mock_get_api_key: MagicMock, # noqa: ARG001
|
||||
gong_connector: GongConnector,
|
||||
) -> None:
|
||||
doc_batch_generator = gong_connector.poll_source(0, time.time())
|
||||
|
||||
doc_batch = next(doc_batch_generator)
|
||||
with pytest.raises(StopIteration):
|
||||
next(doc_batch_generator)
|
||||
|
||||
assert len(doc_batch) == 2
|
||||
checkpoint = gong_connector.build_dummy_checkpoint()
|
||||
|
||||
docs: list[Document] = []
|
||||
for doc in doc_batch:
|
||||
if not isinstance(doc, HierarchyNode):
|
||||
docs.append(doc)
|
||||
while checkpoint.has_more:
|
||||
generator = gong_connector.load_from_checkpoint(0, time.time(), checkpoint)
|
||||
try:
|
||||
while True:
|
||||
item = next(generator)
|
||||
if isinstance(item, Document):
|
||||
docs.append(item)
|
||||
except StopIteration as e:
|
||||
checkpoint = e.value
|
||||
|
||||
assert len(docs) == 2
|
||||
|
||||
assert docs[0].semantic_identifier == "test with chris"
|
||||
assert docs[1].semantic_identifier == "Testing Gong"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc
|
||||
|
||||
|
||||
def test_time_str_to_utc() -> None:
|
||||
str_to_dt = {
|
||||
"Tue, 5 Oct 2021 09:38:25 GMT": datetime.datetime(
|
||||
2021, 10, 5, 9, 38, 25, tzinfo=datetime.timezone.utc
|
||||
),
|
||||
"Sat, 24 Jul 2021 09:21:20 +0000 (UTC)": datetime.datetime(
|
||||
2021, 7, 24, 9, 21, 20, tzinfo=datetime.timezone.utc
|
||||
),
|
||||
"Thu, 29 Jul 2021 04:20:37 -0400 (EDT)": datetime.datetime(
|
||||
2021, 7, 29, 8, 20, 37, tzinfo=datetime.timezone.utc
|
||||
),
|
||||
"30 Jun 2023 18:45:01 +0300": datetime.datetime(
|
||||
2023, 6, 30, 15, 45, 1, tzinfo=datetime.timezone.utc
|
||||
),
|
||||
"22 Mar 2020 20:12:18 +0000 (GMT)": datetime.datetime(
|
||||
2020, 3, 22, 20, 12, 18, tzinfo=datetime.timezone.utc
|
||||
),
|
||||
"Date: Wed, 27 Aug 2025 11:40:00 +0200": datetime.datetime(
|
||||
2025, 8, 27, 9, 40, 0, tzinfo=datetime.timezone.utc
|
||||
),
|
||||
}
|
||||
for strptime, expected_datetime in str_to_dt.items():
|
||||
assert time_str_to_utc(strptime) == expected_datetime
|
||||
|
||||
|
||||
def test_time_str_to_utc_recovers_from_concatenated_headers() -> None:
|
||||
# TZ is dropped during recovery, so the expected result is UTC rather
|
||||
# than the original offset.
|
||||
assert time_str_to_utc(
|
||||
'Sat, 3 Nov 2007 14:33:28 -0200To: "jason" <jason@example.net>'
|
||||
) == datetime.datetime(2007, 11, 3, 14, 33, 28, tzinfo=datetime.timezone.utc)
|
||||
|
||||
assert time_str_to_utc(
|
||||
"Fri, 20 Feb 2015 10:30:00 +0500Cc: someone@example.com"
|
||||
) == datetime.datetime(2015, 2, 20, 10, 30, 0, tzinfo=datetime.timezone.utc)
|
||||
|
||||
|
||||
def test_time_str_to_utc_raises_on_impossible_dates() -> None:
|
||||
for bad in (
|
||||
"Wed, 33 Sep 2007 13:42:59 +0100",
|
||||
"Thu, 11 Oct 2007 31:50:55 +0900",
|
||||
"not a date at all",
|
||||
"",
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
time_str_to_utc(bad)
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
@@ -8,7 +9,6 @@ from unittest.mock import patch
|
||||
|
||||
from onyx.access.models import ExternalAccess
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc
|
||||
from onyx.connectors.gmail.connector import _build_time_range_query
|
||||
from onyx.connectors.gmail.connector import GmailCheckpoint
|
||||
from onyx.connectors.gmail.connector import GmailConnector
|
||||
@@ -51,29 +51,43 @@ def test_build_time_range_query() -> None:
|
||||
assert query is None
|
||||
|
||||
|
||||
def test_time_str_to_utc() -> None:
|
||||
str_to_dt = {
|
||||
"Tue, 5 Oct 2021 09:38:25 GMT": datetime.datetime(
|
||||
2021, 10, 5, 9, 38, 25, tzinfo=datetime.timezone.utc
|
||||
),
|
||||
"Sat, 24 Jul 2021 09:21:20 +0000 (UTC)": datetime.datetime(
|
||||
2021, 7, 24, 9, 21, 20, tzinfo=datetime.timezone.utc
|
||||
),
|
||||
"Thu, 29 Jul 2021 04:20:37 -0400 (EDT)": datetime.datetime(
|
||||
2021, 7, 29, 8, 20, 37, tzinfo=datetime.timezone.utc
|
||||
),
|
||||
"30 Jun 2023 18:45:01 +0300": datetime.datetime(
|
||||
2023, 6, 30, 15, 45, 1, tzinfo=datetime.timezone.utc
|
||||
),
|
||||
"22 Mar 2020 20:12:18 +0000 (GMT)": datetime.datetime(
|
||||
2020, 3, 22, 20, 12, 18, tzinfo=datetime.timezone.utc
|
||||
),
|
||||
"Date: Wed, 27 Aug 2025 11:40:00 +0200": datetime.datetime(
|
||||
2025, 8, 27, 9, 40, 0, tzinfo=datetime.timezone.utc
|
||||
),
|
||||
}
|
||||
for strptime, expected_datetime in str_to_dt.items():
|
||||
assert time_str_to_utc(strptime) == expected_datetime
|
||||
def _thread_with_date(date_header: str | None) -> dict[str, Any]:
|
||||
"""Load the fixture thread and replace (or strip, if None) its Date header."""
|
||||
json_path = os.path.join(os.path.dirname(__file__), "thread.json")
|
||||
with open(json_path, "r") as f:
|
||||
thread = cast(dict[str, Any], json.load(f))
|
||||
thread = copy.deepcopy(thread)
|
||||
|
||||
for message in thread["messages"]:
|
||||
headers: list[dict[str, str]] = message["payload"]["headers"]
|
||||
if date_header is None:
|
||||
message["payload"]["headers"] = [
|
||||
h for h in headers if h.get("name") != "Date"
|
||||
]
|
||||
continue
|
||||
|
||||
replaced = False
|
||||
for header in headers:
|
||||
if header.get("name") == "Date":
|
||||
header["value"] = date_header
|
||||
replaced = True
|
||||
break
|
||||
if not replaced:
|
||||
headers.append({"name": "Date", "value": date_header})
|
||||
|
||||
return thread
|
||||
|
||||
|
||||
def test_thread_to_document_skips_unparseable_dates() -> None:
|
||||
for bad_date in (
|
||||
"Wed, 33 Sep 2007 13:42:59 +0100",
|
||||
"Thu, 11 Oct 2007 31:50:55 +0900",
|
||||
"total garbage not even close to a date",
|
||||
):
|
||||
doc = thread_to_document(_thread_with_date(bad_date), "admin@example.com")
|
||||
assert isinstance(doc, Document), f"failed for {bad_date!r}"
|
||||
assert doc.doc_updated_at is None
|
||||
assert doc.id == "192edefb315737c3"
|
||||
|
||||
|
||||
def test_gmail_checkpoint_progression() -> None:
|
||||
|
||||
0
backend/tests/unit/onyx/connectors/gong/__init__.py
Normal file
0
backend/tests/unit/onyx/connectors/gong/__init__.py
Normal file
483
backend/tests/unit/onyx/connectors/gong/test_gong_checkpoint.py
Normal file
483
backend/tests/unit/onyx/connectors/gong/test_gong_checkpoint.py
Normal file
@@ -0,0 +1,483 @@
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.connectors.gong.connector import GongConnector
|
||||
from onyx.connectors.gong.connector import GongConnectorCheckpoint
|
||||
from onyx.connectors.models import ConnectorFailure
|
||||
from onyx.connectors.models import Document
|
||||
|
||||
|
||||
def _make_transcript(call_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"callId": call_id,
|
||||
"transcript": [
|
||||
{
|
||||
"speakerId": "speaker1",
|
||||
"sentences": [{"text": "Hello world"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _make_call_detail(call_id: str, title: str) -> dict[str, Any]:
|
||||
return {
|
||||
"metaData": {
|
||||
"id": call_id,
|
||||
"started": "2026-01-15T10:00:00Z",
|
||||
"title": title,
|
||||
"purpose": "Test call",
|
||||
"url": f"https://app.gong.io/call?id={call_id}",
|
||||
"system": "test-system",
|
||||
},
|
||||
"parties": [
|
||||
{
|
||||
"speakerId": "speaker1",
|
||||
"name": "Alice",
|
||||
"emailAddress": "alice@test.com",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def connector() -> GongConnector:
|
||||
connector = GongConnector()
|
||||
connector.load_credentials(
|
||||
{
|
||||
"gong_access_key": "test-key",
|
||||
"gong_access_key_secret": "test-secret",
|
||||
}
|
||||
)
|
||||
return connector
|
||||
|
||||
|
||||
class TestGongConnectorCheckpoint:
|
||||
def test_build_dummy_checkpoint(self, connector: GongConnector) -> None:
|
||||
checkpoint = connector.build_dummy_checkpoint()
|
||||
assert checkpoint.has_more is True
|
||||
assert checkpoint.workspace_ids is None
|
||||
assert checkpoint.workspace_index == 0
|
||||
assert checkpoint.cursor is None
|
||||
|
||||
def test_validate_checkpoint_json(self, connector: GongConnector) -> None:
|
||||
original = GongConnectorCheckpoint(
|
||||
has_more=True,
|
||||
workspace_ids=["ws1", None],
|
||||
workspace_index=1,
|
||||
cursor="abc123",
|
||||
)
|
||||
json_str = original.model_dump_json()
|
||||
restored = connector.validate_checkpoint_json(json_str)
|
||||
assert restored == original
|
||||
|
||||
@patch.object(GongConnector, "_throttled_request")
|
||||
def test_first_call_resolves_workspaces(
|
||||
self,
|
||||
mock_request: MagicMock,
|
||||
connector: GongConnector,
|
||||
) -> None:
|
||||
"""First checkpoint call should resolve workspaces and return without fetching."""
|
||||
# No workspaces configured — should resolve to [None]
|
||||
checkpoint = connector.build_dummy_checkpoint()
|
||||
generator = connector.load_from_checkpoint(0, time.time(), checkpoint)
|
||||
|
||||
# Should return immediately (no yields)
|
||||
with pytest.raises(StopIteration) as exc_info:
|
||||
next(generator)
|
||||
|
||||
new_checkpoint = exc_info.value.value
|
||||
assert new_checkpoint.workspace_ids == [None]
|
||||
assert new_checkpoint.has_more is True
|
||||
assert new_checkpoint.workspace_index == 0
|
||||
|
||||
# No API calls should have been made for workspace resolution
|
||||
# when no workspaces are configured
|
||||
mock_request.assert_not_called()
|
||||
|
||||
@patch.object(GongConnector, "_throttled_request")
|
||||
def test_single_page_no_cursor(
|
||||
self,
|
||||
mock_request: MagicMock,
|
||||
connector: GongConnector,
|
||||
) -> None:
|
||||
"""Single page of transcripts with no pagination cursor."""
|
||||
transcript_response = MagicMock()
|
||||
transcript_response.status_code = 200
|
||||
transcript_response.json.return_value = {
|
||||
"callTranscripts": [_make_transcript("call1")],
|
||||
"records": {},
|
||||
}
|
||||
|
||||
details_response = MagicMock()
|
||||
details_response.status_code = 200
|
||||
details_response.json.return_value = {
|
||||
"calls": [_make_call_detail("call1", "Test Call")]
|
||||
}
|
||||
|
||||
mock_request.side_effect = [transcript_response, details_response]
|
||||
|
||||
# Start from a checkpoint that already has workspaces resolved
|
||||
checkpoint = GongConnectorCheckpoint(
|
||||
has_more=True,
|
||||
workspace_ids=[None],
|
||||
workspace_index=0,
|
||||
)
|
||||
|
||||
docs: list[Document] = []
|
||||
failures: list[ConnectorFailure] = []
|
||||
generator = connector.load_from_checkpoint(0, time.time(), checkpoint)
|
||||
try:
|
||||
while True:
|
||||
item = next(generator)
|
||||
if isinstance(item, Document):
|
||||
docs.append(item)
|
||||
elif isinstance(item, ConnectorFailure):
|
||||
failures.append(item)
|
||||
except StopIteration as e:
|
||||
checkpoint = e.value
|
||||
|
||||
assert len(docs) == 1
|
||||
assert docs[0].semantic_identifier == "Test Call"
|
||||
assert len(failures) == 0
|
||||
assert checkpoint.has_more is False
|
||||
assert checkpoint.workspace_index == 1
|
||||
|
||||
@patch.object(GongConnector, "_throttled_request")
|
||||
def test_multi_page_with_cursor(
|
||||
self,
|
||||
mock_request: MagicMock,
|
||||
connector: GongConnector,
|
||||
) -> None:
|
||||
"""Two pages of transcripts — cursor advances between checkpoint calls."""
|
||||
# Page 1: returns cursor
|
||||
page1_response = MagicMock()
|
||||
page1_response.status_code = 200
|
||||
page1_response.json.return_value = {
|
||||
"callTranscripts": [_make_transcript("call1")],
|
||||
"records": {"cursor": "page2cursor"},
|
||||
}
|
||||
|
||||
details1_response = MagicMock()
|
||||
details1_response.status_code = 200
|
||||
details1_response.json.return_value = {
|
||||
"calls": [_make_call_detail("call1", "Call One")]
|
||||
}
|
||||
|
||||
# Page 2: no cursor (done)
|
||||
page2_response = MagicMock()
|
||||
page2_response.status_code = 200
|
||||
page2_response.json.return_value = {
|
||||
"callTranscripts": [_make_transcript("call2")],
|
||||
"records": {},
|
||||
}
|
||||
|
||||
details2_response = MagicMock()
|
||||
details2_response.status_code = 200
|
||||
details2_response.json.return_value = {
|
||||
"calls": [_make_call_detail("call2", "Call Two")]
|
||||
}
|
||||
|
||||
mock_request.side_effect = [
|
||||
page1_response,
|
||||
details1_response,
|
||||
page2_response,
|
||||
details2_response,
|
||||
]
|
||||
|
||||
checkpoint = GongConnectorCheckpoint(
|
||||
has_more=True,
|
||||
workspace_ids=[None],
|
||||
workspace_index=0,
|
||||
)
|
||||
|
||||
all_docs: list[Document] = []
|
||||
|
||||
# First checkpoint call — page 1
|
||||
generator = connector.load_from_checkpoint(0, time.time(), checkpoint)
|
||||
try:
|
||||
while True:
|
||||
item = next(generator)
|
||||
if isinstance(item, Document):
|
||||
all_docs.append(item)
|
||||
except StopIteration as e:
|
||||
checkpoint = e.value
|
||||
|
||||
assert len(all_docs) == 1
|
||||
assert checkpoint.cursor == "page2cursor"
|
||||
assert checkpoint.has_more is True
|
||||
|
||||
# Second checkpoint call — page 2
|
||||
generator = connector.load_from_checkpoint(0, time.time(), checkpoint)
|
||||
try:
|
||||
while True:
|
||||
item = next(generator)
|
||||
if isinstance(item, Document):
|
||||
all_docs.append(item)
|
||||
except StopIteration as e:
|
||||
checkpoint = e.value
|
||||
|
||||
assert len(all_docs) == 2
|
||||
assert all_docs[0].semantic_identifier == "Call One"
|
||||
assert all_docs[1].semantic_identifier == "Call Two"
|
||||
assert checkpoint.has_more is False
|
||||
|
||||
@patch.object(GongConnector, "_throttled_request")
|
||||
def test_missing_call_details_yields_failure(
|
||||
self,
|
||||
mock_request: MagicMock,
|
||||
connector: GongConnector,
|
||||
) -> None:
|
||||
"""When call details are missing after retries, yield ConnectorFailure."""
|
||||
transcript_response = MagicMock()
|
||||
transcript_response.status_code = 200
|
||||
transcript_response.json.return_value = {
|
||||
"callTranscripts": [_make_transcript("call1")],
|
||||
"records": {},
|
||||
}
|
||||
|
||||
# Return empty call details every time (simulating the race condition)
|
||||
empty_details = MagicMock()
|
||||
empty_details.status_code = 200
|
||||
empty_details.json.return_value = {"calls": []}
|
||||
|
||||
mock_request.side_effect = [transcript_response] + [
|
||||
empty_details
|
||||
] * GongConnector.MAX_CALL_DETAILS_ATTEMPTS
|
||||
|
||||
checkpoint = GongConnectorCheckpoint(
|
||||
has_more=True,
|
||||
workspace_ids=[None],
|
||||
workspace_index=0,
|
||||
)
|
||||
|
||||
failures: list[ConnectorFailure] = []
|
||||
docs: list[Document] = []
|
||||
|
||||
with patch("onyx.connectors.gong.connector.time.sleep"):
|
||||
generator = connector.load_from_checkpoint(0, time.time(), checkpoint)
|
||||
try:
|
||||
while True:
|
||||
item = next(generator)
|
||||
if isinstance(item, ConnectorFailure):
|
||||
failures.append(item)
|
||||
elif isinstance(item, Document):
|
||||
docs.append(item)
|
||||
except StopIteration as e:
|
||||
checkpoint = e.value
|
||||
|
||||
assert len(docs) == 0
|
||||
assert len(failures) == 1
|
||||
assert failures[0].failed_document is not None
|
||||
assert failures[0].failed_document.document_id == "call1"
|
||||
assert checkpoint.has_more is False
|
||||
|
||||
@patch.object(GongConnector, "_throttled_request")
|
||||
def test_multi_workspace_iteration(
|
||||
self,
|
||||
mock_request: MagicMock,
|
||||
connector: GongConnector,
|
||||
) -> None:
|
||||
"""Checkpoint iterates through multiple workspaces."""
|
||||
# Workspace 1: one call
|
||||
ws1_transcript = MagicMock()
|
||||
ws1_transcript.status_code = 200
|
||||
ws1_transcript.json.return_value = {
|
||||
"callTranscripts": [_make_transcript("call_ws1")],
|
||||
"records": {},
|
||||
}
|
||||
ws1_details = MagicMock()
|
||||
ws1_details.status_code = 200
|
||||
ws1_details.json.return_value = {
|
||||
"calls": [_make_call_detail("call_ws1", "WS1 Call")]
|
||||
}
|
||||
|
||||
# Workspace 2: one call
|
||||
ws2_transcript = MagicMock()
|
||||
ws2_transcript.status_code = 200
|
||||
ws2_transcript.json.return_value = {
|
||||
"callTranscripts": [_make_transcript("call_ws2")],
|
||||
"records": {},
|
||||
}
|
||||
ws2_details = MagicMock()
|
||||
ws2_details.status_code = 200
|
||||
ws2_details.json.return_value = {
|
||||
"calls": [_make_call_detail("call_ws2", "WS2 Call")]
|
||||
}
|
||||
|
||||
mock_request.side_effect = [
|
||||
ws1_transcript,
|
||||
ws1_details,
|
||||
ws2_transcript,
|
||||
ws2_details,
|
||||
]
|
||||
|
||||
checkpoint = GongConnectorCheckpoint(
|
||||
has_more=True,
|
||||
workspace_ids=["ws1_id", "ws2_id"],
|
||||
workspace_index=0,
|
||||
)
|
||||
|
||||
all_docs: list[Document] = []
|
||||
|
||||
# Checkpoint call 1 — workspace 1
|
||||
generator = connector.load_from_checkpoint(0, time.time(), checkpoint)
|
||||
try:
|
||||
while True:
|
||||
item = next(generator)
|
||||
if isinstance(item, Document):
|
||||
all_docs.append(item)
|
||||
except StopIteration as e:
|
||||
checkpoint = e.value
|
||||
|
||||
assert checkpoint.workspace_index == 1
|
||||
assert checkpoint.has_more is True
|
||||
|
||||
# Checkpoint call 2 — workspace 2
|
||||
generator = connector.load_from_checkpoint(0, time.time(), checkpoint)
|
||||
try:
|
||||
while True:
|
||||
item = next(generator)
|
||||
if isinstance(item, Document):
|
||||
all_docs.append(item)
|
||||
except StopIteration as e:
|
||||
checkpoint = e.value
|
||||
|
||||
assert len(all_docs) == 2
|
||||
assert all_docs[0].semantic_identifier == "WS1 Call"
|
||||
assert all_docs[1].semantic_identifier == "WS2 Call"
|
||||
assert checkpoint.has_more is False
|
||||
assert checkpoint.workspace_index == 2
|
||||
|
||||
@patch.object(GongConnector, "_throttled_request")
|
||||
def test_empty_workspace_404(
|
||||
self,
|
||||
mock_request: MagicMock,
|
||||
connector: GongConnector,
|
||||
) -> None:
|
||||
"""404 from transcript API means no calls — workspace exhausted."""
|
||||
response_404 = MagicMock()
|
||||
response_404.status_code = 404
|
||||
|
||||
mock_request.return_value = response_404
|
||||
|
||||
checkpoint = GongConnectorCheckpoint(
|
||||
has_more=True,
|
||||
workspace_ids=[None],
|
||||
workspace_index=0,
|
||||
)
|
||||
|
||||
generator = connector.load_from_checkpoint(0, time.time(), checkpoint)
|
||||
try:
|
||||
while True:
|
||||
next(generator)
|
||||
except StopIteration as e:
|
||||
checkpoint = e.value
|
||||
|
||||
assert checkpoint.has_more is False
|
||||
assert checkpoint.workspace_index == 1
|
||||
|
||||
@patch.object(GongConnector, "_throttled_request")
|
||||
def test_retry_only_fetches_missing_ids(
|
||||
self,
|
||||
mock_request: MagicMock,
|
||||
connector: GongConnector,
|
||||
) -> None:
|
||||
"""Retry for missing call details should only re-request the missing IDs."""
|
||||
transcript_response = MagicMock()
|
||||
transcript_response.status_code = 200
|
||||
transcript_response.json.return_value = {
|
||||
"callTranscripts": [
|
||||
_make_transcript("call1"),
|
||||
_make_transcript("call2"),
|
||||
],
|
||||
"records": {},
|
||||
}
|
||||
|
||||
# First fetch: returns call1 but not call2
|
||||
partial_details = MagicMock()
|
||||
partial_details.status_code = 200
|
||||
partial_details.json.return_value = {
|
||||
"calls": [_make_call_detail("call1", "Call One")]
|
||||
}
|
||||
|
||||
# Second fetch (retry): returns call2
|
||||
missing_details = MagicMock()
|
||||
missing_details.status_code = 200
|
||||
missing_details.json.return_value = {
|
||||
"calls": [_make_call_detail("call2", "Call Two")]
|
||||
}
|
||||
|
||||
mock_request.side_effect = [
|
||||
transcript_response,
|
||||
partial_details,
|
||||
missing_details,
|
||||
]
|
||||
|
||||
checkpoint = GongConnectorCheckpoint(
|
||||
has_more=True,
|
||||
workspace_ids=[None],
|
||||
workspace_index=0,
|
||||
)
|
||||
|
||||
docs: list[Document] = []
|
||||
with patch("onyx.connectors.gong.connector.time.sleep"):
|
||||
generator = connector.load_from_checkpoint(0, time.time(), checkpoint)
|
||||
try:
|
||||
while True:
|
||||
item = next(generator)
|
||||
if isinstance(item, Document):
|
||||
docs.append(item)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
assert len(docs) == 2
|
||||
assert docs[0].semantic_identifier == "Call One"
|
||||
assert docs[1].semantic_identifier == "Call Two"
|
||||
|
||||
# Verify: 3 API calls total (1 transcript + 1 full details + 1 retry for missing only)
|
||||
assert mock_request.call_count == 3
|
||||
# The retry call should only request call2, not both
|
||||
retry_call_body = mock_request.call_args_list[2][1]["json"]
|
||||
assert retry_call_body["filter"]["callIds"] == ["call2"]
|
||||
|
||||
@patch.object(GongConnector, "_throttled_request")
|
||||
def test_expired_cursor_restarts_workspace(
|
||||
self,
|
||||
mock_request: MagicMock,
|
||||
connector: GongConnector,
|
||||
) -> None:
|
||||
"""Expired pagination cursor resets checkpoint to restart the workspace."""
|
||||
expired_response = MagicMock()
|
||||
expired_response.status_code = 400
|
||||
expired_response.ok = False
|
||||
expired_response.text = '{"requestId":"abc","errors":["cursor has expired"]}'
|
||||
|
||||
mock_request.return_value = expired_response
|
||||
|
||||
# Checkpoint mid-pagination with a (now-expired) cursor
|
||||
checkpoint = GongConnectorCheckpoint(
|
||||
has_more=True,
|
||||
workspace_ids=[None],
|
||||
workspace_index=0,
|
||||
cursor="stale-cursor",
|
||||
)
|
||||
|
||||
docs: list[Document] = []
|
||||
generator = connector.load_from_checkpoint(0, time.time(), checkpoint)
|
||||
try:
|
||||
while True:
|
||||
item = next(generator)
|
||||
if isinstance(item, Document):
|
||||
docs.append(item)
|
||||
except StopIteration as e:
|
||||
checkpoint = e.value
|
||||
|
||||
assert len(docs) == 0
|
||||
# Cursor reset so next call restarts the workspace from scratch
|
||||
assert checkpoint.cursor is None
|
||||
assert checkpoint.workspace_index == 0
|
||||
assert checkpoint.has_more is True
|
||||
@@ -12,12 +12,14 @@ from unittest.mock import patch
|
||||
|
||||
from onyx.background.celery.celery_utils import extract_ids_from_runnable_connector
|
||||
from onyx.connectors.google_drive.connector import GoogleDriveConnector
|
||||
from onyx.connectors.google_drive.file_retrieval import DriveFileFieldType
|
||||
from onyx.connectors.google_drive.models import DriveRetrievalStage
|
||||
from onyx.connectors.google_drive.models import GoogleDriveCheckpoint
|
||||
from onyx.connectors.interfaces import SlimConnector
|
||||
from onyx.connectors.interfaces import SlimConnectorWithPermSync
|
||||
from onyx.connectors.models import SlimDocument
|
||||
from onyx.utils.threadpool_concurrency import ThreadSafeDict
|
||||
from onyx.utils.threadpool_concurrency import ThreadSafeSet
|
||||
|
||||
|
||||
def _make_done_checkpoint() -> GoogleDriveCheckpoint:
|
||||
@@ -198,3 +200,90 @@ class TestCeleryUtilsRouting:
|
||||
|
||||
mock_slim.assert_called_once()
|
||||
mock_perm_sync.assert_not_called()
|
||||
|
||||
|
||||
class TestFailedFolderIdsByEmail:
|
||||
def _make_failed_map(
|
||||
self, entries: dict[str, set[str]]
|
||||
) -> ThreadSafeDict[str, ThreadSafeSet[str]]:
|
||||
return ThreadSafeDict({k: ThreadSafeSet(v) for k, v in entries.items()})
|
||||
|
||||
def test_skips_api_call_for_known_failed_pair(self) -> None:
|
||||
"""_get_folder_metadata must skip the API call for a (folder, email) pair
|
||||
that previously confirmed no accessible parent."""
|
||||
connector = _make_connector()
|
||||
failed_map = self._make_failed_map(
|
||||
{
|
||||
"retriever@example.com": {"folder1"},
|
||||
"admin@example.com": {"folder1"},
|
||||
}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"onyx.connectors.google_drive.connector.get_folder_metadata"
|
||||
) as mock_api:
|
||||
result = connector._get_folder_metadata(
|
||||
folder_id="folder1",
|
||||
retriever_email="retriever@example.com",
|
||||
field_type=DriveFileFieldType.SLIM,
|
||||
failed_folder_ids_by_email=failed_map,
|
||||
)
|
||||
|
||||
mock_api.assert_not_called()
|
||||
assert result is None
|
||||
|
||||
def test_records_failed_pair_when_no_parents(self) -> None:
|
||||
"""_get_folder_metadata must record (email → folder_id) in the map
|
||||
when the API returns a folder with no parents."""
|
||||
connector = _make_connector()
|
||||
failed_map: ThreadSafeDict[str, ThreadSafeSet[str]] = ThreadSafeDict()
|
||||
folder_no_parents: dict = {"id": "folder1", "name": "Orphaned"}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"onyx.connectors.google_drive.connector.get_drive_service",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"onyx.connectors.google_drive.connector.get_folder_metadata",
|
||||
return_value=folder_no_parents,
|
||||
),
|
||||
):
|
||||
connector._get_folder_metadata(
|
||||
folder_id="folder1",
|
||||
retriever_email="retriever@example.com",
|
||||
field_type=DriveFileFieldType.SLIM,
|
||||
failed_folder_ids_by_email=failed_map,
|
||||
)
|
||||
|
||||
assert "folder1" in failed_map.get("retriever@example.com", ThreadSafeSet())
|
||||
assert "folder1" in failed_map.get("admin@example.com", ThreadSafeSet())
|
||||
|
||||
def test_does_not_record_when_parents_found(self) -> None:
|
||||
"""_get_folder_metadata must NOT record a pair when parents are found."""
|
||||
connector = _make_connector()
|
||||
failed_map: ThreadSafeDict[str, ThreadSafeSet[str]] = ThreadSafeDict()
|
||||
folder_with_parents: dict = {
|
||||
"id": "folder1",
|
||||
"name": "Normal",
|
||||
"parents": ["root"],
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"onyx.connectors.google_drive.connector.get_drive_service",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"onyx.connectors.google_drive.connector.get_folder_metadata",
|
||||
return_value=folder_with_parents,
|
||||
),
|
||||
):
|
||||
connector._get_folder_metadata(
|
||||
folder_id="folder1",
|
||||
retriever_email="retriever@example.com",
|
||||
field_type=DriveFileFieldType.SLIM,
|
||||
failed_folder_ids_by_email=failed_map,
|
||||
)
|
||||
|
||||
assert len(failed_map) == 0
|
||||
|
||||
@@ -12,6 +12,10 @@ dependency on pypdf internals (pypdf.generic).
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.file_processing import extract_file_text
|
||||
from onyx.file_processing.extract_file_text import count_pdf_embedded_images
|
||||
from onyx.file_processing.extract_file_text import pdf_to_text
|
||||
from onyx.file_processing.extract_file_text import read_pdf_file
|
||||
from onyx.file_processing.password_validation import is_pdf_protected
|
||||
@@ -96,6 +100,80 @@ class TestReadPdfFile:
|
||||
# Returned list is empty when callback is used
|
||||
assert images == []
|
||||
|
||||
def test_image_cap_skips_images_above_limit(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""When the embedded-image cap is exceeded, remaining images are skipped.
|
||||
|
||||
The cap protects the user-file-processing worker from OOMing on PDFs
|
||||
with thousands of embedded images. Setting the cap to 0 should yield
|
||||
zero extracted images even though the fixture has one.
|
||||
"""
|
||||
monkeypatch.setattr(extract_file_text, "MAX_EMBEDDED_IMAGES_PER_FILE", 0)
|
||||
_, _, images = read_pdf_file(_load("with_image.pdf"), extract_images=True)
|
||||
assert images == []
|
||||
|
||||
def test_image_cap_at_limit_extracts_up_to_cap(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A cap >= image count behaves identically to the uncapped path."""
|
||||
monkeypatch.setattr(extract_file_text, "MAX_EMBEDDED_IMAGES_PER_FILE", 100)
|
||||
_, _, images = read_pdf_file(_load("with_image.pdf"), extract_images=True)
|
||||
assert len(images) == 1
|
||||
|
||||
def test_image_cap_with_callback_stops_streaming_at_limit(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The cap also short-circuits the streaming callback path."""
|
||||
monkeypatch.setattr(extract_file_text, "MAX_EMBEDDED_IMAGES_PER_FILE", 0)
|
||||
collected: list[tuple[bytes, str]] = []
|
||||
|
||||
def callback(data: bytes, name: str) -> None:
|
||||
collected.append((data, name))
|
||||
|
||||
read_pdf_file(
|
||||
_load("with_image.pdf"), extract_images=True, image_callback=callback
|
||||
)
|
||||
assert collected == []
|
||||
|
||||
|
||||
# ── count_pdf_embedded_images ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCountPdfEmbeddedImages:
|
||||
def test_returns_count_for_normal_pdf(self) -> None:
|
||||
assert count_pdf_embedded_images(_load("with_image.pdf"), cap=10) == 1
|
||||
|
||||
def test_short_circuits_above_cap(self) -> None:
|
||||
# with_image.pdf has 1 image. cap=0 means "anything > 0 is over cap" —
|
||||
# function returns on first increment as the over-cap sentinel.
|
||||
assert count_pdf_embedded_images(_load("with_image.pdf"), cap=0) == 1
|
||||
|
||||
def test_returns_zero_for_pdf_without_images(self) -> None:
|
||||
assert count_pdf_embedded_images(_load("simple.pdf"), cap=10) == 0
|
||||
|
||||
def test_returns_zero_for_invalid_pdf(self) -> None:
|
||||
assert count_pdf_embedded_images(BytesIO(b"not a pdf"), cap=10) == 0
|
||||
|
||||
def test_returns_zero_for_password_locked_pdf(self) -> None:
|
||||
# encrypted.pdf has an open password; we can't inspect without it, so
|
||||
# the helper returns 0 — callers rely on the password-protected check
|
||||
# that runs earlier in the upload pipeline.
|
||||
assert count_pdf_embedded_images(_load("encrypted.pdf"), cap=10) == 0
|
||||
|
||||
def test_inspects_owner_password_only_pdf(self) -> None:
|
||||
# owner_protected.pdf is encrypted but has no open password. It should
|
||||
# decrypt with an empty string and count images normally. The fixture
|
||||
# has zero images, so 0 is a real count (not the "bail on encrypted"
|
||||
# path).
|
||||
assert count_pdf_embedded_images(_load("owner_protected.pdf"), cap=10) == 0
|
||||
|
||||
def test_preserves_file_position(self) -> None:
|
||||
pdf = _load("with_image.pdf")
|
||||
pdf.seek(42)
|
||||
count_pdf_embedded_images(pdf, cap=10)
|
||||
assert pdf.tell() == 42
|
||||
|
||||
|
||||
# ── pdf_to_text ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -15,10 +15,19 @@ from onyx.connectors.models import Section
|
||||
from onyx.connectors.models import TabularSection
|
||||
from onyx.indexing.chunking.section_chunker import AccumulatorState
|
||||
from onyx.indexing.chunking.tabular_section_chunker import TabularChunker
|
||||
from onyx.indexing.chunking.tabular_section_chunker.analysis import analyze_sheet
|
||||
from onyx.indexing.chunking.tabular_section_chunker.sheet_descriptor import (
|
||||
build_sheet_descriptor_chunks,
|
||||
)
|
||||
from onyx.indexing.chunking.tabular_section_chunker.total_descriptor import (
|
||||
build_total_descriptor_chunks,
|
||||
)
|
||||
from onyx.indexing.chunking.tabular_section_chunker.total_descriptor import (
|
||||
TOTALS_HEADER,
|
||||
)
|
||||
from onyx.natural_language_processing.utils import BaseTokenizer
|
||||
from onyx.utils.csv_utils import parse_csv_string
|
||||
from onyx.utils.csv_utils import read_csv_header
|
||||
|
||||
|
||||
class CharTokenizer(BaseTokenizer):
|
||||
@@ -587,7 +596,7 @@ class TestTabularChunkerChunkSection:
|
||||
content_chunk = (
|
||||
"sheet:T\n" "Columns: Name, Age\n" "Name=Alice, Age=30\n" "Name=Bob, Age=25"
|
||||
)
|
||||
metadata_chunk = (
|
||||
descriptor_chunk = (
|
||||
"sheet:T\n"
|
||||
"Sheet overview.\n"
|
||||
"This sheet has 2 rows and 2 columns.\n"
|
||||
@@ -596,7 +605,17 @@ class TestTabularChunkerChunkSection:
|
||||
"Categorical columns (groupable, can be counted by value): Name\n"
|
||||
"Values seen in Name: Alice, Bob"
|
||||
)
|
||||
expected_texts = [content_chunk, metadata_chunk]
|
||||
totals_chunk = (
|
||||
"sheet:T\n"
|
||||
"Totals and overall aggregates across all rows. This sheet can answer "
|
||||
"whole-dataset questions about total, overall, grand total, sum across "
|
||||
"all, average, combined, mean, minimum, maximum, and count of values.\n"
|
||||
"Column Age: total (sum across all rows) = 55, average = 27.5, "
|
||||
"minimum = 25, maximum = 30, count = 2.\n"
|
||||
"Column Name most frequent value: Alice (1 occurrences).\n"
|
||||
"Total row count: 2."
|
||||
)
|
||||
expected_texts = [content_chunk, descriptor_chunk, totals_chunk]
|
||||
|
||||
# --- ACT -------------------------------------------------------
|
||||
out = _make_chunker_with_metadata().chunk_section(
|
||||
@@ -607,8 +626,8 @@ class TestTabularChunkerChunkSection:
|
||||
|
||||
# --- ASSERT ----------------------------------------------------
|
||||
assert [p.text for p in out.payloads] == expected_texts
|
||||
# Content first, metadata second — only the first chunk is fresh.
|
||||
assert [p.is_continuation for p in out.payloads] == [False, True]
|
||||
# Content first, metadata chunks follow as continuations.
|
||||
assert [p.is_continuation for p in out.payloads] == [False, True, True]
|
||||
|
||||
|
||||
class TestBuildSheetDescriptorChunks:
|
||||
@@ -627,9 +646,14 @@ class TestBuildSheetDescriptorChunks:
|
||||
heading: str | None = "sheet:T",
|
||||
max_tokens: int = 500,
|
||||
) -> list[str]:
|
||||
section = TabularSection(text=csv_text, link=_DEFAULT_LINK, heading=heading)
|
||||
parsed_rows = list(parse_csv_string(csv_text))
|
||||
headers = parsed_rows[0].header if parsed_rows else read_csv_header(csv_text)
|
||||
if not headers:
|
||||
return []
|
||||
return build_sheet_descriptor_chunks(
|
||||
section=section,
|
||||
headers=headers,
|
||||
analysis=analyze_sheet(headers, parsed_rows),
|
||||
heading=heading or "",
|
||||
tokenizer=CharTokenizer(),
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
@@ -837,3 +861,174 @@ class TestBuildSheetDescriptorChunks:
|
||||
|
||||
# --- ACT / ASSERT ---------------------------------------------
|
||||
assert self._build(csv_text, heading="", max_tokens=30) == expected
|
||||
|
||||
|
||||
class TestBuildTotalDescriptorChunks:
|
||||
"""Direct tests of `build_total_descriptor_chunks` — emits the totals
|
||||
chunk that names aggregate vocabulary (total/sum/average/min/max/
|
||||
count/most frequent) plus per-column aggregates so whole-dataset
|
||||
questions retrieve a chunk whose text actually contains the answer.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _build(
|
||||
csv_text: str,
|
||||
heading: str | None = "sheet:T",
|
||||
max_tokens: int = 1000,
|
||||
) -> list[str]:
|
||||
parsed_rows = list(parse_csv_string(csv_text))
|
||||
headers = parsed_rows[0].header if parsed_rows else read_csv_header(csv_text)
|
||||
if not headers:
|
||||
return []
|
||||
return build_total_descriptor_chunks(
|
||||
headers=headers,
|
||||
analysis=analyze_sheet(headers, parsed_rows),
|
||||
heading=heading or "",
|
||||
tokenizer=CharTokenizer(),
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
def test_numeric_and_categorical_columns_emit_every_line(self) -> None:
|
||||
# --- INPUT -----------------------------------------------------
|
||||
# amount → numeric (total=600, avg=200, min=100, max=300, count=3)
|
||||
# region → categorical (US appears twice, EU once → top=US (2))
|
||||
csv_text = "amount,region\n100,US\n200,EU\n300,US\n"
|
||||
|
||||
# --- EXPECTED --------------------------------------------------
|
||||
expected = [
|
||||
"sheet:T\n"
|
||||
f"{TOTALS_HEADER}\n"
|
||||
"Column amount: total (sum across all rows) = 600, average = 200, "
|
||||
"minimum = 100, maximum = 300, count = 3.\n"
|
||||
"Column region most frequent value: US (2 occurrences).\n"
|
||||
"Total row count: 3."
|
||||
]
|
||||
|
||||
# --- ACT / ASSERT ---------------------------------------------
|
||||
assert self._build(csv_text) == expected
|
||||
|
||||
def test_numeric_only_sheet_has_no_categorical_line(self) -> None:
|
||||
# --- INPUT -----------------------------------------------------
|
||||
# Both columns are all-numeric → no "most frequent value" lines.
|
||||
csv_text = "x,y\n1,2\n3,4\n"
|
||||
|
||||
# --- EXPECTED --------------------------------------------------
|
||||
expected = [
|
||||
"sheet:T\n"
|
||||
f"{TOTALS_HEADER}\n"
|
||||
"Column x: total (sum across all rows) = 4, average = 2, "
|
||||
"minimum = 1, maximum = 3, count = 2.\n"
|
||||
"Column y: total (sum across all rows) = 6, average = 3, "
|
||||
"minimum = 2, maximum = 4, count = 2.\n"
|
||||
"Total row count: 2."
|
||||
]
|
||||
|
||||
# --- ACT / ASSERT ---------------------------------------------
|
||||
assert self._build(csv_text) == expected
|
||||
|
||||
def test_categorical_only_sheet_has_no_numeric_line(self) -> None:
|
||||
# --- INPUT -----------------------------------------------------
|
||||
# Non-numeric low-cardinality column → categorical only. "red"
|
||||
# wins over "blue" 2-to-1.
|
||||
csv_text = "color\nred\nblue\nred\n"
|
||||
|
||||
# --- EXPECTED --------------------------------------------------
|
||||
expected = [
|
||||
"sheet:T\n"
|
||||
f"{TOTALS_HEADER}\n"
|
||||
"Column color most frequent value: red (2 occurrences).\n"
|
||||
"Total row count: 3."
|
||||
]
|
||||
|
||||
# --- ACT / ASSERT ---------------------------------------------
|
||||
assert self._build(csv_text) == expected
|
||||
|
||||
def test_underscored_column_names_get_friendly_alias(self) -> None:
|
||||
# --- INPUT -----------------------------------------------------
|
||||
# Underscored headers get the same `name (name with spaces)` alias
|
||||
# used elsewhere so retrieval matches either surface form.
|
||||
csv_text = "total_cost\n100\n200\n"
|
||||
|
||||
# --- EXPECTED --------------------------------------------------
|
||||
expected = [
|
||||
"sheet:T\n"
|
||||
f"{TOTALS_HEADER}\n"
|
||||
"Column total_cost (total cost): total (sum across all rows) = 300, "
|
||||
"average = 150, minimum = 100, maximum = 200, count = 2.\n"
|
||||
"Total row count: 2."
|
||||
]
|
||||
|
||||
# --- ACT / ASSERT ---------------------------------------------
|
||||
assert self._build(csv_text) == expected
|
||||
|
||||
def test_non_integer_averages_format_with_decimals(self) -> None:
|
||||
# --- INPUT -----------------------------------------------------
|
||||
# Whole-number inputs but a fractional average. `_fmt` drops the
|
||||
# ".0" when the value is integral and falls back to `:.6g` when
|
||||
# it isn't — verify both surfaces on the same line.
|
||||
csv_text = "rate\n1\n2\n"
|
||||
|
||||
# --- EXPECTED --------------------------------------------------
|
||||
# total=3 (int), avg=1.5 (fractional), min=1, max=2, count=2.
|
||||
expected = [
|
||||
"sheet:T\n"
|
||||
f"{TOTALS_HEADER}\n"
|
||||
"Column rate: total (sum across all rows) = 3, average = 1.5, "
|
||||
"minimum = 1, maximum = 2, count = 2.\n"
|
||||
"Total row count: 2."
|
||||
]
|
||||
|
||||
# --- ACT / ASSERT ---------------------------------------------
|
||||
assert self._build(csv_text) == expected
|
||||
|
||||
def test_empty_section_returns_no_chunks(self) -> None:
|
||||
# No parsed rows → no totals to report; builder bails out early.
|
||||
assert self._build("") == []
|
||||
|
||||
def test_header_only_csv_returns_no_chunks(self) -> None:
|
||||
# Header-only CSV yields zero data rows → `parse_csv_string`
|
||||
# returns nothing, so the builder returns an empty list.
|
||||
assert self._build("col1,col2\n") == []
|
||||
|
||||
def test_no_heading_omits_prefix_line(self) -> None:
|
||||
# --- INPUT -----------------------------------------------------
|
||||
# heading=None → prefix is just TOTALS_HEADER, no leading heading
|
||||
# line in the emitted chunk.
|
||||
csv_text = "n\n5\n"
|
||||
|
||||
# --- EXPECTED --------------------------------------------------
|
||||
expected = [
|
||||
f"{TOTALS_HEADER}\n"
|
||||
"Column n: total (sum across all rows) = 5, average = 5, "
|
||||
"minimum = 5, maximum = 5, count = 1.\n"
|
||||
"Total row count: 1."
|
||||
]
|
||||
|
||||
# --- ACT / ASSERT ---------------------------------------------
|
||||
assert self._build(csv_text, heading=None) == expected
|
||||
|
||||
def test_tight_budget_splits_into_multiple_chunks_each_with_header(self) -> None:
|
||||
# --- INPUT -----------------------------------------------------
|
||||
# Three numeric columns under a tight budget force pack_lines to
|
||||
# split across multiple chunks. Every emitted chunk must still
|
||||
# start with `heading + TOTALS_HEADER` so retrieval keeps context
|
||||
# on whichever chunk wins.
|
||||
csv_text = "a,b,c\n1,2,3\n4,5,6\n"
|
||||
|
||||
# --- ACT -------------------------------------------------------
|
||||
# Budget chosen so the three aggregate lines can't all fit under
|
||||
# TOTALS_HEADER in a single chunk.
|
||||
out = self._build(csv_text, heading="S", max_tokens=len(TOTALS_HEADER) + 120)
|
||||
|
||||
# --- ASSERT ----------------------------------------------------
|
||||
# Split actually happened.
|
||||
assert len(out) > 1
|
||||
# Each chunk carries the full prefix (heading + totals header).
|
||||
assert all(c.startswith(f"S\n{TOTALS_HEADER}\n") for c in out)
|
||||
# Collectively, every per-column aggregate and the row count line
|
||||
# must appear somewhere in the output.
|
||||
body = "\n".join(out)
|
||||
assert "Column a: total (sum across all rows) = 5" in body
|
||||
assert "Column b: total (sum across all rows) = 7" in body
|
||||
assert "Column c: total (sum across all rows) = 9" in body
|
||||
assert "Total row count: 2." in body
|
||||
|
||||
@@ -29,6 +29,7 @@ from onyx.llm.utils import get_max_input_tokens
|
||||
VERTEX_OPUS_MODELS_REJECTING_OUTPUT_CONFIG = [
|
||||
"claude-opus-4-5@20251101",
|
||||
"claude-opus-4-6",
|
||||
"claude-opus-4-7",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests for ``ImageGenerationTool._resolve_reference_image_file_ids``.
|
||||
|
||||
The resolver turns the LLM's ``reference_image_file_ids`` argument into a
|
||||
cleaned list of file IDs to hand to ``_load_reference_images``. It trusts
|
||||
the LLM's picks — the LLM can only see file IDs that actually appear in
|
||||
the conversation (via ``[attached image — file_id: <id>]`` tags on user
|
||||
messages and the JSON returned by prior generate_image calls), so we
|
||||
don't re-validate against an allow-list in the tool itself.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.tools.models import ToolCallException
|
||||
from onyx.tools.tool_implementations.images.image_generation_tool import (
|
||||
ImageGenerationTool,
|
||||
)
|
||||
from onyx.tools.tool_implementations.images.image_generation_tool import (
|
||||
REFERENCE_IMAGE_FILE_IDS_FIELD,
|
||||
)
|
||||
|
||||
|
||||
def _make_tool(
|
||||
supports_reference_images: bool = True,
|
||||
max_reference_images: int = 16,
|
||||
) -> ImageGenerationTool:
|
||||
"""Construct a tool with a mock provider so no credentials/network are needed."""
|
||||
with patch(
|
||||
"onyx.tools.tool_implementations.images.image_generation_tool.get_image_generation_provider"
|
||||
) as mock_get_provider:
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.supports_reference_images = supports_reference_images
|
||||
mock_provider.max_reference_images = max_reference_images
|
||||
mock_get_provider.return_value = mock_provider
|
||||
|
||||
return ImageGenerationTool(
|
||||
image_generation_credentials=MagicMock(),
|
||||
tool_id=1,
|
||||
emitter=MagicMock(),
|
||||
model="gpt-image-1",
|
||||
provider="openai",
|
||||
)
|
||||
|
||||
|
||||
class TestResolveReferenceImageFileIds:
|
||||
def test_unset_returns_empty_plain_generation(self) -> None:
|
||||
tool = _make_tool()
|
||||
assert tool._resolve_reference_image_file_ids(llm_kwargs={}) == []
|
||||
|
||||
def test_empty_list_is_treated_like_unset(self) -> None:
|
||||
tool = _make_tool()
|
||||
result = tool._resolve_reference_image_file_ids(
|
||||
llm_kwargs={REFERENCE_IMAGE_FILE_IDS_FIELD: []},
|
||||
)
|
||||
assert result == []
|
||||
|
||||
def test_passes_llm_supplied_ids_through(self) -> None:
|
||||
tool = _make_tool()
|
||||
result = tool._resolve_reference_image_file_ids(
|
||||
llm_kwargs={REFERENCE_IMAGE_FILE_IDS_FIELD: ["upload-1", "gen-1"]},
|
||||
)
|
||||
# Order preserved — first entry is the primary edit source.
|
||||
assert result == ["upload-1", "gen-1"]
|
||||
|
||||
def test_invalid_shape_raises(self) -> None:
|
||||
tool = _make_tool()
|
||||
with pytest.raises(ToolCallException):
|
||||
tool._resolve_reference_image_file_ids(
|
||||
llm_kwargs={REFERENCE_IMAGE_FILE_IDS_FIELD: "not-a-list"},
|
||||
)
|
||||
|
||||
def test_non_string_element_raises(self) -> None:
|
||||
tool = _make_tool()
|
||||
with pytest.raises(ToolCallException):
|
||||
tool._resolve_reference_image_file_ids(
|
||||
llm_kwargs={REFERENCE_IMAGE_FILE_IDS_FIELD: ["ok", 123]},
|
||||
)
|
||||
|
||||
def test_deduplicates_preserving_first_occurrence(self) -> None:
|
||||
tool = _make_tool()
|
||||
result = tool._resolve_reference_image_file_ids(
|
||||
llm_kwargs={REFERENCE_IMAGE_FILE_IDS_FIELD: ["gen-1", "gen-2", "gen-1"]},
|
||||
)
|
||||
assert result == ["gen-1", "gen-2"]
|
||||
|
||||
def test_strips_whitespace_and_skips_empty_strings(self) -> None:
|
||||
tool = _make_tool()
|
||||
result = tool._resolve_reference_image_file_ids(
|
||||
llm_kwargs={REFERENCE_IMAGE_FILE_IDS_FIELD: [" gen-1 ", "", " "]},
|
||||
)
|
||||
assert result == ["gen-1"]
|
||||
|
||||
def test_provider_without_reference_support_raises(self) -> None:
|
||||
tool = _make_tool(supports_reference_images=False)
|
||||
with pytest.raises(ToolCallException):
|
||||
tool._resolve_reference_image_file_ids(
|
||||
llm_kwargs={REFERENCE_IMAGE_FILE_IDS_FIELD: ["gen-1"]},
|
||||
)
|
||||
|
||||
def test_truncates_to_provider_max_preserving_head(self) -> None:
|
||||
"""When the LLM lists more images than the provider allows, keep the
|
||||
HEAD of the list (the primary edit source + earliest extras) rather
|
||||
than the tail, since the LLM put the most important one first."""
|
||||
tool = _make_tool(max_reference_images=2)
|
||||
result = tool._resolve_reference_image_file_ids(
|
||||
llm_kwargs={REFERENCE_IMAGE_FILE_IDS_FIELD: ["a", "b", "c", "d"]},
|
||||
)
|
||||
assert result == ["a", "b"]
|
||||
@@ -1,10 +1,5 @@
|
||||
from onyx.chat.models import ChatMessageSimple
|
||||
from onyx.chat.models import ToolCallSimple
|
||||
from onyx.configs.constants import MessageType
|
||||
from onyx.server.query_and_chat.placement import Placement
|
||||
from onyx.tools.models import ToolCallKickoff
|
||||
from onyx.tools.tool_runner import _extract_image_file_ids_from_tool_response_message
|
||||
from onyx.tools.tool_runner import _extract_recent_generated_image_file_ids
|
||||
from onyx.tools.tool_runner import _merge_tool_calls
|
||||
|
||||
|
||||
@@ -312,62 +307,3 @@ class TestMergeToolCalls:
|
||||
assert len(result) == 1
|
||||
# String should be converted to list item
|
||||
assert result[0].tool_args["queries"] == ["single_query", "q2"]
|
||||
|
||||
|
||||
class TestImageHistoryExtraction:
|
||||
def test_extracts_image_file_ids_from_json_response(self) -> None:
|
||||
msg = '[{"file_id":"img-1","revised_prompt":"v1"},{"file_id":"img-2","revised_prompt":"v2"}]'
|
||||
assert _extract_image_file_ids_from_tool_response_message(msg) == [
|
||||
"img-1",
|
||||
"img-2",
|
||||
]
|
||||
|
||||
def test_extracts_recent_generated_image_ids_from_history(self) -> None:
|
||||
history = [
|
||||
ChatMessageSimple(
|
||||
message="",
|
||||
token_count=1,
|
||||
message_type=MessageType.ASSISTANT,
|
||||
tool_calls=[
|
||||
ToolCallSimple(
|
||||
tool_call_id="call_1",
|
||||
tool_name="generate_image",
|
||||
tool_arguments={"prompt": "test"},
|
||||
token_count=1,
|
||||
)
|
||||
],
|
||||
),
|
||||
ChatMessageSimple(
|
||||
message='[{"file_id":"img-1","revised_prompt":"r1"}]',
|
||||
token_count=1,
|
||||
message_type=MessageType.TOOL_CALL_RESPONSE,
|
||||
tool_call_id="call_1",
|
||||
),
|
||||
]
|
||||
|
||||
assert _extract_recent_generated_image_file_ids(history) == ["img-1"]
|
||||
|
||||
def test_ignores_non_image_tool_responses(self) -> None:
|
||||
history = [
|
||||
ChatMessageSimple(
|
||||
message="",
|
||||
token_count=1,
|
||||
message_type=MessageType.ASSISTANT,
|
||||
tool_calls=[
|
||||
ToolCallSimple(
|
||||
tool_call_id="call_1",
|
||||
tool_name="web_search",
|
||||
tool_arguments={"queries": ["q"]},
|
||||
token_count=1,
|
||||
)
|
||||
],
|
||||
),
|
||||
ChatMessageSimple(
|
||||
message='[{"file_id":"img-1","revised_prompt":"r1"}]',
|
||||
token_count=1,
|
||||
message_type=MessageType.TOOL_CALL_RESPONSE,
|
||||
tool_call_id="call_1",
|
||||
),
|
||||
]
|
||||
|
||||
assert _extract_recent_generated_image_file_ids(history) == []
|
||||
|
||||
@@ -172,7 +172,7 @@ LOG_ONYX_MODEL_INTERACTIONS=False
|
||||
|
||||
## Gen AI Settings
|
||||
# GEN_AI_MAX_TOKENS=
|
||||
# LLM_SOCKET_READ_TIMEOUT=
|
||||
LLM_SOCKET_READ_TIMEOUT=120
|
||||
# MAX_CHUNKS_FED_TO_CHAT=
|
||||
# DISABLE_LITELLM_STREAMING=
|
||||
# LITELLM_EXTRA_HEADERS=
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"description": " This chart shows how long it takes for Onyx to crawl each source connector and collect the current list of documents. The Y axis represents duration in seconds (bucketed), and each band shows how many enumerations completed within that time range.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {
|
||||
@@ -48,6 +47,407 @@
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 12,
|
||||
"options": {
|
||||
"calculate": false,
|
||||
"cellGap": 1,
|
||||
"color": {
|
||||
"exponent": 0.5,
|
||||
"fill": "dark-orange",
|
||||
"mode": "scheme",
|
||||
"reverse": false,
|
||||
"scale": "exponential",
|
||||
"scheme": "Oranges",
|
||||
"steps": 64
|
||||
},
|
||||
"exemplars": {
|
||||
"color": "rgba(255,0,255,0.7)"
|
||||
},
|
||||
"filterValues": {
|
||||
"le": 1e-9
|
||||
},
|
||||
"legend": {
|
||||
"show": true
|
||||
},
|
||||
"rowsFrame": {
|
||||
"layout": "auto"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"showColorScale": false,
|
||||
"yHistogram": false
|
||||
},
|
||||
"yAxis": {
|
||||
"axisPlacement": "left",
|
||||
"reverse": false,
|
||||
"unit": "s"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "10.4.1",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"disableTextWrap": false,
|
||||
"editorMode": "code",
|
||||
"expr": "sum(increase(onyx_celery_task_queue_wait_seconds_bucket{queue=\"connector_pruning\"}[30m])) by (le)",
|
||||
"format": "heatmap",
|
||||
"fullMetaSearch": false,
|
||||
"includeNullMetadata": true,
|
||||
"instant": false,
|
||||
"legendFormat": "__auto",
|
||||
"range": true,
|
||||
"refId": "A",
|
||||
"useBackend": false
|
||||
}
|
||||
],
|
||||
"title": "Pruning Task Queue Waiting Time",
|
||||
"type": "heatmap"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"description": "Shows the 95th percentile execution duration of pruning tasks. A rising p95 indicates pruning jobs are taking longer over time, potentially approaching the 6-hour timeout limit. Sustained values near 21600s (6 hours) indicate connectors with too many documents to prune within the allowed window.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 0
|
||||
},
|
||||
"id": 5,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "histogram_quantile(0.95, sum(rate(onyx_celery_task_duration_seconds_bucket{task_name=~\"connector_pruning.*\"}[1h])) by (le, task_name))",
|
||||
"instant": false,
|
||||
"legendFormat": "{{task_name}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Pruning Task Duration p95",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"description": "Shows the rate of pruning task failures and revocations per hour. Failures indicate crashed tasks (DB errors, timeouts). Revocations indicate cancelled tasks, typically from worker restarts or deployments. Both result in orphaned fences that block future pruning attempts for affected connectors.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 8
|
||||
},
|
||||
"id": 9,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(increase(onyx_celery_task_revoked_total{task_name=~\"connector_pruning.*\"}[1h])) by (task_name)",
|
||||
"hide": false,
|
||||
"instant": false,
|
||||
"legendFormat": "revoked",
|
||||
"range": true,
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(increase(onyx_celery_task_completed_total{task_name=~\"connector_pruning.*\", outcome=\"failure\"}[1h])) by (task_name)",
|
||||
"hide": false,
|
||||
"instant": false,
|
||||
"legendFormat": "failure",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(increase(onyx_celery_task_completed_total{task_name=~\"connector_pruning.*\", outcome=\"success\"}[1h])) by (task_name)",
|
||||
"hide": false,
|
||||
"instant": false,
|
||||
"legendFormat": "success",
|
||||
"range": true,
|
||||
"refId": "C"
|
||||
}
|
||||
],
|
||||
"title": "Pruning Task Success & Failures & Revocations",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"description": "Shows the ratio of successfully completed pruning tasks to total completed tasks. A value of 1.0 (100%) means all pruning jobs are completing cleanly. A drop indicates tasks are crashing or timing out, which leads to orphaned fences and connectors being blocked from future pruning attempts.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 8
|
||||
},
|
||||
"id": 8,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": " sum(rate(onyx_celery_task_completed_total{task_name=~\"connector_pruning.*\", outcome=\"success\"}[1h]))\n /\n sum(rate(onyx_celery_task_completed_total{task_name=~\"connector_pruning.*\"}[1h]))",
|
||||
"instant": false,
|
||||
"legendFormat": "Success Rate",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Pruning Task Success Rate",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"description": " This chart shows how long it takes for Onyx to crawl each source connector and collect the current list of documents. The Y axis represents duration in seconds (bucketed), and each band shows how many enumerations completed within that time range.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 16
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"calculate": false,
|
||||
@@ -166,7 +566,7 @@
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 0
|
||||
"y": 16
|
||||
},
|
||||
"id": 7,
|
||||
"options": {
|
||||
@@ -262,7 +662,7 @@
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 8
|
||||
"y": 24
|
||||
},
|
||||
"id": 6,
|
||||
"options": {
|
||||
@@ -294,223 +694,6 @@
|
||||
"title": "Pruning Enumeration Count",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"description": "Shows the 95th percentile execution duration of pruning tasks. A rising p95 indicates pruning jobs are taking longer over time, potentially approaching the 6-hour timeout limit. Sustained values near 21600s (6 hours) indicate connectors with too many documents to prune within the allowed window.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 8
|
||||
},
|
||||
"id": 5,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "histogram_quantile(0.95, sum(rate(onyx_celery_task_duration_seconds_bucket{task_name=~\"connector_pruning.*\"}[1h])) by (le, task_name))",
|
||||
"instant": false,
|
||||
"legendFormat": "{{task_name}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Pruning Task Duration p95",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"description": "Shows the number of currently executing pruning tasks on the heavy worker, broken down by task type. A value of 0 means no pruning is actively running. A sustained high count may indicate workers are saturated and new pruning jobs are queuing up.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"__systemRef": "hideSeriesFrom",
|
||||
"matcher": {
|
||||
"id": "byNames",
|
||||
"options": {
|
||||
"mode": "exclude",
|
||||
"names": [
|
||||
"connector_pruning_generator_task"
|
||||
],
|
||||
"prefix": "All except:",
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
"id": "custom.hideFrom",
|
||||
"value": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 16
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(onyx_celery_tasks_active{queue=~\"connector_pruning.*|connector_doc_permissions.*|connector_external_group.*|csv_generation|sandbox\"}) by (task_name)",
|
||||
"instant": false,
|
||||
"legendFormat": "{{task_name}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Heavy Worker - Active Tasks",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
@@ -575,7 +758,7 @@
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 16
|
||||
"y": 24
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
@@ -612,7 +795,7 @@
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"description": "Shows the rate of pruning task failures and revocations per hour. Failures indicate crashed tasks (DB errors, timeouts). Revocations indicate cancelled tasks, typically from worker restarts or deployments. Both result in orphaned fences that block future pruning attempts for affected connectors.",
|
||||
"description": "Depth of queues that go heavy worker",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
@@ -671,9 +854,9 @@
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 24
|
||||
"y": 32
|
||||
},
|
||||
"id": 9,
|
||||
"id": 10,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
@@ -693,41 +876,14 @@
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(increase(onyx_celery_task_revoked_total{task_name=~\"connector_pruning.*\"}[1h])) by (task_name)",
|
||||
"hide": false,
|
||||
"expr": "sum by (queue) (onyx_queue_depth{queue=~\"connector_pruning|external_group_sync|permissions_sync\"})",
|
||||
"instant": false,
|
||||
"legendFormat": "revoked",
|
||||
"range": true,
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(increase(onyx_celery_task_completed_total{task_name=~\"connector_pruning.*\", outcome=\"failure\"}[1h])) by (task_name)",
|
||||
"hide": false,
|
||||
"instant": false,
|
||||
"legendFormat": "failure",
|
||||
"legendFormat": "{{queue}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(increase(onyx_celery_task_completed_total{task_name=~\"connector_pruning.*\", outcome=\"success\"}[1h])) by (task_name)",
|
||||
"hide": false,
|
||||
"instant": false,
|
||||
"legendFormat": "success",
|
||||
"range": true,
|
||||
"refId": "C"
|
||||
}
|
||||
],
|
||||
"title": "Heavy Worker - Pruning Task Success & Failures & Revocations",
|
||||
"title": "Heavy Worker Queues Depth",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
@@ -735,7 +891,7 @@
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"description": "Shows the ratio of successfully completed pruning tasks to total completed tasks. A value of 1.0 (100%) means all pruning jobs are completing cleanly. A drop indicates tasks are crashing or timing out, which leads to orphaned fences and connectors being blocked from future pruning attempts.",
|
||||
"description": "Shows the number of currently executing pruning tasks on the heavy worker, broken down by task type. A value of 0 means no pruning is actively running. A sustained high count may indicate workers are saturated and new pruning jobs are queuing up.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
@@ -794,9 +950,9 @@
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 24
|
||||
"y": 32
|
||||
},
|
||||
"id": 8,
|
||||
"id": 4,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
@@ -816,14 +972,14 @@
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": " sum(rate(onyx_celery_task_completed_total{task_name=~\"connector_pruning.*\", outcome=\"success\"}[1h]))\n /\n sum(rate(onyx_celery_task_completed_total{task_name=~\"connector_pruning.*\"}[1h]))",
|
||||
"expr": "sum(onyx_celery_tasks_active{queue=~\"connector_pruning.*|connector_doc_permissions.*|connector_external_group.*|csv_generation|sandbox\"}) by (task_name)",
|
||||
"instant": false,
|
||||
"legendFormat": "Success Rate",
|
||||
"legendFormat": "{{task_name}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Heavy Worker - Pruning Task Success Rate",
|
||||
"title": "Heavy Worker - Active Tasks",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
@@ -851,7 +1007,7 @@
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 32
|
||||
"y": 40
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
@@ -915,13 +1071,13 @@
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"from": "now-24h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "Indexing - Pruning",
|
||||
"uid": "onyx-indexing-pruning",
|
||||
"version": 10,
|
||||
"version": 14,
|
||||
"weekStart": ""
|
||||
}
|
||||
|
||||
@@ -1262,7 +1262,7 @@ configMap:
|
||||
S3_FILE_STORE_BUCKET_NAME: ""
|
||||
# Gen AI Settings
|
||||
GEN_AI_MAX_TOKENS: ""
|
||||
LLM_SOCKET_READ_TIMEOUT: "60"
|
||||
LLM_SOCKET_READ_TIMEOUT: "120"
|
||||
MAX_CHUNKS_FED_TO_CHAT: ""
|
||||
# Query Options
|
||||
DOC_TIME_DECAY: ""
|
||||
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"cohere==5.6.1",
|
||||
"fastapi==0.133.1",
|
||||
"google-genai==1.52.0",
|
||||
"litellm==1.81.6",
|
||||
"litellm[google]==1.81.6",
|
||||
"openai==2.14.0",
|
||||
"pydantic==2.11.7",
|
||||
"prometheus_client>=0.21.1",
|
||||
@@ -69,7 +69,7 @@ backend = [
|
||||
"langchain-core==1.2.28",
|
||||
"lazy_imports==1.0.1",
|
||||
"lxml==5.3.0",
|
||||
"Mako==1.2.4",
|
||||
"Mako==1.3.11",
|
||||
# NOTE: Do not update without understanding the patching behavior in
|
||||
# get_markitdown_converter in
|
||||
# backend/onyx/file_processing/extract_file_text.py and what impacts
|
||||
@@ -96,7 +96,7 @@ backend = [
|
||||
"python-gitlab==5.6.0",
|
||||
"python-pptx==0.6.23",
|
||||
"pypandoc_binary==1.16.2",
|
||||
"pypdf==6.10.0",
|
||||
"pypdf==6.10.2",
|
||||
"pytest-mock==3.12.0",
|
||||
"pytest-playwright==0.7.2",
|
||||
"python-docx==1.1.2",
|
||||
|
||||
251
uv.lock
generated
251
uv.lock
generated
@@ -447,14 +447,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "authlib"
|
||||
version = "1.6.9"
|
||||
version = "1.6.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/28/10/b325d58ffe86815b399334a101e63bc6fa4e1953921cb23703b48a0a0220/authlib-1.6.11.tar.gz", hash = "sha256:64db35b9b01aeccb4715a6c9a6613a06f2bd7be2ab9d2eb89edd1dfc7580a38f", size = 165359, upload-time = "2026-04-16T07:22:50.279Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/2f/55fca558f925a51db046e5b929deb317ddb05afed74b22d89f4eca578980/authlib-1.6.11-py2.py3-none-any.whl", hash = "sha256:c8687a9a26451c51a34a06fa17bb97cb15bba46a6a626755e2d7f50da8bff3e3", size = 244469, upload-time = "2026-04-16T07:22:48.413Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2124,6 +2124,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/d4/90197b416cb61cefd316964fd9e7bd8324bcbafabf40eef14a9f20b81974/google_api_core-2.28.1-py3-none-any.whl", hash = "sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c", size = 173706, upload-time = "2025-10-28T21:34:50.151Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
grpc = [
|
||||
{ name = "grpcio" },
|
||||
{ name = "grpcio-status" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-api-python-client"
|
||||
version = "2.86.0"
|
||||
@@ -2181,6 +2187,124 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/07/8d9a8186e6768b55dfffeb57c719bc03770cf8a970a074616ae6f9e26a57/google_auth_oauthlib-1.0.0-py2.py3-none-any.whl", hash = "sha256:95880ca704928c300f48194d1770cf5b1462835b6e49db61445a520f793fd5fb", size = 18926, upload-time = "2023-02-07T20:53:18.837Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-aiplatform"
|
||||
version = "1.133.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "docstring-parser" },
|
||||
{ name = "google-api-core", extra = ["grpc"] },
|
||||
{ name = "google-auth" },
|
||||
{ name = "google-cloud-bigquery" },
|
||||
{ name = "google-cloud-resource-manager" },
|
||||
{ name = "google-cloud-storage" },
|
||||
{ name = "google-genai" },
|
||||
{ name = "packaging" },
|
||||
{ name = "proto-plus" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/be/31ce7fd658ddebafbe5583977ddee536b2bacc491ad10b5a067388aec66f/google_cloud_aiplatform-1.133.0.tar.gz", hash = "sha256:3a6540711956dd178daaab3c2c05db476e46d94ac25912b8cf4f59b00b058ae0", size = 9921309, upload-time = "2026-01-08T22:11:25.079Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/01/5b/ef74ff65aebb74eaba51078e33ddd897247ba0d1197fd5a7953126205519/google_cloud_aiplatform-1.133.0-py2.py3-none-any.whl", hash = "sha256:dfc81228e987ca10d1c32c7204e2131b3c8d6b7c8e0b4e23bf7c56816bc4c566", size = 8184595, upload-time = "2026-01-08T22:11:22.067Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-bigquery"
|
||||
version = "3.41.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core", extra = ["grpc"] },
|
||||
{ name = "google-auth" },
|
||||
{ name = "google-cloud-core" },
|
||||
{ name = "google-resumable-media" },
|
||||
{ name = "packaging" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/13/6515c7aab55a4a0cf708ffd309fb9af5bab54c13e32dc22c5acd6497193c/google_cloud_bigquery-3.41.0.tar.gz", hash = "sha256:2217e488b47ed576360c9b2cc07d59d883a54b83167c0ef37f915c26b01a06fe", size = 513434, upload-time = "2026-03-30T22:50:55.347Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/40/33/1d3902efadef9194566d499d61507e1f038454e0b55499d2d7f8ab2a4fee/google_cloud_bigquery-3.41.0-py3-none-any.whl", hash = "sha256:2a5b5a737b401cbd824a6e5eac7554100b878668d908e6548836b5d8aaa4dcaa", size = 262343, upload-time = "2026-03-30T22:48:45.444Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-core"
|
||||
version = "2.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core" },
|
||||
{ name = "google-auth" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/24/6ca08b0a03c7b0c620427503ab00353a4ae806b848b93bcea18b6b76fde6/google_cloud_core-2.5.1.tar.gz", hash = "sha256:3dc94bdec9d05a31d9f355045ed0f369fbc0d8c665076c734f065d729800f811", size = 36078, upload-time = "2026-03-30T22:50:08.057Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/73/d9/5bb050cb32826466aa9b25f79e2ca2879fe66cb76782d4ed798dd7506151/google_cloud_core-2.5.1-py3-none-any.whl", hash = "sha256:ea62cdf502c20e3e14be8a32c05ed02113d7bef454e40ff3fab6fe1ec9f1f4e7", size = 29452, upload-time = "2026-03-30T22:48:31.567Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-resource-manager"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core", extra = ["grpc"] },
|
||||
{ name = "google-auth" },
|
||||
{ name = "grpc-google-iam-v1" },
|
||||
{ name = "grpcio" },
|
||||
{ name = "proto-plus" },
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/1a/13060cabf553d52d151d2afc26b39561e82853380d499dd525a0d422d9f0/google_cloud_resource_manager-1.17.0.tar.gz", hash = "sha256:0f486b62e2c58ff992a3a50fa0f4a96eef7750aa6c971bb373398ccb91828660", size = 464971, upload-time = "2026-03-26T22:17:29.204Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/f7/661d7a9023e877a226b5683429c3662f75a29ef45cb1464cf39adb689218/google_cloud_resource_manager-1.17.0-py3-none-any.whl", hash = "sha256:e479baf4b014a57f298e01b8279e3290b032e3476d69c8e5e1427af8f82739a5", size = 404403, upload-time = "2026-03-26T22:15:26.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-storage"
|
||||
version = "3.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core" },
|
||||
{ name = "google-auth" },
|
||||
{ name = "google-cloud-core" },
|
||||
{ name = "google-crc32c" },
|
||||
{ name = "google-resumable-media" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/47/205eb8e9a1739b5345843e5a425775cbdc472cc38e7eda082ba5b8d02450/google_cloud_storage-3.10.1.tar.gz", hash = "sha256:97db9aa4460727982040edd2bd13ff3d5e2260b5331ad22895802da1fc2a5286", size = 17309950, upload-time = "2026-03-23T09:35:23.409Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/ff/ca9ab2417fa913d75aae38bf40bf856bb2749a604b2e0f701b37cfcd23cc/google_cloud_storage-3.10.1-py3-none-any.whl", hash = "sha256:a72f656759b7b99bda700f901adcb3425a828d4a29f911bc26b3ea79c5b1217f", size = 324453, upload-time = "2026-03-23T09:35:21.368Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-crc32c"
|
||||
version = "1.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-genai"
|
||||
version = "1.52.0"
|
||||
@@ -2200,6 +2324,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/66/03f663e7bca7abe9ccfebe6cb3fe7da9a118fd723a5abb278d6117e7990e/google_genai-1.52.0-py3-none-any.whl", hash = "sha256:c8352b9f065ae14b9322b949c7debab8562982f03bf71d44130cd2b798c20743", size = 261219, upload-time = "2025-11-21T02:18:54.515Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-resumable-media"
|
||||
version = "2.8.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-crc32c" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/d1/b1ea14b93b6b78f57fc580125de44e9f593ab88dd2460f1a8a8d18f74754/google_resumable_media-2.8.2.tar.gz", hash = "sha256:f3354a182ebd193ae3f42e3ef95e6c9b10f128320de23ac7637236713b1acd70", size = 2164510, upload-time = "2026-03-30T23:34:25.369Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/f8/50bfaf4658431ff9de45c5c3935af7ab01157a4903c603cd0eee6e78e087/google_resumable_media-2.8.2-py3-none-any.whl", hash = "sha256:82b6d8ccd11765268cdd2a2123f417ec806b8eef3000a9a38dfe3033da5fb220", size = 81511, upload-time = "2026-03-30T23:34:09.671Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "googleapis-common-protos"
|
||||
version = "1.72.0"
|
||||
@@ -2212,6 +2348,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
grpc = [
|
||||
{ name = "grpcio" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.2.4"
|
||||
@@ -2262,6 +2403,85 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grpc-google-iam-v1"
|
||||
version = "0.14.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "googleapis-common-protos", extra = ["grpc"] },
|
||||
{ name = "grpcio" },
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/4f/d098419ad0bfc06c9ce440575f05aa22d8973b6c276e86ac7890093d3c37/grpc_google_iam_v1-0.14.4.tar.gz", hash = "sha256:392b3796947ed6334e61171d9ab06bf7eb357f554e5fc7556ad7aab6d0e17038", size = 23706, upload-time = "2026-04-01T01:57:49.813Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/22/c2dd50c09bf679bd38173656cd4402d2511e563b33bc88f90009cf50613c/grpc_google_iam_v1-0.14.4-py3-none-any.whl", hash = "sha256:412facc320fcbd94034b4df3d557662051d4d8adfa86e0ddb4dca70a3f739964", size = 32675, upload-time = "2026-04-01T01:57:47.69Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grpcio"
|
||||
version = "1.80.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grpcio-status"
|
||||
version = "1.80.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "googleapis-common-protos" },
|
||||
{ name = "grpcio" },
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/ed/105f619bdd00cb47a49aa2feea6232ea2bbb04199d52a22cc6a7d603b5cb/grpcio_status-1.80.0.tar.gz", hash = "sha256:df73802a4c89a3ea88aa2aff971e886fccce162bc2e6511408b3d67a144381cd", size = 13901, upload-time = "2026-03-30T08:54:34.784Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/80/58cd2dfc19a07d022abe44bde7c365627f6c7cb6f692ada6c65ca437d09a/grpcio_status-1.80.0-py3-none-any.whl", hash = "sha256:4b56990363af50dbf2c2ebb80f1967185c07d87aa25aa2bea45ddb75fc181dbe", size = 14638, upload-time = "2026-03-30T08:54:01.569Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -3164,6 +3384,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/05/3516cc7386b220d388aa0bd833308c677e94eceb82b2756dd95e06f6a13f/litellm-1.81.6-py3-none-any.whl", hash = "sha256:573206ba194d49a1691370ba33f781671609ac77c35347f8a0411d852cf6341a", size = 12224343, upload-time = "2026-02-01T04:02:23.704Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
google = [
|
||||
{ name = "google-cloud-aiplatform" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "locket"
|
||||
version = "1.0.0"
|
||||
@@ -3278,14 +3503,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.2.4"
|
||||
version = "1.3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/5f/2ba6e026d33a0e6ddc1dddf9958677f76f5f80c236bd65309d280b166d3e/Mako-1.2.4.tar.gz", hash = "sha256:d60a3903dc3bb01a18ad6a89cdbe2e4eadc69c0bc8ef1e3773ba53d44c3f7a34", size = 497021, upload-time = "2022-11-15T14:37:51.327Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/8a/805404d0c0b9f3d7a326475ca008db57aea9c5c9f2e1e39ed0faa335571c/mako-1.3.11.tar.gz", hash = "sha256:071eb4ab4c5010443152255d77db7faa6ce5916f35226eb02dc34479b6858069", size = 399811, upload-time = "2026-04-14T20:19:51.493Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/3b/68690a035ba7347860f1b8c0cde853230ba69ff41df5884ea7d89fe68cd3/Mako-1.2.4-py3-none-any.whl", hash = "sha256:c97c79c018b9165ac9922ae4f32da095ffd3c4e6872b45eded42926deea46818", size = 78672, upload-time = "2022-11-15T14:37:53.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/a5/19d7aaa7e433713ffe881df33705925a196afb9532efc8475d26593921a6/mako-1.3.11-py3-none-any.whl", hash = "sha256:e372c6e333cf004aa736a15f425087ec977e1fcbd2966aae7f17c8dc1da27a77", size = 78503, upload-time = "2026-04-14T20:19:53.233Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4204,7 +4429,7 @@ dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "google-genai" },
|
||||
{ name = "kubernetes" },
|
||||
{ name = "litellm" },
|
||||
{ name = "litellm", extra = ["google"] },
|
||||
{ name = "openai" },
|
||||
{ name = "prometheus-client" },
|
||||
{ name = "prometheus-fastapi-instrumentator" },
|
||||
@@ -4377,7 +4602,7 @@ requires-dist = [
|
||||
{ name = "fastapi", specifier = "==0.133.1" },
|
||||
{ name = "google-genai", specifier = "==1.52.0" },
|
||||
{ name = "kubernetes", specifier = ">=31.0.0" },
|
||||
{ name = "litellm", specifier = "==1.81.6" },
|
||||
{ name = "litellm", extras = ["google"], specifier = "==1.81.6" },
|
||||
{ name = "openai", specifier = "==2.14.0" },
|
||||
{ name = "prometheus-client", specifier = ">=0.21.1" },
|
||||
{ name = "prometheus-fastapi-instrumentator", specifier = "==7.1.0" },
|
||||
@@ -4430,7 +4655,7 @@ backend = [
|
||||
{ name = "langfuse", specifier = "==3.10.0" },
|
||||
{ name = "lazy-imports", specifier = "==1.0.1" },
|
||||
{ name = "lxml", specifier = "==5.3.0" },
|
||||
{ name = "mako", specifier = "==1.2.4" },
|
||||
{ name = "mako", specifier = "==1.3.11" },
|
||||
{ name = "markitdown", extras = ["pdf", "docx", "pptx", "xlsx", "xls"], specifier = "==0.1.2" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = "==1.26.0" },
|
||||
{ name = "mistune", specifier = "==3.2.0" },
|
||||
@@ -4453,7 +4678,7 @@ backend = [
|
||||
{ name = "pygithub", specifier = "==2.5.0" },
|
||||
{ name = "pympler", specifier = "==1.1" },
|
||||
{ name = "pypandoc-binary", specifier = "==1.16.2" },
|
||||
{ name = "pypdf", specifier = "==6.10.0" },
|
||||
{ name = "pypdf", specifier = "==6.10.2" },
|
||||
{ name = "pytest-mock", specifier = "==3.12.0" },
|
||||
{ name = "pytest-playwright", specifier = "==0.7.2" },
|
||||
{ name = "python-dateutil", specifier = "==2.8.2" },
|
||||
@@ -5703,11 +5928,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.10.0"
|
||||
version = "6.10.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/9f/ca96abf18683ca12602065e4ed2bec9050b672c87d317f1079abc7b6d993/pypdf-6.10.0.tar.gz", hash = "sha256:4c5a48ba258c37024ec2505f7e8fd858525f5502784a2e1c8d415604af29f6ef", size = 5314833, upload-time = "2026-04-10T09:34:57.102Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/3f/9f2167401c2e94833ca3b69535bad89e533b5de75fefe4197a2c224baec2/pypdf-6.10.2.tar.gz", hash = "sha256:7d09ce108eff6bf67465d461b6ef352dcb8d84f7a91befc02f904455c6eea11d", size = 5315679, upload-time = "2026-04-15T16:37:36.978Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/55/f2/7ebe366f633f30a6ad105f650f44f24f98cb1335c4157d21ae47138b3482/pypdf-6.10.0-py3-none-any.whl", hash = "sha256:90005e959e1596c6e6c84c8b0ad383285b3e17011751cedd17f2ce8fcdfc86de", size = 334459, upload-time = "2026-04-10T09:34:54.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/d6/1d5c60cc17bbdf37c1552d9c03862fc6d32c5836732a0415b2d637edc2d0/pypdf-6.10.2-py3-none-any.whl", hash = "sha256:aa53be9826655b51c96741e5d7983ca224d898ac0a77896e64636810517624aa", size = 336308, upload-time = "2026-04-15T16:37:34.851Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -82,7 +82,10 @@ ARG NODE_OPTIONS
|
||||
# SENTRY_AUTH_TOKEN is injected via BuildKit secret mount so it is never written
|
||||
# to any image layer, build cache, or registry manifest.
|
||||
# Use NODE_OPTIONS in the build command
|
||||
RUN --mount=type=secret,id=sentry_auth_token,env=SENTRY_AUTH_TOKEN \
|
||||
RUN --mount=type=secret,id=sentry_auth_token \
|
||||
if [ -f /run/secrets/sentry_auth_token ]; then \
|
||||
export SENTRY_AUTH_TOKEN="$(cat /run/secrets/sentry_auth_token)"; \
|
||||
fi && \
|
||||
NODE_OPTIONS="${NODE_OPTIONS}" npx next build
|
||||
|
||||
# Step 2. Production image, copy all the files and run next
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { LinkButton } from "@opal/components";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
const meta: Meta<typeof LinkButton> = {
|
||||
title: "opal/components/LinkButton",
|
||||
component: LinkButton,
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<TooltipPrimitive.Provider>
|
||||
<Story />
|
||||
</TooltipPrimitive.Provider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LinkButton>;
|
||||
|
||||
// ─── Anchor mode ────────────────────────────────────────────────────────────
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <LinkButton href="/">Home</LinkButton>,
|
||||
};
|
||||
|
||||
export const ExternalLink: Story = {
|
||||
render: () => (
|
||||
<LinkButton href="https://onyx.app" target="_blank">
|
||||
Onyx
|
||||
</LinkButton>
|
||||
),
|
||||
};
|
||||
|
||||
export const LongLabel: Story = {
|
||||
render: () => (
|
||||
<LinkButton href="https://docs.onyx.app" target="_blank">
|
||||
Go read the full Onyx documentation site
|
||||
</LinkButton>
|
||||
),
|
||||
};
|
||||
|
||||
// ─── Button mode ────────────────────────────────────────────────────────────
|
||||
|
||||
export const AsButton: Story = {
|
||||
render: () => (
|
||||
<LinkButton onClick={() => alert("clicked")}>Click me</LinkButton>
|
||||
),
|
||||
};
|
||||
|
||||
// ─── Disabled ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const DisabledLink: Story = {
|
||||
render: () => (
|
||||
<LinkButton href="/" disabled>
|
||||
Disabled link
|
||||
</LinkButton>
|
||||
),
|
||||
};
|
||||
|
||||
export const DisabledButton: Story = {
|
||||
render: () => (
|
||||
<LinkButton onClick={() => alert("should not fire")} disabled>
|
||||
Disabled button
|
||||
</LinkButton>
|
||||
),
|
||||
};
|
||||
|
||||
// ─── Tooltip ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const Tooltip: Story = {
|
||||
render: () => (
|
||||
<LinkButton href="/" tooltip="This is a tooltip">
|
||||
Hover me
|
||||
</LinkButton>
|
||||
),
|
||||
};
|
||||
|
||||
export const TooltipSides: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-8 p-16">
|
||||
<LinkButton href="/" tooltip="Tooltip on top" tooltipSide="top">
|
||||
top
|
||||
</LinkButton>
|
||||
<LinkButton href="/" tooltip="Tooltip on right" tooltipSide="right">
|
||||
right
|
||||
</LinkButton>
|
||||
<LinkButton href="/" tooltip="Tooltip on bottom" tooltipSide="bottom">
|
||||
bottom
|
||||
</LinkButton>
|
||||
<LinkButton href="/" tooltip="Tooltip on left" tooltipSide="left">
|
||||
left
|
||||
</LinkButton>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
// ─── Inline in prose ────────────────────────────────────────────────────────
|
||||
|
||||
export const InlineInProse: Story = {
|
||||
render: () => (
|
||||
<p style={{ maxWidth: "36rem", lineHeight: 1.7 }}>
|
||||
Modifying embedding settings requires a full re-index of all documents and
|
||||
may take hours or days depending on corpus size.{" "}
|
||||
<LinkButton href="https://docs.onyx.app" target="_blank">
|
||||
Learn more
|
||||
</LinkButton>
|
||||
.
|
||||
</p>
|
||||
),
|
||||
};
|
||||
60
web/lib/opal/src/components/buttons/link-button/README.md
Normal file
60
web/lib/opal/src/components/buttons/link-button/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# LinkButton
|
||||
|
||||
**Import:** `import { LinkButton, type LinkButtonProps } from "@opal/components";`
|
||||
|
||||
A compact, anchor-styled link with an underlined label and a trailing external-link glyph. Intended for **inline references** — "Pricing", "Docs", "Learn more" — not for interactive surfaces that need hover backgrounds or prominence tiers. Use [`Button`](../button/README.md) for those.
|
||||
|
||||
## Architecture
|
||||
|
||||
Deliberately **does not** use `Interactive.Stateless` / `Interactive.Container`. Those primitives come with height, rounding, padding, and a colour matrix designed for clickable surfaces — all wrong for an inline text link.
|
||||
|
||||
The component renders a plain `<a>` (when given `href`) or `<button>` (when given `onClick`) with:
|
||||
- `inline-flex` so the label + icon track naturally next to surrounding prose
|
||||
- `text-text-03` that shifts to `text-text-05` on hover
|
||||
- `underline` on the label only (the icon stays non-underlined)
|
||||
- `data-disabled` driven opacity + `cursor-not-allowed` for the disabled state
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `children` | `string` | — | Visible label (required) |
|
||||
| `href` | `string` | — | Destination URL. Renders the component as `<a>`. |
|
||||
| `target` | `string` | — | Anchor target (e.g. `"_blank"`). Adds `rel="noopener noreferrer"` automatically when `"_blank"`. |
|
||||
| `onClick` | `() => void` | — | Click handler. Without `href`, renders the component as `<button>`. |
|
||||
| `disabled` | `boolean` | `false` | Applies disabled styling + suppresses navigation / clicks |
|
||||
| `tooltip` | `string \| RichStr` | — | Hover tooltip text. Pass `markdown(...)` for inline markdown. |
|
||||
| `tooltipSide` | `TooltipSide` | `"top"` | Tooltip placement |
|
||||
|
||||
Exactly one of `href` / `onClick` is expected. Passing both is allowed but only `href` takes effect (renders as an anchor).
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { LinkButton } from "@opal/components";
|
||||
|
||||
// External link — automatic rel="noopener noreferrer"
|
||||
<LinkButton href="https://docs.onyx.app" target="_blank">
|
||||
Read the docs
|
||||
</LinkButton>
|
||||
|
||||
// Internal link
|
||||
<LinkButton href="/admin/settings">Settings</LinkButton>
|
||||
|
||||
// Button-mode (no href)
|
||||
<LinkButton onClick={openModal}>Learn more</LinkButton>
|
||||
|
||||
// Disabled
|
||||
<LinkButton href="/" disabled>
|
||||
Not available
|
||||
</LinkButton>
|
||||
|
||||
// With a tooltip
|
||||
<LinkButton
|
||||
href="/docs/pricing"
|
||||
tooltip="See plan details"
|
||||
tooltipSide="bottom"
|
||||
>
|
||||
Pricing
|
||||
</LinkButton>
|
||||
```
|
||||
117
web/lib/opal/src/components/buttons/link-button/components.tsx
Normal file
117
web/lib/opal/src/components/buttons/link-button/components.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import "@opal/components/buttons/link-button/styles.css";
|
||||
import type { RichStr } from "@opal/types";
|
||||
import type { TooltipSide } from "@opal/components/tooltip/components";
|
||||
|
||||
// Direct file imports to avoid circular resolution through the @opal/components
|
||||
// and @opal/icons barrels, which break CJS-based test runners (jest).
|
||||
import { Tooltip } from "@opal/components/tooltip/components";
|
||||
import SvgExternalLink from "@opal/icons/external-link";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LinkButtonProps {
|
||||
/** Visible label. Always rendered as underlined link text. */
|
||||
children: string;
|
||||
|
||||
/** Destination URL. When provided, the component renders as an `<a>`. */
|
||||
href?: string;
|
||||
|
||||
/** Anchor `target` attribute (e.g. `"_blank"`). Only meaningful with `href`. */
|
||||
target?: string;
|
||||
|
||||
/** Click handler. When provided without `href`, the component renders as a `<button>`. */
|
||||
onClick?: () => void;
|
||||
|
||||
/** Applies disabled styling + suppresses navigation/clicks. */
|
||||
disabled?: boolean;
|
||||
|
||||
/** Tooltip text shown on hover. Pass `markdown(...)` for inline markdown. */
|
||||
tooltip?: string | RichStr;
|
||||
|
||||
/** Which side the tooltip appears on. @default "top" */
|
||||
tooltipSide?: TooltipSide;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LinkButton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A bare, anchor-styled link with a trailing external-link glyph. Renders
|
||||
* as `<a>` when given `href`, or `<button>` when given `onClick`. Intended
|
||||
* for inline references — "Pricing", "Docs", etc. — not for interactive
|
||||
* surfaces that need hover backgrounds or prominence tiers (use `Button`
|
||||
* for those).
|
||||
*
|
||||
* Deliberately does NOT use `Interactive.Stateless` / `Interactive.Container`
|
||||
* — those come with height/rounding/padding and a colour matrix that are
|
||||
* wrong for an inline text link. Styling is kept to: underlined label,
|
||||
* small external-link icon, a subtle color shift on hover, and disabled
|
||||
* opacity.
|
||||
*/
|
||||
function LinkButton({
|
||||
children,
|
||||
href,
|
||||
target,
|
||||
onClick,
|
||||
disabled,
|
||||
tooltip,
|
||||
tooltipSide = "top",
|
||||
}: LinkButtonProps) {
|
||||
const inner = (
|
||||
<>
|
||||
<span className="opal-link-button-label font-secondary-body">
|
||||
{children}
|
||||
</span>
|
||||
<SvgExternalLink size={12} />
|
||||
</>
|
||||
);
|
||||
|
||||
// Always stop propagation so clicks don't bubble to interactive ancestors
|
||||
// (cards, list rows, etc. that commonly wrap a LinkButton). If disabled,
|
||||
// also preventDefault on anchors so the browser doesn't navigate.
|
||||
const handleAnchorClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.stopPropagation();
|
||||
if (disabled) e.preventDefault();
|
||||
};
|
||||
|
||||
const handleButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
const element = href ? (
|
||||
<a
|
||||
className="opal-link-button"
|
||||
href={disabled ? undefined : href}
|
||||
target={target}
|
||||
rel={target === "_blank" ? "noopener noreferrer" : undefined}
|
||||
aria-disabled={disabled || undefined}
|
||||
data-disabled={disabled || undefined}
|
||||
onClick={handleAnchorClick}
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="opal-link-button"
|
||||
onClick={handleButtonClick}
|
||||
disabled={disabled}
|
||||
data-disabled={disabled || undefined}
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip tooltip={tooltip} side={tooltipSide}>
|
||||
{element}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export { LinkButton, type LinkButtonProps };
|
||||
31
web/lib/opal/src/components/buttons/link-button/styles.css
Normal file
31
web/lib/opal/src/components/buttons/link-button/styles.css
Normal file
@@ -0,0 +1,31 @@
|
||||
/* ============================================================================
|
||||
LinkButton — a bare anchor-style link with a trailing external-link icon.
|
||||
|
||||
Intentionally does NOT use `Interactive.Stateless` / `Interactive.Container`.
|
||||
Styling is minimal: inline-flex, underlined label, subtle color shift on
|
||||
hover, disabled opacity. The icon inherits the parent's text color via
|
||||
`currentColor` so `text-text-03` on the root cascades through.
|
||||
============================================================================ */
|
||||
|
||||
.opal-link-button {
|
||||
@apply inline-flex flex-row items-center gap-0.5 text-text-03;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
transition: color 150ms ease-out;
|
||||
}
|
||||
|
||||
.opal-link-button:hover:not([data-disabled]) {
|
||||
@apply text-text-05;
|
||||
}
|
||||
|
||||
.opal-link-button[data-disabled] {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
/* `font-secondary-body` is a plain CSS class defined in globals.css (not a
|
||||
Tailwind utility), so `@apply` can't consume it — other Opal components
|
||||
attach it via `className` on the JSX element, and we do the same here. */
|
||||
.opal-link-button-label {
|
||||
@apply underline;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Card } from "@opal/components";
|
||||
import { useState } from "react";
|
||||
import { Button, Card } from "@opal/components";
|
||||
|
||||
const BACKGROUND_VARIANTS = ["none", "light", "heavy"] as const;
|
||||
const BORDER_VARIANTS = ["none", "dashed", "solid"] as const;
|
||||
@@ -100,3 +101,83 @@ export const AllCombinations: Story = {
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
// ─── Expandable mode ─────────────────────────────────────────────────────────
|
||||
|
||||
export const Expandable: Story = {
|
||||
render: function ExpandableStory() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="w-96">
|
||||
<Card
|
||||
expandable
|
||||
expanded={open}
|
||||
border="solid"
|
||||
expandedContent={
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>First model</p>
|
||||
<p>Second model</p>
|
||||
<p>Third model</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
width="full"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
Toggle (expanded={String(open)})
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ExpandableNoContent: Story = {
|
||||
render: function ExpandableNoContentStory() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="w-96">
|
||||
<Card expandable expanded={open} border="solid">
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
width="full"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
Toggle (no content — renders like a plain card)
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ExpandableRoundingVariants: Story = {
|
||||
render: function ExpandableRoundingStory() {
|
||||
const [openKey, setOpenKey] =
|
||||
useState<(typeof ROUNDING_VARIANTS)[number]>("md");
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{ROUNDING_VARIANTS.map((rounding) => (
|
||||
<Card
|
||||
key={rounding}
|
||||
expandable
|
||||
expanded={openKey === rounding}
|
||||
rounding={rounding}
|
||||
border="solid"
|
||||
expandedContent={<p>content for rounding={rounding}</p>}
|
||||
>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
width="full"
|
||||
onClick={() => setOpenKey(rounding)}
|
||||
>
|
||||
rounding={rounding} (click to expand)
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,11 +2,36 @@
|
||||
|
||||
**Import:** `import { Card, type CardProps } from "@opal/components";`
|
||||
|
||||
A plain container component with configurable background, border, padding, and rounding. Uses a simple `<div>` internally with `overflow-clip`.
|
||||
A container component with configurable background, border, padding, and rounding. Has two mutually-exclusive modes:
|
||||
|
||||
## Architecture
|
||||
- **Plain** (default) — renders children inside a single styled `<div>`.
|
||||
- **Expandable** (`expandable: true`) — renders children as an always-visible header plus an `expandedContent` prop that animates open/closed.
|
||||
|
||||
Padding and rounding are controlled independently:
|
||||
## Plain mode
|
||||
|
||||
Default behavior — a plain container.
|
||||
|
||||
```tsx
|
||||
import { Card } from "@opal/components";
|
||||
|
||||
<Card padding="md" border="solid">
|
||||
<p>Hello</p>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### Plain mode props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `padding` | `PaddingVariants` | `"md"` | Padding preset |
|
||||
| `rounding` | `RoundingVariants` | `"md"` | Border-radius preset |
|
||||
| `background` | `"none" \| "light" \| "heavy"` | `"light"` | Background fill intensity |
|
||||
| `border` | `"none" \| "dashed" \| "solid"` | `"none"` | Border style |
|
||||
| `borderColor` | `StatusVariants` | `"default"` | Status-palette border color (needs `border` ≠ `"none"`) |
|
||||
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
|
||||
| `children` | `React.ReactNode` | — | Card content |
|
||||
|
||||
### Padding scale
|
||||
|
||||
| `padding` | Class |
|
||||
|-----------|---------|
|
||||
@@ -17,6 +42,8 @@ Padding and rounding are controlled independently:
|
||||
| `"2xs"` | `p-0.5` |
|
||||
| `"fit"` | `p-0` |
|
||||
|
||||
### Rounding scale
|
||||
|
||||
| `rounding` | Class |
|
||||
|------------|--------------|
|
||||
| `"xs"` | `rounded-04` |
|
||||
@@ -24,40 +51,92 @@ Padding and rounding are controlled independently:
|
||||
| `"md"` | `rounded-12` |
|
||||
| `"lg"` | `rounded-16` |
|
||||
|
||||
## Props
|
||||
## Expandable mode
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `padding` | `PaddingVariants` | `"sm"` | Padding preset |
|
||||
| `rounding` | `RoundingVariants` | `"md"` | Border-radius preset |
|
||||
| `background` | `"none" \| "light" \| "heavy"` | `"light"` | Background fill intensity |
|
||||
| `border` | `"none" \| "dashed" \| "solid"` | `"none"` | Border style |
|
||||
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
|
||||
| `children` | `React.ReactNode` | — | Card content |
|
||||
|
||||
## Usage
|
||||
Enabled by passing `expandable: true`. The type is a discriminated union — `expanded` and `expandedContent` are only available (and type-checked) when `expandable: true`.
|
||||
|
||||
```tsx
|
||||
import { Card } from "@opal/components";
|
||||
import { useState } from "react";
|
||||
|
||||
// Default card (light background, no border, sm padding, md rounding)
|
||||
<Card>
|
||||
<h2>Card Title</h2>
|
||||
<p>Card content</p>
|
||||
</Card>
|
||||
function ProviderCard() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Large padding + rounding with solid border
|
||||
<Card padding="lg" rounding="lg" border="solid">
|
||||
<p>Spacious card</p>
|
||||
</Card>
|
||||
|
||||
// Compact card with solid border
|
||||
<Card padding="xs" rounding="sm" border="solid">
|
||||
<p>Compact card</p>
|
||||
</Card>
|
||||
|
||||
// Empty state card
|
||||
<Card background="none" border="dashed">
|
||||
<p>No items yet</p>
|
||||
</Card>
|
||||
return (
|
||||
<Card
|
||||
expandable
|
||||
expanded={open}
|
||||
expandedContent={<ModelList />}
|
||||
border="solid"
|
||||
rounding="lg"
|
||||
>
|
||||
{/* always visible — the header region */}
|
||||
<div
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
>
|
||||
<ProviderInfo />
|
||||
<SvgChevronDown
|
||||
className={cn("transition-transform", open && "rotate-180")}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Expandable mode props
|
||||
|
||||
Everything from plain mode, **plus**:
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `expandable` | `true` | — | Required to enable the expandable variant |
|
||||
| `expanded` | `boolean` | `false` | Controlled expanded state. Card never mutates this. |
|
||||
| `expandedContent` | `React.ReactNode` | — | The body that animates open/closed below the header |
|
||||
|
||||
### Behavior
|
||||
|
||||
- **No trigger baked in.** Card does not attach any click handlers. Callers wire their own `onClick` / keyboard / button / etc. to toggle state. This keeps `padding` semantics consistent across modes and avoids surprises with interactive children.
|
||||
- **Always controlled.** `expanded` is a pure one-way visual prop. There is no `defaultExpanded` or `onExpandChange` — the caller owns state entirely (`useState` at the call site).
|
||||
- **No React context.** The component renders a flat tree; there are no compound sub-components (`Card.Header` / `Card.Content`) and no exported context hooks.
|
||||
- **Rounding adapts automatically.** When `expanded && expandedContent !== undefined`, the header's bottom corners flatten and the content's top corners flatten so they meet seamlessly. When collapsed (or when `expandedContent` is undefined), the header is fully rounded.
|
||||
- **Content background is always transparent.** The `background` prop applies to the header only; the content slot never fills its own background so the page shows through and keeps the two regions visually distinct.
|
||||
- **Content has no intrinsic padding.** The `padding` prop applies to the header only. Callers own any padding inside whatever they pass to `expandedContent` — wrap it in a `<div className="p-4">` (or whatever) if you want spacing.
|
||||
- **Animation.** Content uses a pure CSS grid `0fr ↔ 1fr` animation with an opacity fade (~200ms ease-out). No `@radix-ui/react-collapsible` dependency.
|
||||
|
||||
### Accessibility
|
||||
|
||||
Because Card doesn't own the trigger, it also doesn't generate IDs or ARIA attributes. Consumers are responsible for wiring `aria-expanded`, `aria-controls`, `aria-labelledby`, etc. on their trigger element.
|
||||
|
||||
## Complete prop reference
|
||||
|
||||
```ts
|
||||
type CardBaseProps = {
|
||||
padding?: PaddingVariants;
|
||||
rounding?: RoundingVariants;
|
||||
background?: "none" | "light" | "heavy";
|
||||
border?: "none" | "dashed" | "solid";
|
||||
borderColor?: StatusVariants;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
type CardPlainProps = CardBaseProps & { expandable?: false };
|
||||
|
||||
type CardExpandableProps = CardBaseProps & {
|
||||
expandable: true;
|
||||
expanded?: boolean;
|
||||
expandedContent?: React.ReactNode;
|
||||
};
|
||||
|
||||
type CardProps = CardPlainProps | CardExpandableProps;
|
||||
```
|
||||
|
||||
The discriminated union enforces:
|
||||
|
||||
```tsx
|
||||
<Card expanded>…</Card> // ❌ TS error — `expanded` not in plain mode
|
||||
<Card expandable expandedContent={…}>…</Card> // ✅ expandable mode
|
||||
<Card border="solid">…</Card> // ✅ plain mode
|
||||
```
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import "@opal/components/cards/shared.css";
|
||||
import "@opal/components/cards/card/styles.css";
|
||||
import type { PaddingVariants, RoundingVariants } from "@opal/types";
|
||||
import { paddingVariants, cardRoundingVariants } from "@opal/shared";
|
||||
import type {
|
||||
PaddingVariants,
|
||||
RoundingVariants,
|
||||
SizeVariants,
|
||||
StatusVariants,
|
||||
} from "@opal/types";
|
||||
import {
|
||||
paddingVariants,
|
||||
cardRoundingVariants,
|
||||
cardTopRoundingVariants,
|
||||
cardBottomRoundingVariants,
|
||||
} from "@opal/shared";
|
||||
import { cn } from "@opal/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -10,7 +21,10 @@ import { cn } from "@opal/utils";
|
||||
type BackgroundVariant = "none" | "light" | "heavy";
|
||||
type BorderVariant = "none" | "dashed" | "solid";
|
||||
|
||||
type CardProps = {
|
||||
/**
|
||||
* Props shared by both plain and expandable Card modes.
|
||||
*/
|
||||
type CardBaseProps = {
|
||||
/**
|
||||
* Padding preset.
|
||||
*
|
||||
@@ -23,6 +37,10 @@ type CardProps = {
|
||||
* | `"2xs"` | `p-0.5` |
|
||||
* | `"fit"` | `p-0` |
|
||||
*
|
||||
* In expandable mode, applied **only** to the header region. The
|
||||
* `expandedContent` slot has no intrinsic padding — callers own any padding
|
||||
* inside the content they pass in.
|
||||
*
|
||||
* @default "md"
|
||||
*/
|
||||
padding?: PaddingVariants;
|
||||
@@ -37,6 +55,10 @@ type CardProps = {
|
||||
* | `"md"` | `rounded-12` |
|
||||
* | `"lg"` | `rounded-16` |
|
||||
*
|
||||
* In expandable mode when expanded, rounding applies only to the header's
|
||||
* top corners and the expandedContent's bottom corners so the two join seamlessly.
|
||||
* When collapsed, rounding applies to all four corners of the header.
|
||||
*
|
||||
* @default "md"
|
||||
*/
|
||||
rounding?: RoundingVariants;
|
||||
@@ -61,35 +83,177 @@ type CardProps = {
|
||||
*/
|
||||
border?: BorderVariant;
|
||||
|
||||
/**
|
||||
* Border color, drawn from the same status palette as {@link MessageCard}.
|
||||
* Has no visual effect when `border="none"`.
|
||||
*
|
||||
* @default "default"
|
||||
*/
|
||||
borderColor?: StatusVariants;
|
||||
|
||||
/** Ref forwarded to the root `<div>`. */
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
|
||||
/**
|
||||
* In plain mode, the card body. In expandable mode, the always-visible
|
||||
* header region (the part that stays put whether expanded or collapsed).
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
type CardPlainProps = CardBaseProps & {
|
||||
/**
|
||||
* When `false` (or omitted), renders a plain card — same behavior as before
|
||||
* this prop existed. No fold behavior, no `expandedContent` slot.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
expandable?: false;
|
||||
};
|
||||
|
||||
type CardExpandableProps = CardBaseProps & {
|
||||
/**
|
||||
* Enables the expandable variant. Renders `children` as the always-visible
|
||||
* header and `expandedContent` as the body that animates open/closed based on
|
||||
* `expanded`.
|
||||
*/
|
||||
expandable: true;
|
||||
|
||||
/**
|
||||
* Controlled expanded state. The caller owns the state and any trigger
|
||||
* (click-to-toggle) — Card is purely visual and never mutates this value.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
expanded?: boolean;
|
||||
|
||||
/**
|
||||
* The expandable body. Rendered below the header, animating open/closed
|
||||
* when `expanded` changes. If `undefined`, the card behaves visually like
|
||||
* a plain card (no divider, no bottom slot).
|
||||
*/
|
||||
expandedContent?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Max-height constraint on the expandable content area.
|
||||
* - `"md"` (default): caps at 20rem with vertical scroll.
|
||||
* - `"fit"`: no max-height — content takes its natural height.
|
||||
*
|
||||
* @default "md"
|
||||
*/
|
||||
expandableContentHeight?: Extract<SizeVariants, "md" | "fit">;
|
||||
};
|
||||
|
||||
type CardProps = CardPlainProps | CardExpandableProps;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Card({
|
||||
padding: paddingProp = "md",
|
||||
rounding: roundingProp = "md",
|
||||
background = "light",
|
||||
border = "none",
|
||||
ref,
|
||||
children,
|
||||
}: CardProps) {
|
||||
/**
|
||||
* A container with configurable background, border, padding, and rounding.
|
||||
*
|
||||
* Has two mutually-exclusive modes:
|
||||
*
|
||||
* - **Plain** (default): renders `children` inside a single styled `<div>`.
|
||||
* Same shape as the original Card.
|
||||
*
|
||||
* - **Expandable** (`expandable: true`): renders `children` as the header
|
||||
* region and the `expandedContent` prop as an animating body below. Fold state is
|
||||
* fully controlled via the `expanded` prop — Card does not own state and
|
||||
* does not wire a click trigger. Callers attach their own
|
||||
* `onClick={() => setExpanded(v => !v)}` to whatever element they want to
|
||||
* act as the toggle.
|
||||
*
|
||||
* @example Plain
|
||||
* ```tsx
|
||||
* <Card padding="md" border="solid">
|
||||
* <p>Hello</p>
|
||||
* </Card>
|
||||
* ```
|
||||
*
|
||||
* @example Expandable, controlled
|
||||
* ```tsx
|
||||
* const [open, setOpen] = useState(false);
|
||||
* <Card
|
||||
* expandable
|
||||
* expanded={open}
|
||||
* expandedContent={<ModelList />}
|
||||
* border="solid"
|
||||
* >
|
||||
* <button onClick={() => setOpen(v => !v)}>Toggle</button>
|
||||
* </Card>
|
||||
* ```
|
||||
*/
|
||||
function Card(props: CardProps) {
|
||||
const {
|
||||
padding: paddingProp = "md",
|
||||
rounding: roundingProp = "md",
|
||||
background = "light",
|
||||
border = "none",
|
||||
borderColor = "default",
|
||||
ref,
|
||||
children,
|
||||
} = props;
|
||||
|
||||
const padding = paddingVariants[paddingProp];
|
||||
const rounding = cardRoundingVariants[roundingProp];
|
||||
|
||||
// Plain mode — unchanged behavior
|
||||
if (!props.expandable) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("opal-card", padding, cardRoundingVariants[roundingProp])}
|
||||
data-background={background}
|
||||
data-border={border}
|
||||
data-opal-status-border={borderColor}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expandable mode
|
||||
const {
|
||||
expanded = false,
|
||||
expandedContent,
|
||||
expandableContentHeight = "md",
|
||||
} = props;
|
||||
const showContent = expanded && expandedContent !== undefined;
|
||||
const headerRounding = showContent
|
||||
? cardTopRoundingVariants[roundingProp]
|
||||
: cardRoundingVariants[roundingProp];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("opal-card", padding, rounding)}
|
||||
data-background={background}
|
||||
data-border={border}
|
||||
>
|
||||
{children}
|
||||
<div ref={ref} className="opal-card-expandable">
|
||||
<div
|
||||
className={cn("opal-card-expandable-header", padding, headerRounding)}
|
||||
data-background={background}
|
||||
data-border={border}
|
||||
data-opal-status-border={borderColor}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{expandedContent !== undefined && (
|
||||
<div
|
||||
className="opal-card-expandable-wrapper"
|
||||
data-expanded={showContent ? "true" : "false"}
|
||||
>
|
||||
<div className="opal-card-expandable-inner">
|
||||
<div
|
||||
className={cn(
|
||||
"opal-card-expandable-body",
|
||||
cardBottomRoundingVariants[roundingProp]
|
||||
)}
|
||||
data-border={border}
|
||||
data-opal-status-border={borderColor}
|
||||
data-content-height={expandableContentHeight}
|
||||
>
|
||||
{expandedContent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/* ============================================================================
|
||||
Plain Card
|
||||
============================================================================ */
|
||||
|
||||
.opal-card {
|
||||
@apply w-full overflow-clip;
|
||||
}
|
||||
@@ -15,7 +19,8 @@
|
||||
@apply bg-background-tint-01;
|
||||
}
|
||||
|
||||
/* Border variants */
|
||||
/* Border variants. Border *color* lives in `cards/shared.css` and is keyed
|
||||
off the `data-opal-status-border` attribute. */
|
||||
.opal-card[data-border="none"] {
|
||||
border: none;
|
||||
}
|
||||
@@ -27,3 +32,101 @@
|
||||
.opal-card[data-border="solid"] {
|
||||
@apply border;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Expandable Card
|
||||
|
||||
Structure:
|
||||
.opal-card-expandable (flex-col wrapper, no styling)
|
||||
.opal-card-expandable-header (bg + border + padding + rounding)
|
||||
.opal-card-expandable-wrapper (grid animation, overflow clip)
|
||||
.opal-card-expandable-inner (min-h:0, overflow clip for grid)
|
||||
.opal-card-expandable-body (bg + border-minus-top + padding)
|
||||
|
||||
Animation: pure CSS grid `0fr ↔ 1fr` with opacity fade on the wrapper.
|
||||
No JS state machine, no Radix.
|
||||
============================================================================ */
|
||||
|
||||
.opal-card-expandable {
|
||||
@apply w-full flex flex-col;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.opal-card-expandable-header {
|
||||
@apply w-full overflow-clip transition-[border-radius] duration-200 ease-out;
|
||||
}
|
||||
|
||||
.opal-card-expandable-header[data-background="none"] {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
.opal-card-expandable-header[data-background="light"] {
|
||||
@apply bg-background-tint-00;
|
||||
}
|
||||
|
||||
.opal-card-expandable-header[data-background="heavy"] {
|
||||
@apply bg-background-tint-01;
|
||||
}
|
||||
|
||||
.opal-card-expandable-header[data-border="none"] {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.opal-card-expandable-header[data-border="dashed"] {
|
||||
@apply border border-dashed;
|
||||
}
|
||||
|
||||
.opal-card-expandable-header[data-border="solid"] {
|
||||
@apply border;
|
||||
}
|
||||
|
||||
/* ── Content wrapper: grid 0fr↔1fr animation ─────────────────────────── */
|
||||
|
||||
.opal-card-expandable-wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
grid-template-rows 200ms ease-out,
|
||||
opacity 200ms ease-out;
|
||||
}
|
||||
|
||||
.opal-card-expandable-wrapper[data-expanded="true"] {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Content inner: clips the grid child so it collapses to 0 cleanly ── */
|
||||
|
||||
.opal-card-expandable-inner {
|
||||
@apply overflow-hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Content body: carries border + padding. Background is always
|
||||
transparent so the page background shows through the content slot,
|
||||
keeping it visually distinct from the header. ─ */
|
||||
|
||||
.opal-card-expandable-body {
|
||||
@apply w-full bg-transparent;
|
||||
}
|
||||
|
||||
.opal-card-expandable-body[data-content-height="md"] {
|
||||
@apply max-h-[20rem] overflow-y-auto;
|
||||
}
|
||||
|
||||
/* "fit" = no constraint; natural height, no scroll. */
|
||||
|
||||
.opal-card-expandable-body[data-border="none"] {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.opal-card-expandable-body[data-border="dashed"] {
|
||||
@apply border border-t-0 border-dashed;
|
||||
}
|
||||
|
||||
.opal-card-expandable-body[data-border="solid"] {
|
||||
@apply border border-t-0;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import { SvgSparkle, SvgUsers } from "@opal/icons";
|
||||
import { SvgActions, SvgServer, SvgSparkle, SvgUsers } from "@opal/icons";
|
||||
|
||||
const PADDING_VARIANTS = ["fit", "2xs", "xs", "sm", "md", "lg"] as const;
|
||||
|
||||
@@ -26,6 +26,22 @@ export const WithCustomIcon: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const MainUi: Story = {
|
||||
args: {
|
||||
sizePreset: "main-ui",
|
||||
title: "No Actions Found",
|
||||
icon: SvgActions,
|
||||
description: "Provide OpenAPI schema to preview actions here.",
|
||||
},
|
||||
};
|
||||
|
||||
export const MainUiNoDescription: Story = {
|
||||
args: {
|
||||
sizePreset: "main-ui",
|
||||
title: "No Knowledge",
|
||||
},
|
||||
};
|
||||
|
||||
export const PaddingVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
@@ -46,6 +62,12 @@ export const Multiple: Story = {
|
||||
<EmptyMessageCard title="No models available." />
|
||||
<EmptyMessageCard icon={SvgSparkle} title="No agents selected." />
|
||||
<EmptyMessageCard icon={SvgUsers} title="No groups added." />
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
icon={SvgServer}
|
||||
title="No Discord servers configured yet"
|
||||
description="Create a server configuration to get started."
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -6,25 +6,44 @@ A pre-configured Card for empty states. Renders a transparent card with a dashed
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| --------- | --------------------------- | ---------- | -------------------------------- |
|
||||
| `icon` | `IconFunctionComponent` | `SvgEmpty` | Icon displayed alongside the title |
|
||||
| `title` | `string` | — | Primary message text (required) |
|
||||
| `padding` | `PaddingVariants` | `"sm"` | Padding preset for the card |
|
||||
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
|
||||
### Base props (all presets)
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------ | ----------------------------- | ------------- | ---------------------------------- |
|
||||
| `sizePreset` | `"secondary" \| "main-ui"` | `"secondary"` | Controls layout and text sizing |
|
||||
| `icon` | `IconFunctionComponent` | `SvgEmpty` | Icon displayed alongside the title |
|
||||
| `title` | `string \| RichStr` | — | Primary message text (required) |
|
||||
| `padding` | `PaddingVariants` | `"md"` | Padding preset for the card |
|
||||
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
|
||||
|
||||
### `sizePreset="main-ui"` only
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------- | ------------------- | ------- | ------------------------ |
|
||||
| `description` | `string \| RichStr` | — | Optional description text |
|
||||
|
||||
> `description` is **not accepted** when `sizePreset` is `"secondary"` (the default).
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import { SvgSparkle, SvgFileText } from "@opal/icons";
|
||||
import { SvgSparkle, SvgFileText, SvgActions } from "@opal/icons";
|
||||
|
||||
// Default empty state
|
||||
// Default empty state (secondary)
|
||||
<EmptyMessageCard title="No items yet." />
|
||||
|
||||
// With custom icon
|
||||
<EmptyMessageCard icon={SvgSparkle} title="No agents selected." />
|
||||
|
||||
// With custom padding
|
||||
// main-ui with description
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
icon={SvgActions}
|
||||
title="No Actions Found"
|
||||
description="Provide OpenAPI schema to preview actions here."
|
||||
/>
|
||||
|
||||
// Custom padding
|
||||
<EmptyMessageCard padding="xs" icon={SvgFileText} title="No documents available." />
|
||||
```
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Card } from "@opal/components/cards/card/components";
|
||||
import { Content, SizePreset } from "@opal/layouts";
|
||||
import { Content } from "@opal/layouts";
|
||||
import { SvgEmpty } from "@opal/icons";
|
||||
import type {
|
||||
IconFunctionComponent,
|
||||
@@ -32,7 +32,7 @@ type EmptyMessageCardProps =
|
||||
})
|
||||
| (EmptyMessageCardBaseProps & {
|
||||
sizePreset: "main-ui";
|
||||
/** Description text. Only supported when `sizePreset` is `"main-ui"`. */
|
||||
/** Optional description text. */
|
||||
description?: string | RichStr;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import "@opal/components/cards/shared.css";
|
||||
import "@opal/components/cards/message-card/styles.css";
|
||||
import { cn } from "@opal/utils";
|
||||
import type { RichStr, IconFunctionComponent } from "@opal/types";
|
||||
import type {
|
||||
IconFunctionComponent,
|
||||
PaddingVariants,
|
||||
RichStr,
|
||||
StatusVariants,
|
||||
} from "@opal/types";
|
||||
import { paddingVariants } from "@opal/shared";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Button, Divider } from "@opal/components";
|
||||
import {
|
||||
@@ -15,11 +22,9 @@ import {
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MessageCardVariant = "default" | "info" | "success" | "warning" | "error";
|
||||
|
||||
interface MessageCardBaseProps {
|
||||
/** Visual variant controlling background, border, and icon. @default "default" */
|
||||
variant?: MessageCardVariant;
|
||||
variant?: StatusVariants;
|
||||
|
||||
/** Override the default variant icon. */
|
||||
icon?: IconFunctionComponent;
|
||||
@@ -30,6 +35,9 @@ interface MessageCardBaseProps {
|
||||
/** Optional description below the title. */
|
||||
description?: string | RichStr;
|
||||
|
||||
/** Padding preset. @default "sm" */
|
||||
padding?: Extract<PaddingVariants, "sm" | "xs">;
|
||||
|
||||
/**
|
||||
* Content rendered below a divider, under the main content area.
|
||||
* When provided, a `Divider` is inserted between the `ContentAction` and this node.
|
||||
@@ -59,7 +67,7 @@ type MessageCardProps = MessageCardBaseProps &
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VARIANT_CONFIG: Record<
|
||||
MessageCardVariant,
|
||||
StatusVariants,
|
||||
{ icon: IconFunctionComponent; iconClass: string }
|
||||
> = {
|
||||
default: { icon: SvgAlertCircle, iconClass: "stroke-text-03" },
|
||||
@@ -113,6 +121,7 @@ function MessageCard({
|
||||
icon: iconOverride,
|
||||
title,
|
||||
description,
|
||||
padding = "sm",
|
||||
bottomChildren,
|
||||
rightChildren,
|
||||
onClose,
|
||||
@@ -134,7 +143,11 @@ function MessageCard({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="opal-message-card" data-variant={variant} ref={ref}>
|
||||
<div
|
||||
className={cn("opal-message-card", paddingVariants[padding])}
|
||||
data-variant={variant}
|
||||
ref={ref}
|
||||
>
|
||||
<ContentAction
|
||||
icon={(props) => (
|
||||
<Icon {...props} className={cn(props.className, iconClass)} />
|
||||
@@ -143,7 +156,7 @@ function MessageCard({
|
||||
description={description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="lg"
|
||||
paddingVariant="md"
|
||||
rightChildren={right}
|
||||
/>
|
||||
|
||||
@@ -157,4 +170,4 @@ function MessageCard({
|
||||
);
|
||||
}
|
||||
|
||||
export { MessageCard, type MessageCardProps, type MessageCardVariant };
|
||||
export { MessageCard, type MessageCardProps };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.opal-message-card {
|
||||
@apply flex flex-col self-stretch rounded-16 border p-2;
|
||||
@apply flex flex-col w-full self-stretch rounded-16 border;
|
||||
}
|
||||
|
||||
/* Variant colors */
|
||||
|
||||
32
web/lib/opal/src/components/cards/shared.css
Normal file
32
web/lib/opal/src/components/cards/shared.css
Normal file
@@ -0,0 +1,32 @@
|
||||
/* ============================================================================
|
||||
Shared status-border palette for card components.
|
||||
|
||||
Sets the `border-color` per `StatusVariants` value. Components opt in by
|
||||
setting the `data-opal-status-border` attribute on their root element
|
||||
(and, separately, declaring an actual border via Tailwind's `border` /
|
||||
`border-dashed` utility).
|
||||
|
||||
Used by:
|
||||
- Card (`borderColor` prop)
|
||||
- MessageCard (`variant` prop, in addition to its own background)
|
||||
============================================================================ */
|
||||
|
||||
[data-opal-status-border="default"] {
|
||||
@apply border-border-01;
|
||||
}
|
||||
|
||||
[data-opal-status-border="info"] {
|
||||
@apply border-status-info-02;
|
||||
}
|
||||
|
||||
[data-opal-status-border="success"] {
|
||||
@apply border-status-success-02;
|
||||
}
|
||||
|
||||
[data-opal-status-border="warning"] {
|
||||
@apply border-status-warning-02;
|
||||
}
|
||||
|
||||
[data-opal-status-border="error"] {
|
||||
@apply border-status-error-02;
|
||||
}
|
||||
@@ -42,6 +42,12 @@ export {
|
||||
type SidebarTabProps,
|
||||
} from "@opal/components/buttons/sidebar-tab/components";
|
||||
|
||||
/* LinkButton */
|
||||
export {
|
||||
LinkButton,
|
||||
type LinkButtonProps,
|
||||
} from "@opal/components/buttons/link-button/components";
|
||||
|
||||
/* Text */
|
||||
export {
|
||||
Text,
|
||||
@@ -87,7 +93,6 @@ export {
|
||||
export {
|
||||
MessageCard,
|
||||
type MessageCardProps,
|
||||
type MessageCardVariant,
|
||||
} from "@opal/components/cards/message-card/components";
|
||||
|
||||
/* Pagination */
|
||||
|
||||
@@ -184,6 +184,7 @@ export { default as SvgUserSpeaker } from "@opal/icons/user-speaker";
|
||||
export { default as SvgUserSync } from "@opal/icons/user-sync";
|
||||
export { default as SvgUserX } from "@opal/icons/user-x";
|
||||
export { default as SvgUsers } from "@opal/icons/users";
|
||||
export { default as SvgVector } from "@opal/icons/vector";
|
||||
export { default as SvgVolume } from "@opal/icons/volume";
|
||||
export { default as SvgVolumeOff } from "@opal/icons/volume-off";
|
||||
export { default as SvgWallet } from "@opal/icons/wallet";
|
||||
|
||||
20
web/lib/opal/src/icons/vector.tsx
Normal file
20
web/lib/opal/src/icons/vector.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
const SvgVector = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M8 2L6 4M8 2L8 9M8 2L10 4M8 9L14.0622 12.5M8 9L1.93782 12.5M14.0622 12.5L11.3301 13.232M14.0622 12.5L13.3301 9.76794M1.93782 12.5L4.66987 13.2321M1.93782 12.5L2.66987 9.76795"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgVector;
|
||||
@@ -1,48 +1,62 @@
|
||||
import { Content, type ContentProps } from "@opal/layouts/content/components";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CardHeaderProps = ContentProps & {
|
||||
/** Content rendered to the right of the Content block. */
|
||||
rightChildren?: React.ReactNode;
|
||||
interface CardHeaderProps {
|
||||
/** Content rendered in the top-left header slot — typically a {@link Content} block. */
|
||||
headerChildren?: React.ReactNode;
|
||||
|
||||
/** Content rendered below `rightChildren` in the same column. */
|
||||
/** Content rendered to the right of `headerChildren` (top of right column). */
|
||||
topRightChildren?: React.ReactNode;
|
||||
|
||||
/** Content rendered below `topRightChildren`, in the same column. */
|
||||
bottomRightChildren?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Content rendered below the header row, full-width.
|
||||
* Use for expandable sections, search bars, or any content
|
||||
* that should appear beneath the icon/title/actions row.
|
||||
* Content rendered below the entire header (left + right columns),
|
||||
* spanning the full width. Use for expandable sections, search bars, or
|
||||
* any content that should appear beneath the icon/title/actions row.
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
bottomChildren?: React.ReactNode;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card.Header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A card header layout that pairs a {@link Content} block (with `p-2`)
|
||||
* with a right-side column.
|
||||
* A card header layout with three optional slots arranged in two independent
|
||||
* columns, plus a full-width `bottomChildren` slot below.
|
||||
*
|
||||
* The right column contains two vertically stacked slots —
|
||||
* `rightChildren` on top, `bottomRightChildren` below — with no
|
||||
* padding or gap between them.
|
||||
* ```
|
||||
* +------------------+----------------+
|
||||
* | headerChildren | topRight |
|
||||
* + +----------------+
|
||||
* | | bottomRight |
|
||||
* +------------------+----------------+
|
||||
* | bottomChildren (full width) |
|
||||
* +-----------------------------------+
|
||||
* ```
|
||||
*
|
||||
* The optional `children` slot renders below the full header row,
|
||||
* spanning the entire width.
|
||||
* The left column grows to fill available space; the right column shrinks
|
||||
* to fit its content. The two columns are independent in height.
|
||||
*
|
||||
* For the typical icon/title/description pattern, pass a {@link Content}
|
||||
* (or {@link ContentAction}) into `headerChildren`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Card.Header
|
||||
* icon={SvgGlobe}
|
||||
* title="Google"
|
||||
* description="Search engine"
|
||||
* sizePreset="main-ui"
|
||||
* variant="section"
|
||||
* rightChildren={<Button>Connect</Button>}
|
||||
* headerChildren={
|
||||
* <Content
|
||||
* icon={SvgGlobe}
|
||||
* title="Google"
|
||||
* description="Search engine"
|
||||
* sizePreset="main-ui"
|
||||
* variant="section"
|
||||
* />
|
||||
* }
|
||||
* topRightChildren={<Button>Connect</Button>}
|
||||
* bottomRightChildren={
|
||||
* <>
|
||||
* <Button icon={SvgUnplug} size="sm" prominence="tertiary" />
|
||||
@@ -53,29 +67,29 @@ type CardHeaderProps = ContentProps & {
|
||||
* ```
|
||||
*/
|
||||
function Header({
|
||||
rightChildren,
|
||||
headerChildren,
|
||||
topRightChildren,
|
||||
bottomRightChildren,
|
||||
children,
|
||||
...contentProps
|
||||
bottomChildren,
|
||||
}: CardHeaderProps) {
|
||||
const hasRight = rightChildren || bottomRightChildren;
|
||||
const hasRight = topRightChildren != null || bottomRightChildren != null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-row items-stretch w-full">
|
||||
<div className="flex-1 min-w-0 self-start p-2">
|
||||
<Content {...contentProps} />
|
||||
</div>
|
||||
<div className="flex flex-row items-start w-full">
|
||||
{headerChildren != null && (
|
||||
<div className="self-start p-2 grow min-w-0">{headerChildren}</div>
|
||||
)}
|
||||
{hasRight && (
|
||||
<div className="flex flex-col items-end shrink-0">
|
||||
{rightChildren && <div className="flex-1">{rightChildren}</div>}
|
||||
{bottomRightChildren && (
|
||||
{topRightChildren != null && <div>{topRightChildren}</div>}
|
||||
{bottomRightChildren != null && (
|
||||
<div className="flex flex-row">{bottomRightChildren}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{children && <div className="w-full">{children}</div>}
|
||||
{bottomChildren != null && <div className="w-full">{bottomChildren}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,6 +134,20 @@ const cardRoundingVariants: Record<RoundingVariants, string> = {
|
||||
xs: "rounded-04",
|
||||
};
|
||||
|
||||
const cardTopRoundingVariants: Record<RoundingVariants, string> = {
|
||||
lg: "rounded-t-16",
|
||||
md: "rounded-t-12",
|
||||
sm: "rounded-t-08",
|
||||
xs: "rounded-t-04",
|
||||
};
|
||||
|
||||
const cardBottomRoundingVariants: Record<RoundingVariants, string> = {
|
||||
lg: "rounded-b-16",
|
||||
md: "rounded-b-12",
|
||||
sm: "rounded-b-08",
|
||||
xs: "rounded-b-04",
|
||||
};
|
||||
|
||||
export {
|
||||
type ExtremaSizeVariants,
|
||||
type ContainerSizeVariants,
|
||||
@@ -144,6 +158,8 @@ export {
|
||||
paddingXVariants,
|
||||
paddingYVariants,
|
||||
cardRoundingVariants,
|
||||
cardTopRoundingVariants,
|
||||
cardBottomRoundingVariants,
|
||||
widthVariants,
|
||||
heightVariants,
|
||||
};
|
||||
|
||||
@@ -81,6 +81,22 @@ export type ExtremaSizeVariants = Extract<SizeVariants, "fit" | "full">;
|
||||
*/
|
||||
export type OverridableExtremaSizeVariants = ExtremaSizeVariants | number;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status Variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Severity / status variants used by alert-style components (e.g. {@link
|
||||
* MessageCard}, {@link Card}'s `borderColor`). Each variant maps to a
|
||||
* dedicated background/border/icon palette in the design system.
|
||||
*/
|
||||
export type StatusVariants =
|
||||
| "default"
|
||||
| "info"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "error";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icon Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { mutate } from "swr";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { SvgArrowUpCircle, SvgWallet } from "@opal/icons";
|
||||
import type { IconProps } from "@opal/types";
|
||||
@@ -19,7 +18,7 @@ import {
|
||||
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
import { SWR_KEYS } from "@/lib/swr-keys";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import { MessageCard } from "@opal/components";
|
||||
import { LinkButton, MessageCard } from "@opal/components";
|
||||
|
||||
import PlansView from "./PlansView";
|
||||
import CheckoutView from "./CheckoutView";
|
||||
@@ -72,25 +71,10 @@ function FooterLinks({
|
||||
<Text secondaryBody text03>
|
||||
Have a license key?
|
||||
</Text>
|
||||
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
|
||||
<Button action tertiary onClick={onActivateLicense}>
|
||||
<Text secondaryBody text05 className="underline">
|
||||
{licenseText}
|
||||
</Text>
|
||||
</Button>
|
||||
<LinkButton onClick={onActivateLicense}>{licenseText}</LinkButton>
|
||||
</>
|
||||
)}
|
||||
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
|
||||
<Button
|
||||
action
|
||||
tertiary
|
||||
href={billingHelpHref}
|
||||
className="billing-text-link"
|
||||
>
|
||||
<Text secondaryBody text03 className="underline">
|
||||
Billing Help
|
||||
</Text>
|
||||
</Button>
|
||||
<LinkButton href={billingHelpHref}>Billing Help</LinkButton>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { DeleteButton } from "@/components/DeleteButton";
|
||||
import { Button } from "@opal/components";
|
||||
import Switch from "@/refresh-components/inputs/Switch";
|
||||
import { SvgEdit, SvgServer } from "@opal/icons";
|
||||
import EmptyMessage from "@/refresh-components/EmptyMessage";
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import { DiscordGuildConfig } from "@/app/admin/discord-bot/types";
|
||||
import {
|
||||
deleteGuildConfig,
|
||||
@@ -81,7 +81,8 @@ export function DiscordGuildsTable({ guilds, onRefresh }: Props) {
|
||||
|
||||
if (guilds.length === 0) {
|
||||
return (
|
||||
<EmptyMessage
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
icon={SvgServer}
|
||||
title="No Discord servers configured yet"
|
||||
description="Create a server configuration to get started."
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import Switch from "@/refresh-components/inputs/Switch";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import EmptyMessage from "@/refresh-components/EmptyMessage";
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import {
|
||||
@@ -61,7 +61,8 @@ export function DiscordChannelsTable({
|
||||
}: Props) {
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<EmptyMessage
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
title="No channels configured"
|
||||
description="Run !sync-channels in Discord to add channels."
|
||||
/>
|
||||
|
||||
@@ -34,7 +34,8 @@ export const PROVIDERS: ProviderConfig[] = [
|
||||
providerName: LLMProviderName.ANTHROPIC,
|
||||
recommended: true,
|
||||
models: [
|
||||
{ name: "claude-opus-4-6", label: "Claude Opus 4.6", recommended: true },
|
||||
{ name: "claude-opus-4-7", label: "Claude Opus 4.7", recommended: true },
|
||||
{ name: "claude-opus-4-6", label: "Claude Opus 4.6" },
|
||||
{ name: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
||||
],
|
||||
apiKeyPlaceholder: "sk-ant-...",
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
export interface BuildLlmSelection {
|
||||
providerName: string; // e.g., "build-mode-anthropic" (LLMProviderDescriptor.name)
|
||||
provider: string; // e.g., "anthropic"
|
||||
modelName: string; // e.g., "claude-opus-4-6"
|
||||
modelName: string; // e.g., "claude-opus-4-7"
|
||||
}
|
||||
|
||||
// Priority order for smart default LLM selection
|
||||
const LLM_SELECTION_PRIORITY = [
|
||||
{ provider: "anthropic", modelName: "claude-opus-4-6" },
|
||||
{ provider: "anthropic", modelName: "claude-opus-4-7" },
|
||||
{ provider: "openai", modelName: "gpt-5.2" },
|
||||
{ provider: "openrouter", modelName: "minimax/minimax-m2.1" },
|
||||
] as const;
|
||||
@@ -63,10 +63,11 @@ export function getDefaultLlmSelection(
|
||||
export const RECOMMENDED_BUILD_MODELS = {
|
||||
preferred: {
|
||||
provider: "anthropic",
|
||||
modelName: "claude-opus-4-6",
|
||||
displayName: "Claude Opus 4.6",
|
||||
modelName: "claude-opus-4-7",
|
||||
displayName: "Claude Opus 4.7",
|
||||
},
|
||||
alternatives: [
|
||||
{ provider: "anthropic", modelName: "claude-opus-4-6" },
|
||||
{ provider: "anthropic", modelName: "claude-sonnet-4-6" },
|
||||
{ provider: "openai", modelName: "gpt-5.2" },
|
||||
{ provider: "openai", modelName: "gpt-5.1-codex" },
|
||||
@@ -148,7 +149,8 @@ export const BUILD_MODE_PROVIDERS: BuildModeProvider[] = [
|
||||
providerName: "anthropic",
|
||||
recommended: true,
|
||||
models: [
|
||||
{ name: "claude-opus-4-6", label: "Claude Opus 4.6", recommended: true },
|
||||
{ name: "claude-opus-4-7", label: "Claude Opus 4.7", recommended: true },
|
||||
{ name: "claude-opus-4-6", label: "Claude Opus 4.6" },
|
||||
{ name: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
||||
],
|
||||
apiKeyPlaceholder: "sk-ant-...",
|
||||
|
||||
@@ -271,6 +271,22 @@ export default function UserLibraryModal({
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* The exact cap is controlled by the backend env var
|
||||
MAX_EMBEDDED_IMAGES_PER_FILE (default 500). This copy is
|
||||
deliberately vague so it doesn't drift if the limit is
|
||||
tuned per-deployment; the precise number is surfaced in
|
||||
the rejection error the server returns. */}
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="end"
|
||||
padding={0.5}
|
||||
height="fit"
|
||||
>
|
||||
<Text secondaryBody text03>
|
||||
PDFs with many embedded images may be rejected.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{isLoading ? (
|
||||
<Section padding={2} height="fit">
|
||||
<Text secondaryBody text03>
|
||||
|
||||
@@ -7,4 +7,6 @@
|
||||
--container-md: 54.5rem;
|
||||
--container-lg: 62rem;
|
||||
--container-full: 100%;
|
||||
|
||||
--toast-width: 25rem;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { ValidSources } from "@/lib/types";
|
||||
import { SourceIcon } from "./SourceIcon";
|
||||
import { useState } from "react";
|
||||
import { OnyxIcon } from "./icons/icons";
|
||||
import { GithubIcon, OnyxIcon } from "./icons/icons";
|
||||
|
||||
export function WebResultIcon({
|
||||
url,
|
||||
@@ -23,6 +23,8 @@ export function WebResultIcon({
|
||||
<>
|
||||
{hostname.includes("onyx.app") ? (
|
||||
<OnyxIcon size={size} className="dark:text-[#fff] text-[#000]" />
|
||||
) : hostname === "github.com" || hostname.endsWith(".github.com") ? (
|
||||
<GithubIcon size={size} />
|
||||
) : !error ? (
|
||||
<img
|
||||
className="my-0 rounded-full py-0"
|
||||
|
||||
@@ -46,6 +46,7 @@ import freshdeskIcon from "@public/Freshdesk.png";
|
||||
import geminiSVG from "@public/Gemini.svg";
|
||||
import gitbookDarkIcon from "@public/GitBookDark.png";
|
||||
import gitbookLightIcon from "@public/GitBookLight.png";
|
||||
import githubDarkIcon from "@public/GithubDarkMode.png";
|
||||
import githubLightIcon from "@public/Github.png";
|
||||
import gongIcon from "@public/Gong.png";
|
||||
import googleIcon from "@public/Google.png";
|
||||
@@ -855,7 +856,7 @@ export const GitbookIcon = createLogoIcon(gitbookDarkIcon, {
|
||||
darkSrc: gitbookLightIcon,
|
||||
});
|
||||
export const GithubIcon = createLogoIcon(githubLightIcon, {
|
||||
monochromatic: true,
|
||||
darkSrc: githubDarkIcon,
|
||||
});
|
||||
export const GitlabIcon = createLogoIcon(gitlabIcon);
|
||||
export const GmailIcon = createLogoIcon(gmailIcon);
|
||||
|
||||
@@ -12,9 +12,9 @@ interface LLMOption {
|
||||
value: string;
|
||||
icon: ReturnType<typeof getModelIcon>;
|
||||
modelName: string;
|
||||
providerId: number;
|
||||
providerName: string;
|
||||
provider: string;
|
||||
providerDisplayName: string;
|
||||
supportsImageInput: boolean;
|
||||
vendor: string | null;
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export default function LLMSelector({
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${provider.provider}:${modelConfiguration.name}`;
|
||||
const key = `${provider.id}:${modelConfiguration.name}`;
|
||||
if (seenKeys.has(key)) {
|
||||
return; // Skip exact duplicate
|
||||
}
|
||||
@@ -87,10 +87,9 @@ export default function LLMSelector({
|
||||
),
|
||||
icon: getModelIcon(provider.provider, modelConfiguration.name),
|
||||
modelName: modelConfiguration.name,
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
provider: provider.provider,
|
||||
providerDisplayName:
|
||||
provider.provider_display_name || provider.provider,
|
||||
supportsImageInput,
|
||||
vendor: modelConfiguration.vendor || null,
|
||||
};
|
||||
@@ -108,33 +107,34 @@ export default function LLMSelector({
|
||||
requiresImageGeneration,
|
||||
]);
|
||||
|
||||
// Group options by provider using backend-provided display names
|
||||
// Group options by configured provider instance so multiple instances of the
|
||||
// same provider type (e.g., two Anthropic API keys) appear as separate groups
|
||||
// labeled with their user-given names.
|
||||
const groupedOptions = useMemo(() => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
number,
|
||||
{ displayName: string; options: LLMOption[] }
|
||||
>();
|
||||
|
||||
llmOptions.forEach((option) => {
|
||||
const provider = option.provider.toLowerCase();
|
||||
if (!groups.has(provider)) {
|
||||
groups.set(provider, {
|
||||
displayName: option.providerDisplayName,
|
||||
if (!groups.has(option.providerId)) {
|
||||
groups.set(option.providerId, {
|
||||
displayName: option.providerName,
|
||||
options: [],
|
||||
});
|
||||
}
|
||||
groups.get(provider)!.options.push(option);
|
||||
groups.get(option.providerId)!.options.push(option);
|
||||
});
|
||||
|
||||
// Sort groups alphabetically by display name
|
||||
const sortedProviders = Array.from(groups.keys()).sort((a, b) =>
|
||||
const sortedProviderIds = Array.from(groups.keys()).sort((a, b) =>
|
||||
groups.get(a)!.displayName.localeCompare(groups.get(b)!.displayName)
|
||||
);
|
||||
|
||||
return sortedProviders.map((provider) => {
|
||||
const group = groups.get(provider)!;
|
||||
return sortedProviderIds.map((providerId) => {
|
||||
const group = groups.get(providerId)!;
|
||||
return {
|
||||
provider,
|
||||
providerId,
|
||||
displayName: group.displayName,
|
||||
options: group.options,
|
||||
};
|
||||
@@ -179,7 +179,7 @@ export default function LLMSelector({
|
||||
)}
|
||||
{showGrouped
|
||||
? groupedOptions.map((group) => (
|
||||
<InputSelect.Group key={group.provider}>
|
||||
<InputSelect.Group key={group.providerId}>
|
||||
<InputSelect.Label>{group.displayName}</InputSelect.Label>
|
||||
{group.options.map((option) => (
|
||||
<InputSelect.Item
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { Formik, Form, useFormikContext } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { Button, Text } from "@opal/components";
|
||||
import { Button, LinkButton, Text } from "@opal/components";
|
||||
import {
|
||||
SvgCheckCircle,
|
||||
SvgShareWebhook,
|
||||
@@ -286,16 +286,9 @@ export default function HookFormModal({
|
||||
widthVariant="fit"
|
||||
/>
|
||||
{docsUrl && (
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline leading-none"
|
||||
>
|
||||
<Text font="secondary-body" color="text-03">
|
||||
Documentation
|
||||
</Text>
|
||||
</a>
|
||||
<LinkButton href={docsUrl} target="_blank">
|
||||
Documentation
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
useModalClose,
|
||||
} from "@/refresh-components/contexts/ModalContext";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import { Button, SelectCard, Text } from "@opal/components";
|
||||
import { Button, LinkButton, SelectCard, Text } from "@opal/components";
|
||||
import { Disabled, Hoverable } from "@opal/core";
|
||||
import { markdown } from "@opal/utils";
|
||||
import { Content, IllustrationContent } from "@opal/layouts";
|
||||
@@ -23,7 +23,6 @@ import Modal from "@/refresh-components/Modal";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
SvgBubbleText,
|
||||
SvgExternalLink,
|
||||
SvgFileBroadcast,
|
||||
SvgShareWebhook,
|
||||
SvgPlug,
|
||||
@@ -190,17 +189,11 @@ function UnconnectedHookCard({ spec, onConnect }: UnconnectedHookCardProps) {
|
||||
/>
|
||||
|
||||
{spec.docs_url && (
|
||||
<a
|
||||
href={spec.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-6 flex items-center gap-1 w-min"
|
||||
>
|
||||
<span className="underline font-secondary-body text-text-03">
|
||||
<div className="ml-6">
|
||||
<LinkButton href={spec.docs_url} target="_blank">
|
||||
Documentation
|
||||
</span>
|
||||
<SvgExternalLink size={12} className="shrink-0" />
|
||||
</a>
|
||||
</LinkButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -369,17 +362,11 @@ function ConnectedHookCard({
|
||||
/>
|
||||
|
||||
{spec?.docs_url && (
|
||||
<a
|
||||
href={spec.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-6 flex items-center gap-1 w-min"
|
||||
>
|
||||
<span className="underline font-secondary-body text-text-03">
|
||||
<div className="ml-6">
|
||||
<LinkButton href={spec.docs_url} target="_blank">
|
||||
Documentation
|
||||
</span>
|
||||
<SvgExternalLink size={12} className="shrink-0" />
|
||||
</a>
|
||||
</LinkButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@/lib/search/interfaces";
|
||||
import SearchCard from "@/ee/sections/SearchCard";
|
||||
import { Divider, Pagination } from "@opal/components";
|
||||
import EmptyMessage from "@/refresh-components/EmptyMessage";
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import { IllustrationContent } from "@opal/layouts";
|
||||
import SvgNoResult from "@opal/illustrations/no-result";
|
||||
import { getSourceMetadata } from "@/lib/sources";
|
||||
@@ -334,7 +334,11 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
)}
|
||||
>
|
||||
{error ? (
|
||||
<EmptyMessage title="Search failed" description={error} />
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
title="Search failed"
|
||||
description={error}
|
||||
/>
|
||||
) : paginatedResults.length > 0 ? (
|
||||
<>
|
||||
{paginatedResults.map((doc) => (
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* Actions Layout Components
|
||||
*
|
||||
* A namespaced collection of components for building consistent action cards
|
||||
* (MCP servers, OpenAPI tools, etc.). These components provide a standardized
|
||||
* layout that separates presentation from business logic, making it easier to
|
||||
* build and maintain action-related UIs.
|
||||
*
|
||||
* Built on top of ExpandableCard layouts for the underlying card structure.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import * as ActionsLayouts from "@/layouts/actions-layouts";
|
||||
* import * as ExpandableCard from "@/layouts/expandable-card-layouts";
|
||||
* import { SvgServer } from "@opal/icons";
|
||||
* import Switch from "@/components/ui/switch";
|
||||
*
|
||||
* function MyActionCard() {
|
||||
* return (
|
||||
* <ExpandableCard.Root>
|
||||
* <ActionsLayouts.Header
|
||||
* title="My MCP Server"
|
||||
* description="A powerful MCP server for automation"
|
||||
* icon={SvgServer}
|
||||
* rightChildren={
|
||||
* <Button onClick={handleDisconnect}>Disconnect</Button>
|
||||
* }
|
||||
* />
|
||||
* <ActionsLayouts.Content>
|
||||
* <ActionsLayouts.Tool
|
||||
* title="File Reader"
|
||||
* description="Read files from the filesystem"
|
||||
* icon={SvgFile}
|
||||
* rightChildren={
|
||||
* <Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
* }
|
||||
* />
|
||||
* <ActionsLayouts.Tool
|
||||
* title="Web Search"
|
||||
* description="Search the web"
|
||||
* icon={SvgGlobe}
|
||||
* disabled={true}
|
||||
* rightChildren={
|
||||
* <Switch checked={false} disabled />
|
||||
* }
|
||||
* />
|
||||
* </ActionsLayouts.Content>
|
||||
* </ExpandableCard.Root>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { HtmlHTMLAttributes } from "react";
|
||||
import type { IconProps } from "@opal/types";
|
||||
import { WithoutStyles } from "@/types";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import * as ExpandableCard from "@/layouts/expandable-card-layouts";
|
||||
import { Card } from "@/refresh-components/cards";
|
||||
import { Label } from "@opal/layouts";
|
||||
|
||||
/**
|
||||
* Actions Header Component
|
||||
*
|
||||
* The header section of an action card. Displays icon, title, description,
|
||||
* and optional right-aligned actions.
|
||||
*
|
||||
* Features:
|
||||
* - Icon, title, and description display
|
||||
* - Custom right-aligned actions via rightChildren
|
||||
* - Responsive layout with truncated text
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Basic header
|
||||
* <ActionsLayouts.Header
|
||||
* title="File Server"
|
||||
* description="Manage local files"
|
||||
* icon={SvgFolder}
|
||||
* />
|
||||
*
|
||||
* // With actions
|
||||
* <ActionsLayouts.Header
|
||||
* title="API Server"
|
||||
* description="RESTful API integration"
|
||||
* icon={SvgCloud}
|
||||
* rightChildren={
|
||||
* <div className="flex gap-2">
|
||||
* <Button onClick={handleEdit}>Edit</Button>
|
||||
* <Button onClick={handleDelete}>Delete</Button>
|
||||
* </div>
|
||||
* }
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export interface ActionsHeaderProps
|
||||
extends WithoutStyles<HtmlHTMLAttributes<HTMLDivElement>> {
|
||||
// Core content
|
||||
name?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon: React.FunctionComponent<IconProps>;
|
||||
|
||||
// Custom content
|
||||
rightChildren?: React.ReactNode;
|
||||
}
|
||||
function ActionsHeader({
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
rightChildren,
|
||||
...props
|
||||
}: ActionsHeaderProps) {
|
||||
return (
|
||||
<ExpandableCard.Header>
|
||||
<div className="flex flex-col gap-2 pt-4 pb-2">
|
||||
<div className="px-4">
|
||||
<Label label={name}>
|
||||
<ContentAction
|
||||
icon={Icon}
|
||||
title={title}
|
||||
description={description}
|
||||
sizePreset="section"
|
||||
variant="section"
|
||||
rightChildren={rightChildren}
|
||||
paddingVariant="fit"
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
<div {...props} className="px-2" />
|
||||
</div>
|
||||
</ExpandableCard.Header>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions Content Component
|
||||
*
|
||||
* A container for the content area of an action card.
|
||||
* Use this to wrap tools, settings, or other expandable content.
|
||||
* Features a maximum height with scrollable overflow.
|
||||
*
|
||||
* IMPORTANT: Only ONE ActionsContent should be used within a single ExpandableCard.Root.
|
||||
* This component self-registers with the ActionsLayout context to inform
|
||||
* ActionsHeader whether content exists (for border-radius styling). Using
|
||||
* multiple ActionsContent components will cause incorrect unmount behavior -
|
||||
* when any one unmounts, it will incorrectly signal that no content exists,
|
||||
* even if other ActionsContent components remain mounted.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ActionsLayouts.Content>
|
||||
* <ActionsLayouts.Tool {...} />
|
||||
* <ActionsLayouts.Tool {...} />
|
||||
* </ActionsLayouts.Content>
|
||||
* ```
|
||||
*/
|
||||
function ActionsContent({
|
||||
children,
|
||||
...props
|
||||
}: WithoutStyles<React.HTMLAttributes<HTMLDivElement>>) {
|
||||
return (
|
||||
<ExpandableCard.Content {...props}>
|
||||
<div className="flex flex-col gap-2 p-2">{children}</div>
|
||||
</ExpandableCard.Content>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions Tool Component
|
||||
*
|
||||
* Represents a single tool within an actions content area. Displays the tool's
|
||||
* title, description, and icon. The component provides a label wrapper for
|
||||
* custom right-aligned controls (like toggle switches).
|
||||
*
|
||||
* Features:
|
||||
* - Tool title and description
|
||||
* - Custom icon
|
||||
* - Disabled state (applies strikethrough to title)
|
||||
* - Custom right-aligned content via rightChildren
|
||||
* - Responsive layout with truncated text
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Basic tool with switch
|
||||
* <ActionsLayouts.Tool
|
||||
* title="File Reader"
|
||||
* description="Read files from the filesystem"
|
||||
* icon={SvgFile}
|
||||
* rightChildren={
|
||||
* <Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
* }
|
||||
* />
|
||||
*
|
||||
* // Disabled tool
|
||||
* <ActionsLayouts.Tool
|
||||
* title="Premium Feature"
|
||||
* description="This feature requires a premium subscription"
|
||||
* icon={SvgLock}
|
||||
* disabled={true}
|
||||
* rightChildren={
|
||||
* <Switch checked={false} disabled />
|
||||
* }
|
||||
* />
|
||||
*
|
||||
* // Tool with custom action
|
||||
* <ActionsLayouts.Tool
|
||||
* name="config_tool"
|
||||
* title="Configuration"
|
||||
* description="Configure system settings"
|
||||
* icon={SvgSettings}
|
||||
* rightChildren={
|
||||
* <Button onClick={openSettings}>Configure</Button>
|
||||
* }
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export type ActionsToolProps = WithoutStyles<{
|
||||
// Core content
|
||||
name?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: React.FunctionComponent<IconProps>;
|
||||
|
||||
// State
|
||||
disabled?: boolean;
|
||||
rightChildren?: React.ReactNode;
|
||||
}>;
|
||||
function ActionsTool({
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
disabled,
|
||||
rightChildren,
|
||||
}: ActionsToolProps) {
|
||||
return (
|
||||
<Card padding={0.75} variant={disabled ? "disabled" : undefined}>
|
||||
<Label label={name} disabled={disabled}>
|
||||
<ContentAction
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
rightChildren={rightChildren}
|
||||
paddingVariant="fit"
|
||||
/>
|
||||
</Label>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ActionsHeader as Header,
|
||||
ActionsContent as Content,
|
||||
ActionsTool as Tool,
|
||||
};
|
||||
@@ -1,291 +0,0 @@
|
||||
/**
|
||||
* Expandable Card Layout Components
|
||||
*
|
||||
* A namespaced collection of components for building expandable cards with
|
||||
* collapsible content sections. These provide the structural foundation
|
||||
* without opinionated content styling - just pure containers.
|
||||
*
|
||||
* Use these components when you need:
|
||||
* - A card with a header that can have expandable content below it
|
||||
* - Automatic border-radius handling based on whether content exists/is folded
|
||||
* - Controlled or uncontrolled folding state
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import * as ExpandableCard from "@/layouts/expandable-card-layouts";
|
||||
*
|
||||
* // Uncontrolled — Root manages its own state
|
||||
* function MyCard() {
|
||||
* return (
|
||||
* <ExpandableCard.Root>
|
||||
* <ExpandableCard.Header>
|
||||
* <div className="p-4">
|
||||
* <h3>My Header</h3>
|
||||
* </div>
|
||||
* </ExpandableCard.Header>
|
||||
* <ExpandableCard.Content>
|
||||
* <div className="p-4">
|
||||
* <p>Expandable content goes here</p>
|
||||
* </div>
|
||||
* </ExpandableCard.Content>
|
||||
* </ExpandableCard.Root>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* // Controlled — consumer owns the state
|
||||
* function MyControlledCard() {
|
||||
* const [isFolded, setIsFolded] = useState(false);
|
||||
*
|
||||
* return (
|
||||
* <ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
|
||||
* <ExpandableCard.Header>
|
||||
* <button onClick={() => setIsFolded(!isFolded)}>Toggle</button>
|
||||
* </ExpandableCard.Header>
|
||||
* <ExpandableCard.Content>
|
||||
* <p>Content here</p>
|
||||
* </ExpandableCard.Content>
|
||||
* </ExpandableCard.Root>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useMemo,
|
||||
useLayoutEffect,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WithoutStyles } from "@/types";
|
||||
import ShadowDiv from "@/refresh-components/ShadowDiv";
|
||||
import { Section, SectionProps } from "@/layouts/general-layouts";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
} from "@/refresh-components/Collapsible";
|
||||
|
||||
/**
|
||||
* Expandable Card Context
|
||||
*
|
||||
* Provides folding state management for expandable cards without prop drilling.
|
||||
* Also tracks whether content is present via self-registration.
|
||||
*/
|
||||
interface ExpandableCardContextValue {
|
||||
isFolded: boolean;
|
||||
setIsFolded: Dispatch<SetStateAction<boolean>>;
|
||||
hasContent: boolean;
|
||||
registerContent: () => () => void;
|
||||
}
|
||||
|
||||
const ExpandableCardContext = createContext<
|
||||
ExpandableCardContextValue | undefined
|
||||
>(undefined);
|
||||
|
||||
function useExpandableCardContext() {
|
||||
const context = useContext(ExpandableCardContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"ExpandableCard components must be used within an ExpandableCard.Root"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expandable Card Root Component
|
||||
*
|
||||
* The root container and context provider for an expandable card. Provides a
|
||||
* flex column layout with no gap or padding by default.
|
||||
*
|
||||
* Supports both controlled and uncontrolled folding state:
|
||||
* - **Uncontrolled**: Manages its own state. Use `defaultFolded` to set the
|
||||
* initial folding state (defaults to `false`, i.e. expanded).
|
||||
* - **Controlled**: Pass `isFolded` and `onFoldedChange` to manage folding
|
||||
* state externally.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Uncontrolled
|
||||
* <ExpandableCard.Root>
|
||||
* <ExpandableCard.Header>...</ExpandableCard.Header>
|
||||
* <ExpandableCard.Content>...</ExpandableCard.Content>
|
||||
* </ExpandableCard.Root>
|
||||
*
|
||||
* // Uncontrolled, starts folded
|
||||
* <ExpandableCard.Root defaultFolded>
|
||||
* ...
|
||||
* </ExpandableCard.Root>
|
||||
*
|
||||
* // Controlled
|
||||
* const [isFolded, setIsFolded] = useState(false);
|
||||
* <ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
|
||||
* ...
|
||||
* </ExpandableCard.Root>
|
||||
* ```
|
||||
*/
|
||||
export interface ExpandableCardRootProps extends SectionProps {
|
||||
/** Controlled folding state. When provided, the component is controlled. */
|
||||
isFolded?: boolean;
|
||||
/** Callback when folding state changes. Required for controlled usage. */
|
||||
onFoldedChange?: Dispatch<SetStateAction<boolean>>;
|
||||
/** Initial folding state for uncontrolled usage. Defaults to `false`. */
|
||||
defaultFolded?: boolean;
|
||||
}
|
||||
|
||||
function ExpandableCardRoot({
|
||||
isFolded: controlledFolded,
|
||||
onFoldedChange,
|
||||
defaultFolded = false,
|
||||
...props
|
||||
}: ExpandableCardRootProps) {
|
||||
const [uncontrolledFolded, setUncontrolledFolded] = useState(defaultFolded);
|
||||
const isControlled = controlledFolded !== undefined;
|
||||
const isFolded = isControlled ? controlledFolded : uncontrolledFolded;
|
||||
const setIsFolded = isControlled
|
||||
? onFoldedChange ?? (() => {})
|
||||
: setUncontrolledFolded;
|
||||
|
||||
const [hasContent, setHasContent] = useState(false);
|
||||
|
||||
// Registration function for Content to announce its presence
|
||||
const registerContent = useMemo(
|
||||
() => () => {
|
||||
setHasContent(true);
|
||||
return () => setHasContent(false);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ isFolded, setIsFolded, hasContent, registerContent }),
|
||||
[isFolded, setIsFolded, hasContent, registerContent]
|
||||
);
|
||||
|
||||
return (
|
||||
<ExpandableCardContext.Provider value={contextValue}>
|
||||
<Section gap={0} padding={0} {...props} />
|
||||
</ExpandableCardContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expandable Card Header Component
|
||||
*
|
||||
* The header section of an expandable card. This is a pure container that:
|
||||
* - Has a border and neutral background
|
||||
* - Automatically handles border-radius based on content state:
|
||||
* - Fully rounded when no content exists or when content is folded
|
||||
* - Only top-rounded when content is visible
|
||||
*
|
||||
* You are responsible for adding your own padding, layout, and content inside.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ExpandableCard.Header>
|
||||
* <div className="flex items-center justify-between p-4">
|
||||
* <h3>My Title</h3>
|
||||
* <button>Action</button>
|
||||
* </div>
|
||||
* </ExpandableCard.Header>
|
||||
* ```
|
||||
*/
|
||||
export interface ExpandableCardHeaderProps
|
||||
extends WithoutStyles<React.HTMLAttributes<HTMLDivElement>> {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function ExpandableCardHeader({
|
||||
children,
|
||||
...props
|
||||
}: ExpandableCardHeaderProps) {
|
||||
const { isFolded, hasContent } = useExpandableCardContext();
|
||||
|
||||
// Round all corners if there's no content, or if content exists but is folded
|
||||
const shouldFullyRound = !hasContent || isFolded;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
"border bg-background-neutral-00 w-full transition-[border-radius] duration-200 ease-out",
|
||||
shouldFullyRound ? "rounded-16" : "rounded-t-16"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expandable Card Content Component
|
||||
*
|
||||
* The expandable content section of the card. This is a pure container that:
|
||||
* - Self-registers with context to inform Header about its presence
|
||||
* - Animates open/closed using Radix Collapsible (slide down/up)
|
||||
* - Has side and bottom borders that connect to the header
|
||||
* - Has a max-height with scrollable overflow via ShadowDiv
|
||||
*
|
||||
* You are responsible for adding your own content inside.
|
||||
*
|
||||
* IMPORTANT: Only ONE Content component should be used within a single Root.
|
||||
* This component self-registers with the context to inform Header whether
|
||||
* content exists (for border-radius styling). Using multiple Content components
|
||||
* will cause incorrect unmount behavior.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ExpandableCard.Content>
|
||||
* <div className="p-4">
|
||||
* <p>Your expandable content here</p>
|
||||
* </div>
|
||||
* </ExpandableCard.Content>
|
||||
* ```
|
||||
*/
|
||||
export interface ExpandableCardContentProps
|
||||
extends WithoutStyles<React.HTMLAttributes<HTMLDivElement>> {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function ExpandableCardContent({
|
||||
children,
|
||||
...props
|
||||
}: ExpandableCardContentProps) {
|
||||
const { isFolded, registerContent } = useExpandableCardContext();
|
||||
|
||||
// Self-register with context to inform Header that content exists
|
||||
useLayoutEffect(() => {
|
||||
return registerContent();
|
||||
}, [registerContent]);
|
||||
|
||||
return (
|
||||
<Collapsible open={!isFolded} className="w-full">
|
||||
<CollapsibleContent>
|
||||
<div
|
||||
className={cn(
|
||||
"border-x border-b rounded-b-16 overflow-hidden w-full transition-opacity duration-200 ease-out",
|
||||
isFolded ? "opacity-0" : "opacity-100"
|
||||
)}
|
||||
>
|
||||
<ShadowDiv
|
||||
className="flex flex-col rounded-b-16 max-h-[20rem]"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ShadowDiv>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ExpandableCardRoot as Root,
|
||||
ExpandableCardHeader as Header,
|
||||
ExpandableCardContent as Content,
|
||||
};
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MessageCard, type MessageCardVariant } from "@opal/components";
|
||||
import { MessageCard } from "@opal/components";
|
||||
import type { StatusVariants } from "@opal/types";
|
||||
import { NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK } from "@/lib/constants";
|
||||
import { toast, toastStore, MAX_VISIBLE_TOASTS } from "@/hooks/useToast";
|
||||
import type { Toast, ToastLevel } from "@/hooks/useToast";
|
||||
@@ -10,7 +11,7 @@ import type { Toast, ToastLevel } from "@/hooks/useToast";
|
||||
const ANIMATION_DURATION = 200; // matches tailwind fade-out-scale (0.2s)
|
||||
const MAX_TOAST_MESSAGE_LENGTH = 150;
|
||||
|
||||
const LEVEL_TO_VARIANT: Record<ToastLevel, MessageCardVariant> = {
|
||||
const LEVEL_TO_VARIANT: Record<ToastLevel, StatusVariants> = {
|
||||
success: "success",
|
||||
error: "error",
|
||||
warning: "warning",
|
||||
@@ -50,11 +51,7 @@ function ToastContainer() {
|
||||
return (
|
||||
<div
|
||||
data-testid="toast-container"
|
||||
className={cn(
|
||||
"fixed bottom-4 right-4 z-[10000]",
|
||||
"flex flex-col gap-2 items-end",
|
||||
"max-w-[420px]"
|
||||
)}
|
||||
className="fixed bottom-4 right-4 z-[var(--z-toast)] flex flex-col gap-2 items-end max-w-[var(--toast-width)] w-full"
|
||||
>
|
||||
{visible.map((t) => {
|
||||
const text =
|
||||
@@ -65,7 +62,7 @@ function ToastContainer() {
|
||||
<div
|
||||
key={t.id}
|
||||
className={cn(
|
||||
"shadow-02 rounded-12",
|
||||
"w-full",
|
||||
t.leaving ? "animate-fade-out-scale" : "animate-fade-in-scale"
|
||||
)}
|
||||
>
|
||||
@@ -73,6 +70,7 @@ function ToastContainer() {
|
||||
variant={LEVEL_TO_VARIANT[t.level ?? "info"]}
|
||||
title={text}
|
||||
description={buildDescription(t)}
|
||||
padding="xs"
|
||||
onClose={t.dismissible ? () => handleClose(t.id) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import EmptyMessage from "./EmptyMessage";
|
||||
import { SvgFileText, SvgUsers } from "@opal/icons";
|
||||
|
||||
const meta: Meta<typeof EmptyMessage> = {
|
||||
title: "refresh-components/messages/EmptyMessage",
|
||||
component: EmptyMessage,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EmptyMessage>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: "No items found",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
title: "No connectors configured",
|
||||
description:
|
||||
"Set up a connector to start indexing documents from your data sources.",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomIcon: Story = {
|
||||
args: {
|
||||
icon: SvgFileText,
|
||||
title: "No documents available",
|
||||
description: "Upload documents or connect a data source to get started.",
|
||||
},
|
||||
};
|
||||
|
||||
export const UsersEmpty: Story = {
|
||||
args: {
|
||||
icon: SvgUsers,
|
||||
title: "No users in this group",
|
||||
description: "Add users to this group to grant them access.",
|
||||
},
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* EmptyMessage - A component for displaying empty state messages
|
||||
*
|
||||
* Displays a translucent card with an icon and message text to indicate
|
||||
* when no data or content is available.
|
||||
*
|
||||
* Features:
|
||||
* - Translucent card background with dashed border
|
||||
* - Horizontal layout with icon on left, text on right
|
||||
* - 0.5rem gap between icon and text
|
||||
* - Accepts string children for the message text
|
||||
* - Customizable icon
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import EmptyMessage from "@/refresh-components/EmptyMessage";
|
||||
* import { SvgActivity } from "@opal/icons";
|
||||
*
|
||||
* // Basic usage
|
||||
* <EmptyMessage icon={SvgActivity}>
|
||||
* No connectors set up for your organization.
|
||||
* </EmptyMessage>
|
||||
*
|
||||
* // With different icon
|
||||
* <EmptyMessage icon={SvgFileText}>
|
||||
* No documents available.
|
||||
* </EmptyMessage>
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { SvgEmpty } from "@opal/icons";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Content } from "@opal/layouts";
|
||||
import { IconProps } from "@opal/types";
|
||||
|
||||
export interface EmptyMessageProps {
|
||||
icon?: React.FunctionComponent<IconProps>;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default function EmptyMessage({
|
||||
icon: Icon = SvgEmpty,
|
||||
title,
|
||||
description,
|
||||
}: EmptyMessageProps) {
|
||||
return (
|
||||
<Card variant="tertiary">
|
||||
<Content
|
||||
icon={Icon}
|
||||
title={title}
|
||||
sizePreset="main-ui"
|
||||
variant="body"
|
||||
prominence="muted"
|
||||
/>
|
||||
{description && (
|
||||
<Text secondaryBody text03>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -82,6 +82,7 @@ export interface LineItemProps
|
||||
|
||||
selected?: boolean;
|
||||
icon?: React.FunctionComponent<IconProps>;
|
||||
strokeIcon?: boolean;
|
||||
description?: string;
|
||||
rightChildren?: React.ReactNode;
|
||||
href?: string;
|
||||
@@ -154,6 +155,7 @@ export default function LineItem({
|
||||
skeleton,
|
||||
emphasized,
|
||||
icon: Icon,
|
||||
strokeIcon = true,
|
||||
description,
|
||||
children,
|
||||
rightChildren,
|
||||
@@ -245,7 +247,12 @@ export default function LineItem({
|
||||
!!(children && description) && "mt-0.5"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-[1rem] w-[1rem]", iconClassNames[variant])} />
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-[1rem] w-[1rem]",
|
||||
strokeIcon && iconClassNames[variant]
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Section alignItems="start" gap={0}>
|
||||
|
||||
@@ -99,6 +99,7 @@ export default function SwitchList({
|
||||
item.leading) as React.FunctionComponent<IconProps>)
|
||||
: undefined
|
||||
}
|
||||
strokeIcon={false}
|
||||
rightChildren={
|
||||
<Switch
|
||||
checked={item.isEnabled}
|
||||
|
||||
@@ -172,6 +172,7 @@ export default function ModelListContent({
|
||||
<LineItem
|
||||
muted
|
||||
icon={group.Icon}
|
||||
strokeIcon={false}
|
||||
rightChildren={
|
||||
open ? (
|
||||
<SvgChevronDown className="h-4 w-4 stroke-text-04 shrink-0" />
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import * as GeneralLayouts from "@/layouts/general-layouts";
|
||||
import { Button, Divider, MessageCard } from "@opal/components";
|
||||
import { Hoverable } from "@opal/core";
|
||||
import { Button, Card, Divider, MessageCard } from "@opal/components";
|
||||
import { Hoverable, Disabled } from "@opal/core";
|
||||
import { FullPersona } from "@/app/admin/agents/interfaces";
|
||||
import { buildImgUrl } from "@/app/app/components/files/images/utils";
|
||||
import { Formik, Form, FieldArray } from "formik";
|
||||
@@ -14,7 +14,12 @@ import InputTypeInField from "@/refresh-components/form/InputTypeInField";
|
||||
import InputTextAreaField from "@/refresh-components/form/InputTextAreaField";
|
||||
import InputTypeInElementField from "@/refresh-components/form/InputTypeInElementField";
|
||||
import InputDatePickerField from "@/refresh-components/form/InputDatePickerField";
|
||||
import { InputHorizontal, InputVertical } from "@opal/layouts";
|
||||
import {
|
||||
Card as CardLayout,
|
||||
ContentAction,
|
||||
InputHorizontal,
|
||||
InputVertical,
|
||||
} from "@opal/layouts";
|
||||
import { useFormikContext } from "formik";
|
||||
import LLMSelector from "@/components/llm/LLMSelector";
|
||||
import { parseLlmDescriptor, structureValue } from "@/lib/llmConfig/utils";
|
||||
@@ -32,7 +37,6 @@ import {
|
||||
OPEN_URL_TOOL_ID,
|
||||
} from "@/app/app/components/tools/constants";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Card } from "@/refresh-components/cards";
|
||||
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
|
||||
import SwitchField from "@/refresh-components/form/SwitchField";
|
||||
import { Tooltip } from "@opal/components";
|
||||
@@ -72,8 +76,6 @@ import {
|
||||
import useMcpServersForAgentEditor from "@/hooks/useMcpServersForAgentEditor";
|
||||
import useOpenApiTools from "@/hooks/useOpenApiTools";
|
||||
import { useAvailableTools } from "@/hooks/useAvailableTools";
|
||||
import * as ActionsLayouts from "@/layouts/actions-layouts";
|
||||
import * as ExpandableCard from "@/layouts/expandable-card-layouts";
|
||||
import { getActionIcon } from "@/lib/tools/mcpUtils";
|
||||
import { MCPServer, MCPTool, ToolSnapshot } from "@/lib/tools/interfaces";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
@@ -221,7 +223,7 @@ function AgentIconEditor({ existingAgent }: AgentIconEditorProps) {
|
||||
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 mb-2">
|
||||
<Hoverable.Item group="inputAvatar" variant="opacity-on-hover">
|
||||
<Button size="md" prominence="secondary">
|
||||
<Button prominence="secondary" size="md">
|
||||
Edit
|
||||
</Button>
|
||||
</Hoverable.Item>
|
||||
@@ -277,14 +279,21 @@ function OpenApiToolCard({ tool }: OpenApiToolCardProps) {
|
||||
const toolFieldName = `openapi_tool_${tool.id}`;
|
||||
|
||||
return (
|
||||
<ExpandableCard.Root defaultFolded>
|
||||
<ActionsLayouts.Header
|
||||
title={tool.display_name || tool.name}
|
||||
description={tool.description}
|
||||
icon={SvgActions}
|
||||
rightChildren={<SwitchField name={toolFieldName} />}
|
||||
<Card border="solid" rounding="lg" padding="sm">
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<ContentAction
|
||||
icon={SvgActions}
|
||||
title={tool.display_name || tool.name}
|
||||
description={tool.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
/>
|
||||
}
|
||||
topRightChildren={<SwitchField name={toolFieldName} />}
|
||||
/>
|
||||
</ExpandableCard.Root>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -315,87 +324,120 @@ function MCPServerCard({
|
||||
return toolFieldValue === true;
|
||||
}).length;
|
||||
|
||||
const hasTools = enabledTools.length > 0 && filteredTools.length > 0;
|
||||
|
||||
let cardContent: React.ReactNode | undefined;
|
||||
if (isLoading) {
|
||||
cardContent = (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<GeneralLayouts.Section padding={1}>
|
||||
<SimpleLoader />
|
||||
</GeneralLayouts.Section>
|
||||
</div>
|
||||
);
|
||||
} else if (hasTools) {
|
||||
cardContent = (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{filteredTools.map((tool) => {
|
||||
const toolDisabled =
|
||||
!tool.isAvailable ||
|
||||
!getFieldMeta<boolean>(`${serverFieldName}.enabled`).value;
|
||||
return (
|
||||
<Disabled key={tool.id} disabled={toolDisabled}>
|
||||
<Card border="solid" rounding="lg" padding="sm">
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<ContentAction
|
||||
icon={tool.icon ?? SvgSliders}
|
||||
title={tool.name}
|
||||
description={tool.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
/>
|
||||
}
|
||||
topRightChildren={
|
||||
<SwitchField
|
||||
name={`${serverFieldName}.tool_${tool.id}`}
|
||||
disabled={!isServerEnabled}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Disabled>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
|
||||
<ActionsLayouts.Header
|
||||
title={server.name}
|
||||
description={server.description}
|
||||
icon={getActionIcon(server.server_url, server.name)}
|
||||
rightChildren={
|
||||
<GeneralLayouts.Section
|
||||
flexDirection="row"
|
||||
gap={0.5}
|
||||
alignItems="start"
|
||||
>
|
||||
<EnabledCount
|
||||
enabledCount={enabledCount}
|
||||
totalCount={enabledTools.length}
|
||||
/>
|
||||
<SwitchField
|
||||
name={`${serverFieldName}.enabled`}
|
||||
onCheckedChange={(checked) => {
|
||||
enabledTools.forEach((tool) => {
|
||||
setFieldValue(`${serverFieldName}.tool_${tool.id}`, checked);
|
||||
});
|
||||
if (!checked) return;
|
||||
setIsFolded(false);
|
||||
}}
|
||||
<Card
|
||||
expandable
|
||||
expanded={!isFolded}
|
||||
border="solid"
|
||||
rounding="lg"
|
||||
padding="sm"
|
||||
expandedContent={cardContent}
|
||||
>
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<ContentAction
|
||||
icon={getActionIcon(server.server_url, server.name)}
|
||||
title={server.name}
|
||||
description={server.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
rightChildren={
|
||||
<GeneralLayouts.Section
|
||||
flexDirection="row"
|
||||
gap={0.5}
|
||||
alignItems="start"
|
||||
>
|
||||
<EnabledCount
|
||||
enabledCount={enabledCount}
|
||||
totalCount={enabledTools.length}
|
||||
/>
|
||||
<SwitchField
|
||||
name={`${serverFieldName}.enabled`}
|
||||
onCheckedChange={(checked) => {
|
||||
enabledTools.forEach((tool) => {
|
||||
setFieldValue(
|
||||
`${serverFieldName}.tool_${tool.id}`,
|
||||
checked
|
||||
);
|
||||
});
|
||||
if (!checked) return;
|
||||
setIsFolded(false);
|
||||
}}
|
||||
/>
|
||||
</GeneralLayouts.Section>
|
||||
}
|
||||
/>
|
||||
}
|
||||
bottomChildren={
|
||||
<GeneralLayouts.Section flexDirection="row" gap={0.5}>
|
||||
<InputTypeIn
|
||||
placeholder="Search tools..."
|
||||
variant="internal"
|
||||
leftSearchIcon
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{enabledTools.length > 0 && (
|
||||
<Button
|
||||
prominence="internal"
|
||||
rightIcon={isFolded ? SvgExpand : SvgFold}
|
||||
onClick={() => setIsFolded((prev) => !prev)}
|
||||
>
|
||||
{isFolded ? "Expand" : "Fold"}
|
||||
</Button>
|
||||
)}
|
||||
</GeneralLayouts.Section>
|
||||
}
|
||||
>
|
||||
<GeneralLayouts.Section flexDirection="row" gap={0.5}>
|
||||
<InputTypeIn
|
||||
placeholder="Search tools..."
|
||||
variant="internal"
|
||||
leftSearchIcon
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{enabledTools.length > 0 && (
|
||||
<Button
|
||||
prominence="internal"
|
||||
rightIcon={isFolded ? SvgExpand : SvgFold}
|
||||
onClick={() => setIsFolded((prev) => !prev)}
|
||||
>
|
||||
{isFolded ? "Expand" : "Fold"}
|
||||
</Button>
|
||||
)}
|
||||
</GeneralLayouts.Section>
|
||||
</ActionsLayouts.Header>
|
||||
{isLoading ? (
|
||||
<ActionsLayouts.Content>
|
||||
<GeneralLayouts.Section padding={1}>
|
||||
<SimpleLoader />
|
||||
</GeneralLayouts.Section>
|
||||
</ActionsLayouts.Content>
|
||||
) : (
|
||||
enabledTools.length > 0 &&
|
||||
filteredTools.length > 0 && (
|
||||
<ActionsLayouts.Content>
|
||||
{filteredTools.map((tool) => (
|
||||
<ActionsLayouts.Tool
|
||||
key={tool.id}
|
||||
name={`${serverFieldName}.tool_${tool.id}`}
|
||||
title={tool.name}
|
||||
description={tool.description}
|
||||
icon={tool.icon ?? SvgSliders}
|
||||
disabled={
|
||||
!tool.isAvailable ||
|
||||
!getFieldMeta<boolean>(`${serverFieldName}.enabled`).value
|
||||
}
|
||||
rightChildren={
|
||||
<SwitchField
|
||||
name={`${serverFieldName}.tool_${tool.id}`}
|
||||
disabled={!isServerEnabled}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ActionsLayouts.Content>
|
||||
)
|
||||
)}
|
||||
</ExpandableCard.Root>
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1376,17 +1418,11 @@ export default function AgentEditorPage({
|
||||
/>
|
||||
<SimpleCollapsible.Content>
|
||||
<GeneralLayouts.Section gap={0.5}>
|
||||
<Tooltip
|
||||
<Disabled
|
||||
disabled={!isImageGenerationAvailable}
|
||||
tooltip={imageGenerationDisabledTooltip}
|
||||
side="top"
|
||||
>
|
||||
<Card
|
||||
variant={
|
||||
isImageGenerationAvailable
|
||||
? undefined
|
||||
: "disabled"
|
||||
}
|
||||
>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
withLabel="image_generation"
|
||||
title="Image Generation"
|
||||
@@ -1399,57 +1435,55 @@ export default function AgentEditorPage({
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
</Disabled>
|
||||
|
||||
<Card
|
||||
variant={!!webSearchTool ? undefined : "disabled"}
|
||||
>
|
||||
<InputHorizontal
|
||||
withLabel="web_search"
|
||||
title="Web Search"
|
||||
description="Search the web for real-time information and up-to-date results."
|
||||
disabled={!webSearchTool}
|
||||
>
|
||||
<SwitchField
|
||||
name="web_search"
|
||||
<Disabled disabled={!webSearchTool}>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
withLabel="web_search"
|
||||
title="Web Search"
|
||||
description="Search the web for real-time information and up-to-date results."
|
||||
disabled={!webSearchTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
>
|
||||
<SwitchField
|
||||
name="web_search"
|
||||
disabled={!webSearchTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Disabled>
|
||||
|
||||
<Card
|
||||
variant={!!openURLTool ? undefined : "disabled"}
|
||||
>
|
||||
<InputHorizontal
|
||||
withLabel="open_url"
|
||||
title="Open URL"
|
||||
description="Fetch and read content from web URLs."
|
||||
disabled={!openURLTool}
|
||||
>
|
||||
<SwitchField
|
||||
name="open_url"
|
||||
<Disabled disabled={!openURLTool}>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
withLabel="open_url"
|
||||
title="Open URL"
|
||||
description="Fetch and read content from web URLs."
|
||||
disabled={!openURLTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
>
|
||||
<SwitchField
|
||||
name="open_url"
|
||||
disabled={!openURLTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Disabled>
|
||||
|
||||
<Card
|
||||
variant={
|
||||
!!codeInterpreterTool ? undefined : "disabled"
|
||||
}
|
||||
>
|
||||
<InputHorizontal
|
||||
withLabel="code_interpreter"
|
||||
title="Code Interpreter"
|
||||
description="Generate and run code."
|
||||
disabled={!codeInterpreterTool}
|
||||
>
|
||||
<SwitchField
|
||||
name="code_interpreter"
|
||||
<Disabled disabled={!codeInterpreterTool}>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
withLabel="code_interpreter"
|
||||
title="Code Interpreter"
|
||||
description="Generate and run code."
|
||||
disabled={!codeInterpreterTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
>
|
||||
<SwitchField
|
||||
name="code_interpreter"
|
||||
disabled={!codeInterpreterTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Disabled>
|
||||
|
||||
{/* Tools */}
|
||||
<>
|
||||
@@ -1506,73 +1540,77 @@ export default function AgentEditorPage({
|
||||
/>
|
||||
<SimpleCollapsible.Content>
|
||||
<GeneralLayouts.Section>
|
||||
<Card>
|
||||
<InputHorizontal
|
||||
title="Share This Agent"
|
||||
description="with other users, groups, or everyone in your organization."
|
||||
center
|
||||
>
|
||||
<Button
|
||||
prominence="secondary"
|
||||
icon={isShared ? SvgUsers : SvgLock}
|
||||
onClick={() => shareAgentModal.toggle(true)}
|
||||
<Card border="solid" rounding="lg">
|
||||
<GeneralLayouts.Section>
|
||||
<InputHorizontal
|
||||
title="Share This Agent"
|
||||
description="with other users, groups, or everyone in your organization."
|
||||
center
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</InputHorizontal>
|
||||
{canUpdateFeaturedStatus && (
|
||||
<>
|
||||
<InputHorizontal
|
||||
withLabel="is_featured"
|
||||
title="Feature This Agent"
|
||||
description="Show this agent at the top of the explore agents list and automatically pin it to the sidebar for new users with access."
|
||||
<Button
|
||||
prominence="secondary"
|
||||
icon={isShared ? SvgUsers : SvgLock}
|
||||
onClick={() => shareAgentModal.toggle(true)}
|
||||
>
|
||||
<SwitchField name="is_featured" />
|
||||
</InputHorizontal>
|
||||
{values.is_featured && !isShared && (
|
||||
<MessageCard title="This agent is private to you and will only be featured for yourself." />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
Share
|
||||
</Button>
|
||||
</InputHorizontal>
|
||||
{canUpdateFeaturedStatus && (
|
||||
<>
|
||||
<InputHorizontal
|
||||
withLabel="is_featured"
|
||||
title="Feature This Agent"
|
||||
description="Show this agent at the top of the explore agents list and automatically pin it to the sidebar for new users with access."
|
||||
>
|
||||
<SwitchField name="is_featured" />
|
||||
</InputHorizontal>
|
||||
{values.is_featured && !isShared && (
|
||||
<MessageCard title="This agent is private to you and will only be featured for yourself." />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</GeneralLayouts.Section>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<InputHorizontal
|
||||
withLabel="llm_model"
|
||||
title="Default Model"
|
||||
description="This model will be used by Onyx by default in your chats."
|
||||
>
|
||||
<LLMSelector
|
||||
name="llm_model"
|
||||
llmProviders={llmProviders ?? []}
|
||||
currentLlm={getCurrentLlm(
|
||||
values,
|
||||
llmProviders
|
||||
)}
|
||||
onSelect={(selected) =>
|
||||
onLlmSelect(selected, setFieldValue)
|
||||
}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
withLabel="knowledge_cutoff_date"
|
||||
title="Knowledge Cutoff Date"
|
||||
suffix="optional"
|
||||
description="Documents with a last-updated date prior to this will be ignored."
|
||||
>
|
||||
<InputDatePickerField
|
||||
name="knowledge_cutoff_date"
|
||||
maxDate={new Date()}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
withLabel="replace_base_system_prompt"
|
||||
title="Overwrite System Prompt"
|
||||
suffix="(Not Recommended)"
|
||||
description='Remove the base system prompt which includes useful instructions (e.g. "You can use Markdown tables"). This may affect response quality.'
|
||||
>
|
||||
<SwitchField name="replace_base_system_prompt" />
|
||||
</InputHorizontal>
|
||||
<Card border="solid" rounding="lg">
|
||||
<GeneralLayouts.Section>
|
||||
<InputHorizontal
|
||||
withLabel="llm_model"
|
||||
title="Default Model"
|
||||
description="This model will be used by Onyx by default in your chats."
|
||||
>
|
||||
<LLMSelector
|
||||
name="llm_model"
|
||||
llmProviders={llmProviders ?? []}
|
||||
currentLlm={getCurrentLlm(
|
||||
values,
|
||||
llmProviders
|
||||
)}
|
||||
onSelect={(selected) =>
|
||||
onLlmSelect(selected, setFieldValue)
|
||||
}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
withLabel="knowledge_cutoff_date"
|
||||
title="Knowledge Cutoff Date"
|
||||
suffix="optional"
|
||||
description="Documents with a last-updated date prior to this will be ignored."
|
||||
>
|
||||
<InputDatePickerField
|
||||
name="knowledge_cutoff_date"
|
||||
maxDate={new Date()}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
withLabel="replace_base_system_prompt"
|
||||
title="Overwrite System Prompt"
|
||||
suffix="(Not Recommended)"
|
||||
description='Remove the base system prompt which includes useful instructions (e.g. "You can use Markdown tables"). This may affect response quality.'
|
||||
>
|
||||
<SwitchField name="replace_base_system_prompt" />
|
||||
</InputHorizontal>
|
||||
</GeneralLayouts.Section>
|
||||
</Card>
|
||||
|
||||
<GeneralLayouts.Section gap={0.25}>
|
||||
@@ -1605,7 +1643,7 @@ export default function AgentEditorPage({
|
||||
paddingPerpendicular="fit"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Delete This Agent"
|
||||
description="Anyone using this agent will no longer be able to access it."
|
||||
|
||||
@@ -53,7 +53,7 @@ import CharacterCount from "@/refresh-components/CharacterCount";
|
||||
import { InputPrompt } from "@/app/app/interfaces";
|
||||
import usePromptShortcuts from "@/hooks/usePromptShortcuts";
|
||||
import ColorSwatch from "@/refresh-components/ColorSwatch";
|
||||
import EmptyMessage from "@/refresh-components/EmptyMessage";
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import Memories from "@/sections/settings/Memories";
|
||||
import { FederatedConnectorOAuthStatus } from "@/components/chat/FederatedOAuthModal";
|
||||
import {
|
||||
@@ -1701,7 +1701,10 @@ function ConnectorsSettings() {
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<EmptyMessage title="No connectors set up for your organization." />
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
title="No connectors set up for your organization."
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { SWR_KEYS } from "@/lib/swr-keys";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
|
||||
import { Tooltip } from "@opal/components";
|
||||
import InputTextAreaField from "@/refresh-components/form/InputTextAreaField";
|
||||
@@ -26,14 +25,20 @@ import {
|
||||
SvgRefreshCw,
|
||||
} from "@opal/icons";
|
||||
import { ADMIN_ROUTES } from "@/lib/admin-routes";
|
||||
import { Content, InputHorizontal, InputVertical } from "@opal/layouts";
|
||||
import {
|
||||
Card as CardLayout,
|
||||
Content,
|
||||
ContentAction,
|
||||
InputHorizontal,
|
||||
InputVertical,
|
||||
} from "@opal/layouts";
|
||||
import {
|
||||
useSettingsContext,
|
||||
useVectorDbEnabled,
|
||||
} from "@/providers/SettingsProvider";
|
||||
import useCCPairs from "@/hooks/useCCPairs";
|
||||
import { getSourceMetadata } from "@/lib/sources";
|
||||
import EmptyMessage from "@/refresh-components/EmptyMessage";
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import { Settings } from "@/interfaces/settings";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { useAvailableTools } from "@/hooks/useAvailableTools";
|
||||
@@ -44,13 +49,11 @@ import {
|
||||
PYTHON_TOOL_ID,
|
||||
OPEN_URL_TOOL_ID,
|
||||
} from "@/app/app/components/tools/constants";
|
||||
import { Button, Divider, Text, Card as OpalCard } from "@opal/components";
|
||||
import { Button, Divider, Text, Card } from "@opal/components";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import Switch from "@/refresh-components/inputs/Switch";
|
||||
import useMcpServersForAgentEditor from "@/hooks/useMcpServersForAgentEditor";
|
||||
import useOpenApiTools from "@/hooks/useOpenApiTools";
|
||||
import * as ExpandableCard from "@/layouts/expandable-card-layouts";
|
||||
import * as ActionsLayouts from "@/layouts/actions-layouts";
|
||||
import { getActionIcon } from "@/lib/tools/mcpUtils";
|
||||
import { Disabled, Hoverable } from "@opal/core";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
@@ -103,68 +106,94 @@ function MCPServerCard({
|
||||
? "Authenticate this MCP server before enabling its tools."
|
||||
: undefined;
|
||||
|
||||
const expanded = !isFolded;
|
||||
const hasContent = tools.length > 0 && filteredTools.length > 0;
|
||||
|
||||
return (
|
||||
<ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
|
||||
<ActionsLayouts.Header
|
||||
title={server.name}
|
||||
description={server.description}
|
||||
icon={getActionIcon(server.server_url, server.name)}
|
||||
rightChildren={
|
||||
<Tooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={serverEnabled}
|
||||
onCheckedChange={(checked) => onToggleTools(allToolIds, checked)}
|
||||
disabled={needsAuth}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{tools.length > 0 && (
|
||||
<Section flexDirection="row" gap={0.5}>
|
||||
<InputTypeIn
|
||||
placeholder="Search tools..."
|
||||
variant="internal"
|
||||
leftSearchIcon
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
rightIcon={isFolded ? SvgExpand : SvgFold}
|
||||
onClick={() => setIsFolded((prev) => !prev)}
|
||||
prominence="internal"
|
||||
size="lg"
|
||||
>
|
||||
{isFolded ? "Expand" : "Fold"}
|
||||
</Button>
|
||||
</Section>
|
||||
)}
|
||||
</ActionsLayouts.Header>
|
||||
{tools.length > 0 && filteredTools.length > 0 && (
|
||||
<ActionsLayouts.Content>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Card
|
||||
expandable
|
||||
expanded={expanded}
|
||||
border="solid"
|
||||
rounding="lg"
|
||||
padding="sm"
|
||||
expandedContent={
|
||||
hasContent ? (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{filteredTools.map((tool) => (
|
||||
<ActionsLayouts.Tool
|
||||
key={tool.id}
|
||||
title={tool.name}
|
||||
description={tool.description}
|
||||
icon={tool.icon}
|
||||
rightChildren={
|
||||
<Tooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={isToolEnabled(tool.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleTool(tool.id, checked)
|
||||
}
|
||||
disabled={needsAuth}
|
||||
<Card key={tool.id} border="solid" rounding="lg" padding="sm">
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<Content
|
||||
icon={tool.icon}
|
||||
title={tool.name}
|
||||
description={tool.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
}
|
||||
topRightChildren={
|
||||
<Tooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={isToolEnabled(tool.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleTool(tool.id, checked)
|
||||
}
|
||||
disabled={needsAuth}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ActionsLayouts.Content>
|
||||
)}
|
||||
</ExpandableCard.Root>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<ContentAction
|
||||
icon={getActionIcon(server.server_url, server.name)}
|
||||
title={server.name}
|
||||
description={server.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
rightChildren={
|
||||
<Tooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={serverEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleTools(allToolIds, checked)
|
||||
}
|
||||
disabled={needsAuth}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
}
|
||||
bottomChildren={
|
||||
tools.length > 0 ? (
|
||||
<Section flexDirection="row" gap={0.5}>
|
||||
<InputTypeIn
|
||||
placeholder="Search tools..."
|
||||
variant="internal"
|
||||
leftSearchIcon
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
rightIcon={isFolded ? SvgExpand : SvgFold}
|
||||
onClick={() => setIsFolded((prev) => !prev)}
|
||||
prominence="internal"
|
||||
size="lg"
|
||||
>
|
||||
{isFolded ? "Expand" : "Fold"}
|
||||
</Button>
|
||||
</Section>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -304,10 +333,11 @@ function FileSizeLimitFields({
|
||||
maxAllowedUploadSizeMb,
|
||||
}: FileSizeLimitFieldsProps) {
|
||||
return (
|
||||
<div className="flex gap-4 w-full items-start">
|
||||
<div className="flex gap-4 w-full items-start pt-2">
|
||||
<div className="flex-1">
|
||||
<InputVertical
|
||||
title="File Size Limit (MB)"
|
||||
title="File Size Limit"
|
||||
suffix="(MB)"
|
||||
subDescription={
|
||||
maxAllowedUploadSizeMb
|
||||
? `Max: ${maxAllowedUploadSizeMb} MB`
|
||||
@@ -325,7 +355,11 @@ function FileSizeLimitFields({
|
||||
</InputVertical>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<InputVertical title="File Token Limit (thousand tokens)" withLabel>
|
||||
<InputVertical
|
||||
title="File Token Limit"
|
||||
withLabel
|
||||
suffix="(thousand tokens)"
|
||||
>
|
||||
<NumericLimitField
|
||||
name="file_token_count_threshold_k"
|
||||
initialValue={initialTokenThresholdK}
|
||||
@@ -339,7 +373,7 @@ function FileSizeLimitFields({
|
||||
);
|
||||
}
|
||||
|
||||
function ChatPreferencesForm() {
|
||||
export default function ChatPreferencesPage() {
|
||||
const router = useRouter();
|
||||
const settings = useSettingsContext();
|
||||
const s = settings.settings;
|
||||
@@ -523,72 +557,67 @@ function ChatPreferencesForm() {
|
||||
|
||||
<SettingsLayouts.Body>
|
||||
{/* Features */}
|
||||
<Card>
|
||||
<Tooltip
|
||||
tooltip={
|
||||
uniqueSources.length === 0
|
||||
? "Set up connectors to use Search Mode"
|
||||
: undefined
|
||||
}
|
||||
side="top"
|
||||
>
|
||||
<Disabled disabled={uniqueSources.length === 0} allowClick>
|
||||
<div className="w-full">
|
||||
<InputHorizontal
|
||||
title="Search Mode"
|
||||
tag={{ title: "beta", color: "blue" }}
|
||||
description="UI mode for quick document search across your organization."
|
||||
<Card border="solid" rounding="lg">
|
||||
<Section>
|
||||
<Disabled
|
||||
disabled={uniqueSources.length === 0}
|
||||
allowClick
|
||||
tooltip="Set up connectors to use Search Mode"
|
||||
>
|
||||
<InputHorizontal
|
||||
title="Search Mode"
|
||||
tag={{ title: "beta", color: "blue" }}
|
||||
description="UI mode for quick document search across your organization."
|
||||
disabled={uniqueSources.length === 0}
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.search_ui_enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ search_ui_enabled: checked });
|
||||
}}
|
||||
disabled={uniqueSources.length === 0}
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.search_ui_enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ search_ui_enabled: checked });
|
||||
}}
|
||||
disabled={uniqueSources.length === 0}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</div>
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Disabled>
|
||||
</Tooltip>
|
||||
<InputHorizontal
|
||||
title="Multi-Model Generation"
|
||||
tag={{ title: "beta", color: "blue" }}
|
||||
description="Allow multiple models to generate responses in parallel in chat."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.multi_model_chat_enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ multi_model_chat_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
title="Deep Research"
|
||||
description="Agentic research system that works across the web and connected sources. Uses significantly more tokens per query."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.deep_research_enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ deep_research_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
title="Chat Auto-Scroll"
|
||||
description="Automatically scroll to new content as chat generates response. Users can override this in their personal settings."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.auto_scroll ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ auto_scroll: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
title="Multi-Model Generation"
|
||||
tag={{ title: "beta", color: "blue" }}
|
||||
description="Allow multiple models to generate responses in parallel in chat."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.multi_model_chat_enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ multi_model_chat_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
title="Deep Research"
|
||||
description="Agentic research system that works across the web and connected sources. Uses significantly more tokens per query."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.deep_research_enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ deep_research_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
title="Chat Auto-Scroll"
|
||||
description="Automatically scroll to new content as chat generates response. Users can override this in their personal settings."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.auto_scroll ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ auto_scroll: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Section>
|
||||
</Card>
|
||||
|
||||
<Divider paddingParallel="fit" paddingPerpendicular="fit" />
|
||||
@@ -672,7 +701,10 @@ function ChatPreferencesForm() {
|
||||
gap={0.25}
|
||||
>
|
||||
{uniqueSources.length === 0 ? (
|
||||
<EmptyMessage title="No connectors set up" />
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
title="No connectors set up"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Section
|
||||
@@ -684,17 +716,15 @@ function ChatPreferencesForm() {
|
||||
{uniqueSources.slice(0, 3).map((source) => {
|
||||
const meta = getSourceMetadata(source);
|
||||
return (
|
||||
<Card
|
||||
key={source}
|
||||
padding={0.75}
|
||||
className="w-[10rem]"
|
||||
>
|
||||
<Content
|
||||
icon={meta.icon}
|
||||
title={meta.displayName}
|
||||
sizePreset="main-ui"
|
||||
/>
|
||||
</Card>
|
||||
<div key={source} className="w-[10rem]">
|
||||
<Card padding="sm" border="solid">
|
||||
<Content
|
||||
icon={meta.icon}
|
||||
title={meta.displayName}
|
||||
sizePreset="main-ui"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
@@ -720,7 +750,7 @@ function ChatPreferencesForm() {
|
||||
<SimpleCollapsible.Content>
|
||||
<Section gap={0.5}>
|
||||
{vectorDbEnabled && searchTool && (
|
||||
<Card>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Internal Search"
|
||||
description="Search through your organization's connected knowledge base and documents."
|
||||
@@ -736,15 +766,11 @@ function ChatPreferencesForm() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
tooltip={
|
||||
imageGenTool
|
||||
? undefined
|
||||
: "Image generation requires a configured model. Set one up under Configuration > Image Generation, or ask an admin."
|
||||
}
|
||||
side="top"
|
||||
<Disabled
|
||||
disabled={!imageGenTool}
|
||||
tooltip="Image generation requires a configured model. Set one up under Configuration > Image Generation, or ask an admin."
|
||||
>
|
||||
<Card variant={imageGenTool ? undefined : "disabled"}>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Image Generation"
|
||||
description="Generate and manipulate images using AI-powered tools."
|
||||
@@ -765,75 +791,79 @@ function ChatPreferencesForm() {
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
</Disabled>
|
||||
|
||||
<Card variant={webSearchTool ? undefined : "disabled"}>
|
||||
<InputHorizontal
|
||||
title="Web Search"
|
||||
description="Search the web for real-time information and up-to-date results."
|
||||
disabled={!webSearchTool}
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
webSearchTool
|
||||
? isToolEnabled(webSearchTool.id)
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
webSearchTool &&
|
||||
void toggleTool(webSearchTool.id, checked)
|
||||
}
|
||||
<Disabled disabled={!webSearchTool}>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Web Search"
|
||||
description="Search the web for real-time information and up-to-date results."
|
||||
disabled={!webSearchTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
webSearchTool
|
||||
? isToolEnabled(webSearchTool.id)
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
webSearchTool &&
|
||||
void toggleTool(webSearchTool.id, checked)
|
||||
}
|
||||
disabled={!webSearchTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Disabled>
|
||||
|
||||
<Card variant={openURLTool ? undefined : "disabled"}>
|
||||
<InputHorizontal
|
||||
title="Open URL"
|
||||
description="Fetch and read content from web URLs."
|
||||
disabled={!openURLTool}
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
openURLTool
|
||||
? isToolEnabled(openURLTool.id)
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
openURLTool &&
|
||||
void toggleTool(openURLTool.id, checked)
|
||||
}
|
||||
<Disabled disabled={!openURLTool}>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Open URL"
|
||||
description="Fetch and read content from web URLs."
|
||||
disabled={!openURLTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
openURLTool
|
||||
? isToolEnabled(openURLTool.id)
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
openURLTool &&
|
||||
void toggleTool(openURLTool.id, checked)
|
||||
}
|
||||
disabled={!openURLTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Disabled>
|
||||
|
||||
<Card
|
||||
variant={codeInterpreterTool ? undefined : "disabled"}
|
||||
>
|
||||
<InputHorizontal
|
||||
title="Code Interpreter"
|
||||
description="Generate and run code."
|
||||
disabled={!codeInterpreterTool}
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
codeInterpreterTool
|
||||
? isToolEnabled(codeInterpreterTool.id)
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
codeInterpreterTool &&
|
||||
void toggleTool(codeInterpreterTool.id, checked)
|
||||
}
|
||||
<Disabled disabled={!codeInterpreterTool}>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Code Interpreter"
|
||||
description="Generate and run code."
|
||||
disabled={!codeInterpreterTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
codeInterpreterTool
|
||||
? isToolEnabled(codeInterpreterTool.id)
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
codeInterpreterTool &&
|
||||
void toggleTool(codeInterpreterTool.id, checked)
|
||||
}
|
||||
disabled={!codeInterpreterTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Disabled>
|
||||
</Section>
|
||||
|
||||
{/* Separator between built-in tools and MCP/OpenAPI tools */}
|
||||
@@ -858,12 +888,23 @@ function ChatPreferencesForm() {
|
||||
/>
|
||||
))}
|
||||
{openApiTools.map((tool) => (
|
||||
<ExpandableCard.Root key={tool.id} defaultFolded>
|
||||
<ActionsLayouts.Header
|
||||
title={tool.display_name || tool.name}
|
||||
description={tool.description}
|
||||
icon={SvgActions}
|
||||
rightChildren={
|
||||
<Card
|
||||
key={tool.id}
|
||||
border="solid"
|
||||
rounding="lg"
|
||||
padding="sm"
|
||||
>
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<Content
|
||||
icon={SvgActions}
|
||||
title={tool.display_name || tool.name}
|
||||
description={tool.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
}
|
||||
topRightChildren={
|
||||
<Switch
|
||||
checked={isToolEnabled(tool.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
@@ -872,7 +913,7 @@ function ChatPreferencesForm() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ExpandableCard.Root>
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
</SimpleCollapsible.Content>
|
||||
@@ -888,7 +929,7 @@ function ChatPreferencesForm() {
|
||||
<SimpleCollapsible.Header title="Advanced Options" />
|
||||
<SimpleCollapsible.Content>
|
||||
<Section gap={1}>
|
||||
<Card>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Keep Chat History"
|
||||
description="Specify how long Onyx should retain chats in your organization."
|
||||
@@ -921,7 +962,7 @@ function ChatPreferencesForm() {
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputVertical
|
||||
title="File Attachment Size Limit"
|
||||
description="Files attached in chats and projects must fit within both limits to be accepted. Larger files increase latency, memory usage, and token costs."
|
||||
@@ -956,35 +997,39 @@ function ChatPreferencesForm() {
|
||||
</InputVertical>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<InputHorizontal
|
||||
title="Allow Anonymous Users"
|
||||
description="Allow anyone to start chats without logging in. They do not see any other chats and cannot create agents or update settings."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.anonymous_user_enabled ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ anonymous_user_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<Card border="solid" rounding="lg">
|
||||
<Section>
|
||||
<InputHorizontal
|
||||
title="Allow Anonymous Users"
|
||||
description="Allow anyone to start chats without logging in. They do not see any other chats and cannot create agents or update settings."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.anonymous_user_enabled ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({
|
||||
anonymous_user_enabled: checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
|
||||
<InputHorizontal
|
||||
title="Always Start with an Agent"
|
||||
description="This removes the default chat. Users will always start in an agent, and new chats will be created in their last active agent. Set featured agents to help new users get started."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
id="disable_default_assistant"
|
||||
checked={s.disable_default_assistant ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({
|
||||
disable_default_assistant: checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
title="Always Start with an Agent"
|
||||
description="This removes the default chat. Users will always start in an agent, and new chats will be created in their last active agent. Set featured agents to help new users get started."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
id="disable_default_assistant"
|
||||
checked={s.disable_default_assistant ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({
|
||||
disable_default_assistant: checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Section>
|
||||
</Card>
|
||||
</Section>
|
||||
</SimpleCollapsible.Content>
|
||||
@@ -1046,14 +1091,14 @@ function ChatPreferencesForm() {
|
||||
)}
|
||||
</Text>
|
||||
</Section>
|
||||
<OpalCard background="none" border="solid" padding="sm">
|
||||
<Card background="none" border="solid" padding="sm">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
icon={SvgAlertCircle}
|
||||
title="Modify with caution."
|
||||
description="System prompt affects all chats, agents, and projects. Significant changes may degrade response quality."
|
||||
/>
|
||||
</OpalCard>
|
||||
</Card>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
@@ -1078,7 +1123,3 @@ function ChatPreferencesForm() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatPreferencesPage() {
|
||||
return <ChatPreferencesForm />;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { ADMIN_ROUTES } from "@/lib/admin-routes";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { Button, SelectCard } from "@opal/components";
|
||||
import { Card } from "@opal/layouts";
|
||||
import { Card, Content } from "@opal/layouts";
|
||||
import { Disabled, Hoverable } from "@opal/core";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
@@ -114,12 +114,16 @@ export default function CodeInterpreterPage() {
|
||||
<Hoverable.Root group="code-interpreter/Card">
|
||||
<SelectCard state="filled" padding="sm" rounding="lg">
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgTerminal}
|
||||
title="Code Interpreter"
|
||||
description="Built-in Python runtime"
|
||||
rightChildren={
|
||||
headerChildren={
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgTerminal}
|
||||
title="Code Interpreter"
|
||||
description="Built-in Python runtime"
|
||||
/>
|
||||
}
|
||||
topRightChildren={
|
||||
<ConnectionStatus healthy={isHealthy} isLoading={isLoading} />
|
||||
}
|
||||
bottomRightChildren={
|
||||
@@ -162,12 +166,16 @@ export default function CodeInterpreterPage() {
|
||||
onClick={() => handleToggle(true)}
|
||||
>
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgTerminal}
|
||||
title="Code Interpreter (Disconnected)"
|
||||
description="Built-in Python runtime"
|
||||
rightChildren={
|
||||
headerChildren={
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgTerminal}
|
||||
title="Code Interpreter (Disconnected)"
|
||||
description="Built-in Python runtime"
|
||||
/>
|
||||
}
|
||||
topRightChildren={
|
||||
<Section flexDirection="row" alignItems="center" padding={0.5}>
|
||||
{isReconnecting ? (
|
||||
<CheckingStatus />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user