mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-24 11:15:47 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6ef2b5074 | ||
|
|
74132175a8 | ||
|
|
29f707ee2d | ||
|
|
f0eb86fb9f | ||
|
|
b422496a4c | ||
|
|
31d6a45b23 | ||
|
|
36f3ac1ec5 | ||
|
|
74f5b3025a | ||
|
|
c18545d74c | ||
|
|
48171e3700 | ||
|
|
f5a5709876 | ||
|
|
85868b1b83 | ||
|
|
8dc14c23e6 | ||
|
|
23821cc0e8 | ||
|
|
b359e13281 | ||
|
|
717f410a4a | ||
|
|
ada0946a62 | ||
|
|
eb2ac8f5a3 | ||
|
|
fbeb57c592 | ||
|
|
d6da9c9b85 | ||
|
|
5aea2e223e | ||
|
|
1ff91de07e | ||
|
|
b3dbc69faf | ||
|
|
431597b0f9 | ||
|
|
51b4e5f2fb | ||
|
|
9afa04a26b | ||
|
|
70a3a9c0cd | ||
|
|
080165356c | ||
|
|
3ae974bdf6 | ||
|
|
1471658151 | ||
|
|
3e85e9c1a3 | ||
|
|
851033be5f | ||
|
|
91e974a6cc | ||
|
|
38ba4f8a1c | ||
|
|
6f02473064 |
387
.github/workflows/deployment.yml
vendored
387
.github/workflows/deployment.yml
vendored
@@ -8,7 +8,9 @@ on:
|
||||
|
||||
# Set restrictive default permissions for all jobs. Jobs that need more permissions
|
||||
# should explicitly declare them.
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Required for OIDC authentication with AWS
|
||||
id-token: write # zizmor: ignore[excessive-permissions]
|
||||
|
||||
env:
|
||||
EDGE_TAG: ${{ startsWith(github.ref_name, 'nightly-latest') }}
|
||||
@@ -150,16 +152,30 @@ jobs:
|
||||
if: always() && needs.check-version-tag.result == 'failure' && github.event_name != 'workflow_dispatch'
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
environment: release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # ratchet:actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
MONITOR_DEPLOYMENTS_WEBHOOK, deploy/monitor-deployments-webhook
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Send Slack notification
|
||||
uses: ./.github/actions/slack-notify
|
||||
with:
|
||||
webhook-url: ${{ secrets.MONITOR_DEPLOYMENTS_WEBHOOK }}
|
||||
webhook-url: ${{ env.MONITOR_DEPLOYMENTS_WEBHOOK }}
|
||||
failed-jobs: "• check-version-tag"
|
||||
title: "🚨 Version Tag Check Failed"
|
||||
ref-name: ${{ github.ref_name }}
|
||||
@@ -168,6 +184,7 @@ jobs:
|
||||
needs: determine-builds
|
||||
if: needs.determine-builds.outputs.build-desktop == 'true'
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
actions: read
|
||||
strategy:
|
||||
@@ -185,12 +202,33 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # ratchet:actions/checkout@v6.0.1
|
||||
with:
|
||||
# NOTE: persist-credentials is needed for tauri-action to create GitHub releases.
|
||||
persist-credentials: true # zizmor: ignore[artipacked]
|
||||
|
||||
- name: Configure AWS credentials
|
||||
if: startsWith(matrix.platform, 'macos-')
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
if: startsWith(matrix.platform, 'macos-')
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
APPLE_ID, deploy/apple-id
|
||||
APPLE_PASSWORD, deploy/apple-password
|
||||
APPLE_CERTIFICATE, deploy/apple-certificate
|
||||
APPLE_CERTIFICATE_PASSWORD, deploy/apple-certificate-password
|
||||
KEYCHAIN_PASSWORD, deploy/keychain-password
|
||||
APPLE_TEAM_ID, deploy/apple-team-id
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: startsWith(matrix.platform, 'ubuntu-')
|
||||
run: |
|
||||
@@ -285,15 +323,40 @@ jobs:
|
||||
|
||||
Write-Host "Versions set to: $VERSION"
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: startsWith(matrix.platform, 'macos-')
|
||||
run: |
|
||||
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security set-keychain-settings -t 3600 -u build.keychain
|
||||
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security find-identity -v -p codesigning build.keychain
|
||||
|
||||
- name: Verify Certificate
|
||||
if: startsWith(matrix.platform, 'macos-')
|
||||
run: |
|
||||
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep -E "(Developer ID Application|Apple Distribution|Apple Development)" | head -n 1)
|
||||
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
|
||||
echo "Certificate imported."
|
||||
|
||||
- uses: tauri-apps/tauri-action@73fb865345c54760d875b94642314f8c0c894afa # ratchet:tauri-apps/tauri-action@action-v0.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_ID: ${{ env.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ env.APPLE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
|
||||
APPLE_TEAM_ID: ${{ env.APPLE_TEAM_ID }}
|
||||
with:
|
||||
tagName: ${{ needs.determine-builds.outputs.is-test-run != 'true' && 'v__VERSION__' || format('v0.0.0-dev+{0}', needs.determine-builds.outputs.short-sha) }}
|
||||
releaseName: ${{ needs.determine-builds.outputs.is-test-run != 'true' && 'v__VERSION__' || format('v0.0.0-dev+{0}', needs.determine-builds.outputs.short-sha) }}
|
||||
releaseBody: "See the assets to download this version and install."
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
assetNamePattern: "[name]_[arch][ext]"
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
build-web-amd64:
|
||||
@@ -305,6 +368,7 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-web-amd64
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
env:
|
||||
@@ -317,6 +381,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
|
||||
@@ -331,8 +409,8 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push AMD64
|
||||
id: build
|
||||
@@ -363,6 +441,7 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-web-arm64
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
env:
|
||||
@@ -375,6 +454,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
|
||||
@@ -389,8 +482,8 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push ARM64
|
||||
id: build
|
||||
@@ -423,19 +516,34 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-merge-web
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
env:
|
||||
REGISTRY_IMAGE: onyxdotapp/onyx-web-server
|
||||
steps:
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # ratchet:docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
@@ -471,6 +579,7 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-web-cloud-amd64
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
env:
|
||||
@@ -483,6 +592,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
|
||||
@@ -497,8 +620,8 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push AMD64
|
||||
id: build
|
||||
@@ -537,6 +660,7 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-web-cloud-arm64
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
env:
|
||||
@@ -549,6 +673,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
|
||||
@@ -563,8 +701,8 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push ARM64
|
||||
id: build
|
||||
@@ -605,19 +743,34 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-merge-web-cloud
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
env:
|
||||
REGISTRY_IMAGE: onyxdotapp/onyx-web-server-cloud
|
||||
steps:
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # ratchet:docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
@@ -650,6 +803,7 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-backend-amd64
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
env:
|
||||
@@ -662,6 +816,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
|
||||
@@ -676,8 +844,8 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push AMD64
|
||||
id: build
|
||||
@@ -707,6 +875,7 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-backend-arm64
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
env:
|
||||
@@ -719,6 +888,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
|
||||
@@ -733,8 +916,8 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push ARM64
|
||||
id: build
|
||||
@@ -766,19 +949,34 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-merge-backend
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
env:
|
||||
REGISTRY_IMAGE: ${{ contains(github.ref_name, 'cloud') && 'onyxdotapp/onyx-backend-cloud' || 'onyxdotapp/onyx-backend' }}
|
||||
steps:
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # ratchet:docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
@@ -815,6 +1013,7 @@ jobs:
|
||||
- volume=40gb
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
env:
|
||||
@@ -827,6 +1026,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
|
||||
@@ -843,8 +1056,8 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push AMD64
|
||||
id: build
|
||||
@@ -879,6 +1092,7 @@ jobs:
|
||||
- volume=40gb
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
env:
|
||||
@@ -891,6 +1105,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
|
||||
@@ -907,8 +1135,8 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push ARM64
|
||||
id: build
|
||||
@@ -944,19 +1172,34 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-merge-model-server
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
env:
|
||||
REGISTRY_IMAGE: ${{ contains(github.ref_name, 'cloud') && 'onyxdotapp/onyx-model-server-cloud' || 'onyxdotapp/onyx-model-server' }}
|
||||
steps:
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # ratchet:docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
@@ -994,11 +1237,26 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-trivy-scan-web
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
env:
|
||||
REGISTRY_IMAGE: onyxdotapp/onyx-web-server
|
||||
steps:
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # ratchet:nick-fields/retry@v3
|
||||
with:
|
||||
@@ -1014,8 +1272,8 @@ jobs:
|
||||
docker run --rm -v $HOME/.cache/trivy:/root/.cache/trivy \
|
||||
-e TRIVY_DB_REPOSITORY="public.ecr.aws/aquasecurity/trivy-db:2" \
|
||||
-e TRIVY_JAVA_DB_REPOSITORY="public.ecr.aws/aquasecurity/trivy-java-db:1" \
|
||||
-e TRIVY_USERNAME="${{ secrets.DOCKER_USERNAME }}" \
|
||||
-e TRIVY_PASSWORD="${{ secrets.DOCKER_TOKEN }}" \
|
||||
-e TRIVY_USERNAME="${{ env.DOCKER_USERNAME }}" \
|
||||
-e TRIVY_PASSWORD="${{ env.DOCKER_TOKEN }}" \
|
||||
aquasec/trivy@sha256:a22415a38938a56c379387a8163fcb0ce38b10ace73e593475d3658d578b2436 \
|
||||
image \
|
||||
--skip-version-check \
|
||||
@@ -1034,11 +1292,26 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-trivy-scan-web-cloud
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
env:
|
||||
REGISTRY_IMAGE: onyxdotapp/onyx-web-server-cloud
|
||||
steps:
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # ratchet:nick-fields/retry@v3
|
||||
with:
|
||||
@@ -1054,8 +1327,8 @@ jobs:
|
||||
docker run --rm -v $HOME/.cache/trivy:/root/.cache/trivy \
|
||||
-e TRIVY_DB_REPOSITORY="public.ecr.aws/aquasecurity/trivy-db:2" \
|
||||
-e TRIVY_JAVA_DB_REPOSITORY="public.ecr.aws/aquasecurity/trivy-java-db:1" \
|
||||
-e TRIVY_USERNAME="${{ secrets.DOCKER_USERNAME }}" \
|
||||
-e TRIVY_PASSWORD="${{ secrets.DOCKER_TOKEN }}" \
|
||||
-e TRIVY_USERNAME="${{ env.DOCKER_USERNAME }}" \
|
||||
-e TRIVY_PASSWORD="${{ env.DOCKER_TOKEN }}" \
|
||||
aquasec/trivy@sha256:a22415a38938a56c379387a8163fcb0ce38b10ace73e593475d3658d578b2436 \
|
||||
image \
|
||||
--skip-version-check \
|
||||
@@ -1074,6 +1347,7 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-trivy-scan-backend
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
env:
|
||||
REGISTRY_IMAGE: ${{ contains(github.ref_name, 'cloud') && 'onyxdotapp/onyx-backend-cloud' || 'onyxdotapp/onyx-backend' }}
|
||||
steps:
|
||||
@@ -1084,6 +1358,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # ratchet:nick-fields/retry@v3
|
||||
with:
|
||||
@@ -1100,8 +1388,8 @@ jobs:
|
||||
-v ${{ github.workspace }}/backend/.trivyignore:/tmp/.trivyignore:ro \
|
||||
-e TRIVY_DB_REPOSITORY="public.ecr.aws/aquasecurity/trivy-db:2" \
|
||||
-e TRIVY_JAVA_DB_REPOSITORY="public.ecr.aws/aquasecurity/trivy-java-db:1" \
|
||||
-e TRIVY_USERNAME="${{ secrets.DOCKER_USERNAME }}" \
|
||||
-e TRIVY_PASSWORD="${{ secrets.DOCKER_TOKEN }}" \
|
||||
-e TRIVY_USERNAME="${{ env.DOCKER_USERNAME }}" \
|
||||
-e TRIVY_PASSWORD="${{ env.DOCKER_TOKEN }}" \
|
||||
aquasec/trivy@sha256:a22415a38938a56c379387a8163fcb0ce38b10ace73e593475d3658d578b2436 \
|
||||
image \
|
||||
--skip-version-check \
|
||||
@@ -1121,11 +1409,26 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-trivy-scan-model-server
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
env:
|
||||
REGISTRY_IMAGE: ${{ contains(github.ref_name, 'cloud') && 'onyxdotapp/onyx-model-server-cloud' || 'onyxdotapp/onyx-model-server' }}
|
||||
steps:
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # ratchet:nick-fields/retry@v3
|
||||
with:
|
||||
@@ -1141,8 +1444,8 @@ jobs:
|
||||
docker run --rm -v $HOME/.cache/trivy:/root/.cache/trivy \
|
||||
-e TRIVY_DB_REPOSITORY="public.ecr.aws/aquasecurity/trivy-db:2" \
|
||||
-e TRIVY_JAVA_DB_REPOSITORY="public.ecr.aws/aquasecurity/trivy-java-db:1" \
|
||||
-e TRIVY_USERNAME="${{ secrets.DOCKER_USERNAME }}" \
|
||||
-e TRIVY_PASSWORD="${{ secrets.DOCKER_TOKEN }}" \
|
||||
-e TRIVY_USERNAME="${{ env.DOCKER_USERNAME }}" \
|
||||
-e TRIVY_PASSWORD="${{ env.DOCKER_TOKEN }}" \
|
||||
aquasec/trivy@sha256:a22415a38938a56c379387a8163fcb0ce38b10ace73e593475d3658d578b2436 \
|
||||
image \
|
||||
--skip-version-check \
|
||||
@@ -1170,12 +1473,26 @@ jobs:
|
||||
# NOTE: Github-hosted runners have about 20s faster queue times and are preferred here.
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # ratchet:actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
MONITOR_DEPLOYMENTS_WEBHOOK, deploy/monitor-deployments-webhook
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Determine failed jobs
|
||||
id: failed-jobs
|
||||
shell: bash
|
||||
@@ -1241,7 +1558,7 @@ jobs:
|
||||
- name: Send Slack notification
|
||||
uses: ./.github/actions/slack-notify
|
||||
with:
|
||||
webhook-url: ${{ secrets.MONITOR_DEPLOYMENTS_WEBHOOK }}
|
||||
webhook-url: ${{ env.MONITOR_DEPLOYMENTS_WEBHOOK }}
|
||||
failed-jobs: ${{ steps.failed-jobs.outputs.jobs }}
|
||||
title: "🚨 Deployment Workflow Failed"
|
||||
ref-name: ${{ github.ref_name }}
|
||||
|
||||
3
.github/workflows/pr-python-checks.yml
vendored
3
.github/workflows/pr-python-checks.yml
vendored
@@ -50,8 +50,9 @@ jobs:
|
||||
uses: runs-on/cache@50350ad4242587b6c8c2baa2e740b1bc11285ff4 # ratchet:runs-on/cache@v4
|
||||
with:
|
||||
path: backend/.mypy_cache
|
||||
key: mypy-${{ runner.os }}-${{ hashFiles('**/*.py', '**/*.pyi', 'backend/pyproject.toml') }}
|
||||
key: mypy-${{ runner.os }}-${{ github.base_ref || github.event.merge_group.base_ref || 'main' }}-${{ hashFiles('**/*.py', '**/*.pyi', 'backend/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
mypy-${{ runner.os }}-${{ github.base_ref || github.event.merge_group.base_ref || 'main' }}-
|
||||
mypy-${{ runner.os }}-
|
||||
|
||||
- name: Run MyPy
|
||||
|
||||
18
.vscode/launch.template.jsonc
vendored
18
.vscode/launch.template.jsonc
vendored
@@ -151,6 +151,24 @@
|
||||
},
|
||||
"consoleTitle": "Slack Bot Console"
|
||||
},
|
||||
{
|
||||
"name": "Discord Bot",
|
||||
"consoleName": "Discord Bot",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "onyx/onyxbot/discord/client.py",
|
||||
"cwd": "${workspaceFolder}/backend",
|
||||
"envFile": "${workspaceFolder}/.vscode/.env",
|
||||
"env": {
|
||||
"LOG_LEVEL": "DEBUG",
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
"PYTHONPATH": "."
|
||||
},
|
||||
"presentation": {
|
||||
"group": "2"
|
||||
},
|
||||
"consoleTitle": "Discord Bot Console"
|
||||
},
|
||||
{
|
||||
"name": "MCP Server",
|
||||
"consoleName": "MCP Server",
|
||||
|
||||
@@ -86,10 +86,6 @@ from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.long_term_log import LongTermLogger
|
||||
from onyx.utils.telemetry import mt_cloud_telemetry
|
||||
from onyx.utils.timing import log_function_time
|
||||
from onyx.utils.variable_functionality import (
|
||||
fetch_versioned_implementation_with_fallback,
|
||||
)
|
||||
from onyx.utils.variable_functionality import noop_fallback
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
|
||||
logger = setup_logger()
|
||||
@@ -362,21 +358,20 @@ def handle_stream_message_objects(
|
||||
event=MilestoneRecordType.MULTIPLE_ASSISTANTS,
|
||||
)
|
||||
|
||||
# Track user message in PostHog for analytics
|
||||
fetch_versioned_implementation_with_fallback(
|
||||
module="onyx.utils.telemetry",
|
||||
attribute="event_telemetry",
|
||||
fallback=noop_fallback,
|
||||
)(
|
||||
distinct_id=user.email if user else tenant_id,
|
||||
event="user_message_sent",
|
||||
mt_cloud_telemetry(
|
||||
tenant_id=tenant_id,
|
||||
distinct_id=(
|
||||
user.email
|
||||
if user and not getattr(user, "is_anonymous", False)
|
||||
else tenant_id
|
||||
),
|
||||
event=MilestoneRecordType.USER_MESSAGE_SENT,
|
||||
properties={
|
||||
"origin": new_msg_req.origin.value,
|
||||
"has_files": len(new_msg_req.file_descriptors) > 0,
|
||||
"has_project": chat_session.project_id is not None,
|
||||
"has_persona": persona is not None and persona.id != DEFAULT_PERSONA_ID,
|
||||
"deep_research": new_msg_req.deep_research,
|
||||
"tenant_id": tenant_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -341,6 +341,7 @@ class MilestoneRecordType(str, Enum):
|
||||
CREATED_CONNECTOR = "created_connector"
|
||||
CONNECTOR_SUCCEEDED = "connector_succeeded"
|
||||
RAN_QUERY = "ran_query"
|
||||
USER_MESSAGE_SENT = "user_message_sent"
|
||||
MULTIPLE_ASSISTANTS = "multiple_assistants"
|
||||
CREATED_ASSISTANT = "created_assistant"
|
||||
CREATED_ONYX_BOT = "created_onyx_bot"
|
||||
|
||||
@@ -25,11 +25,17 @@ class AsanaConnector(LoadConnector, PollConnector):
|
||||
batch_size: int = INDEX_BATCH_SIZE,
|
||||
continue_on_failure: bool = CONTINUE_ON_CONNECTOR_FAILURE,
|
||||
) -> None:
|
||||
self.workspace_id = asana_workspace_id
|
||||
self.project_ids_to_index: list[str] | None = (
|
||||
asana_project_ids.split(",") if asana_project_ids is not None else None
|
||||
)
|
||||
self.asana_team_id = asana_team_id
|
||||
self.workspace_id = asana_workspace_id.strip()
|
||||
if asana_project_ids:
|
||||
project_ids = [
|
||||
project_id.strip()
|
||||
for project_id in asana_project_ids.split(",")
|
||||
if project_id.strip()
|
||||
]
|
||||
self.project_ids_to_index = project_ids or None
|
||||
else:
|
||||
self.project_ids_to_index = None
|
||||
self.asana_team_id = (asana_team_id.strip() or None) if asana_team_id else None
|
||||
self.batch_size = batch_size
|
||||
self.continue_on_failure = continue_on_failure
|
||||
logger.info(
|
||||
|
||||
@@ -567,6 +567,23 @@ def extract_content_words_from_recency_query(
|
||||
return content_words_filtered[:MAX_CONTENT_WORDS]
|
||||
|
||||
|
||||
def _is_valid_keyword_query(line: str) -> bool:
|
||||
"""Check if a line looks like a valid keyword query vs explanatory text.
|
||||
|
||||
Returns False for lines that appear to be LLM explanations rather than keywords.
|
||||
"""
|
||||
# Reject lines that start with parentheses (explanatory notes)
|
||||
if line.startswith("("):
|
||||
return False
|
||||
|
||||
# Reject lines that are too long (likely sentences, not keywords)
|
||||
# Keywords should be short - reject if > 50 chars or > 6 words
|
||||
if len(line) > 50 or len(line.split()) > 6:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def expand_query_with_llm(query_text: str, llm: LLM) -> list[str]:
|
||||
"""Use LLM to expand query into multiple search variations.
|
||||
|
||||
@@ -589,10 +606,18 @@ def expand_query_with_llm(query_text: str, llm: LLM) -> list[str]:
|
||||
response_clean = _parse_llm_code_block_response(response)
|
||||
|
||||
# Split into lines and filter out empty lines
|
||||
rephrased_queries = [
|
||||
raw_queries = [
|
||||
line.strip() for line in response_clean.split("\n") if line.strip()
|
||||
]
|
||||
|
||||
# Filter out lines that look like explanatory text rather than keywords
|
||||
rephrased_queries = [q for q in raw_queries if _is_valid_keyword_query(q)]
|
||||
|
||||
# Log if we filtered out garbage
|
||||
if len(raw_queries) != len(rephrased_queries):
|
||||
filtered_out = set(raw_queries) - set(rephrased_queries)
|
||||
logger.warning(f"Filtered out non-keyword LLM responses: {filtered_out}")
|
||||
|
||||
# If no queries generated, use empty query
|
||||
if not rephrased_queries:
|
||||
logger.debug("No content keywords extracted from query expansion")
|
||||
|
||||
@@ -2932,8 +2932,6 @@ class PersonaLabel(Base):
|
||||
"Persona",
|
||||
secondary=Persona__PersonaLabel.__table__,
|
||||
back_populates="labels",
|
||||
cascade="all, delete-orphan",
|
||||
single_parent=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -917,7 +917,9 @@ def upsert_persona(
|
||||
existing_persona.icon_name = icon_name
|
||||
existing_persona.is_visible = is_visible
|
||||
existing_persona.search_start_date = search_start_date
|
||||
existing_persona.labels = labels or []
|
||||
if label_ids is not None:
|
||||
existing_persona.labels.clear()
|
||||
existing_persona.labels = labels or []
|
||||
existing_persona.is_default_persona = (
|
||||
is_default_persona
|
||||
if is_default_persona is not None
|
||||
|
||||
@@ -369,6 +369,8 @@ def _patch_openai_responses_chunk_parser() -> None:
|
||||
# New output item added
|
||||
output_item = parsed_chunk.get("item", {})
|
||||
if output_item.get("type") == "function_call":
|
||||
# Track that we've received tool calls via streaming
|
||||
self._has_streamed_tool_calls = True
|
||||
return GenericStreamingChunk(
|
||||
text="",
|
||||
tool_use=ChatCompletionToolCallChunk(
|
||||
@@ -394,6 +396,8 @@ def _patch_openai_responses_chunk_parser() -> None:
|
||||
elif event_type == "response.function_call_arguments.delta":
|
||||
content_part: Optional[str] = parsed_chunk.get("delta", None)
|
||||
if content_part:
|
||||
# Track that we've received tool calls via streaming
|
||||
self._has_streamed_tool_calls = True
|
||||
return GenericStreamingChunk(
|
||||
text="",
|
||||
tool_use=ChatCompletionToolCallChunk(
|
||||
@@ -491,22 +495,72 @@ def _patch_openai_responses_chunk_parser() -> None:
|
||||
|
||||
elif event_type == "response.completed":
|
||||
# Final event signaling all output items (including parallel tool calls) are done
|
||||
# Check if we already received tool calls via streaming events
|
||||
# There is an issue where OpenAI (not via Azure) will give back the tool calls streamed out as tokens
|
||||
# But on Azure, it's only given out all at once. OpenAI also happens to give back the tool calls in the
|
||||
# response.completed event so we need to throw it out here or there are duplicate tool calls.
|
||||
has_streamed_tool_calls = getattr(self, "_has_streamed_tool_calls", False)
|
||||
|
||||
response_data = parsed_chunk.get("response", {})
|
||||
# Determine finish reason based on response content
|
||||
finish_reason = "stop"
|
||||
if response_data.get("output"):
|
||||
for item in response_data["output"]:
|
||||
if isinstance(item, dict) and item.get("type") == "function_call":
|
||||
finish_reason = "tool_calls"
|
||||
break
|
||||
return GenericStreamingChunk(
|
||||
text="",
|
||||
tool_use=None,
|
||||
is_finished=True,
|
||||
finish_reason=finish_reason,
|
||||
usage=None,
|
||||
output_items = response_data.get("output", [])
|
||||
|
||||
# Check if there are function_call items in the output
|
||||
has_function_calls = any(
|
||||
isinstance(item, dict) and item.get("type") == "function_call"
|
||||
for item in output_items
|
||||
)
|
||||
|
||||
if has_function_calls and not has_streamed_tool_calls:
|
||||
# Azure's Responses API returns all tool calls in response.completed
|
||||
# without streaming them incrementally. Extract them here.
|
||||
from litellm.types.utils import (
|
||||
Delta,
|
||||
ModelResponseStream,
|
||||
StreamingChoices,
|
||||
)
|
||||
|
||||
tool_calls = []
|
||||
for idx, item in enumerate(output_items):
|
||||
if isinstance(item, dict) and item.get("type") == "function_call":
|
||||
tool_calls.append(
|
||||
ChatCompletionToolCallChunk(
|
||||
id=item.get("call_id"),
|
||||
index=idx,
|
||||
type="function",
|
||||
function=ChatCompletionToolCallFunctionChunk(
|
||||
name=item.get("name"),
|
||||
arguments=item.get("arguments", ""),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return ModelResponseStream(
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
index=0,
|
||||
delta=Delta(tool_calls=tool_calls),
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
]
|
||||
)
|
||||
elif has_function_calls:
|
||||
# Tool calls were already streamed, just signal completion
|
||||
return GenericStreamingChunk(
|
||||
text="",
|
||||
tool_use=None,
|
||||
is_finished=True,
|
||||
finish_reason="tool_calls",
|
||||
usage=None,
|
||||
)
|
||||
else:
|
||||
return GenericStreamingChunk(
|
||||
text="",
|
||||
tool_use=None,
|
||||
is_finished=True,
|
||||
finish_reason="stop",
|
||||
usage=None,
|
||||
)
|
||||
|
||||
else:
|
||||
pass
|
||||
|
||||
@@ -631,6 +685,40 @@ def _patch_openai_responses_transform_response() -> None:
|
||||
LiteLLMResponsesTransformationHandler.transform_response = _patched_transform_response # type: ignore[method-assign]
|
||||
|
||||
|
||||
def _patch_azure_responses_should_fake_stream() -> None:
|
||||
"""
|
||||
Patches AzureOpenAIResponsesAPIConfig.should_fake_stream to always return False.
|
||||
|
||||
By default, LiteLLM uses "fake streaming" (MockResponsesAPIStreamingIterator) for models
|
||||
not in its database. This causes Azure custom model deployments to buffer the entire
|
||||
response before yielding, resulting in poor time-to-first-token.
|
||||
|
||||
Azure's Responses API supports native streaming, so we override this to always use
|
||||
real streaming (SyncResponsesAPIStreamingIterator).
|
||||
"""
|
||||
from litellm.llms.azure.responses.transformation import (
|
||||
AzureOpenAIResponsesAPIConfig,
|
||||
)
|
||||
|
||||
if (
|
||||
getattr(AzureOpenAIResponsesAPIConfig.should_fake_stream, "__name__", "")
|
||||
== "_patched_should_fake_stream"
|
||||
):
|
||||
return
|
||||
|
||||
def _patched_should_fake_stream(
|
||||
self: Any,
|
||||
model: Optional[str],
|
||||
stream: Optional[bool],
|
||||
custom_llm_provider: Optional[str] = None,
|
||||
) -> bool:
|
||||
# Azure Responses API supports native streaming - never fake it
|
||||
return False
|
||||
|
||||
_patched_should_fake_stream.__name__ = "_patched_should_fake_stream"
|
||||
AzureOpenAIResponsesAPIConfig.should_fake_stream = _patched_should_fake_stream # type: ignore[method-assign]
|
||||
|
||||
|
||||
def apply_monkey_patches() -> None:
|
||||
"""
|
||||
Apply all necessary monkey patches to LiteLLM for compatibility.
|
||||
@@ -640,12 +728,13 @@ def apply_monkey_patches() -> None:
|
||||
- Patching OllamaChatCompletionResponseIterator.chunk_parser for streaming content
|
||||
- Patching OpenAiResponsesToChatCompletionStreamIterator.chunk_parser for OpenAI Responses API
|
||||
- Patching LiteLLMResponsesTransformationHandler.transform_response for non-streaming responses
|
||||
- Patching LiteLLMResponsesTransformationHandler._convert_content_str_to_input_text for tool content types
|
||||
- Patching AzureOpenAIResponsesAPIConfig.should_fake_stream to enable native streaming
|
||||
"""
|
||||
_patch_ollama_transform_request()
|
||||
_patch_ollama_chunk_parser()
|
||||
_patch_openai_responses_chunk_parser()
|
||||
_patch_openai_responses_transform_response()
|
||||
_patch_azure_responses_should_fake_stream()
|
||||
|
||||
|
||||
def _extract_reasoning_content(message: dict) -> Tuple[Optional[str], Optional[str]]:
|
||||
|
||||
@@ -301,6 +301,12 @@ class LitellmLLM(LLM):
|
||||
)
|
||||
is_ollama = self._model_provider == LlmProviderNames.OLLAMA_CHAT
|
||||
is_mistral = self._model_provider == LlmProviderNames.MISTRAL
|
||||
is_vertex_ai = self._model_provider == LlmProviderNames.VERTEX_AI
|
||||
# Vertex Anthropic Opus 4.5 rejects output_config (LiteLLM maps reasoning_effort).
|
||||
# Keep this guard until LiteLLM/Vertex accept the field for this model.
|
||||
is_vertex_opus_4_5 = (
|
||||
is_vertex_ai and "claude-opus-4-5" in self.config.model_name.lower()
|
||||
)
|
||||
|
||||
#########################
|
||||
# Build arguments
|
||||
@@ -331,12 +337,16 @@ class LitellmLLM(LLM):
|
||||
# Temperature
|
||||
temperature = 1 if is_reasoning else self._temperature
|
||||
|
||||
if stream:
|
||||
if stream and not is_vertex_opus_4_5:
|
||||
optional_kwargs["stream_options"] = {"include_usage": True}
|
||||
|
||||
# Use configured default if not provided (if not set in env, low)
|
||||
reasoning_effort = reasoning_effort or ReasoningEffort(DEFAULT_REASONING_EFFORT)
|
||||
if is_reasoning and reasoning_effort != ReasoningEffort.OFF:
|
||||
if (
|
||||
is_reasoning
|
||||
and reasoning_effort != ReasoningEffort.OFF
|
||||
and not is_vertex_opus_4_5
|
||||
):
|
||||
if is_openai_model:
|
||||
# OpenAI API does not accept reasoning params for GPT 5 chat models
|
||||
# (neither reasoning nor reasoning_effort are accepted)
|
||||
|
||||
287
backend/onyx/onyxbot/discord/DISCORD_MULTITENANT_README.md
Normal file
287
backend/onyx/onyxbot/discord/DISCORD_MULTITENANT_README.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# Discord Bot Multitenant Architecture
|
||||
|
||||
This document analyzes how the Discord cache manager and API client coordinate to handle multitenant API keys from a single Discord client.
|
||||
|
||||
## Overview
|
||||
|
||||
The Discord bot uses a **single-client, multi-tenant** architecture where one `OnyxDiscordClient` instance serves multiple tenants (organizations) simultaneously. Tenant isolation is achieved through:
|
||||
|
||||
- **Cache Manager**: Maps Discord guilds to tenants and stores per-tenant API keys
|
||||
- **API Client**: Stateless HTTP client that accepts dynamic API keys per request
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ OnyxDiscordClient │
|
||||
│ │
|
||||
│ ┌─────────────────────────┐ ┌─────────────────────────────┐ │
|
||||
│ │ DiscordCacheManager │ │ OnyxAPIClient │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ guild_id → tenant_id │───▶│ send_chat_message( │ │
|
||||
│ │ tenant_id → api_key │ │ message, │ │
|
||||
│ │ │ │ api_key=<per-tenant>, │ │
|
||||
│ └─────────────────────────┘ │ persona_id=... │ │
|
||||
│ │ ) │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Details
|
||||
|
||||
### 1. Cache Manager (`backend/onyx/onyxbot/discord/cache.py`)
|
||||
|
||||
The `DiscordCacheManager` maintains two critical in-memory mappings:
|
||||
|
||||
```python
|
||||
class DiscordCacheManager:
|
||||
_guild_tenants: dict[int, str] # guild_id → tenant_id
|
||||
_api_keys: dict[str, str] # tenant_id → api_key
|
||||
_lock: asyncio.Lock # Concurrency control
|
||||
```
|
||||
|
||||
#### Key Responsibilities
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `get_tenant(guild_id)` | O(1) lookup: guild → tenant |
|
||||
| `get_api_key(tenant_id)` | O(1) lookup: tenant → API key |
|
||||
| `refresh_all()` | Full cache rebuild from database |
|
||||
| `refresh_guild()` | Incremental update for single guild |
|
||||
|
||||
#### API Key Provisioning Strategy
|
||||
|
||||
API keys are **lazily provisioned** - only created when first needed:
|
||||
|
||||
```python
|
||||
async def _load_tenant_data(self, tenant_id: str) -> tuple[list[int], str | None]:
|
||||
needs_key = tenant_id not in self._api_keys
|
||||
|
||||
with get_session_with_tenant(tenant_id) as db:
|
||||
# Load guild configs
|
||||
configs = get_discord_bot_configs(db)
|
||||
guild_ids = [c.guild_id for c in configs if c.enabled]
|
||||
|
||||
# Only provision API key if not already cached
|
||||
api_key = None
|
||||
if needs_key:
|
||||
api_key = get_or_create_discord_service_api_key(db, tenant_id)
|
||||
|
||||
return guild_ids, api_key
|
||||
```
|
||||
|
||||
This optimization avoids repeated database calls for API key generation.
|
||||
|
||||
#### Concurrency Control
|
||||
|
||||
All write operations acquire an async lock to prevent race conditions:
|
||||
|
||||
```python
|
||||
async def refresh_all(self) -> None:
|
||||
async with self._lock:
|
||||
# Safe to modify _guild_tenants and _api_keys
|
||||
for tenant_id in get_all_tenant_ids():
|
||||
guild_ids, api_key = await self._load_tenant_data(tenant_id)
|
||||
# Update mappings...
|
||||
```
|
||||
|
||||
Read operations (`get_tenant`, `get_api_key`) are lock-free since Python dict lookups are atomic.
|
||||
|
||||
---
|
||||
|
||||
### 2. API Client (`backend/onyx/onyxbot/discord/api_client.py`)
|
||||
|
||||
The `OnyxAPIClient` is a **stateless async HTTP client** that communicates with Onyx API pods.
|
||||
|
||||
#### Key Design: Per-Request API Key Injection
|
||||
|
||||
```python
|
||||
class OnyxAPIClient:
|
||||
async def send_chat_message(
|
||||
self,
|
||||
message: str,
|
||||
api_key: str, # Injected per-request
|
||||
persona_id: int | None,
|
||||
...
|
||||
) -> ChatFullResponse:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}", # Tenant-specific auth
|
||||
}
|
||||
# Make request...
|
||||
```
|
||||
|
||||
The client accepts `api_key` as a parameter to each method, enabling **dynamic tenant selection at request time**. This design allows a single client instance to serve multiple tenants:
|
||||
|
||||
```python
|
||||
# Same client, different tenants
|
||||
await api_client.send_chat_message(msg, api_key=key_for_tenant_1, ...)
|
||||
await api_client.send_chat_message(msg, api_key=key_for_tenant_2, ...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coordination Flow
|
||||
|
||||
### Message Processing Pipeline
|
||||
|
||||
When a Discord message arrives, the client coordinates cache and API client:
|
||||
|
||||
```python
|
||||
async def on_message(self, message: Message) -> None:
|
||||
guild_id = message.guild.id
|
||||
|
||||
# Step 1: Cache lookup - guild → tenant
|
||||
tenant_id = self.cache.get_tenant(guild_id)
|
||||
if not tenant_id:
|
||||
return # Guild not registered
|
||||
|
||||
# Step 2: Cache lookup - tenant → API key
|
||||
api_key = self.cache.get_api_key(tenant_id)
|
||||
if not api_key:
|
||||
logger.warning(f"No API key for tenant {tenant_id}")
|
||||
return
|
||||
|
||||
# Step 3: API call with tenant-specific credentials
|
||||
await process_chat_message(
|
||||
message=message,
|
||||
api_key=api_key, # Tenant-specific
|
||||
persona_id=persona_id, # Tenant-specific
|
||||
api_client=self.api_client,
|
||||
)
|
||||
```
|
||||
|
||||
### Startup Sequence
|
||||
|
||||
```python
|
||||
async def setup_hook(self) -> None:
|
||||
# 1. Initialize API client (create aiohttp session)
|
||||
await self.api_client.initialize()
|
||||
|
||||
# 2. Populate cache with all tenants
|
||||
await self.cache.refresh_all()
|
||||
|
||||
# 3. Start background refresh task
|
||||
self._cache_refresh_task = self.loop.create_task(
|
||||
self._periodic_cache_refresh() # Every 60 seconds
|
||||
)
|
||||
```
|
||||
|
||||
### Shutdown Sequence
|
||||
|
||||
```python
|
||||
async def close(self) -> None:
|
||||
# 1. Cancel background refresh
|
||||
if self._cache_refresh_task:
|
||||
self._cache_refresh_task.cancel()
|
||||
|
||||
# 2. Close Discord connection
|
||||
await super().close()
|
||||
|
||||
# 3. Close API client session
|
||||
await self.api_client.close()
|
||||
|
||||
# 4. Clear cache
|
||||
self.cache.clear()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tenant Isolation Mechanisms
|
||||
|
||||
### 1. Per-Tenant API Keys
|
||||
|
||||
Each tenant has a dedicated service API key:
|
||||
|
||||
```python
|
||||
# backend/onyx/db/discord_bot.py
|
||||
def get_or_create_discord_service_api_key(db_session: Session, tenant_id: str) -> str:
|
||||
existing = get_discord_service_api_key(db_session)
|
||||
if existing:
|
||||
return regenerate_key(existing)
|
||||
|
||||
# Create LIMITED role key (chat-only permissions)
|
||||
return insert_api_key(
|
||||
db_session=db_session,
|
||||
api_key_args=APIKeyArgs(
|
||||
name=DISCORD_SERVICE_API_KEY_NAME,
|
||||
role=UserRole.LIMITED, # Minimal permissions
|
||||
),
|
||||
user_id=None, # Service account (system-owned)
|
||||
).api_key
|
||||
```
|
||||
|
||||
### 2. Database Context Variables
|
||||
|
||||
The cache uses context variables for proper tenant-scoped DB sessions:
|
||||
|
||||
```python
|
||||
context_token = CURRENT_TENANT_ID_CONTEXTVAR.set(tenant_id)
|
||||
try:
|
||||
with get_session_with_tenant(tenant_id) as db:
|
||||
# All DB operations scoped to this tenant
|
||||
...
|
||||
finally:
|
||||
CURRENT_TENANT_ID_CONTEXTVAR.reset(context_token)
|
||||
```
|
||||
|
||||
### 3. Enterprise Gating Support
|
||||
|
||||
Gated tenants are filtered during cache refresh:
|
||||
|
||||
```python
|
||||
gated_tenants = fetch_ee_implementation_or_noop(
|
||||
"onyx.server.tenants.product_gating",
|
||||
"get_gated_tenants",
|
||||
set(),
|
||||
)()
|
||||
|
||||
for tenant_id in get_all_tenant_ids():
|
||||
if tenant_id in gated_tenants:
|
||||
continue # Skip gated tenants
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cache Refresh Strategy
|
||||
|
||||
| Trigger | Method | Scope |
|
||||
|---------|--------|-------|
|
||||
| Startup | `refresh_all()` | All tenants |
|
||||
| Periodic (60s) | `refresh_all()` | All tenants |
|
||||
| Guild registration | `refresh_guild()` | Single tenant |
|
||||
|
||||
### Error Handling
|
||||
|
||||
- **Tenant-level errors**: Logged and skipped (doesn't stop other tenants)
|
||||
- **Missing API key**: Bot silently ignores messages from that guild
|
||||
- **Network errors**: Logged, cache continues with stale data until next refresh
|
||||
|
||||
---
|
||||
|
||||
## Key Design Insights
|
||||
|
||||
1. **Single Client, Multiple Tenants**: One `OnyxAPIClient` and one `DiscordCacheManager` instance serves all tenants via dynamic API key injection.
|
||||
|
||||
2. **Cache-First Architecture**: Guild lookups are O(1) in-memory; API keys are cached after first provisioning to avoid repeated DB calls.
|
||||
|
||||
3. **Graceful Degradation**: If an API key is missing or stale, the bot simply doesn't respond (no crash or error propagation).
|
||||
|
||||
4. **Thread Safety Without Blocking**: `asyncio.Lock` prevents race conditions while maintaining async concurrency for reads.
|
||||
|
||||
5. **Lazy Provisioning**: API keys are only created when first needed, then cached for performance.
|
||||
|
||||
6. **Stateless API Client**: The HTTP client holds no tenant state - all tenant context is injected per-request via the `api_key` parameter.
|
||||
|
||||
---
|
||||
|
||||
## File References
|
||||
|
||||
| Component | Path |
|
||||
|-----------|------|
|
||||
| Cache Manager | `backend/onyx/onyxbot/discord/cache.py` |
|
||||
| API Client | `backend/onyx/onyxbot/discord/api_client.py` |
|
||||
| Discord Client | `backend/onyx/onyxbot/discord/client.py` |
|
||||
| API Key DB Operations | `backend/onyx/db/discord_bot.py` |
|
||||
| Cache Manager Tests | `backend/tests/unit/onyx/onyxbot/discord/test_cache_manager.py` |
|
||||
| API Client Tests | `backend/tests/unit/onyx/onyxbot/discord/test_api_client.py` |
|
||||
@@ -1,30 +1,39 @@
|
||||
from onyx.configs.app_configs import MAX_SLACK_QUERY_EXPANSIONS
|
||||
|
||||
SLACK_QUERY_EXPANSION_PROMPT = f"""
|
||||
Rewrite the user's query and, if helpful, split it into at most {MAX_SLACK_QUERY_EXPANSIONS} \
|
||||
keyword-only queries, so that Slack's keyword search yields the best matches.
|
||||
Rewrite the user's query into at most {MAX_SLACK_QUERY_EXPANSIONS} keyword-only queries for Slack's keyword search.
|
||||
|
||||
Keep in mind the Slack's search behavior:
|
||||
- Pure keyword AND search (no semantics).
|
||||
- Word order matters.
|
||||
- More words = fewer matches, so keep each query concise.
|
||||
- IMPORTANT: Prefer simple 1-2 word queries over longer multi-word queries.
|
||||
Slack search behavior:
|
||||
- Pure keyword AND search (no semantics)
|
||||
- More words = fewer matches, so keep queries concise (1-3 words)
|
||||
|
||||
Critical: Extract ONLY keywords that would actually appear in Slack message content.
|
||||
ALWAYS include:
|
||||
- Person names (e.g., "Sarah Chen", "Mike Johnson") - people search for messages from/about specific people
|
||||
- Project/product names, technical terms, proper nouns
|
||||
- Actual content words: "performance", "bug", "deployment", "API", "error"
|
||||
|
||||
DO NOT include:
|
||||
- Meta-words: "topics", "conversations", "discussed", "summary", "messages", "big", "main", "talking"
|
||||
- Temporal: "today", "yesterday", "week", "month", "recent", "past", "last"
|
||||
- Channels/Users: "general", "eng-general", "engineering", "@username"
|
||||
|
||||
DO include:
|
||||
- Actual content: "performance", "bug", "deployment", "API", "database", "error", "feature"
|
||||
- Meta-words: "topics", "conversations", "discussed", "summary", "messages"
|
||||
- Temporal: "today", "yesterday", "week", "month", "recent", "last"
|
||||
- Channel names: "general", "eng-general", "random"
|
||||
|
||||
Examples:
|
||||
|
||||
Query: "what are the big topics in eng-general this week?"
|
||||
Output:
|
||||
|
||||
Query: "messages with Sarah about the deployment"
|
||||
Output:
|
||||
Sarah deployment
|
||||
Sarah
|
||||
deployment
|
||||
|
||||
Query: "what did Mike say about the budget?"
|
||||
Output:
|
||||
Mike budget
|
||||
Mike
|
||||
budget
|
||||
|
||||
Query: "performance issues in eng-general"
|
||||
Output:
|
||||
performance issues
|
||||
@@ -41,7 +50,7 @@ Now process this query:
|
||||
|
||||
{{query}}
|
||||
|
||||
Output:
|
||||
Output (keywords only, one per line, NO explanations or commentary):
|
||||
"""
|
||||
|
||||
SLACK_DATE_EXTRACTION_PROMPT = """
|
||||
|
||||
@@ -410,26 +410,20 @@ def list_llm_provider_basics(
|
||||
|
||||
all_providers = fetch_existing_llm_providers(db_session)
|
||||
user_group_ids = fetch_user_group_ids(db_session, user) if user else set()
|
||||
is_admin = user and user.role == UserRole.ADMIN
|
||||
is_admin = user is not None and user.role == UserRole.ADMIN
|
||||
|
||||
accessible_providers = []
|
||||
|
||||
for provider in all_providers:
|
||||
# Include all public providers
|
||||
if provider.is_public:
|
||||
accessible_providers.append(LLMProviderDescriptor.from_model(provider))
|
||||
continue
|
||||
|
||||
# Include restricted providers user has access to via groups
|
||||
if is_admin:
|
||||
# Admins see all providers
|
||||
accessible_providers.append(LLMProviderDescriptor.from_model(provider))
|
||||
elif provider.groups:
|
||||
# User must be in at least one of the provider's groups
|
||||
if user_group_ids.intersection({g.id for g in provider.groups}):
|
||||
accessible_providers.append(LLMProviderDescriptor.from_model(provider))
|
||||
elif not provider.personas:
|
||||
# No restrictions = accessible
|
||||
# Use centralized access control logic with persona=None since we're
|
||||
# listing providers without a specific persona context. This correctly:
|
||||
# - Includes all public providers
|
||||
# - Includes providers user can access via group membership
|
||||
# - Excludes persona-only restricted providers (requires specific persona)
|
||||
# - Excludes non-public providers with no restrictions (admin-only)
|
||||
if can_user_access_llm_provider(
|
||||
provider, user_group_ids, persona=None, is_admin=is_admin
|
||||
):
|
||||
accessible_providers.append(LLMProviderDescriptor.from_model(provider))
|
||||
|
||||
end_time = datetime.now(timezone.utc)
|
||||
|
||||
@@ -58,6 +58,7 @@ from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.db.feedback import create_chat_message_feedback
|
||||
from onyx.db.feedback import remove_chat_message_feedback
|
||||
from onyx.db.models import ChatSessionSharedStatus
|
||||
from onyx.db.models import Persona
|
||||
from onyx.db.models import User
|
||||
from onyx.db.persona import get_persona_by_id
|
||||
@@ -266,7 +267,35 @@ def get_chat_session(
|
||||
include_deleted=include_deleted,
|
||||
)
|
||||
except ValueError:
|
||||
raise ValueError("Chat session does not exist or has been deleted")
|
||||
try:
|
||||
# If we failed to get a chat session, try to retrieve the session with
|
||||
# less restrictive filters in order to identify what exactly mismatched
|
||||
# so we can bubble up an accurate error code andmessage.
|
||||
existing_chat_session = get_chat_session_by_id(
|
||||
chat_session_id=session_id,
|
||||
user_id=None,
|
||||
db_session=db_session,
|
||||
is_shared=False,
|
||||
include_deleted=True,
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Chat session not found")
|
||||
|
||||
if not include_deleted and existing_chat_session.deleted:
|
||||
raise HTTPException(status_code=404, detail="Chat session has been deleted")
|
||||
|
||||
if is_shared:
|
||||
if existing_chat_session.shared_status != ChatSessionSharedStatus.PUBLIC:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Chat session is not shared"
|
||||
)
|
||||
elif user_id is not None and existing_chat_session.user_id not in (
|
||||
user_id,
|
||||
None,
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
raise HTTPException(status_code=404, detail="Chat session not found")
|
||||
|
||||
# for chat-seeding: if the session is unassigned, assign it now. This is done here
|
||||
# to avoid another back and forth between FE -> BE before starting the first
|
||||
|
||||
@@ -191,6 +191,18 @@ autorestart=true
|
||||
startretries=5
|
||||
startsecs=60
|
||||
|
||||
# Listens for Discord messages and responds with answers
|
||||
# for all guilds/channels that the OnyxBot has been added to.
|
||||
# If not configured, will continue to probe every 3 minutes for a Discord bot token.
|
||||
[program:discord_bot]
|
||||
command=python onyx/onyxbot/discord/client.py
|
||||
stdout_logfile=/var/log/discord_bot.log
|
||||
stdout_logfile_maxbytes=16MB
|
||||
redirect_stderr=true
|
||||
autorestart=true
|
||||
startretries=5
|
||||
startsecs=60
|
||||
|
||||
# Pushes all logs from the above programs to stdout
|
||||
# No log rotation here, since it's stdout it's handled by the Docker container logging
|
||||
[program:log-redirect-handler]
|
||||
@@ -206,6 +218,7 @@ command=tail -qF
|
||||
/var/log/celery_worker_user_file_processing.log
|
||||
/var/log/celery_worker_docfetching.log
|
||||
/var/log/slack_bot.log
|
||||
/var/log/discord_bot.log
|
||||
/var/log/supervisord_watchdog_celery_beat.log
|
||||
/var/log/mcp_server.log
|
||||
/var/log/mcp_server.err.log
|
||||
|
||||
@@ -476,8 +476,8 @@ class ChatSessionManager:
|
||||
else GENERAL_HEADERS
|
||||
),
|
||||
)
|
||||
# Chat session should return 400 if it doesn't exist
|
||||
return response.status_code == 400
|
||||
# Chat session should return 404 if it doesn't exist or is deleted
|
||||
return response.status_code == 404
|
||||
|
||||
@staticmethod
|
||||
def verify_soft_deleted(
|
||||
|
||||
185
backend/tests/integration/tests/chat/test_chat_session_access.py
Normal file
185
backend/tests/integration/tests/chat/test_chat_session_access.py
Normal file
@@ -0,0 +1,185 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from requests import HTTPError
|
||||
|
||||
from onyx.auth.schemas import UserRole
|
||||
from tests.integration.common_utils.constants import API_SERVER_URL
|
||||
from tests.integration.common_utils.constants import GENERAL_HEADERS
|
||||
from tests.integration.common_utils.managers.chat import ChatSessionManager
|
||||
from tests.integration.common_utils.managers.user import build_email
|
||||
from tests.integration.common_utils.managers.user import DEFAULT_PASSWORD
|
||||
from tests.integration.common_utils.managers.user import UserManager
|
||||
from tests.integration.common_utils.reset import reset_all
|
||||
from tests.integration.common_utils.test_models import DATestUser
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def reset_for_module() -> None:
|
||||
"""Reset all data once before running any tests in this module."""
|
||||
reset_all()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def second_user(admin_user: DATestUser) -> DATestUser:
|
||||
# Ensure admin exists so this new user is created with BASIC role.
|
||||
try:
|
||||
return UserManager.create(name="second_basic_user")
|
||||
except HTTPError as e:
|
||||
response = e.response
|
||||
if response is None:
|
||||
raise
|
||||
if response.status_code not in (400, 409):
|
||||
raise
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
raise
|
||||
detail = payload.get("detail")
|
||||
if not _is_user_already_exists_detail(detail):
|
||||
raise
|
||||
print("Second basic user already exists; logging in instead.")
|
||||
return UserManager.login_as_user(
|
||||
DATestUser(
|
||||
id="",
|
||||
email=build_email("second_basic_user"),
|
||||
password=DEFAULT_PASSWORD,
|
||||
headers=GENERAL_HEADERS,
|
||||
role=UserRole.BASIC,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _is_user_already_exists_detail(detail: object) -> bool:
|
||||
if isinstance(detail, str):
|
||||
normalized = detail.lower()
|
||||
return (
|
||||
"already exists" in normalized
|
||||
or "register_user_already_exists" in normalized
|
||||
)
|
||||
if isinstance(detail, dict):
|
||||
code = detail.get("code")
|
||||
if isinstance(code, str) and code.lower() == "register_user_already_exists":
|
||||
return True
|
||||
message = detail.get("message")
|
||||
if isinstance(message, str) and "already exists" in message.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_chat_session(
|
||||
chat_session_id: str,
|
||||
user: DATestUser,
|
||||
is_shared: bool | None = None,
|
||||
include_deleted: bool | None = None,
|
||||
) -> requests.Response:
|
||||
params: dict[str, str] = {}
|
||||
if is_shared is not None:
|
||||
params["is_shared"] = str(is_shared).lower()
|
||||
if include_deleted is not None:
|
||||
params["include_deleted"] = str(include_deleted).lower()
|
||||
|
||||
return requests.get(
|
||||
f"{API_SERVER_URL}/chat/get-chat-session/{chat_session_id}",
|
||||
params=params,
|
||||
headers=user.headers,
|
||||
cookies=user.cookies,
|
||||
)
|
||||
|
||||
|
||||
def _set_sharing_status(
|
||||
chat_session_id: str, sharing_status: str, user: DATestUser
|
||||
) -> requests.Response:
|
||||
return requests.patch(
|
||||
f"{API_SERVER_URL}/chat/chat-session/{chat_session_id}",
|
||||
json={"sharing_status": sharing_status},
|
||||
headers=user.headers,
|
||||
cookies=user.cookies,
|
||||
)
|
||||
|
||||
|
||||
def test_private_chat_session_access(
|
||||
basic_user: DATestUser, second_user: DATestUser
|
||||
) -> None:
|
||||
"""Verify private sessions are only accessible by the owner and never via share link."""
|
||||
# Create a private chat session owned by basic_user.
|
||||
chat_session = ChatSessionManager.create(user_performing_action=basic_user)
|
||||
|
||||
# Owner can access the private session normally.
|
||||
response = _get_chat_session(str(chat_session.id), basic_user)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Share link should be forbidden when the session is private.
|
||||
response = _get_chat_session(str(chat_session.id), basic_user, is_shared=True)
|
||||
assert response.status_code == 403
|
||||
|
||||
# Other users cannot access private sessions directly.
|
||||
response = _get_chat_session(str(chat_session.id), second_user)
|
||||
assert response.status_code == 403
|
||||
|
||||
# Other users also cannot access private sessions via share link.
|
||||
response = _get_chat_session(str(chat_session.id), second_user, is_shared=True)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_public_shared_chat_session_access(
|
||||
basic_user: DATestUser, second_user: DATestUser
|
||||
) -> None:
|
||||
"""Verify shared sessions are accessible only via share link for non-owners."""
|
||||
# Create a private session, then mark it public.
|
||||
chat_session = ChatSessionManager.create(user_performing_action=basic_user)
|
||||
|
||||
response = _set_sharing_status(str(chat_session.id), "public", basic_user)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Owner can access normally.
|
||||
response = _get_chat_session(str(chat_session.id), basic_user)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Owner can also access via share link.
|
||||
response = _get_chat_session(str(chat_session.id), basic_user, is_shared=True)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Non-owner cannot access without share link.
|
||||
response = _get_chat_session(str(chat_session.id), second_user)
|
||||
assert response.status_code == 403
|
||||
|
||||
# Non-owner can access with share link for public sessions.
|
||||
response = _get_chat_session(str(chat_session.id), second_user, is_shared=True)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_deleted_chat_session_access(
|
||||
basic_user: DATestUser, second_user: DATestUser
|
||||
) -> None:
|
||||
"""Verify deleted sessions return 404, with include_deleted gated by access checks."""
|
||||
# Create and soft-delete a session.
|
||||
chat_session = ChatSessionManager.create(user_performing_action=basic_user)
|
||||
|
||||
deletion_success = ChatSessionManager.soft_delete(
|
||||
chat_session=chat_session, user_performing_action=basic_user
|
||||
)
|
||||
assert deletion_success is True
|
||||
|
||||
# Deleted sessions are not accessible normally.
|
||||
response = _get_chat_session(str(chat_session.id), basic_user)
|
||||
assert response.status_code == 404
|
||||
|
||||
# Owner can fetch deleted session only with include_deleted.
|
||||
response = _get_chat_session(str(chat_session.id), basic_user, include_deleted=True)
|
||||
assert response.status_code == 200
|
||||
assert response.json().get("deleted") is True
|
||||
|
||||
# Non-owner should be blocked even with include_deleted.
|
||||
response = _get_chat_session(
|
||||
str(chat_session.id), second_user, include_deleted=True
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_chat_session_not_found_returns_404(basic_user: DATestUser) -> None:
|
||||
"""Verify unknown IDs return 404."""
|
||||
response = _get_chat_session(str(uuid4()), basic_user)
|
||||
assert response.status_code == 404
|
||||
@@ -309,6 +309,63 @@ def test_get_llm_for_persona_falls_back_when_access_denied(
|
||||
assert fallback_llm.config.model_name == default_provider.default_model_name
|
||||
|
||||
|
||||
def test_list_llm_provider_basics_excludes_non_public_unrestricted(
|
||||
users: tuple[DATestUser, DATestUser],
|
||||
) -> None:
|
||||
"""Test that the /llm/provider endpoint correctly excludes non-public providers
|
||||
with no group/persona restrictions.
|
||||
|
||||
This tests the fix for the bug where non-public providers with no restrictions
|
||||
were incorrectly shown to all users instead of being admin-only.
|
||||
"""
|
||||
admin_user, basic_user = users
|
||||
|
||||
# Create a public provider (should be visible to all)
|
||||
public_provider = LLMProviderManager.create(
|
||||
name="public-provider",
|
||||
is_public=True,
|
||||
set_as_default=True,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
# Create a non-public provider with no restrictions (should be admin-only)
|
||||
non_public_provider = LLMProviderManager.create(
|
||||
name="non-public-unrestricted",
|
||||
is_public=False,
|
||||
groups=[],
|
||||
personas=[],
|
||||
set_as_default=False,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
# Non-admin user calls the /llm/provider endpoint
|
||||
response = requests.get(
|
||||
f"{API_SERVER_URL}/llm/provider",
|
||||
headers=basic_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
providers = response.json()
|
||||
provider_names = [p["name"] for p in providers]
|
||||
|
||||
# Public provider should be visible
|
||||
assert public_provider.name in provider_names
|
||||
|
||||
# Non-public provider with no restrictions should NOT be visible to non-admin
|
||||
assert non_public_provider.name not in provider_names
|
||||
|
||||
# Admin user should see both providers
|
||||
admin_response = requests.get(
|
||||
f"{API_SERVER_URL}/llm/provider",
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert admin_response.status_code == 200
|
||||
admin_providers = admin_response.json()
|
||||
admin_provider_names = [p["name"] for p in admin_providers]
|
||||
|
||||
assert public_provider.name in admin_provider_names
|
||||
assert non_public_provider.name in admin_provider_names
|
||||
|
||||
|
||||
def test_provider_delete_clears_persona_references(reset: None) -> None:
|
||||
"""Test that deleting a provider automatically clears persona references."""
|
||||
admin_user = UserManager.create(name="admin_user")
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
|
||||
from onyx.server.features.persona.models import PersonaUpsertRequest
|
||||
from tests.integration.common_utils.constants import API_SERVER_URL
|
||||
from tests.integration.common_utils.managers.persona import PersonaLabelManager
|
||||
from tests.integration.common_utils.managers.persona import PersonaManager
|
||||
from tests.integration.common_utils.test_models import DATestPersonaLabel
|
||||
from tests.integration.common_utils.test_models import DATestUser
|
||||
|
||||
|
||||
def test_update_persona_with_null_label_ids_preserves_labels(
|
||||
reset: None, admin_user: DATestUser
|
||||
) -> None:
|
||||
persona_label = PersonaLabelManager.create(
|
||||
label=DATestPersonaLabel(name=f"Test label {uuid4()}"),
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
assert persona_label.id is not None
|
||||
persona = PersonaManager.create(
|
||||
label_ids=[persona_label.id],
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
updated_description = f"{persona.description}-updated"
|
||||
update_request = PersonaUpsertRequest(
|
||||
name=persona.name,
|
||||
description=updated_description,
|
||||
system_prompt=persona.system_prompt or "",
|
||||
task_prompt=persona.task_prompt or "",
|
||||
datetime_aware=persona.datetime_aware,
|
||||
document_set_ids=persona.document_set_ids,
|
||||
num_chunks=persona.num_chunks,
|
||||
is_public=persona.is_public,
|
||||
recency_bias=persona.recency_bias,
|
||||
llm_filter_extraction=persona.llm_filter_extraction,
|
||||
llm_relevance_filter=persona.llm_relevance_filter,
|
||||
llm_model_provider_override=persona.llm_model_provider_override,
|
||||
llm_model_version_override=persona.llm_model_version_override,
|
||||
tool_ids=persona.tool_ids,
|
||||
users=[],
|
||||
groups=[],
|
||||
label_ids=None,
|
||||
)
|
||||
|
||||
response = requests.patch(
|
||||
f"{API_SERVER_URL}/persona/{persona.id}",
|
||||
json=update_request.model_dump(mode="json", exclude_none=False),
|
||||
headers=admin_user.headers,
|
||||
cookies=admin_user.cookies,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
fetched = requests.get(
|
||||
f"{API_SERVER_URL}/persona/{persona.id}",
|
||||
headers=admin_user.headers,
|
||||
cookies=admin_user.cookies,
|
||||
)
|
||||
fetched.raise_for_status()
|
||||
fetched_persona = fetched.json()
|
||||
|
||||
assert fetched_persona["description"] == updated_description
|
||||
fetched_label_ids = {label["id"] for label in fetched_persona["labels"]}
|
||||
assert persona_label.id in fetched_label_ids
|
||||
@@ -270,7 +270,7 @@ def test_web_search_endpoints_with_exa(
|
||||
provider_id = _activate_exa_provider(admin_user)
|
||||
assert isinstance(provider_id, int)
|
||||
|
||||
search_request = {"queries": ["latest ai research news"], "max_results": 3}
|
||||
search_request = {"queries": ["wikipedia python programming"], "max_results": 3}
|
||||
|
||||
lite_response = requests.post(
|
||||
f"{API_SERVER_URL}/web-search/search-lite",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Tests for Asana connector configuration parsing."""
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.connectors.asana.connector import AsanaConnector
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"project_ids,expected",
|
||||
[
|
||||
(None, None),
|
||||
("", None),
|
||||
(" ", None),
|
||||
(" 123 ", ["123"]),
|
||||
(" 123 , , 456 , ", ["123", "456"]),
|
||||
],
|
||||
)
|
||||
def test_asana_connector_project_ids_normalization(
|
||||
project_ids: str | None, expected: list[str] | None
|
||||
) -> None:
|
||||
connector = AsanaConnector(
|
||||
asana_workspace_id=" 1153293530468850 ",
|
||||
asana_project_ids=project_ids,
|
||||
asana_team_id=" 1210918501948021 ",
|
||||
)
|
||||
|
||||
assert connector.workspace_id == "1153293530468850"
|
||||
assert connector.project_ids_to_index == expected
|
||||
assert connector.asana_team_id == "1210918501948021"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"team_id,expected",
|
||||
[
|
||||
(None, None),
|
||||
("", None),
|
||||
(" ", None),
|
||||
(" 1210918501948021 ", "1210918501948021"),
|
||||
],
|
||||
)
|
||||
def test_asana_connector_team_id_normalization(
|
||||
team_id: str | None, expected: str | None
|
||||
) -> None:
|
||||
connector = AsanaConnector(
|
||||
asana_workspace_id="1153293530468850",
|
||||
asana_project_ids=None,
|
||||
asana_team_id=team_id,
|
||||
)
|
||||
|
||||
assert connector.asana_team_id == expected
|
||||
@@ -409,6 +409,53 @@ def test_multiple_tool_calls_streaming(default_multi_llm: LitellmLLM) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_vertex_stream_omits_stream_options() -> None:
|
||||
llm = LitellmLLM(
|
||||
api_key="test_key",
|
||||
timeout=30,
|
||||
model_provider=LlmProviderNames.VERTEX_AI,
|
||||
model_name="claude-opus-4-5@20251101",
|
||||
max_input_tokens=get_max_input_tokens(
|
||||
model_provider=LlmProviderNames.VERTEX_AI,
|
||||
model_name="claude-opus-4-5@20251101",
|
||||
),
|
||||
)
|
||||
|
||||
with patch("litellm.completion") as mock_completion:
|
||||
mock_completion.return_value = []
|
||||
|
||||
messages: LanguageModelInput = [UserMessage(content="Hi")]
|
||||
list(llm.stream(messages))
|
||||
|
||||
kwargs = mock_completion.call_args.kwargs
|
||||
assert "stream_options" not in kwargs
|
||||
|
||||
|
||||
def test_vertex_opus_4_5_omits_reasoning_effort() -> None:
|
||||
llm = LitellmLLM(
|
||||
api_key="test_key",
|
||||
timeout=30,
|
||||
model_provider=LlmProviderNames.VERTEX_AI,
|
||||
model_name="claude-opus-4-5@20251101",
|
||||
max_input_tokens=get_max_input_tokens(
|
||||
model_provider=LlmProviderNames.VERTEX_AI,
|
||||
model_name="claude-opus-4-5@20251101",
|
||||
),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("litellm.completion") as mock_completion,
|
||||
patch("onyx.llm.multi_llm.model_is_reasoning_model", return_value=True),
|
||||
):
|
||||
mock_completion.return_value = []
|
||||
|
||||
messages: LanguageModelInput = [UserMessage(content="Hi")]
|
||||
list(llm.stream(messages))
|
||||
|
||||
kwargs = mock_completion.call_args.kwargs
|
||||
assert "reasoning_effort" not in kwargs
|
||||
|
||||
|
||||
def test_user_identity_metadata_enabled(default_multi_llm: LitellmLLM) -> None:
|
||||
with (
|
||||
patch("litellm.completion") as mock_completion,
|
||||
|
||||
57
backend/tests/unit/onyx/utils/test_telemetry.py
Normal file
57
backend/tests/unit/onyx/utils/test_telemetry.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
from onyx.configs.constants import MilestoneRecordType
|
||||
from onyx.utils import telemetry as telemetry_utils
|
||||
|
||||
|
||||
def test_mt_cloud_telemetry_noop_when_not_multi_tenant(monkeypatch: Any) -> None:
|
||||
fetch_impl = Mock()
|
||||
monkeypatch.setattr(
|
||||
telemetry_utils,
|
||||
"fetch_versioned_implementation_with_fallback",
|
||||
fetch_impl,
|
||||
)
|
||||
# mt_cloud_telemetry reads the module-local imported symbol, so patch this path.
|
||||
monkeypatch.setattr("onyx.utils.telemetry.MULTI_TENANT", False)
|
||||
|
||||
telemetry_utils.mt_cloud_telemetry(
|
||||
tenant_id="tenant-1",
|
||||
distinct_id="user@example.com",
|
||||
event=MilestoneRecordType.USER_MESSAGE_SENT,
|
||||
properties={"origin": "web"},
|
||||
)
|
||||
|
||||
fetch_impl.assert_not_called()
|
||||
|
||||
|
||||
def test_mt_cloud_telemetry_calls_event_telemetry_when_multi_tenant(
|
||||
monkeypatch: Any,
|
||||
) -> None:
|
||||
event_telemetry = Mock()
|
||||
fetch_impl = Mock(return_value=event_telemetry)
|
||||
monkeypatch.setattr(
|
||||
telemetry_utils,
|
||||
"fetch_versioned_implementation_with_fallback",
|
||||
fetch_impl,
|
||||
)
|
||||
# mt_cloud_telemetry reads the module-local imported symbol, so patch this path.
|
||||
monkeypatch.setattr("onyx.utils.telemetry.MULTI_TENANT", True)
|
||||
|
||||
telemetry_utils.mt_cloud_telemetry(
|
||||
tenant_id="tenant-1",
|
||||
distinct_id="user@example.com",
|
||||
event=MilestoneRecordType.USER_MESSAGE_SENT,
|
||||
properties={"origin": "web"},
|
||||
)
|
||||
|
||||
fetch_impl.assert_called_once_with(
|
||||
module="onyx.utils.telemetry",
|
||||
attribute="event_telemetry",
|
||||
fallback=telemetry_utils.noop_fallback,
|
||||
)
|
||||
event_telemetry.assert_called_once_with(
|
||||
"user@example.com",
|
||||
MilestoneRecordType.USER_MESSAGE_SENT,
|
||||
{"origin": "web", "tenant_id": "tenant-1"},
|
||||
)
|
||||
@@ -221,6 +221,13 @@ services:
|
||||
- NOTIFY_SLACKBOT_NO_ANSWER=${NOTIFY_SLACKBOT_NO_ANSWER:-}
|
||||
- ONYX_BOT_MAX_QPM=${ONYX_BOT_MAX_QPM:-}
|
||||
- ONYX_BOT_MAX_WAIT_TIME=${ONYX_BOT_MAX_WAIT_TIME:-}
|
||||
# Discord Bot Configuration (runs via supervisord, requires DISCORD_BOT_TOKEN to be set)
|
||||
# IMPORTANT: Only one Discord bot instance can run per token - do not scale background workers
|
||||
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}
|
||||
- DISCORD_BOT_INVOKE_CHAR=${DISCORD_BOT_INVOKE_CHAR:-!}
|
||||
# API Server connection for Discord bot message processing
|
||||
- API_SERVER_PROTOCOL=${API_SERVER_PROTOCOL:-http}
|
||||
- API_SERVER_HOST=${API_SERVER_HOST:-api_server}
|
||||
# Logging
|
||||
# Leave this on pretty please? Nothing sensitive is collected!
|
||||
- DISABLE_TELEMETRY=${DISABLE_TELEMETRY:-}
|
||||
|
||||
@@ -63,6 +63,11 @@ services:
|
||||
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
|
||||
- S3_AWS_ACCESS_KEY_ID=${S3_AWS_ACCESS_KEY_ID:-minioadmin}
|
||||
- S3_AWS_SECRET_ACCESS_KEY=${S3_AWS_SECRET_ACCESS_KEY:-minioadmin}
|
||||
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}
|
||||
- DISCORD_BOT_INVOKE_CHAR=${DISCORD_BOT_INVOKE_CHAR:-!}
|
||||
# API Server connection for Discord bot message processing
|
||||
- API_SERVER_PROTOCOL=${API_SERVER_PROTOCOL:-http}
|
||||
- API_SERVER_HOST=${API_SERVER_HOST:-api_server}
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
|
||||
@@ -82,6 +82,11 @@ services:
|
||||
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
|
||||
- S3_AWS_ACCESS_KEY_ID=${S3_AWS_ACCESS_KEY_ID:-minioadmin}
|
||||
- S3_AWS_SECRET_ACCESS_KEY=${S3_AWS_SECRET_ACCESS_KEY:-minioadmin}
|
||||
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}
|
||||
- DISCORD_BOT_INVOKE_CHAR=${DISCORD_BOT_INVOKE_CHAR:-!}
|
||||
# API Server connection for Discord bot message processing
|
||||
- API_SERVER_PROTOCOL=${API_SERVER_PROTOCOL:-http}
|
||||
- API_SERVER_HOST=${API_SERVER_HOST:-api_server}
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
|
||||
@@ -129,6 +129,11 @@ services:
|
||||
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
|
||||
- S3_AWS_ACCESS_KEY_ID=${S3_AWS_ACCESS_KEY_ID:-minioadmin}
|
||||
- S3_AWS_SECRET_ACCESS_KEY=${S3_AWS_SECRET_ACCESS_KEY:-minioadmin}
|
||||
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}
|
||||
- DISCORD_BOT_INVOKE_CHAR=${DISCORD_BOT_INVOKE_CHAR:-!}
|
||||
# API Server connection for Discord bot message processing
|
||||
- API_SERVER_PROTOCOL=${API_SERVER_PROTOCOL:-http}
|
||||
- API_SERVER_HOST=${API_SERVER_HOST:-api_server}
|
||||
# PRODUCTION: Uncomment the line below to use if IAM_AUTH is true and you are using iam auth for postgres
|
||||
# volumes:
|
||||
# - ./bundle.pem:/app/bundle.pem:ro
|
||||
|
||||
@@ -77,6 +77,13 @@ MINIO_ROOT_PASSWORD=minioadmin
|
||||
## CORS origins for MCP clients (comma-separated list)
|
||||
# MCP_SERVER_CORS_ORIGINS=
|
||||
|
||||
## Discord Bot Configuration
|
||||
## The Discord bot allows users to interact with Onyx from Discord servers
|
||||
## Bot token from Discord Developer Portal (required to enable the bot)
|
||||
# DISCORD_BOT_TOKEN=
|
||||
## Command prefix for bot commands (default: "!")
|
||||
# DISCORD_BOT_INVOKE_CHAR=!
|
||||
|
||||
## Celery Configuration
|
||||
# CELERY_BROKER_POOL_LIMIT=
|
||||
# CELERY_WORKER_DOCFETCHING_CONCURRENCY=
|
||||
|
||||
98
deployment/helm/charts/onyx/templates/discordbot.yaml
Normal file
98
deployment/helm/charts/onyx/templates/discordbot.yaml
Normal file
@@ -0,0 +1,98 @@
|
||||
{{- if .Values.discordbot.enabled }}
|
||||
# Discord bot MUST run as a single replica - Discord only allows one client connection per bot token.
|
||||
# Do NOT enable HPA or increase replicas. Message processing is offloaded to scalable API pods via HTTP.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "onyx.fullname" . }}-discordbot
|
||||
labels:
|
||||
{{- include "onyx.labels" . | nindent 4 }}
|
||||
{{- with .Values.discordbot.deploymentLabels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
# CRITICAL: Discord bots cannot be horizontally scaled - only one WebSocket connection per token is allowed
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate # Ensure old pod is terminated before new one starts to avoid duplicate connections
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "onyx.selectorLabels" . | nindent 6 }}
|
||||
{{- if .Values.discordbot.deploymentLabels }}
|
||||
{{- toYaml .Values.discordbot.deploymentLabels | nindent 6 }}
|
||||
{{- end }}
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
{{- with .Values.discordbot.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "onyx.labels" . | nindent 8 }}
|
||||
{{- with .Values.discordbot.deploymentLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.discordbot.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "onyx.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.discordbot.podSecurityContext | nindent 8 }}
|
||||
{{- with .Values.discordbot.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.discordbot.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.discordbot.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: discordbot
|
||||
securityContext:
|
||||
{{- toYaml .Values.discordbot.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.discordbot.image.repository }}:{{ .Values.discordbot.image.tag | default .Values.global.version }}"
|
||||
imagePullPolicy: {{ .Values.global.pullPolicy }}
|
||||
command: ["python", "onyx/onyxbot/discord/client.py"]
|
||||
resources:
|
||||
{{- toYaml .Values.discordbot.resources | nindent 12 }}
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ .Values.config.envConfigMapName }}
|
||||
env:
|
||||
{{- include "onyx.envSecrets" . | nindent 12}}
|
||||
# Discord bot token - required for bot to connect
|
||||
{{- if .Values.discordbot.botToken }}
|
||||
- name: DISCORD_BOT_TOKEN
|
||||
value: {{ .Values.discordbot.botToken | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.discordbot.botTokenSecretName }}
|
||||
- name: DISCORD_BOT_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.discordbot.botTokenSecretName }}
|
||||
key: {{ .Values.discordbot.botTokenSecretKey | default "token" }}
|
||||
{{- end }}
|
||||
# Command prefix for bot commands (default: "!")
|
||||
{{- if .Values.discordbot.invokeChar }}
|
||||
- name: DISCORD_BOT_INVOKE_CHAR
|
||||
value: {{ .Values.discordbot.invokeChar | quote }}
|
||||
{{- end }}
|
||||
{{- with .Values.discordbot.volumeMounts }}
|
||||
volumeMounts:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.discordbot.volumes }}
|
||||
volumes:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -655,6 +655,44 @@ celery_worker_user_file_processing:
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
||||
# Discord bot for Onyx
|
||||
# The bot offloads message processing to scalable API pods via HTTP requests.
|
||||
discordbot:
|
||||
enabled: false # Disabled by default - requires bot token configuration
|
||||
# Bot token can be provided directly or via a Kubernetes secret
|
||||
# Option 1: Direct token (not recommended for production)
|
||||
botToken: ""
|
||||
# Option 2: Reference a Kubernetes secret (recommended)
|
||||
botTokenSecretName: "" # Name of the secret containing the bot token
|
||||
botTokenSecretKey: "token" # Key within the secret (default: "token")
|
||||
# Command prefix for bot commands (default: "!")
|
||||
invokeChar: "!"
|
||||
image:
|
||||
repository: onyxdotapp/onyx-backend
|
||||
tag: "" # Overrides the image tag whose default is the chart appVersion.
|
||||
podAnnotations: {}
|
||||
podLabels:
|
||||
scope: onyx-backend
|
||||
app: discord-bot
|
||||
deploymentLabels:
|
||||
app: discord-bot
|
||||
podSecurityContext:
|
||||
{}
|
||||
securityContext:
|
||||
{}
|
||||
resources:
|
||||
requests:
|
||||
cpu: "500m"
|
||||
memory: "512Mi"
|
||||
limits:
|
||||
cpu: "1000m"
|
||||
memory: "2000Mi"
|
||||
volumes: []
|
||||
volumeMounts: []
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
||||
slackbot:
|
||||
enabled: true
|
||||
replicaCount: 1
|
||||
@@ -1090,6 +1128,8 @@ configMap:
|
||||
ONYX_BOT_DISPLAY_ERROR_MSGS: ""
|
||||
ONYX_BOT_RESPOND_EVERY_CHANNEL: ""
|
||||
NOTIFY_SLACKBOT_NO_ANSWER: ""
|
||||
DISCORD_BOT_TOKEN: ""
|
||||
DISCORD_BOT_INVOKE_CHAR: ""
|
||||
# Logging
|
||||
# Optional Telemetry, please keep it on (nothing sensitive is collected)? <3
|
||||
DISABLE_TELEMETRY: ""
|
||||
|
||||
3
desktop/.gitignore
vendored
3
desktop/.gitignore
vendored
@@ -22,3 +22,6 @@ npm-debug.log*
|
||||
# Local env files
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Generated files
|
||||
src-tauri/gen/schemas/acl-manifests.json
|
||||
|
||||
96
desktop/src-tauri/Cargo.lock
generated
96
desktop/src-tauri/Cargo.lock
generated
@@ -706,16 +706,6 @@ dependencies = [
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -993,16 +983,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.1.16"
|
||||
@@ -1122,24 +1102,6 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "global-hotkey"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"keyboard-types",
|
||||
"objc2 0.6.3",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"thiserror 2.0.17",
|
||||
"windows-sys 0.59.0",
|
||||
"x11rb",
|
||||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.18.0"
|
||||
@@ -1713,12 +1675,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.1"
|
||||
@@ -2248,7 +2204,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
@@ -2878,19 +2833,6 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -3605,21 +3547,6 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-global-shortcut"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405"
|
||||
dependencies = [
|
||||
"global-hotkey",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-shell"
|
||||
version = "2.3.3"
|
||||
@@ -5021,29 +4948,6 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"rustix",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb-protocol"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xkeysym"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
|
||||
@@ -11,7 +11,6 @@ tauri-build = { version = "2.0", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2.0", features = ["macos-private-api", "tray-icon", "image-png"] }
|
||||
tauri-plugin-shell = "2.0"
|
||||
tauri-plugin-global-shortcut = "2.0"
|
||||
tauri-plugin-window-state = "2.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2354,72 +2354,6 @@
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:default",
|
||||
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-is-registered",
|
||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-register",
|
||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-register-all",
|
||||
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-unregister",
|
||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-unregister-all",
|
||||
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-is-registered",
|
||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register",
|
||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register-all",
|
||||
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
|
||||
@@ -2354,72 +2354,6 @@
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:default",
|
||||
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-is-registered",
|
||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-register",
|
||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-register-all",
|
||||
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-unregister",
|
||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-unregister-all",
|
||||
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-is-registered",
|
||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register",
|
||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register-all",
|
||||
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
|
||||
@@ -20,10 +20,9 @@ use tauri::Wry;
|
||||
use tauri::{
|
||||
webview::PageLoadPayload, AppHandle, Manager, Webview, WebviewUrl, WebviewWindowBuilder,
|
||||
};
|
||||
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut};
|
||||
use url::Url;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tokio::time::sleep;
|
||||
use url::Url;
|
||||
#[cfg(target_os = "macos")]
|
||||
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
|
||||
|
||||
@@ -76,39 +75,25 @@ fn get_config_path() -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
/// Load config from file, or create default if it doesn't exist
|
||||
fn load_config() -> AppConfig {
|
||||
fn load_config() -> (AppConfig, bool) {
|
||||
let config_path = match get_config_path() {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
eprintln!("Could not determine config directory, using defaults");
|
||||
return AppConfig::default();
|
||||
return (AppConfig::default(), false);
|
||||
}
|
||||
};
|
||||
|
||||
if config_path.exists() {
|
||||
match fs::read_to_string(&config_path) {
|
||||
Ok(contents) => match serde_json::from_str(&contents) {
|
||||
Ok(config) => {
|
||||
return config;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to parse config: {}, using defaults", e);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Failed to read config: {}, using defaults", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create default config file
|
||||
if let Err(e) = save_config(&AppConfig::default()) {
|
||||
eprintln!("Failed to create default config: {}", e);
|
||||
} else {
|
||||
println!("Created default config at {:?}", config_path);
|
||||
}
|
||||
if !config_path.exists() {
|
||||
return (AppConfig::default(), false);
|
||||
}
|
||||
|
||||
AppConfig::default()
|
||||
match fs::read_to_string(&config_path) {
|
||||
Ok(contents) => match serde_json::from_str(&contents) {
|
||||
Ok(config) => (config, true),
|
||||
Err(_) => (AppConfig::default(), false),
|
||||
},
|
||||
Err(_) => (AppConfig::default(), false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save config to file
|
||||
@@ -128,7 +113,11 @@ fn save_config(config: &AppConfig) -> Result<(), String> {
|
||||
}
|
||||
|
||||
// Global config state
|
||||
struct ConfigState(RwLock<AppConfig>);
|
||||
struct ConfigState {
|
||||
config: RwLock<AppConfig>,
|
||||
config_initialized: RwLock<bool>,
|
||||
app_base_url: RwLock<Option<Url>>,
|
||||
}
|
||||
|
||||
fn focus_main_window(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
@@ -142,7 +131,7 @@ fn focus_main_window(app: &AppHandle) {
|
||||
|
||||
fn trigger_new_chat(app: &AppHandle) {
|
||||
let state = app.state::<ConfigState>();
|
||||
let server_url = state.0.read().unwrap().server_url.clone();
|
||||
let server_url = state.config.read().unwrap().server_url.clone();
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let url = format!("{}/chat", server_url);
|
||||
@@ -152,7 +141,7 @@ fn trigger_new_chat(app: &AppHandle) {
|
||||
|
||||
fn trigger_new_window(app: &AppHandle) {
|
||||
let state = app.state::<ConfigState>();
|
||||
let server_url = state.0.read().unwrap().server_url.clone();
|
||||
let server_url = state.config.read().unwrap().server_url.clone();
|
||||
let handle = app.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
@@ -206,6 +195,30 @@ fn open_docs() {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_settings(app: &AppHandle) {
|
||||
// Navigate main window to the settings page (index.html) with settings flag
|
||||
let state = app.state::<ConfigState>();
|
||||
let settings_url = state
|
||||
.app_base_url
|
||||
.read()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.and_then(|mut url| {
|
||||
url.set_query(None);
|
||||
url.set_fragment(Some("settings"));
|
||||
url.set_path("/");
|
||||
Some(url)
|
||||
})
|
||||
.or_else(|| Url::parse("tauri://localhost/#settings").ok());
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if let Some(url) = settings_url {
|
||||
let _ = window.navigate(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tauri Commands
|
||||
// ============================================================================
|
||||
@@ -213,7 +226,27 @@ fn open_docs() {
|
||||
/// Get the current server URL
|
||||
#[tauri::command]
|
||||
fn get_server_url(state: tauri::State<ConfigState>) -> String {
|
||||
state.0.read().unwrap().server_url.clone()
|
||||
state.config.read().unwrap().server_url.clone()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct BootstrapState {
|
||||
server_url: String,
|
||||
config_exists: bool,
|
||||
}
|
||||
|
||||
/// Get the server URL plus whether a config file exists
|
||||
#[tauri::command]
|
||||
fn get_bootstrap_state(state: tauri::State<ConfigState>) -> BootstrapState {
|
||||
let server_url = state.config.read().unwrap().server_url.clone();
|
||||
let config_initialized = *state.config_initialized.read().unwrap();
|
||||
let config_exists = config_initialized
|
||||
&& get_config_path().map(|path| path.exists()).unwrap_or(false);
|
||||
|
||||
BootstrapState {
|
||||
server_url,
|
||||
config_exists,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a new server URL and save to config
|
||||
@@ -224,9 +257,10 @@ fn set_server_url(state: tauri::State<ConfigState>, url: String) -> Result<Strin
|
||||
return Err("URL must start with http:// or https://".to_string());
|
||||
}
|
||||
|
||||
let mut config = state.0.write().unwrap();
|
||||
let mut config = state.config.write().unwrap();
|
||||
config.server_url = url.trim_end_matches('/').to_string();
|
||||
save_config(&config)?;
|
||||
*state.config_initialized.write().unwrap() = true;
|
||||
|
||||
Ok(config.server_url.clone())
|
||||
}
|
||||
@@ -315,7 +349,7 @@ fn open_config_directory() -> Result<(), String> {
|
||||
/// Navigate to a specific path on the configured server
|
||||
#[tauri::command]
|
||||
fn navigate_to(window: tauri::WebviewWindow, state: tauri::State<ConfigState>, path: &str) {
|
||||
let base_url = state.0.read().unwrap().server_url.clone();
|
||||
let base_url = state.config.read().unwrap().server_url.clone();
|
||||
let url = format!("{}{}", base_url, path);
|
||||
let _ = window.eval(&format!("window.location.href = '{}'", url));
|
||||
}
|
||||
@@ -341,7 +375,7 @@ fn go_forward(window: tauri::WebviewWindow) {
|
||||
/// Open a new window
|
||||
#[tauri::command]
|
||||
async fn new_window(app: AppHandle, state: tauri::State<'_, ConfigState>) -> Result<(), String> {
|
||||
let server_url = state.0.read().unwrap().server_url.clone();
|
||||
let server_url = state.config.read().unwrap().server_url.clone();
|
||||
let window_label = format!("onyx-{}", uuid::Uuid::new_v4());
|
||||
|
||||
let builder = WebviewWindowBuilder::new(
|
||||
@@ -385,9 +419,10 @@ async fn new_window(app: AppHandle, state: tauri::State<'_, ConfigState>) -> Res
|
||||
/// Reset config to defaults
|
||||
#[tauri::command]
|
||||
fn reset_config(state: tauri::State<ConfigState>) -> Result<(), String> {
|
||||
let mut config = state.0.write().unwrap();
|
||||
let mut config = state.config.write().unwrap();
|
||||
*config = AppConfig::default();
|
||||
save_config(&config)?;
|
||||
*state.config_initialized.write().unwrap() = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -412,74 +447,6 @@ async fn start_drag_window(window: tauri::Window) -> Result<(), String> {
|
||||
window.start_dragging().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Shortcuts Setup
|
||||
// ============================================================================
|
||||
|
||||
fn setup_shortcuts(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let new_chat = Shortcut::new(Some(Modifiers::SUPER), Code::KeyN);
|
||||
let reload = Shortcut::new(Some(Modifiers::SUPER), Code::KeyR);
|
||||
let back = Shortcut::new(Some(Modifiers::SUPER), Code::BracketLeft);
|
||||
let forward = Shortcut::new(Some(Modifiers::SUPER), Code::BracketRight);
|
||||
let new_window_shortcut = Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyN);
|
||||
let show_app = Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::Space);
|
||||
let open_settings = Shortcut::new(Some(Modifiers::SUPER), Code::Comma);
|
||||
|
||||
let app_handle = app.clone();
|
||||
|
||||
// Avoid hijacking the system-wide Cmd+R on macOS.
|
||||
#[cfg(target_os = "macos")]
|
||||
let shortcuts = [
|
||||
new_chat,
|
||||
back,
|
||||
forward,
|
||||
new_window_shortcut,
|
||||
show_app,
|
||||
open_settings,
|
||||
];
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let shortcuts = [
|
||||
new_chat,
|
||||
reload,
|
||||
back,
|
||||
forward,
|
||||
new_window_shortcut,
|
||||
show_app,
|
||||
open_settings,
|
||||
];
|
||||
|
||||
app.global_shortcut().on_shortcuts(
|
||||
shortcuts,
|
||||
move |_app, shortcut, _event| {
|
||||
if shortcut == &new_chat {
|
||||
trigger_new_chat(&app_handle);
|
||||
}
|
||||
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
if shortcut == &reload {
|
||||
let _ = window.eval("window.location.reload()");
|
||||
} else if shortcut == &back {
|
||||
let _ = window.eval("window.history.back()");
|
||||
} else if shortcut == &forward {
|
||||
let _ = window.eval("window.history.forward()");
|
||||
} else if shortcut == &open_settings {
|
||||
// Open config file for editing
|
||||
let _ = open_config_file();
|
||||
}
|
||||
}
|
||||
|
||||
if shortcut == &new_window_shortcut {
|
||||
trigger_new_window(&app_handle);
|
||||
} else if shortcut == &show_app {
|
||||
focus_main_window(&app_handle);
|
||||
}
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Menu Setup
|
||||
// ============================================================================
|
||||
@@ -495,6 +462,7 @@ fn setup_app_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
true,
|
||||
Some("CmdOrCtrl+Shift+N"),
|
||||
)?;
|
||||
let settings_item = MenuItem::with_id(app, "open_settings", "Settings...", true, Some("CmdOrCtrl+Comma"))?;
|
||||
let docs_item = MenuItem::with_id(app, "open_docs", "Onyx Documentation", true, None::<&str>)?;
|
||||
|
||||
if let Some(file_menu) = menu
|
||||
@@ -503,12 +471,13 @@ fn setup_app_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
.filter_map(|item| item.as_submenu().cloned())
|
||||
.find(|submenu| submenu.text().ok().as_deref() == Some("File"))
|
||||
{
|
||||
file_menu.insert_items(&[&new_chat_item, &new_window_item], 0)?;
|
||||
file_menu.insert_items(&[&new_chat_item, &new_window_item, &settings_item], 0)?;
|
||||
} else {
|
||||
let file_menu = SubmenuBuilder::new(app, "File")
|
||||
.items(&[
|
||||
&new_chat_item,
|
||||
&new_window_item,
|
||||
&settings_item,
|
||||
&PredefinedMenuItem::close_window(app, None)?,
|
||||
])
|
||||
.build()?;
|
||||
@@ -537,7 +506,7 @@ fn build_tray_menu(app: &AppHandle) -> tauri::Result<Menu<Wry>> {
|
||||
TRAY_MENU_OPEN_APP_ID,
|
||||
"Open Onyx",
|
||||
true,
|
||||
Some("CmdOrCtrl+Shift+Space"),
|
||||
None::<&str>,
|
||||
)?;
|
||||
let open_chat = MenuItem::with_id(
|
||||
app,
|
||||
@@ -625,22 +594,19 @@ fn setup_tray_icon(app: &AppHandle) -> tauri::Result<()> {
|
||||
|
||||
fn main() {
|
||||
// Load config at startup
|
||||
let config = load_config();
|
||||
let server_url = config.server_url.clone();
|
||||
|
||||
println!("Starting Onyx Desktop");
|
||||
println!("Server URL: {}", server_url);
|
||||
if let Some(path) = get_config_path() {
|
||||
println!("Config file: {:?}", path);
|
||||
}
|
||||
let (config, config_initialized) = load_config();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.manage(ConfigState(RwLock::new(config)))
|
||||
.manage(ConfigState {
|
||||
config: RwLock::new(config),
|
||||
config_initialized: RwLock::new(config_initialized),
|
||||
app_base_url: RwLock::new(None),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_server_url,
|
||||
get_bootstrap_state,
|
||||
set_server_url,
|
||||
get_config_path_cmd,
|
||||
open_config_file,
|
||||
@@ -657,16 +623,12 @@ fn main() {
|
||||
"open_docs" => open_docs(),
|
||||
"new_chat" => trigger_new_chat(app),
|
||||
"new_window" => trigger_new_window(app),
|
||||
"open_settings" => open_settings(app),
|
||||
_ => {}
|
||||
})
|
||||
.setup(move |app| {
|
||||
let app_handle = app.handle();
|
||||
|
||||
// Setup global shortcuts
|
||||
if let Err(e) = setup_shortcuts(&app_handle) {
|
||||
eprintln!("Failed to setup shortcuts: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = setup_app_menu(&app_handle) {
|
||||
eprintln!("Failed to setup menu: {}", e);
|
||||
}
|
||||
@@ -675,7 +637,7 @@ fn main() {
|
||||
eprintln!("Failed to setup tray icon: {}", e);
|
||||
}
|
||||
|
||||
// Update main window URL to configured server and inject title bar
|
||||
// Setup main window with vibrancy effect
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
// Apply vibrancy effect for translucent glass look
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -683,14 +645,12 @@ fn main() {
|
||||
let _ = apply_vibrancy(&window, NSVisualEffectMaterial::Sidebar, None, None);
|
||||
}
|
||||
|
||||
if let Ok(target) = Url::parse(&server_url) {
|
||||
if let Ok(current) = window.url() {
|
||||
if current != target {
|
||||
let _ = window.navigate(target);
|
||||
}
|
||||
} else {
|
||||
let _ = window.navigate(target);
|
||||
}
|
||||
if let Ok(url) = window.url() {
|
||||
let mut base_url = url;
|
||||
base_url.set_query(None);
|
||||
base_url.set_fragment(None);
|
||||
base_url.set_path("/");
|
||||
*app.state::<ConfigState>().app_base_url.write().unwrap() = Some(base_url);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
{
|
||||
"title": "Onyx",
|
||||
"label": "main",
|
||||
"url": "https://cloud.onyx.app",
|
||||
"url": "index.html",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
@@ -52,7 +52,7 @@
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "cloud.onyx.app",
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": "-",
|
||||
"signingIdentity": null,
|
||||
"dmg": {
|
||||
"windowSize": {
|
||||
"width": 660,
|
||||
|
||||
@@ -4,28 +4,61 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Onyx</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--background-900: #f5f5f5;
|
||||
--background-800: #ffffff;
|
||||
--text-light-05: rgba(0, 0, 0, 0.95);
|
||||
--text-light-03: rgba(0, 0, 0, 0.6);
|
||||
--white-10: rgba(0, 0, 0, 0.1);
|
||||
--white-15: rgba(0, 0, 0, 0.15);
|
||||
--white-20: rgba(0, 0, 0, 0.2);
|
||||
--white-30: rgba(0, 0, 0, 0.3);
|
||||
--font-hanken-grotesk: "Hanken Grotesk", -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background-900: #1a1a1a;
|
||||
--background-800: #262626;
|
||||
--text-light-05: rgba(255, 255, 255, 0.95);
|
||||
--text-light-03: rgba(255, 255, 255, 0.6);
|
||||
--white-10: rgba(255, 255, 255, 0.08);
|
||||
--white-15: rgba(255, 255, 255, 0.12);
|
||||
--white-20: rgba(255, 255, 255, 0.15);
|
||||
--white-30: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: #fff;
|
||||
font-family: var(--font-hanken-grotesk);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--background-900) 0%,
|
||||
var(--background-800) 100%
|
||||
);
|
||||
min-height: 100vh;
|
||||
color: var(--text-light-05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
transition:
|
||||
background 0.3s ease,
|
||||
color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Draggable titlebar area for macOS */
|
||||
.titlebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -33,198 +66,486 @@
|
||||
right: 0;
|
||||
height: 28px;
|
||||
-webkit-app-region: drag;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
.settings-container {
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 20px;
|
||||
margin: 0 auto 1.5rem;
|
||||
body.show-settings .settings-container {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
background: var(--background-800);
|
||||
backdrop-filter: blur(24px);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--white-10);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
transition:
|
||||
background 0.3s ease,
|
||||
border 0.3s ease;
|
||||
}
|
||||
|
||||
.dark .settings-panel {
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid var(--white-10);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: var(--background-900);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
overflow: hidden;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
.settings-icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--text-light-05);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-light-05);
|
||||
}
|
||||
|
||||
p {
|
||||
color: #a0a0a0;
|
||||
margin-bottom: 2rem;
|
||||
.settings-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
.settings-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.settings-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-light-03);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
background: var(--background-900);
|
||||
border-radius: 16px;
|
||||
padding: 4px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 2rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.loading span {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.4s ease-in-out infinite;
|
||||
.setting-row-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.loading span:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.loading span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.loading span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
.setting-label {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--text-light-05);
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
.setting-description {
|
||||
font-size: 12px;
|
||||
color: var(--text-light-03);
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 2rem;
|
||||
.setting-divider {
|
||||
height: 1px;
|
||||
background: var(--white-10);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--white-10);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
font-size: 14px;
|
||||
background: var(--background-800);
|
||||
color: var(--text-light-05);
|
||||
font-family: var(--font-hanken-grotesk);
|
||||
transition: all 0.2s;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
|
||||
.input-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--white-30);
|
||||
background: var(--background-900);
|
||||
box-shadow: 0 0 0 2px var(--white-10);
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
margin-top: 3rem;
|
||||
padding: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
text-align: left;
|
||||
.input-field::placeholder {
|
||||
color: var(--text-light-03);
|
||||
}
|
||||
|
||||
.shortcuts h3 {
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #a0a0a0;
|
||||
margin-bottom: 1rem;
|
||||
.input-field.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
padding-left: 12px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shortcut:last-child {
|
||||
border-bottom: none;
|
||||
.error-message.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.shortcut-key {
|
||||
font-family:
|
||||
SF Mono,
|
||||
Monaco,
|
||||
monospace;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 0.25rem 0.5rem;
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--white-15);
|
||||
transition: 0.3s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: var(--background-800);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dark .toggle-slider:before {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider {
|
||||
background-color: var(--white-30);
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
font-family: var(--font-hanken-grotesk);
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: #286df8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button.primary:hover {
|
||||
background: #1e5cd6;
|
||||
box-shadow: 0 4px 12px rgba(40, 109, 248, 0.3);
|
||||
}
|
||||
|
||||
.button.primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
kbd {
|
||||
background: var(--white-10);
|
||||
border: 1px solid var(--white-15);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 6px;
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
color: var(--text-light-05);
|
||||
font-size: 11px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="titlebar"></div>
|
||||
|
||||
<div class="container">
|
||||
<div class="logo">O</div>
|
||||
<h1>Onyx</h1>
|
||||
<p>Connecting to Onyx Cloud...</p>
|
||||
<div class="settings-container">
|
||||
<div class="settings-panel">
|
||||
<div class="settings-header">
|
||||
<div class="settings-icon">
|
||||
<svg
|
||||
viewBox="0 0 56 56"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M28 0 10.869 7.77 28 15.539l17.131-7.77L28 0Zm0 40.461-17.131 7.77L28 56l17.131-7.77L28 40.461Zm20.231-29.592L56 28.001l-7.769 17.131L40.462 28l7.769-17.131ZM15.538 28 7.77 10.869 0 28l7.769 17.131L15.538 28Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="settings-title">Settings</h1>
|
||||
</div>
|
||||
|
||||
<div class="loading">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<section class="settings-section">
|
||||
<div class="section-title">GENERAL</div>
|
||||
<div class="settings-group">
|
||||
<div class="setting-row">
|
||||
<div class="setting-row-content">
|
||||
<label class="setting-label" for="onyxDomain"
|
||||
>Root Domain</label
|
||||
>
|
||||
<div class="setting-description">
|
||||
The root URL for your Onyx instance
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-divider"></div>
|
||||
<div class="setting-row" style="padding: 12px">
|
||||
<input
|
||||
type="text"
|
||||
id="onyxDomain"
|
||||
class="input-field"
|
||||
placeholder="https://cloud.onyx.app"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="error-message" id="errorMessage">
|
||||
Please enter a valid URL starting with http:// or https://
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button
|
||||
class="btn"
|
||||
onclick="window.location.href='https://cloud.onyx.app'"
|
||||
>
|
||||
Open Onyx Cloud
|
||||
</button>
|
||||
|
||||
<p style="margin-top: 1.5rem; font-size: 0.875rem; color: #666">
|
||||
Self-hosted? Press
|
||||
<span
|
||||
class="shortcut-key"
|
||||
style="display: inline; padding: 0.15rem 0.4rem"
|
||||
>⌘ ,</span
|
||||
>
|
||||
to configure your server URL.
|
||||
</p>
|
||||
|
||||
<div class="shortcuts">
|
||||
<h3>Keyboard Shortcuts</h3>
|
||||
<div class="shortcut">
|
||||
<span>New Chat</span>
|
||||
<span class="shortcut-key">⌘ N</span>
|
||||
</div>
|
||||
<div class="shortcut">
|
||||
<span>New Window</span>
|
||||
<span class="shortcut-key">⌘ ⇧ N</span>
|
||||
</div>
|
||||
<div class="shortcut">
|
||||
<span>Reload</span>
|
||||
<span class="shortcut-key">⌘ R</span>
|
||||
</div>
|
||||
<div class="shortcut">
|
||||
<span>Back</span>
|
||||
<span class="shortcut-key">⌘ [</span>
|
||||
</div>
|
||||
<div class="shortcut">
|
||||
<span>Forward</span>
|
||||
<span class="shortcut-key">⌘ ]</span>
|
||||
</div>
|
||||
<div class="shortcut">
|
||||
<span>Settings / Config</span>
|
||||
<span class="shortcut-key">⌘ ,</span>
|
||||
<button class="button primary" id="saveBtn">Save & Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-redirect to Onyx Cloud after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.href = "https://cloud.onyx.app";
|
||||
}, 1500);
|
||||
// Import Tauri API
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
|
||||
// Configuration
|
||||
const DEFAULT_DOMAIN = "https://cloud.onyx.app";
|
||||
let currentServerUrl = "";
|
||||
|
||||
// DOM elements
|
||||
const domainInput = document.getElementById("onyxDomain");
|
||||
const errorMessage = document.getElementById("errorMessage");
|
||||
const saveBtn = document.getElementById("saveBtn");
|
||||
|
||||
// Theme detection based on system preferences
|
||||
function applySystemTheme() {
|
||||
const darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
function updateTheme(e) {
|
||||
if (e.matches) {
|
||||
document.documentElement.classList.add("dark");
|
||||
document.body.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.body.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
|
||||
// Apply initial theme
|
||||
updateTheme(darkModeQuery);
|
||||
|
||||
// Listen for changes
|
||||
darkModeQuery.addEventListener("change", updateTheme);
|
||||
}
|
||||
|
||||
function showSettings() {
|
||||
document.body.classList.add("show-settings");
|
||||
}
|
||||
|
||||
// Apply system theme immediately
|
||||
applySystemTheme();
|
||||
|
||||
// Initialize the app
|
||||
async function init() {
|
||||
try {
|
||||
const bootstrap = await invoke("get_bootstrap_state");
|
||||
currentServerUrl = bootstrap.server_url;
|
||||
|
||||
// Set the input value
|
||||
domainInput.value = currentServerUrl || DEFAULT_DOMAIN;
|
||||
|
||||
// Check if user came here explicitly (via Settings menu/shortcut)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isExplicitSettings =
|
||||
window.location.hash === "#settings" ||
|
||||
urlParams.get("settings") === "true";
|
||||
|
||||
// If user explicitly opened settings, show modal
|
||||
if (isExplicitSettings) {
|
||||
// Modal is already visible, user can edit and save
|
||||
showSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, check if this is first launch
|
||||
// First launch = config doesn't exist
|
||||
if (!bootstrap.config_exists || !currentServerUrl) {
|
||||
// First launch - show modal, require user to configure
|
||||
showSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
// Not first launch and not explicit settings
|
||||
// Auto-redirect to configured domain
|
||||
window.location.href = currentServerUrl;
|
||||
} catch (error) {
|
||||
// On error, default to cloud
|
||||
domainInput.value = DEFAULT_DOMAIN;
|
||||
showSettings();
|
||||
}
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
function validateUrl(url) {
|
||||
const trimmedUrl = url.trim();
|
||||
if (!trimmedUrl) {
|
||||
return { valid: false, error: "URL cannot be empty" };
|
||||
}
|
||||
if (
|
||||
!trimmedUrl.startsWith("http://") &&
|
||||
!trimmedUrl.startsWith("https://")
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "URL must start with http:// or https://",
|
||||
};
|
||||
}
|
||||
try {
|
||||
new URL(trimmedUrl);
|
||||
return { valid: true, url: trimmedUrl };
|
||||
} catch {
|
||||
return { valid: false, error: "Please enter a valid URL" };
|
||||
}
|
||||
}
|
||||
|
||||
// Show error
|
||||
function showError(message) {
|
||||
domainInput.classList.add("error");
|
||||
errorMessage.textContent = message;
|
||||
errorMessage.classList.add("visible");
|
||||
}
|
||||
|
||||
// Clear error
|
||||
function clearError() {
|
||||
domainInput.classList.remove("error");
|
||||
errorMessage.classList.remove("visible");
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
async function saveConfiguration() {
|
||||
clearError();
|
||||
|
||||
const validation = validateUrl(domainInput.value);
|
||||
if (!validation.valid) {
|
||||
showError(validation.error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = "Saving...";
|
||||
|
||||
// Call Tauri command to save the URL
|
||||
await invoke("set_server_url", { url: validation.url });
|
||||
|
||||
// Success - redirect to the new URL (login page)
|
||||
window.location.href = validation.url;
|
||||
} catch (error) {
|
||||
showError(error || "Failed to save configuration");
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = "Save & Connect";
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
domainInput.addEventListener("input", clearError);
|
||||
domainInput.addEventListener("keypress", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
saveConfiguration();
|
||||
}
|
||||
});
|
||||
saveBtn.addEventListener("click", saveConfiguration);
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
// This script injects a draggable title bar that matches Onyx design system
|
||||
|
||||
(function () {
|
||||
console.log("[Onyx Desktop] Title bar script loaded");
|
||||
|
||||
const TITLEBAR_ID = "onyx-desktop-titlebar";
|
||||
const TITLEBAR_HEIGHT = 36;
|
||||
const STYLE_ID = "onyx-desktop-titlebar-style";
|
||||
@@ -31,12 +29,7 @@
|
||||
try {
|
||||
await invoke("start_drag_window");
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[Onyx Desktop] Failed to start dragging via invoke:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
const appWindow =
|
||||
@@ -46,14 +39,7 @@
|
||||
if (appWindow?.startDragging) {
|
||||
try {
|
||||
await appWindow.startDragging();
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[Onyx Desktop] Failed to start dragging via appWindow:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error("[Onyx Desktop] No Tauri drag API available.");
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +113,23 @@
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function updateTitleBarTheme(isDark) {
|
||||
const titleBar = document.getElementById(TITLEBAR_ID);
|
||||
if (!titleBar) return;
|
||||
|
||||
if (isDark) {
|
||||
titleBar.style.background =
|
||||
"linear-gradient(180deg, rgba(18, 18, 18, 0.82) 0%, rgba(18, 18, 18, 0.72) 100%)";
|
||||
titleBar.style.borderBottom = "1px solid rgba(255, 255, 255, 0.08)";
|
||||
titleBar.style.boxShadow = "0 8px 28px rgba(0, 0, 0, 0.2)";
|
||||
} else {
|
||||
titleBar.style.background =
|
||||
"linear-gradient(180deg, rgba(255, 255, 255, 0.94) 0%, rgba(255, 255, 255, 0.78) 100%)";
|
||||
titleBar.style.borderBottom = "1px solid rgba(0, 0, 0, 0.06)";
|
||||
titleBar.style.boxShadow = "0 8px 28px rgba(0, 0, 0, 0.04)";
|
||||
}
|
||||
}
|
||||
|
||||
function buildTitleBar() {
|
||||
const titleBar = document.createElement("div");
|
||||
titleBar.id = TITLEBAR_ID;
|
||||
@@ -148,6 +151,11 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Apply initial styles matching current theme
|
||||
const htmlHasDark = document.documentElement.classList.contains("dark");
|
||||
const bodyHasDark = document.body?.classList.contains("dark");
|
||||
const isDark = htmlHasDark || bodyHasDark;
|
||||
|
||||
// Apply styles matching Onyx design system with translucent glass effect
|
||||
titleBar.style.cssText = `
|
||||
position: fixed;
|
||||
@@ -170,19 +178,27 @@
|
||||
-webkit-backdrop-filter: blur(18px) saturate(180%);
|
||||
-webkit-app-region: drag;
|
||||
padding: 0 12px;
|
||||
transition: background 0.3s ease, border-bottom 0.3s ease, box-shadow 0.3s ease;
|
||||
`;
|
||||
|
||||
// Apply correct theme
|
||||
updateTitleBarTheme(isDark);
|
||||
|
||||
return titleBar;
|
||||
}
|
||||
|
||||
function mountTitleBar() {
|
||||
if (!document.body) {
|
||||
console.error("[Onyx Desktop] document.body not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = document.getElementById(TITLEBAR_ID);
|
||||
if (existing?.parentElement === document.body) {
|
||||
// Update theme on existing titlebar
|
||||
const htmlHasDark = document.documentElement.classList.contains("dark");
|
||||
const bodyHasDark = document.body?.classList.contains("dark");
|
||||
const isDark = htmlHasDark || bodyHasDark;
|
||||
updateTitleBarTheme(isDark);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -193,7 +209,14 @@
|
||||
const titleBar = buildTitleBar();
|
||||
document.body.insertBefore(titleBar, document.body.firstChild);
|
||||
injectStyles();
|
||||
console.log("[Onyx Desktop] Title bar injected");
|
||||
|
||||
// Ensure theme is applied immediately after mount
|
||||
setTimeout(() => {
|
||||
const htmlHasDark = document.documentElement.classList.contains("dark");
|
||||
const bodyHasDark = document.body?.classList.contains("dark");
|
||||
const isDark = htmlHasDark || bodyHasDark;
|
||||
updateTitleBarTheme(isDark);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function syncViewportHeight() {
|
||||
@@ -210,9 +233,66 @@
|
||||
}
|
||||
}
|
||||
|
||||
function observeThemeChanges() {
|
||||
let lastKnownTheme = null;
|
||||
|
||||
function checkAndUpdateTheme() {
|
||||
// Check both html and body for dark class (some apps use body)
|
||||
const htmlHasDark = document.documentElement.classList.contains("dark");
|
||||
const bodyHasDark = document.body?.classList.contains("dark");
|
||||
const isDark = htmlHasDark || bodyHasDark;
|
||||
|
||||
if (lastKnownTheme !== isDark) {
|
||||
lastKnownTheme = isDark;
|
||||
updateTitleBarTheme(isDark);
|
||||
}
|
||||
}
|
||||
|
||||
// Immediate check on setup
|
||||
checkAndUpdateTheme();
|
||||
|
||||
// Watch for theme changes on the HTML element
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
checkAndUpdateTheme();
|
||||
});
|
||||
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
// Also observe body if it exists
|
||||
if (document.body) {
|
||||
const bodyObserver = new MutationObserver(() => {
|
||||
checkAndUpdateTheme();
|
||||
});
|
||||
bodyObserver.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
}
|
||||
|
||||
// Also check periodically in case classList is manipulated directly
|
||||
// or the theme loads asynchronously after page load
|
||||
const intervalId = setInterval(() => {
|
||||
checkAndUpdateTheme();
|
||||
}, 300);
|
||||
|
||||
// Clean up after 30 seconds once theme should be stable
|
||||
setTimeout(() => {
|
||||
clearInterval(intervalId);
|
||||
// But keep checking every 2 seconds for manual theme changes
|
||||
setInterval(() => {
|
||||
checkAndUpdateTheme();
|
||||
}, 2000);
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function init() {
|
||||
mountTitleBar();
|
||||
syncViewportHeight();
|
||||
observeThemeChanges();
|
||||
|
||||
window.addEventListener("resize", syncViewportHeight, { passive: true });
|
||||
window.visualViewport?.addEventListener("resize", syncViewportHeight, {
|
||||
passive: true,
|
||||
|
||||
15
web/lib/opal/src/icons/DiscordMono.tsx
Normal file
15
web/lib/opal/src/icons/DiscordMono.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
const SvgDiscordMono = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 52 52"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path d="M32.7571 7.80005C32.288 8.63286 31.8668 9.4944 31.4839 10.3751C27.8463 9.82945 24.1417 9.82945 20.4946 10.3751C20.1213 9.4944 19.6905 8.63286 19.2214 7.80005C15.804 8.384 12.4727 9.40825 9.31379 10.8537C3.05329 20.1296 1.35894 29.1661 2.20134 38.0782C5.86763 40.7872 9.97429 42.8549 14.349 44.1759C15.3349 42.8549 16.2061 41.4477 16.9527 39.9831C15.536 39.4566 14.1671 38.7961 12.8556 38.0303C13.2002 37.7814 13.5353 37.523 13.8608 37.2741C21.5476 40.8925 30.4501 40.8925 38.1465 37.2741C38.4719 37.5421 38.807 37.8006 39.1516 38.0303C37.8401 38.8057 36.4713 39.4566 35.0449 39.9927C35.7916 41.4573 36.6627 42.8645 37.6487 44.1855C42.0233 42.8645 46.1299 40.8064 49.7965 38.0973C50.7918 27.7589 48.0924 18.799 42.6646 10.8633C39.5154 9.41784 36.1841 8.39355 32.7666 7.81919L32.7571 7.80005ZM18.0248 32.5931C15.6604 32.5931 13.698 30.4488 13.698 27.7972C13.698 25.1456 15.5838 22.9918 18.0153 22.9918C20.4468 22.9918 22.3804 25.1552 22.3421 27.7972C22.3038 30.4393 20.4372 32.5931 18.0248 32.5931ZM33.9728 32.5931C31.5988 32.5931 29.6556 30.4488 29.6556 27.7972C29.6556 25.1456 31.5414 22.9918 33.9728 22.9918C36.4043 22.9918 38.3284 25.1552 38.29 27.7972C38.2518 30.4393 36.3851 32.5931 33.9728 32.5931Z" />
|
||||
</svg>
|
||||
);
|
||||
export default SvgDiscordMono;
|
||||
21
web/lib/opal/src/icons/hash.tsx
Normal file
21
web/lib/opal/src/icons/hash.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
const SvgHash = ({ 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="M2.66667 6H13.3333M2.66667 10H13.3333M6.66667 2L5.33334 14M10.6667 2L9.33334 14"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgHash;
|
||||
@@ -46,6 +46,7 @@ export { default as SvgCopy } from "@opal/icons/copy";
|
||||
export { default as SvgCornerRightUpDot } from "@opal/icons/corner-right-up-dot";
|
||||
export { default as SvgCpu } from "@opal/icons/cpu";
|
||||
export { default as SvgDevKit } from "@opal/icons/dev-kit";
|
||||
export { default as SvgDiscordMono } from "@opal/icons/DiscordMono";
|
||||
export { default as SvgDownloadCloud } from "@opal/icons/download-cloud";
|
||||
export { default as SvgEdit } from "@opal/icons/edit";
|
||||
export { default as SvgEditBig } from "@opal/icons/edit-big";
|
||||
@@ -67,6 +68,7 @@ export { default as SvgFolderPlus } from "@opal/icons/folder-plus";
|
||||
export { default as SvgGlobe } from "@opal/icons/globe";
|
||||
export { default as SvgHardDrive } from "@opal/icons/hard-drive";
|
||||
export { default as SvgHashSmall } from "@opal/icons/hash-small";
|
||||
export { default as SvgHash } from "@opal/icons/hash";
|
||||
export { default as SvgHeadsetMic } from "@opal/icons/headset-mic";
|
||||
export { default as SvgHourglass } from "@opal/icons/hourglass";
|
||||
export { default as SvgImage } from "@opal/icons/image";
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 581 B |
@@ -22,7 +22,10 @@ import Modal from "@/refresh-components/Modal";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { deleteApiKey, regenerateApiKey } from "@/app/admin/api-key/lib";
|
||||
import OnyxApiKeyForm from "@/app/admin/api-key/OnyxApiKeyForm";
|
||||
import { APIKey } from "@/app/admin/api-key/types";
|
||||
import {
|
||||
APIKey,
|
||||
DISCORD_SERVICE_API_KEY_NAME,
|
||||
} from "@/app/admin/api-key/types";
|
||||
import CreateButton from "@/refresh-components/buttons/CreateButton";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
|
||||
@@ -61,6 +64,11 @@ function Main() {
|
||||
);
|
||||
}
|
||||
|
||||
// Filter out the discord service key from the displayed list
|
||||
const filteredApiKeys = apiKeys.filter(
|
||||
(key) => key.api_key_name !== DISCORD_SERVICE_API_KEY_NAME
|
||||
);
|
||||
|
||||
const introSection = (
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<Text as="p">
|
||||
@@ -76,7 +84,7 @@ function Main() {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (apiKeys.length === 0) {
|
||||
if (filteredApiKeys.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
{popup}
|
||||
@@ -139,7 +147,7 @@ function Main() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeys.map((apiKey) => (
|
||||
{filteredApiKeys.map((apiKey) => (
|
||||
<TableRow key={apiKey.api_key_id}>
|
||||
<TableCell>
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { UserRole } from "@/lib/types";
|
||||
|
||||
// Discord bot service API key name - should match backend constant
|
||||
export const DISCORD_SERVICE_API_KEY_NAME = "discord-bot-service";
|
||||
|
||||
export interface APIKey {
|
||||
api_key_id: number;
|
||||
api_key_display: string;
|
||||
|
||||
188
web/src/app/admin/discord-bot/BotConfigCard.tsx
Normal file
188
web/src/app/admin/discord-bot/BotConfigCard.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import PasswordInputTypeIn from "@/refresh-components/inputs/PasswordInputTypeIn";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
|
||||
import {
|
||||
useDiscordBotConfig,
|
||||
useDiscordGuilds,
|
||||
} from "@/app/admin/discord-bot/hooks";
|
||||
import { createBotConfig, deleteBotConfig } from "@/app/admin/discord-bot/lib";
|
||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
|
||||
import { getFormattedDateTime } from "@/lib/dateUtils";
|
||||
|
||||
interface Props {
|
||||
setPopup: (popup: PopupSpec) => void;
|
||||
}
|
||||
|
||||
export function BotConfigCard({ setPopup }: Props) {
|
||||
const {
|
||||
data: botConfig,
|
||||
isLoading,
|
||||
isManaged,
|
||||
refreshBotConfig,
|
||||
} = useDiscordBotConfig();
|
||||
const { data: guilds } = useDiscordGuilds();
|
||||
|
||||
const [botToken, setBotToken] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
// Don't render anything if managed externally (Cloud or env var)
|
||||
if (isManaged) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show loading while fetching initial state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="between"
|
||||
alignItems="center"
|
||||
>
|
||||
<Text mainContentEmphasis text05>
|
||||
Bot Token
|
||||
</Text>
|
||||
</Section>
|
||||
<ThreeDotsLoader />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const isConfigured = botConfig?.configured ?? false;
|
||||
const hasServerConfigs = (guilds?.length ?? 0) > 0;
|
||||
|
||||
const handleSaveToken = async () => {
|
||||
if (!botToken.trim()) {
|
||||
setPopup({ type: "error", message: "Please enter a bot token" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createBotConfig(botToken.trim());
|
||||
setBotToken("");
|
||||
refreshBotConfig();
|
||||
setPopup({ type: "success", message: "Bot token saved successfully" });
|
||||
} catch (err) {
|
||||
setPopup({
|
||||
type: "error",
|
||||
message:
|
||||
err instanceof Error ? err.message : "Failed to save bot token",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteToken = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await deleteBotConfig();
|
||||
refreshBotConfig();
|
||||
setPopup({ type: "success", message: "Bot token deleted" });
|
||||
} catch (err) {
|
||||
setPopup({
|
||||
type: "error",
|
||||
message:
|
||||
err instanceof Error ? err.message : "Failed to delete bot token",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDeleteConfirm && (
|
||||
<ConfirmEntityModal
|
||||
danger
|
||||
entityType="Discord bot token"
|
||||
entityName="Discord Bot Token"
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
onSubmit={handleDeleteToken}
|
||||
additionalDetails="This will disconnect your Discord bot. You will need to re-enter the token to use the bot again."
|
||||
/>
|
||||
)}
|
||||
<Card>
|
||||
<Section flexDirection="row" justifyContent="between">
|
||||
<Section flexDirection="row" gap={0.5} width="fit">
|
||||
<Text mainContentEmphasis text05>
|
||||
Bot Token
|
||||
</Text>
|
||||
{isConfigured ? (
|
||||
<Badge variant="success">Configured</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Not Configured</Badge>
|
||||
)}
|
||||
</Section>
|
||||
{isConfigured && (
|
||||
<SimpleTooltip
|
||||
tooltip={
|
||||
hasServerConfigs ? "Delete server configs first" : undefined
|
||||
}
|
||||
disabled={!hasServerConfigs}
|
||||
>
|
||||
<Button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={isSubmitting || hasServerConfigs}
|
||||
danger
|
||||
>
|
||||
Delete Discord Token
|
||||
</Button>
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{isConfigured ? (
|
||||
<Section flexDirection="column" alignItems="start" gap={0.5}>
|
||||
<Text text03 secondaryBody>
|
||||
Your Discord bot token is configured.
|
||||
{botConfig?.created_at && (
|
||||
<>
|
||||
{" "}
|
||||
Added {getFormattedDateTime(new Date(botConfig.created_at))}.
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<Text text03 secondaryBody>
|
||||
To change the token, delete the current one and add a new one.
|
||||
</Text>
|
||||
</Section>
|
||||
) : (
|
||||
<Section flexDirection="column" alignItems="start" gap={0.75}>
|
||||
<Text text03 secondaryBody>
|
||||
Enter your Discord bot token to enable the bot. You can get this
|
||||
from the Discord Developer Portal.
|
||||
</Text>
|
||||
<Section flexDirection="row" alignItems="end" gap={0.5}>
|
||||
<PasswordInputTypeIn
|
||||
value={botToken}
|
||||
onChange={(e) => setBotToken(e.target.value)}
|
||||
placeholder="Enter bot token..."
|
||||
disabled={isSubmitting}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveToken}
|
||||
disabled={isSubmitting || !botToken.trim()}
|
||||
>
|
||||
{isSubmitting ? "Saving..." : "Save Token"}
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
169
web/src/app/admin/discord-bot/DiscordGuildsTable.tsx
Normal file
169
web/src/app/admin/discord-bot/DiscordGuildsTable.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DeleteButton } from "@/components/DeleteButton";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import Switch from "@/refresh-components/inputs/Switch";
|
||||
import { SvgEdit, SvgServer } from "@opal/icons";
|
||||
import EmptyMessage from "@/refresh-components/EmptyMessage";
|
||||
import { DiscordGuildConfig } from "@/app/admin/discord-bot/types";
|
||||
import {
|
||||
deleteGuildConfig,
|
||||
updateGuildConfig,
|
||||
} from "@/app/admin/discord-bot/lib";
|
||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
|
||||
|
||||
interface Props {
|
||||
guilds: DiscordGuildConfig[];
|
||||
onRefresh: () => void;
|
||||
setPopup: (popup: PopupSpec) => void;
|
||||
}
|
||||
|
||||
export function DiscordGuildsTable({ guilds, onRefresh, setPopup }: Props) {
|
||||
const router = useRouter();
|
||||
const [guildToDelete, setGuildToDelete] = useState<DiscordGuildConfig | null>(
|
||||
null
|
||||
);
|
||||
const [updatingGuildIds, setUpdatingGuildIds] = useState<Set<number>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
const handleDelete = async (guildId: number) => {
|
||||
try {
|
||||
await deleteGuildConfig(guildId);
|
||||
onRefresh();
|
||||
setPopup({ type: "success", message: "Server configuration deleted" });
|
||||
} catch (err) {
|
||||
setPopup({
|
||||
type: "error",
|
||||
message:
|
||||
err instanceof Error ? err.message : "Failed to delete server config",
|
||||
});
|
||||
} finally {
|
||||
setGuildToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (guild: DiscordGuildConfig) => {
|
||||
if (!guild.guild_id) {
|
||||
setPopup({
|
||||
type: "error",
|
||||
message: "Server must be registered before it can be enabled",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdatingGuildIds((prev) => new Set(prev).add(guild.id));
|
||||
try {
|
||||
await updateGuildConfig(guild.id, {
|
||||
enabled: !guild.enabled,
|
||||
default_persona_id: guild.default_persona_id,
|
||||
});
|
||||
onRefresh();
|
||||
setPopup({
|
||||
type: "success",
|
||||
message: `Server ${!guild.enabled ? "enabled" : "disabled"}`,
|
||||
});
|
||||
} catch (err) {
|
||||
setPopup({
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : "Failed to update server",
|
||||
});
|
||||
} finally {
|
||||
setUpdatingGuildIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(guild.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (guilds.length === 0) {
|
||||
return (
|
||||
<EmptyMessage
|
||||
icon={SvgServer}
|
||||
title="No Discord servers configured yet"
|
||||
description="Create a server configuration to get started."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{guildToDelete && (
|
||||
<ConfirmEntityModal
|
||||
danger
|
||||
entityType="Discord server configuration"
|
||||
entityName={guildToDelete.guild_name || `Server #${guildToDelete.id}`}
|
||||
onClose={() => setGuildToDelete(null)}
|
||||
onSubmit={() => handleDelete(guildToDelete.id)}
|
||||
additionalDetails="This will remove all settings for this Discord server."
|
||||
/>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Server</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Registered</TableHead>
|
||||
<TableHead>Enabled</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{guilds.map((guild) => (
|
||||
<TableRow key={guild.id}>
|
||||
<TableCell>
|
||||
<Button
|
||||
internal
|
||||
disabled={!guild.guild_id}
|
||||
onClick={() => router.push(`/admin/discord-bot/${guild.id}`)}
|
||||
leftIcon={SvgEdit}
|
||||
>
|
||||
{guild.guild_name || `Server #${guild.id}`}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{guild.guild_id ? (
|
||||
<Badge variant="success">Registered</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Pending</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{guild.registered_at
|
||||
? new Date(guild.registered_at).toLocaleDateString()
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{!guild.guild_id ? (
|
||||
"-"
|
||||
) : (
|
||||
<Switch
|
||||
checked={guild.enabled}
|
||||
onCheckedChange={() => handleToggleEnabled(guild)}
|
||||
disabled={updatingGuildIds.has(guild.id)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DeleteButton onClick={() => setGuildToDelete(guild)} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} 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 Text from "@/refresh-components/texts/Text";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import {
|
||||
DiscordChannelConfig,
|
||||
DiscordChannelType,
|
||||
} from "@/app/admin/discord-bot/types";
|
||||
import { SvgHash, SvgBubbleText, SvgLock } from "@opal/icons";
|
||||
import { IconProps } from "@opal/types";
|
||||
import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
|
||||
function getChannelIcon(
|
||||
channelType: DiscordChannelType,
|
||||
isPrivate: boolean = false
|
||||
): React.ComponentType<IconProps> {
|
||||
// TODO: Need different icon for private channel vs private forum
|
||||
if (isPrivate) {
|
||||
return SvgLock;
|
||||
}
|
||||
switch (channelType) {
|
||||
case "forum":
|
||||
return SvgBubbleText;
|
||||
case "text":
|
||||
default:
|
||||
return SvgHash;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
channels: DiscordChannelConfig[];
|
||||
personas: Persona[];
|
||||
onChannelUpdate: (
|
||||
channelId: number,
|
||||
field:
|
||||
| "enabled"
|
||||
| "require_bot_invocation"
|
||||
| "thread_only_mode"
|
||||
| "persona_override_id",
|
||||
value: boolean | number | null
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DiscordChannelsTable({
|
||||
channels,
|
||||
personas,
|
||||
onChannelUpdate,
|
||||
disabled = false,
|
||||
}: Props) {
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<EmptyMessage
|
||||
title="No channels configured"
|
||||
description="Run !sync-channels in Discord to add channels."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="[&>th]:whitespace-nowrap">
|
||||
<TableHead>Channel</TableHead>
|
||||
<TableHead>Enabled</TableHead>
|
||||
<TableHead>Require @mention</TableHead>
|
||||
<TableHead>Thread Only Mode</TableHead>
|
||||
<TableHead>Agent Override</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{channels.map((channel) => {
|
||||
const ChannelIcon = getChannelIcon(
|
||||
channel.channel_type,
|
||||
channel.is_private
|
||||
);
|
||||
return (
|
||||
<TableRow key={channel.id}>
|
||||
<TableCell>
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
gap={0.5}
|
||||
width="fit"
|
||||
>
|
||||
<ChannelIcon width={16} height={16} />
|
||||
<Text text04 mainUiBody>
|
||||
{channel.channel_name}
|
||||
</Text>
|
||||
</Section>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={channel.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChannelUpdate(channel.id, "enabled", checked)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={channel.require_bot_invocation}
|
||||
onCheckedChange={(checked) =>
|
||||
onChannelUpdate(
|
||||
channel.id,
|
||||
"require_bot_invocation",
|
||||
checked
|
||||
)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{channel.channel_type !== "forum" && (
|
||||
<Switch
|
||||
checked={channel.thread_only_mode}
|
||||
onCheckedChange={(checked) =>
|
||||
onChannelUpdate(channel.id, "thread_only_mode", checked)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<InputSelect
|
||||
value={channel.persona_override_id?.toString() ?? "default"}
|
||||
onValueChange={(value: string) =>
|
||||
onChannelUpdate(
|
||||
channel.id,
|
||||
"persona_override_id",
|
||||
value === "default" ? null : parseInt(value)
|
||||
)
|
||||
}
|
||||
disabled={disabled}
|
||||
className="w-[160px]"
|
||||
>
|
||||
<InputSelect.Trigger placeholder="-" />
|
||||
<InputSelect.Content>
|
||||
<InputSelect.Item value="default">-</InputSelect.Item>
|
||||
{personas.map((persona) => (
|
||||
<InputSelect.Item
|
||||
key={persona.id}
|
||||
value={persona.id.toString()}
|
||||
>
|
||||
{persona.name}
|
||||
</InputSelect.Item>
|
||||
))}
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
420
web/src/app/admin/discord-bot/[guild-id]/page.tsx
Normal file
420
web/src/app/admin/discord-bot/[guild-id]/page.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
"use client";
|
||||
|
||||
import { use, useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { Section, LineItemLayout } from "@/layouts/general-layouts";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import { Callout } from "@/components/ui/callout";
|
||||
import Message from "@/refresh-components/messages/Message";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import { SvgServer } from "@opal/icons";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import {
|
||||
useDiscordGuild,
|
||||
useDiscordChannels,
|
||||
} from "@/app/admin/discord-bot/hooks";
|
||||
import {
|
||||
updateGuildConfig,
|
||||
bulkUpdateChannelConfigs,
|
||||
} from "@/app/admin/discord-bot/lib";
|
||||
import { DiscordChannelsTable } from "@/app/admin/discord-bot/[guild-id]/DiscordChannelsTable";
|
||||
import { DiscordChannelConfig } from "@/app/admin/discord-bot/types";
|
||||
import { useAdminPersonas } from "@/hooks/useAdminPersonas";
|
||||
import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ "guild-id": string }>;
|
||||
}
|
||||
|
||||
function GuildDetailContent({
|
||||
guildId,
|
||||
personas,
|
||||
localChannels,
|
||||
onChannelUpdate,
|
||||
handleEnableAll,
|
||||
handleDisableAll,
|
||||
disabled,
|
||||
}: {
|
||||
guildId: number;
|
||||
personas: Persona[];
|
||||
localChannels: DiscordChannelConfig[];
|
||||
onChannelUpdate: (
|
||||
channelId: number,
|
||||
field:
|
||||
| "enabled"
|
||||
| "require_bot_invocation"
|
||||
| "thread_only_mode"
|
||||
| "persona_override_id",
|
||||
value: boolean | number | null
|
||||
) => void;
|
||||
handleEnableAll: () => void;
|
||||
handleDisableAll: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const {
|
||||
data: guild,
|
||||
isLoading: guildLoading,
|
||||
error: guildError,
|
||||
} = useDiscordGuild(guildId);
|
||||
const { isLoading: channelsLoading, error: channelsError } =
|
||||
useDiscordChannels(guildId);
|
||||
|
||||
if (guildLoading) {
|
||||
return <ThreeDotsLoader />;
|
||||
}
|
||||
|
||||
if (guildError || !guild) {
|
||||
return (
|
||||
<ErrorCallout
|
||||
errorTitle="Failed to load server"
|
||||
errorMsg={guildError?.info?.detail || "Server not found"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isRegistered = !!guild.guild_id;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isRegistered && (
|
||||
<Callout type="notice" title="Waiting for Registration">
|
||||
Use the !register command in your Discord server with the registration
|
||||
key to complete setup.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<Card variant={disabled ? "disabled" : "primary"}>
|
||||
<LineItemLayout
|
||||
title="Channel Configuration"
|
||||
description="Run !sync-channels in Discord to update the channel list."
|
||||
rightChildren={
|
||||
isRegistered && !channelsLoading && !channelsError ? (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="end"
|
||||
alignItems="center"
|
||||
width="fit"
|
||||
gap={0.5}
|
||||
>
|
||||
<Button onClick={handleEnableAll} disabled={disabled} secondary>
|
||||
Enable All
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDisableAll}
|
||||
disabled={disabled}
|
||||
secondary
|
||||
>
|
||||
Disable All
|
||||
</Button>
|
||||
</Section>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{!isRegistered ? (
|
||||
<Text text03 secondaryBody>
|
||||
Channel configuration will be available after the server is
|
||||
registered.
|
||||
</Text>
|
||||
) : channelsLoading ? (
|
||||
<ThreeDotsLoader />
|
||||
) : channelsError ? (
|
||||
<ErrorCallout
|
||||
errorTitle="Failed to load channels"
|
||||
errorMsg={channelsError?.info?.detail || "Could not load channels"}
|
||||
/>
|
||||
) : (
|
||||
<DiscordChannelsTable
|
||||
channels={localChannels}
|
||||
personas={personas}
|
||||
onChannelUpdate={onChannelUpdate}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page({ params }: Props) {
|
||||
const unwrappedParams = use(params);
|
||||
const guildId = Number(unwrappedParams["guild-id"]);
|
||||
const { popup, setPopup } = usePopup();
|
||||
const { data: guild, refreshGuild } = useDiscordGuild(guildId);
|
||||
const {
|
||||
data: channels,
|
||||
isLoading: channelsLoading,
|
||||
error: channelsError,
|
||||
refreshChannels,
|
||||
} = useDiscordChannels(guildId);
|
||||
const { personas, isLoading: personasLoading } = useAdminPersonas({
|
||||
includeDefault: true,
|
||||
});
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
// Local state for channel configurations
|
||||
const [localChannels, setLocalChannels] = useState<DiscordChannelConfig[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
// Track the original server state to detect changes
|
||||
const [originalChannels, setOriginalChannels] = useState<
|
||||
DiscordChannelConfig[]
|
||||
>([]);
|
||||
|
||||
// Sync local state with fetched channels
|
||||
useEffect(() => {
|
||||
if (channels) {
|
||||
setLocalChannels(channels);
|
||||
setOriginalChannels(channels);
|
||||
}
|
||||
}, [channels]);
|
||||
|
||||
// Check if there are unsaved changes
|
||||
const hasUnsavedChanges = useMemo(() => {
|
||||
for (const local of localChannels) {
|
||||
const original = originalChannels.find((c) => c.id === local.id);
|
||||
if (!original) return true;
|
||||
if (
|
||||
local.enabled !== original.enabled ||
|
||||
local.require_bot_invocation !== original.require_bot_invocation ||
|
||||
local.thread_only_mode !== original.thread_only_mode ||
|
||||
local.persona_override_id !== original.persona_override_id
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [localChannels, originalChannels]);
|
||||
|
||||
// Get list of changed channels for bulk update
|
||||
const getChangedChannels = useCallback(() => {
|
||||
const changes: {
|
||||
channelConfigId: number;
|
||||
update: {
|
||||
enabled: boolean;
|
||||
require_bot_invocation: boolean;
|
||||
thread_only_mode: boolean;
|
||||
persona_override_id: number | null;
|
||||
};
|
||||
}[] = [];
|
||||
|
||||
for (const local of localChannels) {
|
||||
const original = originalChannels.find((c) => c.id === local.id);
|
||||
if (!original) continue;
|
||||
if (
|
||||
local.enabled !== original.enabled ||
|
||||
local.require_bot_invocation !== original.require_bot_invocation ||
|
||||
local.thread_only_mode !== original.thread_only_mode ||
|
||||
local.persona_override_id !== original.persona_override_id
|
||||
) {
|
||||
changes.push({
|
||||
channelConfigId: local.id,
|
||||
update: {
|
||||
enabled: local.enabled,
|
||||
require_bot_invocation: local.require_bot_invocation,
|
||||
thread_only_mode: local.thread_only_mode,
|
||||
persona_override_id: local.persona_override_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}, [localChannels, originalChannels]);
|
||||
|
||||
const handleChannelUpdate = useCallback(
|
||||
(
|
||||
channelId: number,
|
||||
field:
|
||||
| "enabled"
|
||||
| "require_bot_invocation"
|
||||
| "thread_only_mode"
|
||||
| "persona_override_id",
|
||||
value: boolean | number | null
|
||||
) => {
|
||||
setLocalChannels((prev) =>
|
||||
prev.map((channel) =>
|
||||
channel.id === channelId ? { ...channel, [field]: value } : channel
|
||||
)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleEnableAll = useCallback(() => {
|
||||
setLocalChannels((prev) =>
|
||||
prev.map((channel) => ({ ...channel, enabled: true }))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleDisableAll = useCallback(() => {
|
||||
setLocalChannels((prev) =>
|
||||
prev.map((channel) => ({ ...channel, enabled: false }))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
const changes = getChangedChannels();
|
||||
if (changes.length === 0) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const { succeeded, failed } = await bulkUpdateChannelConfigs(
|
||||
guildId,
|
||||
changes
|
||||
);
|
||||
|
||||
if (failed > 0) {
|
||||
setPopup({
|
||||
type: "error",
|
||||
message: `Updated ${succeeded} channels, but ${failed} failed`,
|
||||
});
|
||||
// Refresh to get actual server state when some updates failed
|
||||
refreshChannels();
|
||||
} else {
|
||||
setPopup({
|
||||
type: "success",
|
||||
message: `Updated ${succeeded} channel${succeeded !== 1 ? "s" : ""}`,
|
||||
});
|
||||
// Update original to match local (avoids flash from refresh)
|
||||
setOriginalChannels(localChannels);
|
||||
}
|
||||
} catch (err) {
|
||||
setPopup({
|
||||
type: "error",
|
||||
message:
|
||||
err instanceof Error ? err.message : "Failed to update channels",
|
||||
});
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDefaultPersonaChange = async (personaId: number | null) => {
|
||||
if (!guild) return;
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await updateGuildConfig(guildId, {
|
||||
enabled: guild.enabled,
|
||||
default_persona_id: personaId,
|
||||
});
|
||||
refreshGuild();
|
||||
setPopup({
|
||||
type: "success",
|
||||
message: personaId
|
||||
? "Default assistant updated"
|
||||
: "Default assistant cleared",
|
||||
});
|
||||
} catch (err) {
|
||||
setPopup({
|
||||
type: "error",
|
||||
message:
|
||||
err instanceof Error ? err.message : "Failed to update assistant",
|
||||
});
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const registeredText = guild?.registered_at
|
||||
? `Registered: ${new Date(guild.registered_at).toLocaleString()}`
|
||||
: "Pending registration";
|
||||
|
||||
const isRegistered = !!guild?.guild_id;
|
||||
const isUpdateDisabled =
|
||||
!isRegistered ||
|
||||
channelsLoading ||
|
||||
!!channelsError ||
|
||||
!hasUnsavedChanges ||
|
||||
!guild?.enabled ||
|
||||
isUpdating;
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
{popup}
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgServer}
|
||||
title={guild?.guild_name || `Server #${guildId}`}
|
||||
description={registeredText}
|
||||
backButton
|
||||
rightChildren={
|
||||
<Button onClick={handleSaveChanges} disabled={isUpdateDisabled}>
|
||||
Update Configuration
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
{/* Default Persona Selector */}
|
||||
<Card variant={!guild?.enabled ? "disabled" : "primary"}>
|
||||
<LineItemLayout
|
||||
title="Default Agent"
|
||||
description="The agent used by the bot in all channels unless overridden."
|
||||
rightChildren={
|
||||
<InputSelect
|
||||
value={guild?.default_persona_id?.toString() ?? "default"}
|
||||
onValueChange={(value: string) =>
|
||||
handleDefaultPersonaChange(
|
||||
value === "default" ? null : parseInt(value)
|
||||
)
|
||||
}
|
||||
disabled={isUpdating || !guild?.enabled || personasLoading}
|
||||
className="w-[200px]"
|
||||
>
|
||||
<InputSelect.Trigger placeholder="Select agent" />
|
||||
<InputSelect.Content>
|
||||
<InputSelect.Item value="default">
|
||||
Default Assistant
|
||||
</InputSelect.Item>
|
||||
{personas.map((persona) => (
|
||||
<InputSelect.Item
|
||||
key={persona.id}
|
||||
value={persona.id.toString()}
|
||||
>
|
||||
{persona.name}
|
||||
</InputSelect.Item>
|
||||
))}
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<GuildDetailContent
|
||||
guildId={guildId}
|
||||
personas={personas}
|
||||
localChannels={localChannels}
|
||||
onChannelUpdate={handleChannelUpdate}
|
||||
handleEnableAll={handleEnableAll}
|
||||
handleDisableAll={handleDisableAll}
|
||||
disabled={!guild?.enabled}
|
||||
/>
|
||||
|
||||
{/* Unsaved changes indicator - sticky at bottom, centered in content area */}
|
||||
<div
|
||||
className={cn(
|
||||
"sticky z-toast bottom-4 w-fit mx-auto transition-all duration-300 ease-in-out",
|
||||
hasUnsavedChanges &&
|
||||
isRegistered &&
|
||||
!channelsLoading &&
|
||||
guild?.enabled
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-4 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<Message
|
||||
warning
|
||||
text="You have unsaved changes"
|
||||
description="Click Update to save them."
|
||||
close={false}
|
||||
/>
|
||||
</div>
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
73
web/src/app/admin/discord-bot/hooks.ts
Normal file
73
web/src/app/admin/discord-bot/hooks.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import {
|
||||
DiscordBotConfig,
|
||||
DiscordGuildConfig,
|
||||
DiscordChannelConfig,
|
||||
} from "@/app/admin/discord-bot/types";
|
||||
|
||||
const BASE_URL = "/api/manage/admin/discord-bot";
|
||||
|
||||
/**
|
||||
* Custom fetcher for bot config that handles 403 specially.
|
||||
* 403 means bot config is managed externally (Cloud or env var).
|
||||
*/
|
||||
async function botConfigFetcher(url: string): Promise<DiscordBotConfig | null> {
|
||||
const res = await fetch(url);
|
||||
|
||||
if (res.status === 403) {
|
||||
// Bot config is managed externally - return null to indicate not accessible
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch bot config");
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for bot config. Returns null when managed externally (Cloud/env var).
|
||||
*/
|
||||
export function useDiscordBotConfig() {
|
||||
const url = `${BASE_URL}/config`;
|
||||
const swrResponse = useSWR<DiscordBotConfig | null>(url, botConfigFetcher);
|
||||
return {
|
||||
...swrResponse,
|
||||
// null = managed externally (403), undefined = loading
|
||||
isManaged: swrResponse.data === null,
|
||||
refreshBotConfig: () => swrResponse.mutate(),
|
||||
};
|
||||
}
|
||||
|
||||
export function useDiscordGuilds() {
|
||||
const url = `${BASE_URL}/guilds`;
|
||||
const swrResponse = useSWR<DiscordGuildConfig[]>(url, errorHandlingFetcher);
|
||||
return {
|
||||
...swrResponse,
|
||||
refreshGuilds: () => swrResponse.mutate(),
|
||||
};
|
||||
}
|
||||
|
||||
export function useDiscordGuild(configId: number) {
|
||||
const url = `${BASE_URL}/guilds/${configId}`;
|
||||
const swrResponse = useSWR<DiscordGuildConfig>(url, errorHandlingFetcher);
|
||||
return {
|
||||
...swrResponse,
|
||||
refreshGuild: () => swrResponse.mutate(),
|
||||
};
|
||||
}
|
||||
|
||||
export function useDiscordChannels(guildConfigId: number) {
|
||||
const url = guildConfigId
|
||||
? `${BASE_URL}/guilds/${guildConfigId}/channels`
|
||||
: null;
|
||||
const swrResponse = useSWR<DiscordChannelConfig[]>(url, errorHandlingFetcher);
|
||||
return {
|
||||
...swrResponse,
|
||||
refreshChannels: () => swrResponse.mutate(),
|
||||
};
|
||||
}
|
||||
147
web/src/app/admin/discord-bot/lib.ts
Normal file
147
web/src/app/admin/discord-bot/lib.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
DiscordBotConfig,
|
||||
DiscordGuildConfig,
|
||||
DiscordGuildConfigCreateResponse,
|
||||
DiscordGuildConfigUpdate,
|
||||
DiscordChannelConfig,
|
||||
DiscordChannelConfigUpdate,
|
||||
} from "@/app/admin/discord-bot/types";
|
||||
|
||||
const BASE_URL = "/api/manage/admin/discord-bot";
|
||||
|
||||
// === Bot Config (Self-hosted only) ===
|
||||
|
||||
export async function fetchBotConfig(): Promise<DiscordBotConfig> {
|
||||
const response = await fetch(`${BASE_URL}/config`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch bot config");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createBotConfig(
|
||||
botToken: string
|
||||
): Promise<DiscordBotConfig> {
|
||||
const response = await fetch(`${BASE_URL}/config`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bot_token: botToken }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || "Failed to create bot config");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteBotConfig(): Promise<void> {
|
||||
const response = await fetch(`${BASE_URL}/config`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete bot config");
|
||||
}
|
||||
}
|
||||
|
||||
// === Guild Config ===
|
||||
|
||||
export async function fetchGuildConfigs(): Promise<DiscordGuildConfig[]> {
|
||||
const response = await fetch(`${BASE_URL}/guilds`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch guild configs");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createGuildConfig(): Promise<DiscordGuildConfigCreateResponse> {
|
||||
const response = await fetch(`${BASE_URL}/guilds`, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || "Failed to create guild config");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchGuildConfig(
|
||||
configId: number
|
||||
): Promise<DiscordGuildConfig> {
|
||||
const response = await fetch(`${BASE_URL}/guilds/${configId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch guild config");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function updateGuildConfig(
|
||||
configId: number,
|
||||
update: DiscordGuildConfigUpdate
|
||||
): Promise<DiscordGuildConfig> {
|
||||
const response = await fetch(`${BASE_URL}/guilds/${configId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(update),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || "Failed to update guild config");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteGuildConfig(configId: number): Promise<void> {
|
||||
const response = await fetch(`${BASE_URL}/guilds/${configId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete guild config");
|
||||
}
|
||||
}
|
||||
|
||||
// === Channel Config ===
|
||||
|
||||
export async function fetchChannelConfigs(
|
||||
guildConfigId: number
|
||||
): Promise<DiscordChannelConfig[]> {
|
||||
const response = await fetch(`${BASE_URL}/guilds/${guildConfigId}/channels`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch channel configs");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function updateChannelConfig(
|
||||
guildConfigId: number,
|
||||
channelConfigId: number,
|
||||
update: DiscordChannelConfigUpdate
|
||||
): Promise<DiscordChannelConfig> {
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/guilds/${guildConfigId}/channels/${channelConfigId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(update),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || "Failed to update channel config");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function bulkUpdateChannelConfigs(
|
||||
guildConfigId: number,
|
||||
updates: { channelConfigId: number; update: DiscordChannelConfigUpdate }[]
|
||||
): Promise<{ succeeded: number; failed: number }> {
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const { channelConfigId, update } of updates) {
|
||||
try {
|
||||
await updateChannelConfig(guildConfigId, channelConfigId, update);
|
||||
succeeded++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { succeeded, failed };
|
||||
}
|
||||
141
web/src/app/admin/discord-bot/page.tsx
Normal file
141
web/src/app/admin/discord-bot/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import CreateButton from "@/refresh-components/buttons/CreateButton";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import { SvgKey } from "@opal/icons";
|
||||
import {
|
||||
useDiscordGuilds,
|
||||
useDiscordBotConfig,
|
||||
} from "@/app/admin/discord-bot/hooks";
|
||||
import { createGuildConfig } from "@/app/admin/discord-bot/lib";
|
||||
import { DiscordGuildsTable } from "@/app/admin/discord-bot/DiscordGuildsTable";
|
||||
import { BotConfigCard } from "@/app/admin/discord-bot/BotConfigCard";
|
||||
import { SvgDiscordMono } from "@opal/icons";
|
||||
|
||||
function DiscordBotContent() {
|
||||
const { popup, setPopup } = usePopup();
|
||||
const { data: guilds, isLoading, error, refreshGuilds } = useDiscordGuilds();
|
||||
const { data: botConfig, isManaged } = useDiscordBotConfig();
|
||||
const [registrationKey, setRegistrationKey] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
// Bot is available if:
|
||||
// - Managed externally (Cloud/env) - assume it's configured
|
||||
// - Self-hosted and explicitly configured via UI
|
||||
const isBotAvailable = isManaged || botConfig?.configured === true;
|
||||
|
||||
const handleCreateGuild = async () => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const result = await createGuildConfig();
|
||||
setRegistrationKey(result.registration_key);
|
||||
refreshGuilds();
|
||||
setPopup({ type: "success", message: "Server configuration created!" });
|
||||
} catch (err) {
|
||||
setPopup({
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : "Failed to create server",
|
||||
});
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <ThreeDotsLoader />;
|
||||
}
|
||||
|
||||
if (error || !guilds) {
|
||||
return (
|
||||
<ErrorCallout
|
||||
errorTitle="Failed to load Discord servers"
|
||||
errorMsg={error?.info?.detail || "An unknown error occurred"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{popup}
|
||||
|
||||
<BotConfigCard setPopup={setPopup} />
|
||||
|
||||
<Modal open={!!registrationKey}>
|
||||
<Modal.Content width="sm">
|
||||
<Modal.Header
|
||||
title="Registration Key"
|
||||
icon={SvgKey}
|
||||
onClose={() => setRegistrationKey(null)}
|
||||
description="This key will only be shown once!"
|
||||
/>
|
||||
<Modal.Body>
|
||||
<Text text04 mainUiBody>
|
||||
Copy the command and send it from any text channel in your server!
|
||||
</Text>
|
||||
<Card variant="secondary">
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="between"
|
||||
alignItems="center"
|
||||
>
|
||||
<Text text03 secondaryMono>
|
||||
!register {registrationKey}
|
||||
</Text>
|
||||
<CopyIconButton
|
||||
getCopyText={() => `!register ${registrationKey}`}
|
||||
/>
|
||||
</Section>
|
||||
</Card>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
|
||||
<Card variant={!isBotAvailable ? "disabled" : "primary"}>
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="between"
|
||||
alignItems="center"
|
||||
>
|
||||
<Text mainContentEmphasis text05>
|
||||
Server Configurations
|
||||
</Text>
|
||||
<CreateButton
|
||||
onClick={handleCreateGuild}
|
||||
disabled={isCreating || !isBotAvailable}
|
||||
>
|
||||
{isCreating ? "Creating..." : "Add Server"}
|
||||
</CreateButton>
|
||||
</Section>
|
||||
<DiscordGuildsTable
|
||||
guilds={guilds}
|
||||
onRefresh={refreshGuilds}
|
||||
setPopup={setPopup}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgDiscordMono}
|
||||
title="Discord Bots"
|
||||
description="Connect Onyx to your Discord servers. Users can ask questions directly in Discord channels."
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<DiscordBotContent />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
46
web/src/app/admin/discord-bot/types.ts
Normal file
46
web/src/app/admin/discord-bot/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Types matching backend Pydantic models
|
||||
|
||||
export interface DiscordBotConfig {
|
||||
configured: boolean;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface DiscordGuildConfig {
|
||||
id: number;
|
||||
guild_id: number | null;
|
||||
guild_name: string | null;
|
||||
registered_at: string | null;
|
||||
default_persona_id: number | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface DiscordGuildConfigCreateResponse {
|
||||
id: number;
|
||||
registration_key: string; // Shown once!
|
||||
}
|
||||
|
||||
export type DiscordChannelType = "text" | "forum";
|
||||
|
||||
export interface DiscordChannelConfig {
|
||||
id: number;
|
||||
channel_id: number;
|
||||
channel_name: string;
|
||||
channel_type: DiscordChannelType;
|
||||
is_private: boolean;
|
||||
require_bot_invocation: boolean;
|
||||
thread_only_mode: boolean;
|
||||
persona_override_id: number | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface DiscordChannelConfigUpdate {
|
||||
require_bot_invocation: boolean;
|
||||
thread_only_mode: boolean;
|
||||
persona_override_id: number | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface DiscordGuildConfigUpdate {
|
||||
enabled: boolean;
|
||||
default_persona_id: number | null;
|
||||
}
|
||||
@@ -101,7 +101,7 @@ function Main() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Section alignItems="stretch">
|
||||
<Section alignItems="stretch" justifyContent="start" height="auto">
|
||||
{popup}
|
||||
|
||||
<Text>
|
||||
|
||||
@@ -25,7 +25,6 @@ import { useDocumentSets } from "@/lib/hooks/useDocumentSets";
|
||||
import { useAgents } from "@/hooks/useAgents";
|
||||
import { ChatPopup } from "@/app/chat/components/ChatPopup";
|
||||
import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal";
|
||||
import { SEARCH_TOOL_ID } from "@/app/chat/components/tools/constants";
|
||||
import { useUser } from "@/components/user/UserProvider";
|
||||
import NoAssistantModal from "@/components/modals/NoAssistantModal";
|
||||
import TextView from "@/components/chat/TextView";
|
||||
@@ -378,9 +377,7 @@ export default function ChatPage({ firstMessage }: ChatPageProps) {
|
||||
|
||||
const retrievalEnabled = useMemo(() => {
|
||||
if (liveAssistant) {
|
||||
return liveAssistant.tools.some(
|
||||
(tool) => tool.in_code_tool_id === SEARCH_TOOL_ID
|
||||
);
|
||||
return personaIncludesRetrieval(liveAssistant);
|
||||
}
|
||||
return false;
|
||||
}, [liveAssistant]);
|
||||
|
||||
@@ -68,7 +68,7 @@ export const CodeBlock = memo(function CodeBlock({
|
||||
"bg-background-tint-00",
|
||||
"rounded",
|
||||
"text-xs",
|
||||
"inline-block",
|
||||
"inline",
|
||||
"whitespace-pre-wrap",
|
||||
"break-words",
|
||||
"py-0.5",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { User } from "@/lib/types";
|
||||
import { FiPlus, FiX } from "react-icons/fi";
|
||||
import { SearchMultiSelectDropdown } from "@/components/Dropdown";
|
||||
import { UsersIcon } from "@/components/icons/icons";
|
||||
import { FiX } from "react-icons/fi";
|
||||
import InputComboBox from "@/refresh-components/inputs/InputComboBox/InputComboBox";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
|
||||
interface UserEditorProps {
|
||||
@@ -51,35 +50,27 @@ export const UserEditor = ({
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
<SearchMultiSelectDropdown
|
||||
<InputComboBox
|
||||
placeholder="Search..."
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onValueChange={(selectedValue) => {
|
||||
setSelectedUserIds([
|
||||
...Array.from(new Set([...selectedUserIds, selectedValue])),
|
||||
]);
|
||||
}}
|
||||
options={allUsers
|
||||
.filter(
|
||||
(user) =>
|
||||
!selectedUserIds.includes(user.id) &&
|
||||
!existingUsers.map((user) => user.id).includes(user.id)
|
||||
)
|
||||
.map((user) => {
|
||||
return {
|
||||
name: user.email,
|
||||
value: user.id,
|
||||
};
|
||||
})}
|
||||
onSelect={(option) => {
|
||||
setSelectedUserIds([
|
||||
...Array.from(
|
||||
new Set([...selectedUserIds, option.value as string])
|
||||
),
|
||||
]);
|
||||
}}
|
||||
itemComponent={({ option }) => (
|
||||
<div className="flex px-4 py-2.5 cursor-pointer hover:bg-accent-background-hovered">
|
||||
<UsersIcon className="mr-2 my-auto" />
|
||||
{option.name}
|
||||
<div className="ml-auto my-auto">
|
||||
<FiPlus />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
.map((user) => ({
|
||||
label: user.email,
|
||||
value: user.id,
|
||||
}))}
|
||||
strict
|
||||
leftSearchIcon
|
||||
/>
|
||||
{onSubmit && (
|
||||
<Button
|
||||
|
||||
@@ -41,7 +41,8 @@ export const ConnectorMultiSelect = ({
|
||||
(connector) => !selectedIds.includes(connector.cc_pair_id)
|
||||
);
|
||||
|
||||
const allConnectorsSelected = unselectedConnectors.length === 0;
|
||||
const allConnectorsSelected =
|
||||
connectors.length > 0 && unselectedConnectors.length === 0;
|
||||
|
||||
const filteredUnselectedConnectors = unselectedConnectors.filter(
|
||||
(connector) => {
|
||||
@@ -50,17 +51,8 @@ export const ConnectorMultiSelect = ({
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (allConnectorsSelected && open) {
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
setSearchQuery("");
|
||||
}
|
||||
}, [allConnectorsSelected, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (allConnectorsSelected) {
|
||||
inputRef.current?.blur();
|
||||
setSearchQuery("");
|
||||
}
|
||||
}, [allConnectorsSelected, selectedIds]);
|
||||
@@ -111,7 +103,7 @@ export const ConnectorMultiSelect = ({
|
||||
? "All connectors selected"
|
||||
: placeholder;
|
||||
|
||||
const isInputDisabled = disabled || allConnectorsSelected;
|
||||
const isInputDisabled = disabled;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full space-y-2 mb-4">
|
||||
@@ -129,32 +121,39 @@ export const ConnectorMultiSelect = ({
|
||||
value={searchQuery}
|
||||
disabled={isInputDisabled}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!allConnectorsSelected) {
|
||||
setSearchQuery(e.target.value);
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={
|
||||
allConnectorsSelected
|
||||
? "rounded-12 bg-background-neutral-01"
|
||||
: "rounded-12"
|
||||
}
|
||||
className="rounded-12"
|
||||
/>
|
||||
|
||||
{open && !allConnectorsSelected && (
|
||||
{open && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute z-50 w-full mt-1 rounded-12 border border-border-02 bg-background-neutral-00 shadow-md default-scrollbar max-h-[300px] overflow-auto"
|
||||
>
|
||||
{filteredUnselectedConnectors.length === 0 ? (
|
||||
<div className="py-4 text-center text-xs text-text-03">
|
||||
{searchQuery
|
||||
? "No matching connectors found"
|
||||
: "No more connectors available"}
|
||||
{allConnectorsSelected ? (
|
||||
<div className="py-4 px-3">
|
||||
<Text as="p" text03 className="text-center text-xs">
|
||||
All available connectors have been selected. Remove connectors
|
||||
below to add different ones.
|
||||
</Text>
|
||||
</div>
|
||||
) : filteredUnselectedConnectors.length === 0 ? (
|
||||
<div className="py-4 px-3">
|
||||
<Text as="p" text03 className="text-center text-xs">
|
||||
{searchQuery
|
||||
? "No matching connectors found"
|
||||
: connectors.length === 0
|
||||
? "No private connectors available. Create a private connector first."
|
||||
: "No more connectors available"}
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChangeEvent,
|
||||
FC,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
JSX,
|
||||
} from "react";
|
||||
import { ChevronDownIcon } from "./icons/icons";
|
||||
import { forwardRef, useEffect, useRef, useState, JSX } from "react";
|
||||
import { FiCheck, FiChevronDown, FiInfo } from "react-icons/fi";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import { SvgPlus } from "@opal/icons";
|
||||
export interface Option<T> {
|
||||
name: string;
|
||||
value: T;
|
||||
@@ -28,230 +16,6 @@ export interface Option<T> {
|
||||
|
||||
export type StringOrNumberOption = Option<string | number>;
|
||||
|
||||
function StandardDropdownOption<T>({
|
||||
index,
|
||||
option,
|
||||
handleSelect,
|
||||
}: {
|
||||
index: number;
|
||||
option: Option<T>;
|
||||
handleSelect: (option: Option<T>) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleSelect(option)}
|
||||
className={`w-full text-left block px-4 py-2.5 text-sm bg-white dark:bg-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-700 ${
|
||||
index !== 0 ? "border-t border-neutral-200 dark:border-neutral-700" : ""
|
||||
}`}
|
||||
role="menuitem"
|
||||
>
|
||||
<p className="font-medium text-xs text-neutral-900 dark:text-neutral-100">
|
||||
{option.name}
|
||||
</p>
|
||||
{option.description && (
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{option.description}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchMultiSelectDropdown({
|
||||
options,
|
||||
onSelect,
|
||||
itemComponent,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onSearchTermChange,
|
||||
initialSearchTerm = "",
|
||||
allowCustomValues = false,
|
||||
}: {
|
||||
options: StringOrNumberOption[];
|
||||
onSelect: (selected: StringOrNumberOption) => void;
|
||||
itemComponent?: (props: { option: StringOrNumberOption }) => JSX.Element;
|
||||
onCreate?: (name: string) => void;
|
||||
onDelete?: (name: string) => void;
|
||||
onSearchTermChange?: (term: string) => void;
|
||||
initialSearchTerm?: string;
|
||||
allowCustomValues?: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState(initialSearchTerm);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleSelect = (option: StringOrNumberOption) => {
|
||||
onSelect(option);
|
||||
setIsOpen(false);
|
||||
setSearchTerm(""); // Clear search term after selection
|
||||
};
|
||||
|
||||
const filteredOptions = options.filter((option) =>
|
||||
option.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Handle selecting a custom value not in the options list
|
||||
const handleCustomValueSelect = () => {
|
||||
if (allowCustomValues && searchTerm.trim() !== "") {
|
||||
const customOption: StringOrNumberOption = {
|
||||
name: searchTerm,
|
||||
value: searchTerm,
|
||||
};
|
||||
onSelect(customOption);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
// If allowCustomValues is enabled and there's text in the search field,
|
||||
// treat clicking outside as selecting the custom value
|
||||
if (allowCustomValues && searchTerm.trim() !== "") {
|
||||
handleCustomValueSelect();
|
||||
}
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [allowCustomValues, searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(initialSearchTerm);
|
||||
}, [initialSearchTerm]);
|
||||
|
||||
return (
|
||||
<div className="relative text-left w-full" ref={dropdownRef}>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={
|
||||
allowCustomValues ? "Search or enter custom value..." : "Search..."
|
||||
}
|
||||
value={searchTerm}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setSearchTerm(newValue);
|
||||
if (onSearchTermChange) {
|
||||
onSearchTermChange(newValue);
|
||||
}
|
||||
if (newValue) {
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
allowCustomValues &&
|
||||
searchTerm.trim() !== ""
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleCustomValueSelect();
|
||||
}
|
||||
}}
|
||||
className="inline-flex justify-between w-full px-4 py-2 text-sm bg-white dark:bg-transparent text-neutral-800 dark:text-neutral-200 border border-neutral-200 dark:border-neutral-700 rounded-md shadow-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-0 right-0 text-sm h-full px-2 border-l border-neutral-200 dark:border-neutral-700"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<ChevronDownIcon className="my-auto w-4 h-4 text-neutral-600 dark:text-neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-10 mt-1 w-full rounded-md shadow-lg bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 max-h-60 overflow-y-auto">
|
||||
<div
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="options-menu"
|
||||
>
|
||||
{filteredOptions.map((option, index) =>
|
||||
itemComponent ? (
|
||||
<div
|
||||
key={option.name}
|
||||
onClick={() => {
|
||||
handleSelect(option);
|
||||
}}
|
||||
>
|
||||
{itemComponent({ option })}
|
||||
</div>
|
||||
) : (
|
||||
<StandardDropdownOption
|
||||
key={index}
|
||||
option={option}
|
||||
index={index}
|
||||
handleSelect={handleSelect}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{allowCustomValues &&
|
||||
searchTerm.trim() !== "" &&
|
||||
!filteredOptions.some(
|
||||
(option) =>
|
||||
option.name.toLowerCase() === searchTerm.toLowerCase()
|
||||
) && (
|
||||
<Button
|
||||
className="w-full"
|
||||
role="menuitem"
|
||||
onClick={handleCustomValueSelect}
|
||||
leftIcon={SvgPlus}
|
||||
>
|
||||
Use "{searchTerm}" as custom value
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onCreate &&
|
||||
searchTerm.trim() !== "" &&
|
||||
!filteredOptions.some(
|
||||
(option) =>
|
||||
option.name.toLowerCase() === searchTerm.toLowerCase()
|
||||
) && (
|
||||
<>
|
||||
<div className="border-t border-background-300"></div>
|
||||
<Button
|
||||
className="w-full"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
onCreate(searchTerm);
|
||||
setIsOpen(false);
|
||||
setSearchTerm("");
|
||||
}}
|
||||
leftIcon={SvgPlus}
|
||||
>
|
||||
Create label "{searchTerm}"
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredOptions.length === 0 &&
|
||||
((!onCreate && !allowCustomValues) ||
|
||||
searchTerm.trim() === "") && (
|
||||
<div className="px-4 py-2.5 text-sm text-text-500">
|
||||
No matches found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CustomDropdown = ({
|
||||
children,
|
||||
dropdown,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FormikProps, ErrorMessage } from "formik";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import { SearchMultiSelectDropdown } from "@/components/Dropdown";
|
||||
import InputComboBox from "@/refresh-components/inputs/InputComboBox/InputComboBox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SvgX } from "@opal/icons";
|
||||
export type GenericMultiSelectFormType<T extends string> = {
|
||||
@@ -84,15 +84,11 @@ export function GenericMultiSelect<
|
||||
const selectedIds = (formikProps.values[fieldName] as number[]) || [];
|
||||
const selectedItems = items.filter((item) => selectedIds.includes(item.id));
|
||||
|
||||
const handleSelect = (option: { name: string; value: string | number }) => {
|
||||
const handleSelect = (itemId: number) => {
|
||||
if (disabled) return;
|
||||
const currentIds = (formikProps.values[fieldName] as number[]) || [];
|
||||
const numValue =
|
||||
typeof option.value === "string"
|
||||
? parseInt(option.value, 10)
|
||||
: option.value;
|
||||
if (!currentIds.includes(numValue)) {
|
||||
formikProps.setFieldValue(fieldName, [...currentIds, numValue]);
|
||||
if (!currentIds.includes(itemId)) {
|
||||
formikProps.setFieldValue(fieldName, [...currentIds, itemId]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -118,14 +114,24 @@ export function GenericMultiSelect<
|
||||
)}
|
||||
|
||||
<div className={cn(disabled && "opacity-50 pointer-events-none")}>
|
||||
<SearchMultiSelectDropdown
|
||||
<InputComboBox
|
||||
placeholder="Search..."
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onValueChange={(selectedValue) => {
|
||||
const numValue = parseInt(selectedValue, 10);
|
||||
if (!isNaN(numValue)) {
|
||||
handleSelect(numValue);
|
||||
}
|
||||
}}
|
||||
options={items
|
||||
.filter((item) => !selectedIds.includes(item.id))
|
||||
.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
value: String(item.id),
|
||||
}))}
|
||||
onSelect={handleSelect}
|
||||
strict
|
||||
leftSearchIcon
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -28,13 +28,10 @@ export default function SourceTile({
|
||||
w-40
|
||||
cursor-pointer
|
||||
shadow-md
|
||||
bg-background-tint-00
|
||||
hover:bg-background-tint-02
|
||||
relative
|
||||
${
|
||||
preSelect
|
||||
? "bg-background-tint-01 subtle-pulse"
|
||||
: "bg-background-tint-00"
|
||||
}
|
||||
${preSelect ? "subtle-pulse" : ""}
|
||||
`}
|
||||
href={navigationUrl as Route}
|
||||
>
|
||||
|
||||
@@ -13,7 +13,6 @@ import React, {
|
||||
const DEFAULT_ANCHOR_OFFSET_PX = 16; // 1rem
|
||||
const DEFAULT_FADE_THRESHOLD_PX = 80; // 5rem
|
||||
const DEFAULT_BUTTON_THRESHOLD_PX = 32; // 2rem
|
||||
const SCROLL_DEBOUNCE_MS = 100;
|
||||
const FADE_OVERLAY_HEIGHT = "h-8"; // 2rem
|
||||
|
||||
export interface ScrollState {
|
||||
@@ -30,8 +29,8 @@ export interface ChatScrollContainerProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
/**
|
||||
* CSS selector for the element to anchor at top (e.g., "#message-123")
|
||||
* When set, positions this element at top with spacer below content
|
||||
* CSS selector for the anchor element (e.g., "#message-123")
|
||||
* Used to scroll to a specific message position
|
||||
*/
|
||||
anchorSelector?: string;
|
||||
|
||||
@@ -90,7 +89,6 @@ const ChatScrollContainer = React.memo(
|
||||
const scrolledForSessionRef = useRef<string | null>(null);
|
||||
const prevAnchorSelectorRef = useRef<string | null>(null);
|
||||
|
||||
const [spacerHeight, setSpacerHeight] = useState(0);
|
||||
const [hasContentAbove, setHasContentAbove] = useState(false);
|
||||
const [hasContentBelow, setHasContentBelow] = useState(false);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
@@ -110,22 +108,6 @@ const ChatScrollContainer = React.memo(
|
||||
const isStreamingRef = useRef(isStreaming);
|
||||
isStreamingRef.current = isStreaming;
|
||||
|
||||
// Calculate spacer height to position anchor at top
|
||||
const calcSpacerHeight = useCallback(
|
||||
(anchorElement: HTMLElement): number => {
|
||||
if (!endDivRef.current || !scrollContainerRef.current) return 0;
|
||||
const contentEnd = endDivRef.current.offsetTop;
|
||||
const contentFromAnchor = contentEnd - anchorElement.offsetTop;
|
||||
return Math.max(
|
||||
0,
|
||||
scrollContainerRef.current.clientHeight -
|
||||
contentFromAnchor -
|
||||
anchorOffsetPx
|
||||
);
|
||||
},
|
||||
[anchorOffsetPx]
|
||||
);
|
||||
|
||||
// Get current scroll state
|
||||
const getScrollState = useCallback((): ScrollState => {
|
||||
const container = scrollContainerRef.current;
|
||||
@@ -226,17 +208,7 @@ const ChatScrollContainer = React.memo(
|
||||
// Update button visibility based on actual position
|
||||
onScrollButtonVisibilityChangeRef.current?.(!state.isAtBottom);
|
||||
}
|
||||
|
||||
// Recalculate spacer for non-auto-scroll mode during user scroll
|
||||
if (!autoScrollRef.current && anchorSelector && endDivRef.current) {
|
||||
const anchorElement = container.querySelector(
|
||||
anchorSelector
|
||||
) as HTMLElement;
|
||||
if (anchorElement) {
|
||||
setSpacerHeight(calcSpacerHeight(anchorElement));
|
||||
}
|
||||
}
|
||||
}, [anchorSelector, calcSpacerHeight, updateScrollState, getScrollState]);
|
||||
}, [updateScrollState, getScrollState]);
|
||||
|
||||
// Watch for content changes (MutationObserver + ResizeObserver)
|
||||
useEffect(() => {
|
||||
@@ -253,16 +225,6 @@ const ChatScrollContainer = React.memo(
|
||||
// Capture whether we were at bottom BEFORE content changed
|
||||
const wasAtBottom = isAtBottomRef.current;
|
||||
|
||||
// Update spacer for non-auto-scroll mode
|
||||
if (!autoScrollRef.current && anchorSelector) {
|
||||
const anchorElement = container.querySelector(
|
||||
anchorSelector
|
||||
) as HTMLElement;
|
||||
if (anchorElement) {
|
||||
setSpacerHeight(calcSpacerHeight(anchorElement));
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll: follow content if we were at bottom
|
||||
if (autoScrollRef.current && wasAtBottom) {
|
||||
// scrollToBottom handles isAutoScrollingRef and ref updates
|
||||
@@ -290,7 +252,7 @@ const ChatScrollContainer = React.memo(
|
||||
resizeObserver.disconnect();
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [anchorSelector, calcSpacerHeight, updateScrollState, scrollToBottom]);
|
||||
}, [updateScrollState, scrollToBottom]);
|
||||
|
||||
// Handle session changes and anchor changes
|
||||
useEffect(() => {
|
||||
@@ -329,13 +291,6 @@ const ChatScrollContainer = React.memo(
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate spacer
|
||||
if (!autoScrollRef.current) {
|
||||
setSpacerHeight(calcSpacerHeight(anchorElement));
|
||||
} else {
|
||||
setSpacerHeight(0);
|
||||
}
|
||||
|
||||
// Determine scroll behavior
|
||||
// New session with existing content = instant, new anchor = smooth
|
||||
const isLoadingExistingContent =
|
||||
@@ -344,12 +299,21 @@ const ChatScrollContainer = React.memo(
|
||||
? "instant"
|
||||
: "smooth";
|
||||
|
||||
// Defer scroll to next tick so spacer height takes effect
|
||||
// Defer scroll to next tick for layout to settle
|
||||
const timeoutId = setTimeout(() => {
|
||||
const targetScrollTop = Math.max(
|
||||
0,
|
||||
anchorElement.offsetTop - anchorOffsetPx
|
||||
);
|
||||
let targetScrollTop: number;
|
||||
|
||||
// When loading an existing conversation, scroll to bottom
|
||||
// Otherwise (e.g., anchor change during conversation), scroll to anchor
|
||||
if (isLoadingExistingContent) {
|
||||
targetScrollTop = container.scrollHeight - container.clientHeight;
|
||||
} else {
|
||||
targetScrollTop = Math.max(
|
||||
0,
|
||||
anchorElement.offsetTop - anchorOffsetPx
|
||||
);
|
||||
}
|
||||
|
||||
container.scrollTo({ top: targetScrollTop, behavior });
|
||||
|
||||
// Update prevScrollTopRef so scroll direction is measured from new position
|
||||
@@ -357,9 +321,8 @@ const ChatScrollContainer = React.memo(
|
||||
|
||||
updateScrollState();
|
||||
|
||||
// When autoScroll is on, assume we're "at bottom" after positioning
|
||||
// so that MutationObserver will continue auto-scrolling
|
||||
if (autoScrollRef.current) {
|
||||
// Mark as "at bottom" after scrolling to bottom so auto-scroll continues
|
||||
if (isLoadingExistingContent || autoScrollRef.current) {
|
||||
isAtBottomRef.current = true;
|
||||
}
|
||||
|
||||
@@ -369,13 +332,7 @@ const ChatScrollContainer = React.memo(
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [
|
||||
sessionId,
|
||||
anchorSelector,
|
||||
anchorOffsetPx,
|
||||
calcSpacerHeight,
|
||||
updateScrollState,
|
||||
]);
|
||||
}, [sessionId, anchorSelector, anchorOffsetPx, updateScrollState]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 w-full relative overflow-hidden mb-[7.5rem]">
|
||||
@@ -400,13 +357,8 @@ const ChatScrollContainer = React.memo(
|
||||
>
|
||||
{children}
|
||||
|
||||
{/* End marker - before spacer so we can measure content end */}
|
||||
{/* End marker to measure content end */}
|
||||
<div ref={endDivRef} />
|
||||
|
||||
{/* Spacer to allow scrolling anchor to top */}
|
||||
{spacerHeight > 0 && (
|
||||
<div style={{ height: spacerHeight }} aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +113,7 @@ const MessageList = React.memo(
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-[min(50rem,100%)] px-6">
|
||||
<div className="w-[min(50rem,100%)] h-full px-6 rounded-2xl backdrop-blur-md">
|
||||
<Spacer />
|
||||
{messages.map((message, i) => {
|
||||
const messageReactComponentKey = `message-${message.nodeId}`;
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import ErrorPageLayout from "@/components/errorPages/ErrorPageLayout";
|
||||
import { fetchCustomerPortal } from "@/lib/billing/utils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import InlineExternalLink from "@/refresh-components/InlineExternalLink";
|
||||
import { logout } from "@/lib/user";
|
||||
@@ -33,37 +31,6 @@ const fetchResubscriptionSession = async () => {
|
||||
export default function AccessRestricted() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleManageSubscription = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetchCustomerPortal();
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
`Failed to create customer portal session: ${
|
||||
errorData.message || response.statusText
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
const { url } = await response.json();
|
||||
|
||||
if (!url) {
|
||||
throw new Error("No portal URL returned from the server");
|
||||
}
|
||||
|
||||
router.push(url);
|
||||
} catch (error) {
|
||||
console.error("Error creating customer portal session:", error);
|
||||
setError("Error opening customer portal. Please try again later.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResubscribe = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -119,13 +86,6 @@ export default function AccessRestricted() {
|
||||
<Button onClick={handleResubscribe} disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : "Resubscribe"}
|
||||
</Button>
|
||||
<Button
|
||||
secondary
|
||||
onClick={handleManageSubscription}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Manage Existing Subscription
|
||||
</Button>
|
||||
<Button
|
||||
secondary
|
||||
onClick={async () => {
|
||||
|
||||
@@ -871,7 +871,7 @@ export const MicrosoftIconSVG = createLogoIcon(microsoftSVG);
|
||||
export const MistralIcon = createLogoIcon(mistralSVG);
|
||||
export const MixedBreadIcon = createLogoIcon(mixedBreadSVG);
|
||||
export const NomicIcon = createLogoIcon(nomicSVG);
|
||||
export const CodaIcon = createLogoIcon(codaIcon, { monochromatic: true });
|
||||
export const CodaIcon = createLogoIcon(codaIcon);
|
||||
export const NotionIcon = createLogoIcon(notionIcon, { monochromatic: true });
|
||||
export const OCIStorageIcon = createLogoIcon(OCIStorageSVG);
|
||||
export const OllamaIcon = createLogoIcon(ollamaIcon);
|
||||
|
||||
@@ -119,7 +119,7 @@ function AppFooter() {
|
||||
}](https://www.onyx.app/) - Open Source AI Platform`;
|
||||
|
||||
return (
|
||||
<footer className="w-full flex flex-row justify-center items-center gap-2 pb-2">
|
||||
<footer className="w-full flex flex-row justify-center items-center gap-2 pb-2 mt-auto">
|
||||
<MinimalMarkdown
|
||||
content={customFooterContent}
|
||||
className={cn("max-w-full text-center")}
|
||||
|
||||
@@ -983,9 +983,10 @@ export default function AgentEditorPage({
|
||||
const hasUploadingFiles = values.user_file_ids.some(
|
||||
(fileId: string) => {
|
||||
const status = fileStatusMap.get(fileId);
|
||||
return (
|
||||
status === undefined || status === UserFileStatus.UPLOADING
|
||||
);
|
||||
if (status === undefined) {
|
||||
return fileId.startsWith("temp_");
|
||||
}
|
||||
return status === UserFileStatus.UPLOADING;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
SvgUsers,
|
||||
SvgZoomIn,
|
||||
SvgPaintBrush,
|
||||
SvgDiscordMono,
|
||||
} from "@opal/icons";
|
||||
import SvgMcp from "@opal/icons/mcp";
|
||||
const connectors_items = () => [
|
||||
@@ -90,11 +91,18 @@ const custom_assistants_items = (
|
||||
];
|
||||
|
||||
if (!isCurator) {
|
||||
items.push({
|
||||
name: "Slack Bots",
|
||||
icon: SlackIconSkeleton,
|
||||
link: "/admin/bots",
|
||||
});
|
||||
items.push(
|
||||
{
|
||||
name: "Slack Bots",
|
||||
icon: SlackIconSkeleton,
|
||||
link: "/admin/bots",
|
||||
},
|
||||
{
|
||||
name: "Discord Bots",
|
||||
icon: SvgDiscordMono,
|
||||
link: "/admin/discord-bot",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
items.push(
|
||||
|
||||
@@ -442,7 +442,7 @@ const ChatButton = memo(
|
||||
>
|
||||
<Popover.Anchor>
|
||||
<SidebarTab
|
||||
href={`/chat?chatId=${chatSession.id}`}
|
||||
href={isDragging ? undefined : `/chat?chatId=${chatSession.id}`}
|
||||
onClick={handleClick}
|
||||
transient={active}
|
||||
rightChildren={rightMenu}
|
||||
|
||||
105
web/tests/e2e/admin/discord-bot/admin-workflows.spec.ts
Normal file
105
web/tests/e2e/admin/discord-bot/admin-workflows.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* E2E tests for Discord bot admin workflow flows.
|
||||
*
|
||||
* These tests verify complete user journeys that span multiple pages/components.
|
||||
* Individual component tests are in their respective spec files.
|
||||
*/
|
||||
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
gotoDiscordBotPage,
|
||||
gotoGuildDetailPage,
|
||||
} from "./fixtures";
|
||||
|
||||
// Disable retries for Discord bot tests - attempt once at most
|
||||
test.describe.configure({ retries: 0 });
|
||||
|
||||
test.describe("Admin Workflow E2E Flows", () => {
|
||||
test("complete setup and configuration flow", async ({
|
||||
adminPage,
|
||||
mockRegisteredGuild,
|
||||
mockBotConfigured: _mockBotConfigured,
|
||||
}) => {
|
||||
// Start at list page
|
||||
await gotoDiscordBotPage(adminPage);
|
||||
|
||||
// Verify list page loads
|
||||
await expect(
|
||||
adminPage
|
||||
.locator('[aria-label="admin-page-title"]')
|
||||
.getByText("Discord Bots")
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
adminPage.locator("text=Server Configurations").first()
|
||||
).toBeVisible();
|
||||
|
||||
// Navigate to guild detail page
|
||||
const guildButton = adminPage.locator(
|
||||
`button:has-text("${mockRegisteredGuild.name}")`
|
||||
);
|
||||
await expect(guildButton).toBeVisible({ timeout: 10000 });
|
||||
await guildButton.click();
|
||||
|
||||
// Verify detail page loads
|
||||
await expect(adminPage).toHaveURL(
|
||||
new RegExp(`/admin/discord-bot/${mockRegisteredGuild.id}`)
|
||||
);
|
||||
await expect(
|
||||
adminPage.locator("text=Channel Configuration").first()
|
||||
).toBeVisible();
|
||||
|
||||
// Configure a channel: toggle enabled, show unsaved changes, save
|
||||
const channelRow = adminPage.locator("tbody tr").first();
|
||||
await expect(channelRow).toBeVisible();
|
||||
|
||||
const enableToggle = channelRow.locator('[role="switch"]').first();
|
||||
if (await enableToggle.isVisible()) {
|
||||
const initialState = await enableToggle.getAttribute("aria-checked");
|
||||
await enableToggle.click();
|
||||
|
||||
await expect(enableToggle).toHaveAttribute(
|
||||
"aria-checked",
|
||||
initialState === "true" ? "false" : "true"
|
||||
);
|
||||
}
|
||||
|
||||
// Verify unsaved changes indicator
|
||||
await expect(
|
||||
adminPage.locator("text=You have unsaved changes")
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Save changes - wait for the bulk update API call
|
||||
// Update button is now in the header
|
||||
const updateButton = adminPage.locator(
|
||||
'button:has-text("Update Configuration")'
|
||||
);
|
||||
// Verify button is visible and enabled before clicking
|
||||
await expect(updateButton).toBeEnabled({ timeout: 5000 });
|
||||
|
||||
const bulkUpdatePromise = adminPage.waitForResponse(
|
||||
(response) =>
|
||||
response
|
||||
.url()
|
||||
.includes(
|
||||
`/api/manage/admin/discord-bot/guilds/${mockRegisteredGuild.id}/channels`
|
||||
) && response.request().method() === "PATCH"
|
||||
);
|
||||
|
||||
await updateButton.click();
|
||||
await bulkUpdatePromise;
|
||||
|
||||
// Verify success toast
|
||||
const successToast = adminPage.locator("text=/updated/i");
|
||||
await expect(successToast).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Navigate back to list
|
||||
const backButton = adminPage.locator(
|
||||
'button:has-text("Back"), a:has-text("Back"), button[aria-label*="back" i]'
|
||||
);
|
||||
if (await backButton.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await backButton.click();
|
||||
await expect(adminPage).toHaveURL(/\/admin\/discord-bot$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
143
web/tests/e2e/admin/discord-bot/bot-config.spec.ts
Normal file
143
web/tests/e2e/admin/discord-bot/bot-config.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* E2E tests for Discord bot configuration page.
|
||||
*
|
||||
* Tests the bot token configuration card which allows admins to:
|
||||
* - Enter and save a Discord bot token
|
||||
* - View configuration status (Configured/Not Configured badge)
|
||||
* - Delete the bot token configuration
|
||||
*/
|
||||
|
||||
import { test, expect, gotoDiscordBotPage } from "./fixtures";
|
||||
|
||||
// Disable retries for Discord bot tests - attempt once at most
|
||||
test.describe.configure({ retries: 0 });
|
||||
|
||||
test.describe("Bot Configuration Page", () => {
|
||||
test("bot config page loads", async ({ adminPage }) => {
|
||||
await gotoDiscordBotPage(adminPage);
|
||||
|
||||
// Page should load without errors
|
||||
await expect(adminPage).toHaveURL(/\/admin\/discord-bot/);
|
||||
// Page title should contain "Discord"
|
||||
await expect(
|
||||
adminPage
|
||||
.locator('[aria-label="admin-page-title"]')
|
||||
.getByText("Discord Bots")
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("bot config shows token input when not configured", async ({
|
||||
adminPage,
|
||||
}) => {
|
||||
await gotoDiscordBotPage(adminPage);
|
||||
|
||||
// When not configured, should show:
|
||||
// - "Not Configured" badge OR
|
||||
// - Token input field with "Save Token" button
|
||||
const notConfiguredBadge = adminPage.locator("text=Not Configured");
|
||||
const tokenInput = adminPage.locator('input[placeholder*="token" i]');
|
||||
const saveTokenButton = adminPage.locator('button:has-text("Save Token")');
|
||||
|
||||
// Either not configured state with input, or already configured
|
||||
const configuredBadge = adminPage.locator("text=Configured").first();
|
||||
|
||||
// Check that at least one of the states is visible
|
||||
// Check configured state first, then fall back to not configured state
|
||||
const isConfigured = await configuredBadge
|
||||
.isVisible({ timeout: 5000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (isConfigured) {
|
||||
// Bot is configured - verify configured badge is visible
|
||||
await expect(configuredBadge).toBeVisible();
|
||||
} else {
|
||||
// Bot is not configured - verify not configured badge and input are visible
|
||||
await expect(notConfiguredBadge).toBeVisible({ timeout: 10000 });
|
||||
await expect(tokenInput).toBeVisible();
|
||||
await expect(saveTokenButton).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("bot config save token validation", async ({ adminPage }) => {
|
||||
await gotoDiscordBotPage(adminPage);
|
||||
|
||||
const tokenInput = adminPage.locator('input[placeholder*="token" i]');
|
||||
const saveTokenButton = adminPage.locator('button:has-text("Save Token")');
|
||||
|
||||
// Only run if token input is visible (not already configured)
|
||||
if (await tokenInput.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
// Save button should be disabled when input is empty
|
||||
await expect(saveTokenButton).toBeDisabled();
|
||||
|
||||
// Enter a token
|
||||
await tokenInput.fill("test_bot_token_12345");
|
||||
|
||||
// Save button should now be enabled
|
||||
await expect(saveTokenButton).toBeEnabled();
|
||||
|
||||
// Clear input
|
||||
await tokenInput.clear();
|
||||
|
||||
// Button should be disabled again
|
||||
await expect(saveTokenButton).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
test("bot config shows configured state", async ({
|
||||
adminPage,
|
||||
mockBotConfigured,
|
||||
}) => {
|
||||
await gotoDiscordBotPage(adminPage);
|
||||
|
||||
// With mockBotConfigured, should show configured state
|
||||
const configuredBadge = adminPage.locator("text=Configured").first();
|
||||
const deleteButton = adminPage.locator(
|
||||
'button:has-text("Delete Discord Token")'
|
||||
);
|
||||
|
||||
// Should show configured badge
|
||||
await expect(configuredBadge).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Should show delete button when configured
|
||||
await expect(deleteButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("bot config delete shows confirmation modal", async ({
|
||||
adminPage,
|
||||
mockBotConfigured,
|
||||
}) => {
|
||||
await gotoDiscordBotPage(adminPage);
|
||||
|
||||
// Wait for configured state to be visible
|
||||
const configuredBadge = adminPage.locator("text=Configured").first();
|
||||
await expect(configuredBadge).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Find and click delete button
|
||||
const deleteButton = adminPage.locator(
|
||||
'button:has-text("Delete Discord Token")'
|
||||
);
|
||||
await expect(deleteButton).toBeVisible();
|
||||
await deleteButton.click();
|
||||
|
||||
// Confirmation modal should appear
|
||||
const modal = adminPage.locator('[role="dialog"]');
|
||||
await expect(modal).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Modal should have cancel and confirm buttons
|
||||
const cancelButton = adminPage.locator('button:has-text("Cancel")');
|
||||
const confirmButton = adminPage.locator(
|
||||
'button:has-text("Delete"), button:has-text("Confirm")'
|
||||
);
|
||||
|
||||
// At least one of these buttons should be visible
|
||||
await expect(cancelButton.or(confirmButton).first()).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Cancel to avoid actually deleting
|
||||
if (await cancelButton.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await cancelButton.click();
|
||||
await expect(modal).not.toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
291
web/tests/e2e/admin/discord-bot/channel-config.spec.ts
Normal file
291
web/tests/e2e/admin/discord-bot/channel-config.spec.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* E2E tests for Discord guild detail page and channel configuration.
|
||||
*
|
||||
* Tests the guild detail page which includes:
|
||||
* - Guild enabled/disabled toggle
|
||||
* - Default Agent (persona) selector
|
||||
* - Channel Configuration section with:
|
||||
* - List of channels with icons (text/forum)
|
||||
* - Enabled toggle per channel
|
||||
* - Require @mention toggle
|
||||
* - Thread Only Mode toggle
|
||||
* - Agent Override dropdown
|
||||
*/
|
||||
|
||||
import { test, expect, gotoGuildDetailPage } from "./fixtures";
|
||||
|
||||
// Disable retries for Discord bot tests - attempt once at most
|
||||
test.describe.configure({ retries: 0 });
|
||||
|
||||
test.describe("Guild Detail Page & Channel Configuration", () => {
|
||||
test("guild detail page loads", async ({
|
||||
adminPage,
|
||||
mockRegisteredGuild,
|
||||
}) => {
|
||||
await gotoGuildDetailPage(adminPage, mockRegisteredGuild.id);
|
||||
|
||||
// Page should load with guild info
|
||||
await expect(adminPage).toHaveURL(
|
||||
new RegExp(`/admin/discord-bot/${mockRegisteredGuild.id}`)
|
||||
);
|
||||
|
||||
// Should show the guild name in the header
|
||||
await expect(
|
||||
adminPage.locator(`text=${mockRegisteredGuild.name}`)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("guild default agent dropdown shows options", async ({
|
||||
adminPage,
|
||||
mockRegisteredGuild,
|
||||
}) => {
|
||||
await gotoGuildDetailPage(adminPage, mockRegisteredGuild.id);
|
||||
|
||||
// Should show "Default Agent" section
|
||||
await expect(adminPage.locator("text=Default Agent")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Find the persona/agent dropdown (InputSelect)
|
||||
const agentDropdown = adminPage.locator(
|
||||
'button:has-text("Default Assistant")'
|
||||
);
|
||||
|
||||
if (await agentDropdown.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await agentDropdown.click();
|
||||
|
||||
// Dropdown should show available options
|
||||
const options = adminPage.locator('[role="option"]');
|
||||
await expect(options.first()).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Channel Configuration", () => {
|
||||
test("channels table displays with action buttons", async ({
|
||||
adminPage,
|
||||
mockRegisteredGuild,
|
||||
}) => {
|
||||
await gotoGuildDetailPage(adminPage, mockRegisteredGuild.id);
|
||||
|
||||
// Channel list table should be visible
|
||||
const channelTable = adminPage.locator("table");
|
||||
await expect(channelTable).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Should show our mock channels
|
||||
await expect(adminPage.locator("text=general")).toBeVisible();
|
||||
await expect(adminPage.locator("text=help-forum")).toBeVisible();
|
||||
await expect(adminPage.locator("text=private-support")).toBeVisible();
|
||||
|
||||
// Should show action buttons
|
||||
await expect(
|
||||
adminPage.locator('button:has-text("Enable All")')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
adminPage.locator('button:has-text("Disable All")')
|
||||
).toBeVisible();
|
||||
// Update button is now in the header, not in the channel config section
|
||||
await expect(
|
||||
adminPage.locator('button:has-text("Update Configuration")')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("channels table has correct columns", async ({
|
||||
adminPage,
|
||||
mockRegisteredGuild,
|
||||
}) => {
|
||||
await gotoGuildDetailPage(adminPage, mockRegisteredGuild.id);
|
||||
|
||||
// Table headers should be visible
|
||||
await expect(adminPage.locator("th:has-text('Channel')")).toBeVisible();
|
||||
await expect(adminPage.locator("th:has-text('Enabled')")).toBeVisible();
|
||||
await expect(
|
||||
adminPage.locator("th:has-text('Require @mention')")
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
adminPage.locator("th:has-text('Thread Only Mode')")
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
adminPage.locator("th:has-text('Agent Override')")
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("channel enabled toggle updates state", async ({
|
||||
adminPage,
|
||||
mockRegisteredGuild,
|
||||
}) => {
|
||||
await gotoGuildDetailPage(adminPage, mockRegisteredGuild.id);
|
||||
|
||||
// Find the row for "general" channel
|
||||
const generalRow = adminPage.locator("tr").filter({
|
||||
hasText: "general",
|
||||
});
|
||||
|
||||
// Find the first switch in that row (Enabled toggle)
|
||||
const enabledToggle = generalRow.locator('[role="switch"]').first();
|
||||
await expect(enabledToggle).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Get initial state
|
||||
const initialState = await enabledToggle.getAttribute("aria-checked");
|
||||
|
||||
// Click to toggle
|
||||
await enabledToggle.click();
|
||||
|
||||
// State should change (local state update)
|
||||
await expect(enabledToggle).toHaveAttribute(
|
||||
"aria-checked",
|
||||
initialState === "true" ? "false" : "true"
|
||||
);
|
||||
});
|
||||
|
||||
test("channel require mention toggle works", async ({
|
||||
adminPage,
|
||||
mockRegisteredGuild,
|
||||
}) => {
|
||||
await gotoGuildDetailPage(adminPage, mockRegisteredGuild.id);
|
||||
|
||||
// Find the row for "general" channel
|
||||
const generalRow = adminPage.locator("tr").filter({
|
||||
hasText: "general",
|
||||
});
|
||||
|
||||
// Find switches - second one should be "require @mention"
|
||||
const switches = generalRow.locator('[role="switch"]');
|
||||
const requireMentionToggle = switches.nth(1);
|
||||
|
||||
await expect(requireMentionToggle).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Get initial state
|
||||
const initialState =
|
||||
await requireMentionToggle.getAttribute("aria-checked");
|
||||
|
||||
// Click to toggle
|
||||
await requireMentionToggle.click();
|
||||
|
||||
// State should change
|
||||
await expect(requireMentionToggle).toHaveAttribute(
|
||||
"aria-checked",
|
||||
initialState === "true" ? "false" : "true"
|
||||
);
|
||||
});
|
||||
|
||||
test("channel thread only mode toggle works for text channels", async ({
|
||||
adminPage,
|
||||
mockRegisteredGuild,
|
||||
}) => {
|
||||
await gotoGuildDetailPage(adminPage, mockRegisteredGuild.id);
|
||||
|
||||
// Find the row for "general" channel (text type)
|
||||
const generalRow = adminPage.locator("tr").filter({
|
||||
hasText: "general",
|
||||
});
|
||||
|
||||
// Find switches - third one should be "thread only mode"
|
||||
const switches = generalRow.locator('[role="switch"]');
|
||||
const threadOnlyToggle = switches.nth(2);
|
||||
|
||||
await expect(threadOnlyToggle).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Toggle should be clickable for text channels
|
||||
await threadOnlyToggle.click();
|
||||
|
||||
// Verify it changed
|
||||
const newState = await threadOnlyToggle.getAttribute("aria-checked");
|
||||
expect(newState).toBe("true");
|
||||
});
|
||||
|
||||
test("forum channels do not show thread only toggle", async ({
|
||||
adminPage,
|
||||
mockRegisteredGuild,
|
||||
}) => {
|
||||
await gotoGuildDetailPage(adminPage, mockRegisteredGuild.id);
|
||||
|
||||
// Find the row for "help-forum" channel (forum type)
|
||||
const forumRow = adminPage.locator("tr").filter({
|
||||
hasText: "help-forum",
|
||||
});
|
||||
|
||||
// Forum channels should only have 2 switches (Enabled, Require @mention)
|
||||
// Thread Only Mode is not applicable to forums
|
||||
const switches = forumRow.locator('[role="switch"]');
|
||||
const count = await switches.count();
|
||||
|
||||
// Should have fewer switches than text channels (2 vs 3)
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
test("enable all button works", async ({
|
||||
adminPage,
|
||||
mockRegisteredGuild,
|
||||
}) => {
|
||||
await gotoGuildDetailPage(adminPage, mockRegisteredGuild.id);
|
||||
|
||||
const enableAllButton = adminPage.locator('button:has-text("Enable All")');
|
||||
await expect(enableAllButton).toBeVisible({ timeout: 10000 });
|
||||
await enableAllButton.click();
|
||||
|
||||
// Wait for UI to update - all enabled toggles should be checked
|
||||
const rows = adminPage.locator("tbody tr");
|
||||
const rowCount = await rows.count();
|
||||
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
const toggle = rows.nth(i).locator('[role="switch"]').first();
|
||||
if (await toggle.isVisible()) {
|
||||
await expect(toggle).toHaveAttribute("aria-checked", "true");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("disable all button works", async ({
|
||||
adminPage,
|
||||
mockRegisteredGuild,
|
||||
}) => {
|
||||
await gotoGuildDetailPage(adminPage, mockRegisteredGuild.id);
|
||||
|
||||
const disableAllButton = adminPage.locator(
|
||||
'button:has-text("Disable All")'
|
||||
);
|
||||
await expect(disableAllButton).toBeVisible({ timeout: 10000 });
|
||||
await disableAllButton.click();
|
||||
|
||||
// Wait for UI to update - all enabled toggles should be unchecked
|
||||
const rows = adminPage.locator("tbody tr");
|
||||
const rowCount = await rows.count();
|
||||
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
const toggle = rows.nth(i).locator('[role="switch"]').first();
|
||||
if (await toggle.isVisible()) {
|
||||
await expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("unsaved changes indicator appears", async ({
|
||||
adminPage,
|
||||
mockRegisteredGuild,
|
||||
}) => {
|
||||
await gotoGuildDetailPage(adminPage, mockRegisteredGuild.id);
|
||||
|
||||
// Find the unsaved changes message container (always in DOM, hidden with opacity-0)
|
||||
const unsavedMessage = adminPage.locator("text=You have unsaved changes");
|
||||
// The container div has class "sticky" and controls visibility via opacity
|
||||
const messageContainer = adminPage
|
||||
.locator("div.sticky")
|
||||
.filter({ has: unsavedMessage })
|
||||
.first();
|
||||
|
||||
// Initially hidden (opacity-0)
|
||||
await expect(messageContainer).toHaveCSS("opacity", "0");
|
||||
|
||||
// Make a change
|
||||
const generalRow = adminPage.locator("tr").filter({
|
||||
hasText: "general",
|
||||
});
|
||||
const enabledToggle = generalRow.locator('[role="switch"]').first();
|
||||
await enabledToggle.click();
|
||||
|
||||
// Unsaved changes indicator should appear (opacity-100)
|
||||
await expect(messageContainer).toHaveCSS("opacity", "1", { timeout: 5000 });
|
||||
await expect(unsavedMessage).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
320
web/tests/e2e/admin/discord-bot/fixtures.ts
Normal file
320
web/tests/e2e/admin/discord-bot/fixtures.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Playwright fixtures for Discord bot admin UI tests.
|
||||
*
|
||||
* These fixtures provide:
|
||||
* - Authenticated admin page
|
||||
* - API client for backend operations
|
||||
* - Mock data for guilds and channels (since real Discord integration isn't available in tests)
|
||||
*/
|
||||
|
||||
import { test as base, expect, Page } from "@playwright/test";
|
||||
import { loginAs } from "@tests/e2e/utils/auth";
|
||||
import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient";
|
||||
|
||||
/**
|
||||
* Mock data types matching backend response schemas
|
||||
*/
|
||||
interface MockGuild {
|
||||
id: number;
|
||||
guild_id: string | null;
|
||||
guild_name: string | null;
|
||||
registration_key: string;
|
||||
registered_at: string | null;
|
||||
enabled: boolean;
|
||||
default_persona_id: number | null;
|
||||
}
|
||||
|
||||
interface MockChannel {
|
||||
id: number;
|
||||
channel_id: string;
|
||||
channel_name: string;
|
||||
channel_type: "text" | "forum";
|
||||
is_private: boolean;
|
||||
enabled: boolean;
|
||||
require_bot_invocation: boolean;
|
||||
thread_only_mode: boolean;
|
||||
persona_override_id: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constants for mock data
|
||||
*/
|
||||
const MOCK_GUILD_ID = 999;
|
||||
|
||||
/**
|
||||
* Helper to authenticate and clear cookies
|
||||
*/
|
||||
async function authenticateAdmin(page: Page): Promise<void> {
|
||||
await page.context().clearCookies();
|
||||
await loginAs(page, "admin");
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create JSON response
|
||||
*/
|
||||
function jsonResponse(data: unknown, status = 200) {
|
||||
return {
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(data),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates mock channel data for a registered guild
|
||||
*/
|
||||
function createMockChannels(): MockChannel[] {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
channel_id: "1234567890123456789",
|
||||
channel_name: "general",
|
||||
channel_type: "text",
|
||||
is_private: false,
|
||||
enabled: true,
|
||||
require_bot_invocation: false,
|
||||
thread_only_mode: false,
|
||||
persona_override_id: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
channel_id: "1234567890123456790",
|
||||
channel_name: "help-forum",
|
||||
channel_type: "forum",
|
||||
is_private: false,
|
||||
enabled: false,
|
||||
require_bot_invocation: true,
|
||||
thread_only_mode: false,
|
||||
persona_override_id: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
channel_id: "1234567890123456791",
|
||||
channel_name: "private-support",
|
||||
channel_type: "text",
|
||||
is_private: true,
|
||||
enabled: true,
|
||||
require_bot_invocation: true,
|
||||
thread_only_mode: true,
|
||||
persona_override_id: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock registered guild
|
||||
*/
|
||||
function createMockRegisteredGuild(id: number): MockGuild {
|
||||
return {
|
||||
id,
|
||||
guild_id: "987654321098765432",
|
||||
guild_name: "Test Discord Server",
|
||||
registration_key: "test-key-12345",
|
||||
registered_at: new Date().toISOString(),
|
||||
enabled: true,
|
||||
default_persona_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock pending guild (not yet registered)
|
||||
*/
|
||||
function createMockPendingGuild(id: number): MockGuild {
|
||||
return {
|
||||
id,
|
||||
guild_id: null,
|
||||
guild_name: null,
|
||||
registration_key: "pending-key-67890",
|
||||
registered_at: null,
|
||||
enabled: false,
|
||||
default_persona_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Extend base test with Discord bot fixtures
|
||||
export const test = base.extend<{
|
||||
adminPage: Page;
|
||||
apiClient: OnyxApiClient;
|
||||
seededGuild: { id: number; name: string; registrationKey: string };
|
||||
mockRegisteredGuild: {
|
||||
id: number;
|
||||
name: string;
|
||||
guild: MockGuild;
|
||||
channels: MockChannel[];
|
||||
};
|
||||
mockBotConfigured: boolean;
|
||||
}>({
|
||||
// Admin page fixture - ensures proper authentication before each test
|
||||
adminPage: async ({ page }, use) => {
|
||||
await authenticateAdmin(page);
|
||||
await use(page);
|
||||
},
|
||||
|
||||
// API client fixture - provides access to OnyxApiClient for backend operations
|
||||
apiClient: async ({ page }, use) => {
|
||||
await authenticateAdmin(page);
|
||||
const client = new OnyxApiClient(page);
|
||||
await use(client);
|
||||
},
|
||||
|
||||
// Seeded guild fixture - creates a real pending guild via API
|
||||
seededGuild: async ({ page }, use) => {
|
||||
await authenticateAdmin(page);
|
||||
|
||||
const apiClient = new OnyxApiClient(page);
|
||||
const guild = await apiClient.createDiscordGuild();
|
||||
|
||||
await use({
|
||||
id: guild.id,
|
||||
name: guild.guild_name || "Pending",
|
||||
registrationKey: guild.registration_key,
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
await apiClient.deleteDiscordGuild(guild.id);
|
||||
},
|
||||
|
||||
// Mock registered guild fixture - provides a fully mocked registered guild with channels
|
||||
// This intercepts API calls to simulate a registered guild without needing Discord
|
||||
mockRegisteredGuild: async ({ page }, use) => {
|
||||
await authenticateAdmin(page);
|
||||
|
||||
// Use a mutable object so we can update it when PATCH requests come in
|
||||
let mockGuild = createMockRegisteredGuild(MOCK_GUILD_ID);
|
||||
const mockChannels = createMockChannels();
|
||||
|
||||
// Mock the guild list endpoint
|
||||
await page.route(
|
||||
"**/api/manage/admin/discord-bot/guilds",
|
||||
async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
await route.fulfill(jsonResponse([mockGuild]));
|
||||
} else if (method === "POST") {
|
||||
// Allow creating new guilds - return a new pending guild
|
||||
const newGuild = createMockPendingGuild(MOCK_GUILD_ID + 1);
|
||||
await route.fulfill(jsonResponse(newGuild));
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Mock the specific guild endpoint
|
||||
await page.route(
|
||||
`**/api/manage/admin/discord-bot/guilds/${MOCK_GUILD_ID}`,
|
||||
async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
await route.fulfill(jsonResponse(mockGuild));
|
||||
} else if (method === "PATCH") {
|
||||
// Handle updates - merge with current state and update mockGuild
|
||||
const body = (await route.request().postDataJSON()) || {};
|
||||
mockGuild = { ...mockGuild, ...body };
|
||||
await route.fulfill(jsonResponse(mockGuild));
|
||||
} else if (method === "DELETE") {
|
||||
await route.fulfill({ status: 204, body: "" });
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Mock the channels endpoint for this guild
|
||||
await page.route(
|
||||
`**/api/manage/admin/discord-bot/guilds/${MOCK_GUILD_ID}/channels`,
|
||||
async (route) => {
|
||||
await route.fulfill(jsonResponse(mockChannels));
|
||||
}
|
||||
);
|
||||
|
||||
// Mock channel update endpoint
|
||||
await page.route(
|
||||
`**/api/manage/admin/discord-bot/guilds/${MOCK_GUILD_ID}/channels/*`,
|
||||
async (route) => {
|
||||
if (route.request().method() === "PATCH") {
|
||||
const body = (await route.request().postDataJSON()) || {};
|
||||
// Extract channel ID from URL: .../channels/{id}
|
||||
const urlMatch = route
|
||||
.request()
|
||||
.url()
|
||||
.match(/\/channels\/(\d+)/);
|
||||
const channelIdStr = urlMatch?.[1];
|
||||
const channelId = channelIdStr ? parseInt(channelIdStr, 10) : null;
|
||||
const channel = channelId
|
||||
? mockChannels.find((c) => c.id === channelId)
|
||||
: null;
|
||||
|
||||
if (channel) {
|
||||
const updatedChannel = { ...channel, ...body };
|
||||
await route.fulfill(jsonResponse(updatedChannel));
|
||||
} else {
|
||||
await route.fulfill(
|
||||
jsonResponse({ error: "Channel not found" }, 404)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await use({
|
||||
id: MOCK_GUILD_ID,
|
||||
name: mockGuild.guild_name!,
|
||||
guild: mockGuild,
|
||||
channels: mockChannels,
|
||||
});
|
||||
|
||||
// No cleanup needed - routes are automatically cleared when page closes
|
||||
},
|
||||
|
||||
// Mock bot configuration state
|
||||
mockBotConfigured: async ({ page }, use) => {
|
||||
const configResponse = {
|
||||
configured: true,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await page.route(
|
||||
"**/api/manage/admin/discord-bot/config",
|
||||
async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET" || method === "POST") {
|
||||
await route.fulfill(jsonResponse(configResponse));
|
||||
} else if (method === "DELETE") {
|
||||
await route.fulfill({ status: 204, body: "" });
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await use(true);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
|
||||
/**
|
||||
* Navigation helpers for Discord bot pages.
|
||||
* These wait for specific UI elements that indicate the page has loaded.
|
||||
*/
|
||||
export async function gotoDiscordBotPage(adminPage: Page): Promise<void> {
|
||||
await adminPage.goto("/admin/discord-bot");
|
||||
await adminPage.waitForLoadState("networkidle");
|
||||
// Wait for the page title
|
||||
await adminPage.waitForSelector("text=Discord Bots", { timeout: 15000 });
|
||||
}
|
||||
|
||||
export async function gotoGuildDetailPage(
|
||||
adminPage: Page,
|
||||
guildId: number
|
||||
): Promise<void> {
|
||||
await adminPage.goto(`/admin/discord-bot/${guildId}`);
|
||||
await adminPage.waitForLoadState("networkidle");
|
||||
// Wait for Channel Configuration section (the main content area on guild detail page)
|
||||
await adminPage.waitForSelector("text=Channel Configuration", {
|
||||
timeout: 15000,
|
||||
});
|
||||
}
|
||||
311
web/tests/e2e/admin/discord-bot/guilds-list.spec.ts
Normal file
311
web/tests/e2e/admin/discord-bot/guilds-list.spec.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* E2E tests for Discord guilds list page.
|
||||
*
|
||||
* Tests the server configurations table which shows:
|
||||
* - List of registered and pending Discord servers
|
||||
* - Status badges (Registered/Pending)
|
||||
* - Enabled/Disabled status
|
||||
* - Add Server and Delete actions
|
||||
*/
|
||||
|
||||
import { test, expect, gotoDiscordBotPage } from "./fixtures";
|
||||
|
||||
// Disable retries for Discord bot tests - attempt once at most
|
||||
test.describe.configure({ retries: 0 });
|
||||
|
||||
test.describe("Guilds List Page", () => {
|
||||
test("guilds page shows server configurations", async ({ adminPage }) => {
|
||||
await gotoDiscordBotPage(adminPage);
|
||||
|
||||
// Should show Server Configurations section
|
||||
// Use .first() to avoid strict mode violation if it appears in multiple places
|
||||
const serverConfigSection = adminPage
|
||||
.locator("text=Server Configurations")
|
||||
.first();
|
||||
await expect(serverConfigSection).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("guilds page empty state", async ({ adminPage }) => {
|
||||
await gotoDiscordBotPage(adminPage);
|
||||
|
||||
// Should show either:
|
||||
// - "No Discord servers configured yet" empty message
|
||||
// - OR a table with servers
|
||||
// - OR Add Server button
|
||||
const emptyState = adminPage.locator(
|
||||
"text=No Discord servers configured yet"
|
||||
);
|
||||
const addButton = adminPage.locator('button:has-text("Add Server")');
|
||||
const serverTable = adminPage.locator("table");
|
||||
|
||||
// Check each state separately to avoid strict mode violation
|
||||
// (empty state and add button can both be visible when bot not configured)
|
||||
const hasEmptyState = await emptyState
|
||||
.isVisible({ timeout: 5000 })
|
||||
.catch(() => false);
|
||||
const hasAddButton = await addButton
|
||||
.isVisible({ timeout: 5000 })
|
||||
.catch(() => false);
|
||||
const hasTable = await serverTable
|
||||
.isVisible({ timeout: 5000 })
|
||||
.catch(() => false);
|
||||
|
||||
expect(hasEmptyState || hasAddButton || hasTable).toBe(true);
|
||||
});
|
||||
|
||||
test("guilds page shows mock registered guild", async ({
|
||||
adminPage,
|
||||
mockRegisteredGuild,
|
||||
}) => {
|
||||
await gotoDiscordBotPage(adminPage);
|
||||
|
||||
// Mock guild should appear in the list
|
||||
const guildName = adminPage.locator(`text=${mockRegisteredGuild.name}`);
|
||||
await expect(guildName).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Find the table row containing the guild to scope badges
|
||||
const tableRow = adminPage.locator("tr").filter({
|
||||
hasText: mockRegisteredGuild.name,
|
||||
});
|
||||
|
||||
// Should show Registered badge in the guild's row
|
||||
const registeredBadge = tableRow.locator("text=Registered");
|
||||
await expect(registeredBadge).toBeVisible();
|
||||
|
||||
// Should show enabled toggle switch in the guild's row (in Enabled column)
|
||||
const enabledSwitch = tableRow.locator('[role="switch"]').first();
|
||||
await expect(enabledSwitch).toBeVisible();
|
||||
await expect(enabledSwitch).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
test("guild enabled toggle works in table", async ({
|
||||
adminPage,
|
||||
mockRegisteredGuild,
|
||||
mockBotConfigured: _mockBotConfigured,
|
||||
}) => {
|
||||
await gotoDiscordBotPage(adminPage);
|
||||
|
||||
// Find the table row containing the guild
|
||||
const tableRow = adminPage.locator("tr").filter({
|
||||
hasText: mockRegisteredGuild.name,
|
||||
});
|
||||
await expect(tableRow).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Find the enabled toggle switch in that row
|
||||
const enabledSwitch = tableRow.locator('[role="switch"]').first();
|
||||
await expect(enabledSwitch).toBeVisible({ timeout: 10000 });
|
||||
await expect(enabledSwitch).toHaveAttribute("aria-checked", "true");
|
||||
await expect(enabledSwitch).toBeEnabled();
|
||||
|
||||
const initialState = await enabledSwitch.getAttribute("aria-checked");
|
||||
const expectedState = initialState === "true" ? "false" : "true";
|
||||
const guildUrl = `/api/manage/admin/discord-bot/guilds/${mockRegisteredGuild.id}`;
|
||||
const guildsListUrl = `/api/manage/admin/discord-bot/guilds`;
|
||||
|
||||
// Set up response waiters before clicking
|
||||
const patchPromise = adminPage.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(guildUrl) &&
|
||||
response.request().method() === "PATCH"
|
||||
);
|
||||
|
||||
// refreshGuilds() calls the list endpoint, not the individual guild endpoint
|
||||
const getPromise = adminPage.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(guildsListUrl) &&
|
||||
response.request().method() === "GET"
|
||||
);
|
||||
|
||||
await enabledSwitch.click();
|
||||
|
||||
// Wait for PATCH then GET (refreshGuilds) to complete
|
||||
await patchPromise;
|
||||
await getPromise;
|
||||
|
||||
// Verify the toggle state changed
|
||||
await expect(enabledSwitch).toHaveAttribute("aria-checked", expectedState);
|
||||
});
|
||||
|
||||
test("guilds page add server modal and copy key", async ({ adminPage }) => {
|
||||
await gotoDiscordBotPage(adminPage);
|
||||
|
||||
const addButton = adminPage.locator('button:has-text("Add Server")');
|
||||
|
||||
if (await addButton.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
// Button might be disabled if bot not configured
|
||||
if (await addButton.isEnabled()) {
|
||||
await addButton.click();
|
||||
|
||||
// Should show modal with registration key
|
||||
const modal = adminPage.locator('[role="dialog"]');
|
||||
await expect(modal).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Modal should show "Registration Key" title
|
||||
await expect(modal.getByText("Registration Key")).toBeVisible();
|
||||
|
||||
// Should show the !register command (scoped to modal)
|
||||
await expect(modal.getByText("!register")).toBeVisible();
|
||||
|
||||
// Find and click copy button
|
||||
const copyButton = adminPage.locator("button").filter({
|
||||
has: adminPage.locator("svg"),
|
||||
});
|
||||
|
||||
const copyButtons = await copyButton.all();
|
||||
for (const btn of copyButtons) {
|
||||
const ariaLabel = await btn.getAttribute("aria-label");
|
||||
if (ariaLabel?.toLowerCase().includes("copy")) {
|
||||
await btn.click();
|
||||
|
||||
// Toast notification should appear
|
||||
const toast = adminPage.locator("text=/copied/i");
|
||||
await expect(toast).toBeVisible({ timeout: 5000 });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("guilds page delete shows confirmation", async ({
|
||||
adminPage,
|
||||
mockRegisteredGuild,
|
||||
mockBotConfigured: _mockBotConfigured,
|
||||
}) => {
|
||||
await gotoDiscordBotPage(adminPage);
|
||||
|
||||
// Wait for table to load with mock guild
|
||||
await expect(
|
||||
adminPage.locator(`text=${mockRegisteredGuild.name}`)
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Wait for table to be fully loaded and stable
|
||||
await adminPage.waitForLoadState("networkidle");
|
||||
|
||||
// Find the table row containing the guild
|
||||
const tableRow = adminPage.locator("tr").filter({
|
||||
hasText: mockRegisteredGuild.name,
|
||||
});
|
||||
await expect(tableRow).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Find delete button in that row - it's an IconButton (last button in Actions column)
|
||||
// The DeleteButton uses IconButton with tooltip="Delete" and SvgTrash icon
|
||||
const deleteButton = tableRow.locator("button").last();
|
||||
|
||||
if (await deleteButton.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
// Ensure the button is visible and scrolled into view
|
||||
await deleteButton.scrollIntoViewIfNeeded();
|
||||
await deleteButton.waitFor({ state: "visible" });
|
||||
|
||||
// Wait for any animations/transitions to complete
|
||||
await adminPage.waitForTimeout(300);
|
||||
|
||||
// Use force click to bypass any overlay/interception issues
|
||||
// The SettingsLayouts.Body div may be intercepting pointer events
|
||||
await deleteButton.click({ force: true });
|
||||
|
||||
// Confirmation modal should appear
|
||||
const modal = adminPage.locator('[role="dialog"]');
|
||||
await expect(modal).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Cancel to avoid actually deleting
|
||||
const cancelButton = adminPage.locator('button:has-text("Cancel")');
|
||||
if (await cancelButton.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await cancelButton.click();
|
||||
await expect(modal).not.toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("guilds page navigate to guild detail", async ({
|
||||
adminPage,
|
||||
mockRegisteredGuild,
|
||||
mockBotConfigured: _mockBotConfigured,
|
||||
}) => {
|
||||
// Wait for bot config API to complete to ensure Card is enabled
|
||||
// The Card is disabled when bot is not configured
|
||||
// Set up the wait BEFORE navigation so we can catch the response
|
||||
const configResponsePromise = adminPage.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/api/manage/admin/discord-bot/config") &&
|
||||
response.request().method() === "GET"
|
||||
);
|
||||
|
||||
await gotoDiscordBotPage(adminPage);
|
||||
await configResponsePromise;
|
||||
|
||||
// Wait for table to load with mock guild
|
||||
const guildButton = adminPage.locator(
|
||||
`button:has-text("${mockRegisteredGuild.name}")`
|
||||
);
|
||||
await expect(guildButton).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Ensure button is enabled (it's disabled if bot not configured or guild not registered)
|
||||
// mockBotConfigured ensures bot is configured, mockRegisteredGuild ensures guild is registered
|
||||
await expect(guildButton).toBeEnabled();
|
||||
|
||||
// Click on the guild name to navigate to detail page
|
||||
await guildButton.click();
|
||||
|
||||
// Should navigate to guild detail page
|
||||
await expect(adminPage).toHaveURL(
|
||||
new RegExp(`/admin/discord-bot/${mockRegisteredGuild.id}`)
|
||||
);
|
||||
|
||||
// Verify detail page loaded correctly
|
||||
// "Channel Configuration" is in a LineItemLayout in the body content, not the page title
|
||||
await expect(
|
||||
adminPage.locator("text=Channel Configuration").first()
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("loading state shows loader", async ({ adminPage }) => {
|
||||
// Intercept API to delay response
|
||||
await adminPage.route(
|
||||
"**/api/manage/admin/discord-bot/**",
|
||||
async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
await route.continue();
|
||||
}
|
||||
);
|
||||
|
||||
await adminPage.goto("/admin/discord-bot");
|
||||
|
||||
// Should show loading indicator (ThreeDotsLoader)
|
||||
// The loader should appear while data is being fetched
|
||||
// ThreeDotsLoader uses react-loader-spinner's ThreeDots with ariaLabel="grid-loading"
|
||||
const loader = adminPage.locator('[aria-label="grid-loading"]');
|
||||
// Give it a moment to appear
|
||||
await expect(loader).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Wait for page to finish loading
|
||||
await adminPage.waitForLoadState("networkidle");
|
||||
|
||||
// After loading, page title should be visible
|
||||
await expect(
|
||||
adminPage
|
||||
.locator('[aria-label="admin-page-title"]')
|
||||
.getByText("Discord Bots")
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("error state shows error message", async ({ adminPage }) => {
|
||||
// Intercept API to return error
|
||||
await adminPage.route("**/api/manage/admin/discord-bot/guilds", (route) => {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "Internal Server Error" }),
|
||||
});
|
||||
});
|
||||
|
||||
await adminPage.goto("/admin/discord-bot");
|
||||
await adminPage.waitForLoadState("networkidle");
|
||||
|
||||
// Should show error message from ErrorCallout
|
||||
// ErrorCallout shows both title ("Failed to load Discord servers") and detail ("Internal Server Error")
|
||||
// Use .first() to get the first matching element (the title)
|
||||
const errorMessage = adminPage.locator("text=/failed|error/i").first();
|
||||
await expect(errorMessage).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
@@ -582,18 +582,10 @@ test.describe("End-to-End Default Assistant Flow", () => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Verify greeting message appears
|
||||
const greetingElement = await page.waitForSelector(
|
||||
'[data-testid="onyx-logo"]',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
expect(greetingElement).toBeTruthy();
|
||||
await expect(page.locator('[data-testid="onyx-logo"]')).toBeVisible();
|
||||
|
||||
// Verify Onyx logo is displayed
|
||||
const logoElement = await page.waitForSelector(
|
||||
'[data-testid="onyx-logo"]',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
expect(logoElement).toBeTruthy();
|
||||
await expect(page.locator('[data-testid="onyx-logo"]')).toBeVisible();
|
||||
|
||||
// Send a message using the chat input
|
||||
await sendMessage(page, "Hello, can you help me?");
|
||||
@@ -608,10 +600,6 @@ test.describe("End-to-End Default Assistant Flow", () => {
|
||||
await startNewChat(page);
|
||||
|
||||
// Verify we're back to default assistant with greeting
|
||||
const newGreeting = await page.waitForSelector(
|
||||
'[data-testid="onyx-logo"]',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
expect(newGreeting).toBeTruthy();
|
||||
await expect(page.locator('[data-testid="onyx-logo"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ export async function loginAs(
|
||||
// Try to fetch current user info from the page context
|
||||
const me = await page.evaluate(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me", { credentials: "include" });
|
||||
const res = await fetch("/api/me", { credentials: "include" });
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
@@ -113,10 +113,10 @@ export async function loginAs(
|
||||
}
|
||||
});
|
||||
console.log(
|
||||
`[loginAs] /api/auth/me => ok=${me.ok} status=${me.status} url=${me.url}`
|
||||
`[loginAs] /api/me => ok=${me.ok} status=${me.status} url=${me.url}`
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(`[loginAs] Failed to query /api/auth/me: ${String(e)}`);
|
||||
console.log(`[loginAs] Failed to query /api/me: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
// Function to generate a random email and password
|
||||
|
||||
@@ -709,4 +709,174 @@ export class OnyxApiClient {
|
||||
|
||||
this.log(`Deleted image generation config: ${imageProviderId}`);
|
||||
}
|
||||
|
||||
// === Discord Bot Methods ===
|
||||
|
||||
/**
|
||||
* Creates a Discord guild configuration.
|
||||
* Returns the guild config with registration key (shown once).
|
||||
*
|
||||
* @returns The created guild config with id and registration_key
|
||||
*/
|
||||
async createDiscordGuild(): Promise<{
|
||||
id: number;
|
||||
registration_key: string;
|
||||
guild_name: string | null;
|
||||
}> {
|
||||
const response = await this.post("/manage/admin/discord-bot/guilds");
|
||||
|
||||
const guild = await this.handleResponse<{
|
||||
id: number;
|
||||
registration_key: string;
|
||||
guild_name: string | null;
|
||||
}>(response, "Failed to create Discord guild config");
|
||||
|
||||
this.log(
|
||||
`Created Discord guild config: id=${guild.id}, registration_key=${guild.registration_key}`
|
||||
);
|
||||
return guild;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all Discord guild configurations.
|
||||
*
|
||||
* @returns Array of guild configs
|
||||
*/
|
||||
async listDiscordGuilds(): Promise<
|
||||
Array<{
|
||||
id: number;
|
||||
guild_id: string | null;
|
||||
guild_name: string | null;
|
||||
enabled: boolean;
|
||||
}>
|
||||
> {
|
||||
const response = await this.get("/manage/admin/discord-bot/guilds");
|
||||
return await this.handleResponse(response, "Failed to list Discord guilds");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a specific Discord guild configuration.
|
||||
*
|
||||
* @param guildId - The internal guild config ID
|
||||
* @returns The guild config or null if not found
|
||||
*/
|
||||
async getDiscordGuild(guildId: number): Promise<{
|
||||
id: number;
|
||||
guild_id: string | null;
|
||||
guild_name: string | null;
|
||||
enabled: boolean;
|
||||
default_persona_id: number | null;
|
||||
} | null> {
|
||||
const response = await this.get(
|
||||
`/manage/admin/discord-bot/guilds/${guildId}`
|
||||
);
|
||||
if (response.status() === 404) {
|
||||
return null;
|
||||
}
|
||||
return await this.handleResponse(
|
||||
response,
|
||||
`Failed to get Discord guild ${guildId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a Discord guild configuration.
|
||||
*
|
||||
* @param guildId - The internal guild config ID
|
||||
* @param updates - The fields to update
|
||||
* @returns The updated guild config
|
||||
*/
|
||||
async updateDiscordGuild(
|
||||
guildId: number,
|
||||
updates: { enabled?: boolean; default_persona_id?: number | null }
|
||||
): Promise<{
|
||||
id: number;
|
||||
guild_id: string | null;
|
||||
guild_name: string | null;
|
||||
enabled: boolean;
|
||||
}> {
|
||||
const response = await this.page.request.patch(
|
||||
`${this.baseUrl}/manage/admin/discord-bot/guilds/${guildId}`,
|
||||
{ data: updates }
|
||||
);
|
||||
return await this.handleResponse(
|
||||
response,
|
||||
`Failed to update Discord guild ${guildId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a Discord guild configuration.
|
||||
*
|
||||
* @param guildId - The internal guild config ID
|
||||
*/
|
||||
async deleteDiscordGuild(guildId: number): Promise<void> {
|
||||
const response = await this.delete(
|
||||
`/manage/admin/discord-bot/guilds/${guildId}`
|
||||
);
|
||||
|
||||
await this.handleResponseSoft(
|
||||
response,
|
||||
`Failed to delete Discord guild ${guildId}`
|
||||
);
|
||||
|
||||
this.log(`Deleted Discord guild config: ${guildId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists channels for a Discord guild configuration.
|
||||
*
|
||||
* @param guildConfigId - The internal guild config ID
|
||||
* @returns Array of channel configs
|
||||
*/
|
||||
async listDiscordChannels(guildConfigId: number): Promise<
|
||||
Array<{
|
||||
id: number;
|
||||
channel_id: string;
|
||||
channel_name: string;
|
||||
channel_type: string;
|
||||
enabled: boolean;
|
||||
}>
|
||||
> {
|
||||
const response = await this.get(
|
||||
`/manage/admin/discord-bot/guilds/${guildConfigId}/channels`
|
||||
);
|
||||
return await this.handleResponse(
|
||||
response,
|
||||
`Failed to list channels for guild ${guildConfigId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a Discord channel configuration.
|
||||
*
|
||||
* @param guildConfigId - The internal guild config ID
|
||||
* @param channelConfigId - The internal channel config ID
|
||||
* @param updates - The fields to update
|
||||
* @returns The updated channel config
|
||||
*/
|
||||
async updateDiscordChannel(
|
||||
guildConfigId: number,
|
||||
channelConfigId: number,
|
||||
updates: {
|
||||
enabled?: boolean;
|
||||
thread_only_mode?: boolean;
|
||||
require_bot_invocation?: boolean;
|
||||
persona_override_id?: number | null;
|
||||
}
|
||||
): Promise<{
|
||||
id: number;
|
||||
channel_id: string;
|
||||
channel_name: string;
|
||||
enabled: boolean;
|
||||
}> {
|
||||
const response = await this.page.request.patch(
|
||||
`${this.baseUrl}/manage/admin/discord-bot/guilds/${guildConfigId}/channels/${channelConfigId}`,
|
||||
{ data: updates }
|
||||
);
|
||||
return await this.handleResponse(
|
||||
response,
|
||||
`Failed to update channel ${channelConfigId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,9 @@ export async function waitForUnifiedGreeting(page: Page): Promise<string> {
|
||||
// Ensure the Action Management popover is open
|
||||
export async function openActionManagement(page: Page): Promise<void> {
|
||||
const actionToggle = page.locator(TOOL_IDS.actionToggle);
|
||||
await actionToggle.waitFor({ timeout: 5000 });
|
||||
await actionToggle.waitFor();
|
||||
await actionToggle.click();
|
||||
await page.locator(TOOL_IDS.options).waitFor({ timeout: 5000 });
|
||||
await page.locator(TOOL_IDS.options).waitFor();
|
||||
}
|
||||
|
||||
// Check presence of the Action Management toggle
|
||||
|
||||
Reference in New Issue
Block a user