Compare commits

...

35 Commits

Author SHA1 Message Date
justin-tahara
e6ef2b5074 Fixing mypy 2026-02-09 15:47:10 -08:00
justin-tahara
74132175a8 Fixing mypy 2026-02-09 15:47:10 -08:00
Justin Tahara
29f707ee2d fix(posthog): Chat metrics for Cloud (#8278) 2026-02-09 15:47:10 -08:00
Justin Tahara
f0eb86fb9f fix(ui): Updating Dropdown Modal component (#8033) 2026-02-06 11:59:09 -08:00
Justin Tahara
b422496a4c fix(agents): Removing Label Dependency (#8189) 2026-02-06 11:39:09 -08:00
Justin Tahara
31d6a45b23 chore(chat): Cleaning Error Codes + Tests (#8186) 2026-02-06 11:02:41 -08:00
Justin Tahara
36f3ac1ec5 feat: onyx discord bot - supervisord and kube deployment (#7706) 2026-02-02 15:05:21 -08:00
Wenxi Onyx
74f5b3025a fix: discord svg (can't cherry-pick) 2026-02-02 10:03:39 -08:00
Justin Tahara
c18545d74c feat(desktop): Ensure that UI reflects Light/Dark Toggle (#7684) 2026-02-02 10:03:39 -08:00
Justin Tahara
48171e3700 fix(ui): Agent Saving with other people files (#8095) 2026-02-02 10:03:39 -08:00
Wenxi
f5a5709876 feat: onyx discord bot - frontend (#7497) 2026-02-02 10:03:39 -08:00
Justin Tahara
85868b1b83 fix(desktop): Remove Global Shortcuts (#7914) 2026-01-30 13:46:20 -08:00
Justin Tahara
8dc14c23e6 fix(asana): Workspace Team ID mismatch (#7674) 2026-01-30 13:19:02 -08:00
Jamison Lahman
23821cc0e8 chore(mypy): fix mypy cache issues switching between HEAD and release (#7732) 2026-01-27 15:52:57 -08:00
Jamison Lahman
b359e13281 fix(citations): enable citation sidebar w/ web_search-only assistants (#7888) 2026-01-27 13:26:29 -08:00
Justin Tahara
717f410a4a fix(llm): Hide private models from Agent Creation (#7873) 2026-01-27 12:21:06 -08:00
SubashMohan
ada0946a62 fix(layout): adjust footer margin and prevent page refresh on chatsession drop (#7759) 2026-01-27 11:57:18 -08:00
Jamison Lahman
eb2ac8f5a3 fix(fe): inline code text wraps (#7574) 2026-01-27 11:33:03 -08:00
Nikolas Garza
fbeb57c592 fix(slack): Extract person names and filter garbage in query expansion (#7632) 2026-01-27 11:26:52 -08:00
Nikolas Garza
d6da9c9b85 fix: scroll to bottom when loading existing conversations (#7614) 2026-01-27 11:26:52 -08:00
Nikolas Garza
5aea2e223e fix(billing): remove grandfathered pricing option when subscription lapses (#7583) 2026-01-27 11:26:52 -08:00
Nikolas Garza
1ff91de07e fix: deflake chat user journey test (#7646) 2026-01-27 11:18:27 -08:00
Nikolas Garza
b3dbc69faf fix(tests): use crawler-friendly search query in Exa integration test (#7746) 2026-01-27 11:13:01 -08:00
Yuhong Sun
431597b0f9 fix: LiteLLM Azure models don't stream (#7761) 2026-01-27 10:49:17 -08:00
Yuhong Sun
51b4e5f2fb fix: Azure OpenAI Tool Calls (#7727) 2026-01-27 10:49:17 -08:00
Justin Tahara
9afa04a26b fix(ui): Coda Logo (#7656) 2026-01-26 17:43:54 -08:00
Justin Tahara
70a3a9c0cd fix(ui): User Groups Connectors Fix (#7658) 2026-01-26 17:43:45 -08:00
Justin Tahara
080165356c fix(ui): First Connector Result (#7657) 2026-01-26 17:43:35 -08:00
Justin Tahara
3ae974bdf6 fix(ui): Fix Token Rate Limits Page (#7659) 2026-01-26 17:42:57 -08:00
Justin Tahara
1471658151 fix(vertex ai): Extra Args for Opus 4.5 (#7586) 2026-01-26 17:42:43 -08:00
Justin Tahara
3e85e9c1a3 feat(desktop): Domain Configuration (#7655) 2026-01-26 17:12:33 -08:00
Justin Tahara
851033be5f feat(desktop): Properly Sign Mac App (#7608) 2026-01-26 17:12:24 -08:00
Jamison Lahman
91e974a6cc chore(desktop): make artifact filename version-agnostic (#7679) 2026-01-26 16:20:39 -08:00
Jamison Lahman
38ba4f8a1c chore(deployments): fix region (#7640) 2026-01-26 16:20:39 -08:00
Jamison Lahman
6f02473064 chore(deployments): fetch secrets from AWS (#7584) 2026-01-26 16:20:39 -08:00
80 changed files with 5055 additions and 1093 deletions

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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",

View File

@@ -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,
},
)

View File

@@ -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"

View File

@@ -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(

View File

@@ -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")

View File

@@ -2932,8 +2932,6 @@ class PersonaLabel(Base):
"Persona",
secondary=Persona__PersonaLabel.__table__,
back_populates="labels",
cascade="all, delete-orphan",
single_parent=True,
)

View File

@@ -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

View File

@@ -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]]:

View File

@@ -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)

View 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` |

View File

@@ -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 = """

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View 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

View File

@@ -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")

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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,

View 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"},
)

View File

@@ -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:-}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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=

View 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 }}

View File

@@ -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
View File

@@ -22,3 +22,6 @@ npm-debug.log*
# Local env files
.env
.env.local
# Generated files
src-tauri/gen/schemas/acl-manifests.json

View File

@@ -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"

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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")]

View File

@@ -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,

View File

@@ -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>

View File

@@ -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,

View 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;

View 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;

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View 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>
</>
);
}

View 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>
</>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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(),
};
}

View 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 };
}

View 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>
);
}

View 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;
}

View File

@@ -101,7 +101,7 @@ function Main() {
};
return (
<Section alignItems="stretch">
<Section alignItems="stretch" justifyContent="start" height="auto">
{popup}
<Text>

View File

@@ -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]);

View File

@@ -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",

View File

@@ -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

View File

@@ -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>

View File

@@ -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 &quot;{searchTerm}&quot; 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 &quot;{searchTerm}&quot;
</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,

View File

@@ -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>

View File

@@ -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}
>

View File

@@ -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>

View File

@@ -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}`;

View File

@@ -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 () => {

View File

@@ -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);

View File

@@ -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")}

View File

@@ -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;
}
);

View File

@@ -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(

View File

@@ -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}

View 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$/);
}
});
});

View 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 });
}
});
});

View 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 });
});
});

View 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,
});
}

View 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 });
});
});

View File

@@ -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();
});
});

View File

@@ -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

View File

@@ -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}`
);
}
}

View File

@@ -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