Compare commits

...

38 Commits

Author SHA1 Message Date
Justin Tahara
e3af8c6c8a feat(desktop): Domain Configuration (#7655) 2026-01-26 16:42:58 -08:00
Justin Tahara
d6e46ed792 feat(desktop): Properly Sign Mac App (#7608) 2026-01-26 16:42:47 -08:00
Jamison Lahman
4ce1f4ecdd chore(desktop): make artifact filename version-agnostic (#7679) 2026-01-26 16:24:06 -08:00
Jamison Lahman
a4678884d7 chore(deployments): fix region (#7640) 2026-01-26 16:24:06 -08:00
Jamison Lahman
c861ba68f1 chore(deployments): fetch secrets from AWS (#7584) 2026-01-26 16:24:06 -08:00
Raunak Bhagat
b1d0e0bb0b Fix actions-steps collapsing/opening issue 2026-01-25 12:49:32 -08:00
Raunak Bhagat
0d78bf52e3 Stop header from collapsing over and over again 2026-01-25 12:49:32 -08:00
Yuhong Sun
bd743282e6 fix: LiteLLM Azure models don't stream (#7761) 2026-01-25 12:47:48 -08:00
Raunak Bhagat
d44d1d92b3 2.9 fixes (#7756) 2026-01-24 17:36:20 -08:00
Raunak Bhagat
4cedcfee59 Fix notifications popover some more 2026-01-24 17:30:45 -08:00
Raunak Bhagat
90a721a76e Fix line-items 2026-01-24 17:30:45 -08:00
Raunak Bhagat
3ccd99e931 Fix notifications 2026-01-24 17:30:45 -08:00
Raunak Bhagat
9076bf603f Fix actions popover 2026-01-24 17:30:45 -08:00
Nikolas Garza
8c6e0a70c3 fix(chat): prevent streaming text from appearing in bursts after citations (#7745) 2026-01-24 16:58:12 -08:00
Yuhong Sun
bebe9555d4 fix: Azure OpenAI Tool Calls (#7727) 2026-01-24 16:55:27 -08:00
Nikolas Garza
c530722c9f fix(tests): use crawler-friendly search query in Exa integration test (#7746) 2026-01-24 16:53:40 -08:00
Jamison Lahman
68380b4ddb chore(fe): align assistant icon with chat bar (#7537) 2026-01-24 16:34:57 -08:00
Jamison Lahman
b3380746ab fix(fe): chat header is sticky and transparent (#7487) 2026-01-24 16:34:57 -08:00
Nikolas Garza
56be114c87 fix(fe): show scroll-down button when user scrolls up during streaming (#7562) 2026-01-24 16:34:57 -08:00
Nikolas Garza
54f467da5c fix: improve scroll behavior (#7364) 2026-01-24 16:34:57 -08:00
Nikolas Garza
8726b112fe fix(slack): Extract person names and filter garbage in query expansion (#7632) 2026-01-23 22:59:23 -08:00
Raunak Bhagat
92181d07b2 fix: Fix scrollability issues for modals (#7718) 2026-01-23 22:05:53 -08:00
Raunak Bhagat
3a73f7fab2 fix: Fix layout issues with AgentEditorPage (#7730) 2026-01-23 20:29:21 -08:00
Raunak Bhagat
7dabaca7cd fix: Add back agent sharing (#7731) 2026-01-23 19:13:36 -08:00
Raunak Bhagat
dec4748825 Close modal on success only 2026-01-23 17:39:52 -08:00
Raunak Bhagat
072836cd86 Cherry-pick agent-deletion 2026-01-23 17:39:52 -08:00
Evan Lohn
2705b5fb0e Revert "fix: modal header in index attempt errors (#7601)"
This reverts commit f945ab6b05.
2026-01-23 15:02:41 -08:00
Evan Lohn
37dcde4226 fix: prevent updates from overwriting perm syncing (#7384) 2026-01-23 14:52:44 -08:00
Evan Lohn
a765b5f622 fix(mcp): per-user auth (#7400) 2026-01-23 14:51:56 -08:00
Evan Lohn
5e093368d1 fix: bedrock non-anthropic prompt caching (#7435) 2026-01-23 14:50:13 -08:00
Evan Lohn
f945ab6b05 fix: modal header in index attempt errors (#7601) 2026-01-23 14:48:29 -08:00
Justin Tahara
11b7a22404 fix(ui): Coda Logo (#7656) 2026-01-23 14:45:29 -08:00
Justin Tahara
8e34f944cc fix(ui): First Connector Result (#7657) 2026-01-23 14:45:18 -08:00
Jamison Lahman
32606dc752 revert: "feat: Enable triple click on content in the chat" (#7393) to release v2.9 (#7710) 2026-01-23 14:21:22 -08:00
Jamison Lahman
1f6c4b40bf fix(fe): inline code text wraps (#7574) to release v2.9 (#7707) 2026-01-23 13:40:28 -08:00
Nikolas Garza
1943f1c745 feat(billing): add annual pricing support to subscription checkout (#7506) 2026-01-23 10:40:16 -08:00
Jamison Lahman
82460729a6 fix(db): ensure migrations are atomic (#7474) to release v2.9 (#7648) 2026-01-21 14:58:04 -08:00
Wenxi
c445e6a8c0 fix: delete old notifications first in migration (#7454) 2026-01-20 08:31:00 -08:00
135 changed files with 4225 additions and 2517 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

@@ -225,7 +225,6 @@ def do_run_migrations(
) -> None:
if create_schema:
connection.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{schema_name}"'))
connection.execute(text("COMMIT"))
connection.execute(text(f'SET search_path TO "{schema_name}"'))
@@ -309,6 +308,7 @@ async def run_async_migrations() -> None:
schema_name=schema,
create_schema=create_schema,
)
await connection.commit()
except Exception as e:
logger.error(f"Error migrating schema {schema}: {e}")
if not continue_on_error:
@@ -346,6 +346,7 @@ async def run_async_migrations() -> None:
schema_name=schema,
create_schema=create_schema,
)
await connection.commit()
except Exception as e:
logger.error(f"Error migrating schema {schema}: {e}")
if not continue_on_error:

View File

@@ -85,103 +85,122 @@ class UserRow(NamedTuple):
def upgrade() -> None:
conn = op.get_bind()
# Start transaction
conn.execute(sa.text("BEGIN"))
# Step 1: Create or update the unified assistant (ID 0)
search_assistant = conn.execute(
sa.text("SELECT * FROM persona WHERE id = 0")
).fetchone()
try:
# Step 1: Create or update the unified assistant (ID 0)
search_assistant = conn.execute(
sa.text("SELECT * FROM persona WHERE id = 0")
).fetchone()
if search_assistant:
# Update existing Search assistant to be the unified assistant
conn.execute(
sa.text(
"""
UPDATE persona
SET name = :name,
description = :description,
system_prompt = :system_prompt,
num_chunks = :num_chunks,
is_default_persona = true,
is_visible = true,
deleted = false,
display_priority = :display_priority,
llm_filter_extraction = :llm_filter_extraction,
llm_relevance_filter = :llm_relevance_filter,
recency_bias = :recency_bias,
chunks_above = :chunks_above,
chunks_below = :chunks_below,
datetime_aware = :datetime_aware,
starter_messages = null
WHERE id = 0
"""
),
INSERT_DICT,
)
else:
# Create new unified assistant with ID 0
conn.execute(
sa.text(
"""
INSERT INTO persona (
id, name, description, system_prompt, num_chunks,
is_default_persona, is_visible, deleted, display_priority,
llm_filter_extraction, llm_relevance_filter, recency_bias,
chunks_above, chunks_below, datetime_aware, starter_messages,
builtin_persona
) VALUES (
0, :name, :description, :system_prompt, :num_chunks,
true, true, false, :display_priority, :llm_filter_extraction,
:llm_relevance_filter, :recency_bias, :chunks_above, :chunks_below,
:datetime_aware, null, true
)
"""
),
INSERT_DICT,
)
# Step 2: Mark ALL builtin assistants as deleted (except the unified assistant ID 0)
if search_assistant:
# Update existing Search assistant to be the unified assistant
conn.execute(
sa.text(
"""
UPDATE persona
SET deleted = true, is_visible = false, is_default_persona = false
WHERE builtin_persona = true AND id != 0
SET name = :name,
description = :description,
system_prompt = :system_prompt,
num_chunks = :num_chunks,
is_default_persona = true,
is_visible = true,
deleted = false,
display_priority = :display_priority,
llm_filter_extraction = :llm_filter_extraction,
llm_relevance_filter = :llm_relevance_filter,
recency_bias = :recency_bias,
chunks_above = :chunks_above,
chunks_below = :chunks_below,
datetime_aware = :datetime_aware,
starter_messages = null
WHERE id = 0
"""
)
),
INSERT_DICT,
)
else:
# Create new unified assistant with ID 0
conn.execute(
sa.text(
"""
INSERT INTO persona (
id, name, description, system_prompt, num_chunks,
is_default_persona, is_visible, deleted, display_priority,
llm_filter_extraction, llm_relevance_filter, recency_bias,
chunks_above, chunks_below, datetime_aware, starter_messages,
builtin_persona
) VALUES (
0, :name, :description, :system_prompt, :num_chunks,
true, true, false, :display_priority, :llm_filter_extraction,
:llm_relevance_filter, :recency_bias, :chunks_above, :chunks_below,
:datetime_aware, null, true
)
"""
),
INSERT_DICT,
)
# Step 3: Add all built-in tools to the unified assistant
# First, get the tool IDs for SearchTool, ImageGenerationTool, and WebSearchTool
search_tool = conn.execute(
sa.text("SELECT id FROM tool WHERE in_code_tool_id = 'SearchTool'")
).fetchone()
# Step 2: Mark ALL builtin assistants as deleted (except the unified assistant ID 0)
conn.execute(
sa.text(
"""
UPDATE persona
SET deleted = true, is_visible = false, is_default_persona = false
WHERE builtin_persona = true AND id != 0
"""
)
)
if not search_tool:
raise ValueError(
"SearchTool not found in database. Ensure tools migration has run first."
)
# Step 3: Add all built-in tools to the unified assistant
# First, get the tool IDs for SearchTool, ImageGenerationTool, and WebSearchTool
search_tool = conn.execute(
sa.text("SELECT id FROM tool WHERE in_code_tool_id = 'SearchTool'")
).fetchone()
image_gen_tool = conn.execute(
sa.text("SELECT id FROM tool WHERE in_code_tool_id = 'ImageGenerationTool'")
).fetchone()
if not search_tool:
raise ValueError(
"SearchTool not found in database. Ensure tools migration has run first."
)
if not image_gen_tool:
raise ValueError(
"ImageGenerationTool not found in database. Ensure tools migration has run first."
)
image_gen_tool = conn.execute(
sa.text("SELECT id FROM tool WHERE in_code_tool_id = 'ImageGenerationTool'")
).fetchone()
# WebSearchTool is optional - may not be configured
web_search_tool = conn.execute(
sa.text("SELECT id FROM tool WHERE in_code_tool_id = 'WebSearchTool'")
).fetchone()
if not image_gen_tool:
raise ValueError(
"ImageGenerationTool not found in database. Ensure tools migration has run first."
)
# Clear existing tool associations for persona 0
conn.execute(sa.text("DELETE FROM persona__tool WHERE persona_id = 0"))
# WebSearchTool is optional - may not be configured
web_search_tool = conn.execute(
sa.text("SELECT id FROM tool WHERE in_code_tool_id = 'WebSearchTool'")
).fetchone()
# Add tools to the unified assistant
# Clear existing tool associations for persona 0
conn.execute(sa.text("DELETE FROM persona__tool WHERE persona_id = 0"))
# Add tools to the unified assistant
conn.execute(
sa.text(
"""
INSERT INTO persona__tool (persona_id, tool_id)
VALUES (0, :tool_id)
ON CONFLICT DO NOTHING
"""
),
{"tool_id": search_tool[0]},
)
conn.execute(
sa.text(
"""
INSERT INTO persona__tool (persona_id, tool_id)
VALUES (0, :tool_id)
ON CONFLICT DO NOTHING
"""
),
{"tool_id": image_gen_tool[0]},
)
if web_search_tool:
conn.execute(
sa.text(
"""
@@ -190,191 +209,148 @@ def upgrade() -> None:
ON CONFLICT DO NOTHING
"""
),
{"tool_id": search_tool[0]},
{"tool_id": web_search_tool[0]},
)
conn.execute(
sa.text(
"""
INSERT INTO persona__tool (persona_id, tool_id)
VALUES (0, :tool_id)
ON CONFLICT DO NOTHING
# Step 4: Migrate existing chat sessions from all builtin assistants to unified assistant
conn.execute(
sa.text(
"""
),
{"tool_id": image_gen_tool[0]},
UPDATE chat_session
SET persona_id = 0
WHERE persona_id IN (
SELECT id FROM persona WHERE builtin_persona = true AND id != 0
)
"""
)
)
if web_search_tool:
# Step 5: Migrate user preferences - remove references to all builtin assistants
# First, get all builtin assistant IDs (except 0)
builtin_assistants_result = conn.execute(
sa.text(
"""
SELECT id FROM persona
WHERE builtin_persona = true AND id != 0
"""
)
).fetchall()
builtin_assistant_ids = [row[0] for row in builtin_assistants_result]
# Get all users with preferences
users_result = conn.execute(
sa.text(
"""
SELECT id, chosen_assistants, visible_assistants,
hidden_assistants, pinned_assistants
FROM "user"
"""
)
).fetchall()
for user_row in users_result:
user = UserRow(*user_row)
user_id: UUID = user.id
updates: dict[str, Any] = {}
# Remove all builtin assistants from chosen_assistants
if user.chosen_assistants:
new_chosen: list[int] = [
assistant_id
for assistant_id in user.chosen_assistants
if assistant_id not in builtin_assistant_ids
]
if new_chosen != user.chosen_assistants:
updates["chosen_assistants"] = json.dumps(new_chosen)
# Remove all builtin assistants from visible_assistants
if user.visible_assistants:
new_visible: list[int] = [
assistant_id
for assistant_id in user.visible_assistants
if assistant_id not in builtin_assistant_ids
]
if new_visible != user.visible_assistants:
updates["visible_assistants"] = json.dumps(new_visible)
# Add all builtin assistants to hidden_assistants
if user.hidden_assistants:
new_hidden: list[int] = list(user.hidden_assistants)
for old_id in builtin_assistant_ids:
if old_id not in new_hidden:
new_hidden.append(old_id)
if new_hidden != user.hidden_assistants:
updates["hidden_assistants"] = json.dumps(new_hidden)
else:
updates["hidden_assistants"] = json.dumps(builtin_assistant_ids)
# Remove all builtin assistants from pinned_assistants
if user.pinned_assistants:
new_pinned: list[int] = [
assistant_id
for assistant_id in user.pinned_assistants
if assistant_id not in builtin_assistant_ids
]
if new_pinned != user.pinned_assistants:
updates["pinned_assistants"] = json.dumps(new_pinned)
# Apply updates if any
if updates:
set_clause = ", ".join([f"{k} = :{k}" for k in updates.keys()])
updates["user_id"] = str(user_id) # Convert UUID to string for SQL
conn.execute(
sa.text(
"""
INSERT INTO persona__tool (persona_id, tool_id)
VALUES (0, :tool_id)
ON CONFLICT DO NOTHING
"""
),
{"tool_id": web_search_tool[0]},
sa.text(f'UPDATE "user" SET {set_clause} WHERE id = :user_id'),
updates,
)
# Step 4: Migrate existing chat sessions from all builtin assistants to unified assistant
conn.execute(
sa.text(
"""
UPDATE chat_session
SET persona_id = 0
WHERE persona_id IN (
SELECT id FROM persona WHERE builtin_persona = true AND id != 0
)
"""
)
)
# Step 5: Migrate user preferences - remove references to all builtin assistants
# First, get all builtin assistant IDs (except 0)
builtin_assistants_result = conn.execute(
sa.text(
"""
SELECT id FROM persona
WHERE builtin_persona = true AND id != 0
"""
)
).fetchall()
builtin_assistant_ids = [row[0] for row in builtin_assistants_result]
# Get all users with preferences
users_result = conn.execute(
sa.text(
"""
SELECT id, chosen_assistants, visible_assistants,
hidden_assistants, pinned_assistants
FROM "user"
"""
)
).fetchall()
for user_row in users_result:
user = UserRow(*user_row)
user_id: UUID = user.id
updates: dict[str, Any] = {}
# Remove all builtin assistants from chosen_assistants
if user.chosen_assistants:
new_chosen: list[int] = [
assistant_id
for assistant_id in user.chosen_assistants
if assistant_id not in builtin_assistant_ids
]
if new_chosen != user.chosen_assistants:
updates["chosen_assistants"] = json.dumps(new_chosen)
# Remove all builtin assistants from visible_assistants
if user.visible_assistants:
new_visible: list[int] = [
assistant_id
for assistant_id in user.visible_assistants
if assistant_id not in builtin_assistant_ids
]
if new_visible != user.visible_assistants:
updates["visible_assistants"] = json.dumps(new_visible)
# Add all builtin assistants to hidden_assistants
if user.hidden_assistants:
new_hidden: list[int] = list(user.hidden_assistants)
for old_id in builtin_assistant_ids:
if old_id not in new_hidden:
new_hidden.append(old_id)
if new_hidden != user.hidden_assistants:
updates["hidden_assistants"] = json.dumps(new_hidden)
else:
updates["hidden_assistants"] = json.dumps(builtin_assistant_ids)
# Remove all builtin assistants from pinned_assistants
if user.pinned_assistants:
new_pinned: list[int] = [
assistant_id
for assistant_id in user.pinned_assistants
if assistant_id not in builtin_assistant_ids
]
if new_pinned != user.pinned_assistants:
updates["pinned_assistants"] = json.dumps(new_pinned)
# Apply updates if any
if updates:
set_clause = ", ".join([f"{k} = :{k}" for k in updates.keys()])
updates["user_id"] = str(user_id) # Convert UUID to string for SQL
conn.execute(
sa.text(f'UPDATE "user" SET {set_clause} WHERE id = :user_id'),
updates,
)
# Commit transaction
conn.execute(sa.text("COMMIT"))
except Exception as e:
# Rollback on error
conn.execute(sa.text("ROLLBACK"))
raise e
def downgrade() -> None:
conn = op.get_bind()
# Start transaction
conn.execute(sa.text("BEGIN"))
try:
# Only restore General (ID -1) and Art (ID -3) assistants
# Step 1: Keep Search assistant (ID 0) as default but restore original state
conn.execute(
sa.text(
"""
UPDATE persona
SET is_default_persona = true,
is_visible = true,
deleted = false
WHERE id = 0
# Only restore General (ID -1) and Art (ID -3) assistants
# Step 1: Keep Search assistant (ID 0) as default but restore original state
conn.execute(
sa.text(
"""
)
UPDATE persona
SET is_default_persona = true,
is_visible = true,
deleted = false
WHERE id = 0
"""
)
)
# Step 2: Restore General assistant (ID -1)
conn.execute(
sa.text(
"""
UPDATE persona
SET deleted = false,
is_visible = true,
is_default_persona = true
WHERE id = :general_assistant_id
# Step 2: Restore General assistant (ID -1)
conn.execute(
sa.text(
"""
),
{"general_assistant_id": GENERAL_ASSISTANT_ID},
)
UPDATE persona
SET deleted = false,
is_visible = true,
is_default_persona = true
WHERE id = :general_assistant_id
"""
),
{"general_assistant_id": GENERAL_ASSISTANT_ID},
)
# Step 3: Restore Art assistant (ID -3)
conn.execute(
sa.text(
"""
UPDATE persona
SET deleted = false,
is_visible = true,
is_default_persona = true
WHERE id = :art_assistant_id
# Step 3: Restore Art assistant (ID -3)
conn.execute(
sa.text(
"""
),
{"art_assistant_id": ART_ASSISTANT_ID},
)
UPDATE persona
SET deleted = false,
is_visible = true,
is_default_persona = true
WHERE id = :art_assistant_id
"""
),
{"art_assistant_id": ART_ASSISTANT_ID},
)
# Note: We don't restore the original tool associations, names, or descriptions
# as those would require more complex logic to determine original state.
# We also cannot restore original chat session persona_ids as we don't
# have the original mappings.
# Other builtin assistants remain deleted as per the requirement.
# Commit transaction
conn.execute(sa.text("COMMIT"))
except Exception as e:
# Rollback on error
conn.execute(sa.text("ROLLBACK"))
raise e
# Note: We don't restore the original tool associations, names, or descriptions
# as those would require more complex logic to determine original state.
# We also cannot restore original chat session persona_ids as we don't
# have the original mappings.
# Other builtin assistants remain deleted as per the requirement.

View File

@@ -24,6 +24,9 @@ def upgrade() -> None:
# in unique constraints, but we want NULL == NULL for deduplication).
# The '{}' represents an empty JSONB object as the NULL replacement.
# Clean up legacy notifications first
op.execute("DELETE FROM notification WHERE title = 'New Notification'")
op.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS ix_notification_user_type_data
@@ -40,9 +43,6 @@ def upgrade() -> None:
"""
)
# Clean up legacy 'reindex' notifications that are no longer needed
op.execute("DELETE FROM notification WHERE title = 'New Notification'")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_notification_user_type_data")

View File

@@ -42,20 +42,13 @@ TOOL_DESCRIPTIONS = {
def upgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("BEGIN"))
try:
for tool_id, description in TOOL_DESCRIPTIONS.items():
conn.execute(
sa.text(
"UPDATE tool SET description = :description WHERE in_code_tool_id = :tool_id"
),
{"description": description, "tool_id": tool_id},
)
conn.execute(sa.text("COMMIT"))
except Exception as e:
conn.execute(sa.text("ROLLBACK"))
raise e
for tool_id, description in TOOL_DESCRIPTIONS.items():
conn.execute(
sa.text(
"UPDATE tool SET description = :description WHERE in_code_tool_id = :tool_id"
),
{"description": description, "tool_id": tool_id},
)
def downgrade() -> None:

View File

@@ -70,80 +70,66 @@ BUILT_IN_TOOLS = [
def upgrade() -> None:
conn = op.get_bind()
# Start transaction
conn.execute(sa.text("BEGIN"))
# Get existing tools to check what already exists
existing_tools = conn.execute(
sa.text("SELECT in_code_tool_id FROM tool WHERE in_code_tool_id IS NOT NULL")
).fetchall()
existing_tool_ids = {row[0] for row in existing_tools}
try:
# Get existing tools to check what already exists
existing_tools = conn.execute(
sa.text(
"SELECT in_code_tool_id FROM tool WHERE in_code_tool_id IS NOT NULL"
# Insert or update built-in tools
for tool in BUILT_IN_TOOLS:
in_code_id = tool["in_code_tool_id"]
# Handle historical rename: InternetSearchTool -> WebSearchTool
if (
in_code_id == "WebSearchTool"
and "WebSearchTool" not in existing_tool_ids
and "InternetSearchTool" in existing_tool_ids
):
# Rename the existing InternetSearchTool row in place and update fields
conn.execute(
sa.text(
"""
UPDATE tool
SET name = :name,
display_name = :display_name,
description = :description,
in_code_tool_id = :in_code_tool_id
WHERE in_code_tool_id = 'InternetSearchTool'
"""
),
tool,
)
).fetchall()
existing_tool_ids = {row[0] for row in existing_tools}
# Keep the local view of existing ids in sync to avoid duplicate insert
existing_tool_ids.discard("InternetSearchTool")
existing_tool_ids.add("WebSearchTool")
continue
# Insert or update built-in tools
for tool in BUILT_IN_TOOLS:
in_code_id = tool["in_code_tool_id"]
# Handle historical rename: InternetSearchTool -> WebSearchTool
if (
in_code_id == "WebSearchTool"
and "WebSearchTool" not in existing_tool_ids
and "InternetSearchTool" in existing_tool_ids
):
# Rename the existing InternetSearchTool row in place and update fields
conn.execute(
sa.text(
"""
UPDATE tool
SET name = :name,
display_name = :display_name,
description = :description,
in_code_tool_id = :in_code_tool_id
WHERE in_code_tool_id = 'InternetSearchTool'
"""
),
tool,
)
# Keep the local view of existing ids in sync to avoid duplicate insert
existing_tool_ids.discard("InternetSearchTool")
existing_tool_ids.add("WebSearchTool")
continue
if in_code_id in existing_tool_ids:
# Update existing tool
conn.execute(
sa.text(
"""
UPDATE tool
SET name = :name,
display_name = :display_name,
description = :description
WHERE in_code_tool_id = :in_code_tool_id
"""
),
tool,
)
else:
# Insert new tool
conn.execute(
sa.text(
"""
INSERT INTO tool (name, display_name, description, in_code_tool_id)
VALUES (:name, :display_name, :description, :in_code_tool_id)
"""
),
tool,
)
# Commit transaction
conn.execute(sa.text("COMMIT"))
except Exception as e:
# Rollback on error
conn.execute(sa.text("ROLLBACK"))
raise e
if in_code_id in existing_tool_ids:
# Update existing tool
conn.execute(
sa.text(
"""
UPDATE tool
SET name = :name,
display_name = :display_name,
description = :description
WHERE in_code_tool_id = :in_code_tool_id
"""
),
tool,
)
else:
# Insert new tool
conn.execute(
sa.text(
"""
INSERT INTO tool (name, display_name, description, in_code_tool_id)
VALUES (:name, :display_name, :description, :in_code_tool_id)
"""
),
tool,
)
def downgrade() -> None:

View File

@@ -109,7 +109,6 @@ CHECK_TTL_MANAGEMENT_TASK_FREQUENCY_IN_HOURS = float(
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY")
STRIPE_PRICE_ID = os.environ.get("STRIPE_PRICE")
# JWT Public Key URL
JWT_PUBLIC_KEY_URL: str | None = os.getenv("JWT_PUBLIC_KEY_URL", None)

View File

@@ -3,30 +3,42 @@ from uuid import UUID
from sqlalchemy.orm import Session
from onyx.configs.constants import NotificationType
from onyx.db.models import Persona
from onyx.db.models import Persona__User
from onyx.db.models import Persona__UserGroup
from onyx.db.notification import create_notification
from onyx.server.features.persona.models import PersonaSharedNotificationData
def make_persona_private(
def update_persona_access(
persona_id: int,
creator_user_id: UUID | None,
user_ids: list[UUID] | None,
group_ids: list[int] | None,
db_session: Session,
is_public: bool | None = None,
user_ids: list[UUID] | None = None,
group_ids: list[int] | None = None,
) -> None:
"""NOTE(rkuo): This function batches all updates into a single commit. If we don't
dedupe the inputs, the commit will exception."""
"""Updates the access settings for a persona including public status, user shares,
and group shares.
db_session.query(Persona__User).filter(
Persona__User.persona_id == persona_id
).delete(synchronize_session="fetch")
db_session.query(Persona__UserGroup).filter(
Persona__UserGroup.persona_id == persona_id
).delete(synchronize_session="fetch")
NOTE: This function batches all updates. If we don't dedupe the inputs,
the commit will exception.
NOTE: Callers are responsible for committing."""
if is_public is not None:
persona = db_session.query(Persona).filter(Persona.id == persona_id).first()
if persona:
persona.is_public = is_public
# NOTE: For user-ids and group-ids, `None` means "leave unchanged", `[]` means "clear all shares",
# and a non-empty list means "replace with these shares".
if user_ids is not None:
db_session.query(Persona__User).filter(
Persona__User.persona_id == persona_id
).delete(synchronize_session="fetch")
if user_ids:
user_ids_set = set(user_ids)
for user_id in user_ids_set:
db_session.add(Persona__User(persona_id=persona_id, user_id=user_id))
@@ -41,11 +53,13 @@ def make_persona_private(
).model_dump(),
)
if group_ids:
if group_ids is not None:
db_session.query(Persona__UserGroup).filter(
Persona__UserGroup.persona_id == persona_id
).delete(synchronize_session="fetch")
group_ids_set = set(group_ids)
for group_id in group_ids_set:
db_session.add(
Persona__UserGroup(persona_id=persona_id, user_group_id=group_id)
)
db_session.commit()

View File

@@ -1,9 +1,9 @@
from typing import cast
from typing import Literal
import requests
import stripe
from ee.onyx.configs.app_configs import STRIPE_PRICE_ID
from ee.onyx.configs.app_configs import STRIPE_SECRET_KEY
from ee.onyx.server.tenants.access import generate_data_plane_token
from ee.onyx.server.tenants.models import BillingInformation
@@ -16,15 +16,21 @@ stripe.api_key = STRIPE_SECRET_KEY
logger = setup_logger()
def fetch_stripe_checkout_session(tenant_id: str) -> str:
def fetch_stripe_checkout_session(
tenant_id: str,
billing_period: Literal["monthly", "annual"] = "monthly",
) -> str:
token = generate_data_plane_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
url = f"{CONTROL_PLANE_API_BASE_URL}/create-checkout-session"
params = {"tenant_id": tenant_id}
response = requests.post(url, headers=headers, params=params)
payload = {
"tenant_id": tenant_id,
"billing_period": billing_period,
}
response = requests.post(url, headers=headers, json=payload)
response.raise_for_status()
return response.json()["sessionId"]
@@ -72,22 +78,24 @@ def fetch_billing_information(
def register_tenant_users(tenant_id: str, number_of_users: int) -> stripe.Subscription:
"""
Send a request to the control service to register the number of users for a tenant.
Update the number of seats for a tenant's subscription.
Preserves the existing price (monthly, annual, or grandfathered).
"""
if not STRIPE_PRICE_ID:
raise Exception("STRIPE_PRICE_ID is not set")
response = fetch_tenant_stripe_information(tenant_id)
stripe_subscription_id = cast(str, response.get("stripe_subscription_id"))
subscription = stripe.Subscription.retrieve(stripe_subscription_id)
subscription_item = subscription["items"]["data"][0]
# Use existing price to preserve the customer's current plan
current_price_id = subscription_item.price.id
updated_subscription = stripe.Subscription.modify(
stripe_subscription_id,
items=[
{
"id": subscription["items"]["data"][0].id,
"price": STRIPE_PRICE_ID,
"id": subscription_item.id,
"price": current_price_id,
"quantity": number_of_users,
}
],

View File

@@ -10,6 +10,7 @@ from ee.onyx.server.tenants.billing import fetch_billing_information
from ee.onyx.server.tenants.billing import fetch_stripe_checkout_session
from ee.onyx.server.tenants.billing import fetch_tenant_stripe_information
from ee.onyx.server.tenants.models import BillingInformation
from ee.onyx.server.tenants.models import CreateSubscriptionSessionRequest
from ee.onyx.server.tenants.models import ProductGatingFullSyncRequest
from ee.onyx.server.tenants.models import ProductGatingRequest
from ee.onyx.server.tenants.models import ProductGatingResponse
@@ -104,15 +105,18 @@ async def create_customer_portal_session(
@router.post("/create-subscription-session")
async def create_subscription_session(
request: CreateSubscriptionSessionRequest | None = None,
_: User = Depends(current_admin_user),
) -> SubscriptionSessionResponse:
try:
tenant_id = CURRENT_TENANT_ID_CONTEXTVAR.get()
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found")
session_id = fetch_stripe_checkout_session(tenant_id)
billing_period = request.billing_period if request else "monthly"
session_id = fetch_stripe_checkout_session(tenant_id, billing_period)
return SubscriptionSessionResponse(sessionId=session_id)
except Exception as e:
logger.exception("Failed to create resubscription session")
logger.exception("Failed to create subscription session")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,4 +1,5 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel
@@ -73,6 +74,12 @@ class SubscriptionSessionResponse(BaseModel):
sessionId: str
class CreateSubscriptionSessionRequest(BaseModel):
"""Request to create a subscription checkout session."""
billing_period: Literal["monthly", "annual"] = "monthly"
class TenantByDomainResponse(BaseModel):
tenant_id: str
number_of_users: int

View File

@@ -566,6 +566,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.
@@ -586,10 +603,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

@@ -444,6 +444,8 @@ def upsert_documents(
logger.info("No documents to upsert. Skipping.")
return
includes_permissions = any(doc.external_access for doc in seen_documents.values())
insert_stmt = insert(DbDocument).values(
[
model_to_dict(
@@ -479,21 +481,38 @@ def upsert_documents(
]
)
update_set = {
"from_ingestion_api": insert_stmt.excluded.from_ingestion_api,
"boost": insert_stmt.excluded.boost,
"hidden": insert_stmt.excluded.hidden,
"semantic_id": insert_stmt.excluded.semantic_id,
"link": insert_stmt.excluded.link,
"primary_owners": insert_stmt.excluded.primary_owners,
"secondary_owners": insert_stmt.excluded.secondary_owners,
"doc_metadata": insert_stmt.excluded.doc_metadata,
}
if includes_permissions:
# Use COALESCE to preserve existing permissions when new values are NULL.
# This prevents subsequent indexing runs (which don't fetch permissions)
# from overwriting permissions set by permission sync jobs.
update_set.update(
{
"external_user_emails": func.coalesce(
insert_stmt.excluded.external_user_emails,
DbDocument.external_user_emails,
),
"external_user_group_ids": func.coalesce(
insert_stmt.excluded.external_user_group_ids,
DbDocument.external_user_group_ids,
),
"is_public": func.coalesce(
insert_stmt.excluded.is_public,
DbDocument.is_public,
),
}
)
on_conflict_stmt = insert_stmt.on_conflict_do_update(
index_elements=["id"], # Conflict target
set_={
"from_ingestion_api": insert_stmt.excluded.from_ingestion_api,
"boost": insert_stmt.excluded.boost,
"hidden": insert_stmt.excluded.hidden,
"semantic_id": insert_stmt.excluded.semantic_id,
"link": insert_stmt.excluded.link,
"primary_owners": insert_stmt.excluded.primary_owners,
"secondary_owners": insert_stmt.excluded.secondary_owners,
"external_user_emails": insert_stmt.excluded.external_user_emails,
"external_user_group_ids": insert_stmt.excluded.external_user_group_ids,
"is_public": insert_stmt.excluded.is_public,
"doc_metadata": insert_stmt.excluded.doc_metadata,
},
index_elements=["id"], set_=update_set # Conflict target
)
db_session.execute(on_conflict_stmt)
db_session.commit()

View File

@@ -187,13 +187,25 @@ def _get_persona_by_name(
return result
def make_persona_private(
def update_persona_access(
persona_id: int,
creator_user_id: UUID | None,
user_ids: list[UUID] | None,
group_ids: list[int] | None,
db_session: Session,
is_public: bool | None = None,
user_ids: list[UUID] | None = None,
group_ids: list[int] | None = None,
) -> None:
"""Updates the access settings for a persona including public status and user shares.
NOTE: Callers are responsible for committing."""
if is_public is not None:
persona = db_session.query(Persona).filter(Persona.id == persona_id).first()
if persona:
persona.is_public = is_public
# NOTE: For user-ids and group-ids, `None` means "leave unchanged", `[]` means "clear all shares",
# and a non-empty list means "replace with these shares".
if user_ids is not None:
db_session.query(Persona__User).filter(
Persona__User.persona_id == persona_id
@@ -212,11 +224,15 @@ def make_persona_private(
).model_dump(),
)
db_session.commit()
# MIT doesn't support group-based sharing, so we allow clearing (no-op since
# there shouldn't be any) but raise an error if trying to add actual groups.
if group_ids is not None:
db_session.query(Persona__UserGroup).filter(
Persona__UserGroup.persona_id == persona_id
).delete(synchronize_session="fetch")
# May cause error if someone switches down to MIT from EE
if group_ids:
raise NotImplementedError("Onyx MIT does not support private Personas")
if group_ids:
raise NotImplementedError("Onyx MIT does not support group-based sharing")
def create_update_persona(
@@ -282,20 +298,21 @@ def create_update_persona(
llm_filter_extraction=create_persona_request.llm_filter_extraction,
is_default_persona=create_persona_request.is_default_persona,
user_file_ids=converted_user_file_ids,
commit=False,
)
versioned_make_persona_private = fetch_versioned_implementation(
"onyx.db.persona", "make_persona_private"
versioned_update_persona_access = fetch_versioned_implementation(
"onyx.db.persona", "update_persona_access"
)
# Privatize Persona
versioned_make_persona_private(
versioned_update_persona_access(
persona_id=persona.id,
creator_user_id=user.id if user else None,
db_session=db_session,
user_ids=create_persona_request.users,
group_ids=create_persona_request.groups,
db_session=db_session,
)
db_session.commit()
except ValueError as e:
logger.exception("Failed to create persona")
@@ -304,11 +321,13 @@ def create_update_persona(
return FullPersonaSnapshot.from_model(persona)
def update_persona_shared_users(
def update_persona_shared(
persona_id: int,
user_ids: list[UUID],
user: User | None,
db_session: Session,
user_ids: list[UUID] | None = None,
group_ids: list[int] | None = None,
is_public: bool | None = None,
) -> None:
"""Simplified version of `create_update_persona` which only touches the
accessibility rather than any of the logic (e.g. prompt, connected data sources,
@@ -317,22 +336,25 @@ def update_persona_shared_users(
db_session=db_session, persona_id=persona_id, user=user, get_editable=True
)
if persona.is_public:
raise HTTPException(status_code=400, detail="Cannot share public persona")
if user and user.role != UserRole.ADMIN and persona.user_id != user.id:
raise HTTPException(
status_code=403, detail="You don't have permission to modify this persona"
)
versioned_make_persona_private = fetch_versioned_implementation(
"onyx.db.persona", "make_persona_private"
versioned_update_persona_access = fetch_versioned_implementation(
"onyx.db.persona", "update_persona_access"
)
# Privatize Persona
versioned_make_persona_private(
versioned_update_persona_access(
persona_id=persona_id,
creator_user_id=user.id if user else None,
user_ids=user_ids,
group_ids=None,
db_session=db_session,
is_public=is_public,
user_ids=user_ids,
group_ids=group_ids,
)
db_session.commit()
def update_persona_public_status(
persona_id: int,

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

@@ -63,7 +63,7 @@ def process_with_prompt_cache(
return suffix, None
# Get provider adapter
provider_adapter = get_provider_adapter(llm_config.model_provider)
provider_adapter = get_provider_adapter(llm_config)
# If provider doesn't support caching, combine and return unchanged
if not provider_adapter.supports_caching():

View File

@@ -1,14 +1,17 @@
"""Factory for creating provider-specific prompt cache adapters."""
from onyx.llm.constants import LlmProviderNames
from onyx.llm.interfaces import LLMConfig
from onyx.llm.prompt_cache.providers.anthropic import AnthropicPromptCacheProvider
from onyx.llm.prompt_cache.providers.base import PromptCacheProvider
from onyx.llm.prompt_cache.providers.noop import NoOpPromptCacheProvider
from onyx.llm.prompt_cache.providers.openai import OpenAIPromptCacheProvider
from onyx.llm.prompt_cache.providers.vertex import VertexAIPromptCacheProvider
ANTHROPIC_BEDROCK_TAG = "anthropic."
def get_provider_adapter(provider: str) -> PromptCacheProvider:
def get_provider_adapter(llm_config: LLMConfig) -> PromptCacheProvider:
"""Get the appropriate prompt cache provider adapter for a given provider.
Args:
@@ -17,11 +20,14 @@ def get_provider_adapter(provider: str) -> PromptCacheProvider:
Returns:
PromptCacheProvider instance for the given provider
"""
if provider == LlmProviderNames.OPENAI:
if llm_config.model_provider == LlmProviderNames.OPENAI:
return OpenAIPromptCacheProvider()
elif provider in [LlmProviderNames.ANTHROPIC, LlmProviderNames.BEDROCK]:
elif llm_config.model_provider == LlmProviderNames.ANTHROPIC or (
llm_config.model_provider == LlmProviderNames.BEDROCK
and ANTHROPIC_BEDROCK_TAG in llm_config.model_name
):
return AnthropicPromptCacheProvider()
elif provider == LlmProviderNames.VERTEX_AI:
elif llm_config.model_provider == LlmProviderNames.VERTEX_AI:
return VertexAIPromptCacheProvider()
else:
# Default to no-op for providers without caching support

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

@@ -697,7 +697,7 @@ def save_user_credentials(
# TODO: fix and/or type correctly w/base model
config_data = MCPConnectionData(
headers=auth_template.config.get("headers", {}),
header_substitutions=auth_template.config.get(HEADER_SUBSTITUTIONS, {}),
header_substitutions=request.credentials,
)
for oauth_field_key in MCPOAuthKeys:
field_key: Literal["client_info", "tokens", "metadata"] = (

View File

@@ -34,7 +34,7 @@ from onyx.db.persona import mark_persona_as_not_deleted
from onyx.db.persona import update_persona_is_default
from onyx.db.persona import update_persona_label
from onyx.db.persona import update_persona_public_status
from onyx.db.persona import update_persona_shared_users
from onyx.db.persona import update_persona_shared
from onyx.db.persona import update_persona_visibility
from onyx.db.persona import update_personas_display_priority
from onyx.file_store.file_store import get_default_file_store
@@ -366,7 +366,9 @@ def delete_label(
class PersonaShareRequest(BaseModel):
user_ids: list[UUID]
user_ids: list[UUID] | None = None
group_ids: list[int] | None = None
is_public: bool | None = None
# We notify each user when a user is shared with them
@@ -377,11 +379,13 @@ def share_persona(
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> None:
update_persona_shared_users(
update_persona_shared(
persona_id=persona_id,
user_ids=persona_share_request.user_ids,
user=user,
db_session=db_session,
user_ids=persona_share_request.user_ids,
group_ids=persona_share_request.group_ids,
is_public=persona_share_request.is_public,
)

View File

@@ -41,6 +41,12 @@ API_KEY_RECORDS: Dict[str, Dict[str, Any]] = {
},
}
# These are inferrable from the file anyways, no need to obfuscate.
# use them to test your auth with this server
#
# mcp_live-kid_alice_001-S3cr3tAlice
# mcp_live-kid_bob_001-S3cr3tBob
# ---- verifier ---------------------------------------------------------------
class ApiKeyVerifier(TokenVerifier):

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

@@ -21,9 +21,9 @@ 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 +76,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 +114,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 +132,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 +142,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 +196,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 +227,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 +258,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 +350,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 +376,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 +420,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(())
}
@@ -423,7 +459,7 @@ fn setup_shortcuts(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
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 open_settings_shortcut = Shortcut::new(Some(Modifiers::SUPER), Code::Comma);
let app_handle = app.clone();
@@ -435,7 +471,7 @@ fn setup_shortcuts(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
forward,
new_window_shortcut,
show_app,
open_settings,
open_settings_shortcut,
];
#[cfg(not(target_os = "macos"))]
@@ -446,7 +482,7 @@ fn setup_shortcuts(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
forward,
new_window_shortcut,
show_app,
open_settings,
open_settings_shortcut,
];
app.global_shortcut().on_shortcuts(
@@ -463,9 +499,8 @@ fn setup_shortcuts(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
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();
} else if shortcut == &open_settings_shortcut {
open_settings(&app_handle);
}
}
@@ -495,6 +530,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 +539,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()?;
@@ -625,22 +662,20 @@ 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,6 +692,7 @@ 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| {
@@ -675,7 +711,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 +719,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,43 @@
<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;
}
* {
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, #f5f5f5 0%, #ffffff 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;
}
/* Draggable titlebar area for macOS */
.titlebar {
position: fixed;
top: 0;
@@ -33,198 +48,451 @@
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: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.95),
rgba(245, 245, 245, 0.95)
);
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);
}
.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: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
font-weight: bold;
overflow: hidden;
}
h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
.settings-icon svg {
width: 24px;
height: 24px;
color: #000;
}
.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: rgba(0, 0, 0, 0.03);
border-radius: 16px;
padding: 4px;
}
.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: rgba(0, 0, 0, 0.05);
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: rgba(0, 0, 0, 0.08);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.05);
}
.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: rgba(0, 0, 0, 0.15);
transition: 0.3s;
border-radius: 24px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: 0.3s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: rgba(0, 0, 0, 0.3);
}
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: rgba(0, 0, 0, 0.1);
border: 1px solid var(--white-10);
border-radius: 4px;
font-size: 0.75rem;
padding: 2px 6px;
font-family: monospace;
font-weight: 500;
color: var(--text-light-05);
font-size: 11px;
}
</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");
function showSettings() {
document.body.classList.add("show-settings");
}
// 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) {}
}
}
@@ -177,7 +163,6 @@
function mountTitleBar() {
if (!document.body) {
console.error("[Onyx Desktop] document.body not found");
return;
}
@@ -193,7 +178,6 @@
const titleBar = buildTitleBar();
document.body.insertBefore(titleBar, document.body.firstChild);
injectStyles();
console.log("[Onyx Desktop] Title bar injected");
}
function syncViewportHeight() {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 B

After

Width:  |  Height:  |  Size: 581 B

View File

@@ -25,7 +25,7 @@ export default function OnyxApiKeyForm({
return (
<Modal open onOpenChange={onClose}>
<Modal.Content tall>
<Modal.Content width="sm" height="lg">
<Modal.Header
icon={SvgKey}
title={isUpdate ? "Update API Key" : "Create a new API Key"}

View File

@@ -105,7 +105,7 @@ function Main() {
{popup}
<Modal open={!!fullApiKey}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
title="New API Key"
icon={SvgKey}

View File

@@ -10,10 +10,7 @@ import {
} from "@/lib/types";
import BackButton from "@/refresh-components/buttons/BackButton";
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import {
FetchAssistantsResponse,
fetchAssistantsSS,
} from "@/lib/assistants/fetchAssistantsSS";
import { FetchAssistantsResponse, fetchAssistantsSS } from "@/lib/agentsSS";
import { getStandardAnswerCategoriesIfEE } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE";
async function EditslackChannelConfigPage(props: {

View File

@@ -4,7 +4,7 @@ import { fetchSS } from "@/lib/utilsSS";
import { ErrorCallout } from "@/components/ErrorCallout";
import { DocumentSetSummary, ValidSources } from "@/lib/types";
import BackButton from "@/refresh-components/buttons/BackButton";
import { fetchAssistantsSS } from "@/lib/assistants/fetchAssistantsSS";
import { fetchAssistantsSS } from "@/lib/agentsSS";
import { getStandardAnswerCategoriesIfEE } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE";
import { redirect } from "next/navigation";
import { SourceIcon } from "@/components/SourceIcon";

View File

@@ -1,9 +1,10 @@
"use client";
import { useState, ReactNode } from "react";
import useSWR, { useSWRConfig, KeyedMutator } from "swr";
import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
import {
LLMProviderView,
ModelConfiguration,
WellKnownLLMProviderDescriptor,
} from "../../interfaces";
import { errorHandlingFetcher } from "@/lib/fetcher";
@@ -114,7 +115,7 @@ export function ProviderFormEntrypointWrapper({
{formIsVisible && (
<Modal open onOpenChange={onClose}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgSettings}
title={`Setup ${providerName}`}
@@ -196,7 +197,7 @@ export function ProviderFormEntrypointWrapper({
{formIsVisible && (
<Modal open onOpenChange={onClose}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgSettings}
title={`${existingLlmProvider ? "Configure" : "Setup"} ${

View File

@@ -130,7 +130,7 @@ export default function UpgradingPage({
{popup}
{isCancelling && (
<Modal open onOpenChange={() => setIsCancelling(false)}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgX}
title="Cancel Embedding Model Switch"

View File

@@ -81,7 +81,7 @@ export const WebProviderSetupModal = memo(
return (
<Modal open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Modal.Content mini preventAccidentalClose>
<Modal.Content width="sm" preventAccidentalClose>
<Modal.Header
icon={LogoArrangement}
title={`Set up ${providerLabel}`}

View File

@@ -125,7 +125,7 @@ export default function IndexAttemptErrorsModal({
return (
<Modal open onOpenChange={onClose}>
<Modal.Content large>
<Modal.Content width="lg" height="full">
<Modal.Header
icon={SvgAlertTriangle}
title="Indexing Errors"

View File

@@ -353,7 +353,7 @@ export default function InlineFileManagement({
{/* Confirmation Modal */}
<Modal open={showSaveConfirm} onOpenChange={setShowSaveConfirm}>
<Modal.Content mini>
<Modal.Content width="sm">
<Modal.Header
icon={SvgFolderPlus}
title="Confirm File Changes"

View File

@@ -128,7 +128,7 @@ export default function ReIndexModal({
return (
<Modal open onOpenChange={hide}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header icon={SvgRefreshCw} title="Run Indexing" onClose={hide} />
<Modal.Body>
<Text as="p">

View File

@@ -584,7 +584,7 @@ export default function AddConnector({
open
onOpenChange={() => setCreateCredentialFormToggle(false)}
>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgKey}
title={`Create a ${getSourceDisplayName(

View File

@@ -323,7 +323,7 @@ const RerankingDetailsForm = forwardRef<
open
onOpenChange={() => setShowGpuWarningModalModel(null)}
>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgAlertTriangle}
title="GPU Not Enabled"
@@ -358,7 +358,7 @@ const RerankingDetailsForm = forwardRef<
setShowLiteLLMConfigurationModal(false);
}}
>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgKey}
title="API Key Configuration"
@@ -462,7 +462,7 @@ const RerankingDetailsForm = forwardRef<
setIsApiKeyModalOpen(false);
}}
>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgKey}
title="API Key Configuration"

View File

@@ -14,7 +14,7 @@ export default function AlreadyPickedModal({
}: AlreadyPickedModalProps) {
return (
<Modal open onOpenChange={onClose}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgCheck}
title={`${model.model_name} already chosen`}

View File

@@ -21,7 +21,7 @@ export default function DeleteCredentialsModal({
}: DeleteCredentialsModalProps) {
return (
<Modal open onOpenChange={onCancel}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgTrash}
title={`Delete ${getFormattedProviderName(

View File

@@ -13,7 +13,7 @@ export default function InstantSwitchConfirmModal({
}: InstantSwitchConfirmModalProps) {
return (
<Modal open onOpenChange={onClose}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgAlertTriangle}
title="Are you sure you want to do an instant switch?"

View File

@@ -20,7 +20,7 @@ export default function ModelSelectionConfirmationModal({
}: ModelSelectionConfirmationModalProps) {
return (
<Modal open onOpenChange={onCancel}>
<Modal.Content tall>
<Modal.Content width="sm" height="lg">
<Modal.Header
icon={SvgServer}
title="Update Embedding Model"

View File

@@ -186,7 +186,7 @@ export default function ProviderCreationModal({
return (
<Modal open onOpenChange={onCancel}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgSettings}
title={`Configure ${getFormattedProviderName(

View File

@@ -17,7 +17,7 @@ export default function SelectModelModal({
}: SelectModelModalProps) {
return (
<Modal open onOpenChange={onCancel}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgServer}
title={`Select ${model.model_name}`}

View File

@@ -539,7 +539,7 @@ export default function EmbeddingForm() {
)}
{showPoorModel && (
<Modal open onOpenChange={() => setShowPoorModel(false)}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgAlertTriangle}
title={`Are you sure you want to select ${selectedProvider.model_name}?`}

View File

@@ -299,7 +299,7 @@ function Main() {
)}
{configureModalShown && (
<Modal open onOpenChange={() => setConfigureModalShown(false)}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgSettings}
title="Configure Knowledge Graph"

View File

@@ -308,7 +308,7 @@ export function SettingsForm() {
)}
{showConfirmModal && (
<Modal open onOpenChange={() => setShowConfirmModal(false)}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgAlertTriangle}
title="Enable Anonymous Users"

View File

@@ -63,7 +63,7 @@ export default function CreateRateLimitModal({
return (
<Modal open={isOpen} onOpenChange={() => setIsOpen(false)}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgSettings}
title="Create a Token Rate Limit"

View File

@@ -351,7 +351,7 @@ const AddUserButton = ({
{bulkAddUsersModal && (
<Modal open onOpenChange={() => setBulkAddUsersModal(false)}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgUserPlus}
title="Bulk Add Users"

View File

@@ -0,0 +1,323 @@
"use client";
import { cn, noProp } from "@/lib/utils";
import Text from "@/refresh-components/texts/Text";
import Button from "@/refresh-components/buttons/Button";
import { useCallback, useMemo, useState, useEffect } from "react";
import ShareChatSessionModal from "@/app/chat/components/modal/ShareChatSessionModal";
import IconButton from "@/refresh-components/buttons/IconButton";
import LineItem from "@/refresh-components/buttons/LineItem";
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
import useChatSessions from "@/hooks/useChatSessions";
import { usePopup } from "@/components/admin/connectors/Popup";
import {
handleMoveOperation,
shouldShowMoveModal,
showErrorNotification,
} from "@/sections/sidebar/sidebarUtils";
import { LOCAL_STORAGE_KEYS } from "@/sections/sidebar/constants";
import { deleteChatSession } from "@/app/chat/services/lib";
import { useRouter } from "next/navigation";
import MoveCustomAgentChatModal from "@/components/modals/MoveCustomAgentChatModal";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
import { PopoverMenu } from "@/refresh-components/Popover";
import { PopoverSearchInput } from "@/sections/sidebar/ChatButton";
import SimplePopover from "@/refresh-components/SimplePopover";
import { useAppSidebarContext } from "@/refresh-components/contexts/AppSidebarContext";
import useScreenSize from "@/hooks/useScreenSize";
import {
SvgFolderIn,
SvgMoreHorizontal,
SvgShare,
SvgSidebar,
SvgTrash,
} from "@opal/icons";
import { useSettingsContext } from "@/components/settings/SettingsProvider";
/**
* Chat Header Component
*
* Renders the header for chat sessions with share, move, and delete actions.
* Designed to be rendered inside ChatScrollContainer with sticky positioning.
*
* Features:
* - Share chat functionality
* - Move chat to project (with confirmation for custom agents)
* - Delete chat with confirmation
* - Mobile-responsive sidebar toggle
* - Custom header content from enterprise settings
*/
export default function ChatHeader() {
const settings = useSettingsContext();
const { isMobile } = useScreenSize();
const { setFolded } = useAppSidebarContext();
const [showShareModal, setShowShareModal] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [showMoveCustomAgentModal, setShowMoveCustomAgentModal] =
useState(false);
const [pendingMoveProjectId, setPendingMoveProjectId] = useState<
number | null
>(null);
const [showMoveOptions, setShowMoveOptions] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [popoverOpen, setPopoverOpen] = useState(false);
const [popoverItems, setPopoverItems] = useState<React.ReactNode[]>([]);
const {
projects,
fetchProjects,
refreshCurrentProjectDetails,
currentProjectId,
} = useProjectsContext();
const { currentChatSession, refreshChatSessions, currentChatSessionId } =
useChatSessions();
const { popup, setPopup } = usePopup();
const router = useRouter();
const customHeaderContent =
settings?.enterpriseSettings?.custom_header_content;
const availableProjects = useMemo(() => {
if (!projects) return [];
return projects.filter((project) => project.id !== currentProjectId);
}, [projects, currentProjectId]);
const filteredProjects = useMemo(() => {
if (!searchTerm) return availableProjects;
const term = searchTerm.toLowerCase();
return availableProjects.filter((project) =>
project.name.toLowerCase().includes(term)
);
}, [availableProjects, searchTerm]);
const resetMoveState = useCallback(() => {
setShowMoveOptions(false);
setSearchTerm("");
setPendingMoveProjectId(null);
setShowMoveCustomAgentModal(false);
}, []);
const performMove = useCallback(
async (targetProjectId: number) => {
if (!currentChatSession) return;
try {
await handleMoveOperation(
{
chatSession: currentChatSession,
targetProjectId,
refreshChatSessions,
refreshCurrentProjectDetails,
fetchProjects,
currentProjectId,
},
setPopup
);
resetMoveState();
setPopoverOpen(false);
} catch (error) {
console.error("Failed to move chat session:", error);
}
},
[
currentChatSession,
refreshChatSessions,
refreshCurrentProjectDetails,
fetchProjects,
currentProjectId,
setPopup,
resetMoveState,
]
);
const handleMoveClick = useCallback(
(projectId: number) => {
if (!currentChatSession) return;
if (shouldShowMoveModal(currentChatSession)) {
setPendingMoveProjectId(projectId);
setShowMoveCustomAgentModal(true);
return;
}
void performMove(projectId);
},
[currentChatSession, performMove]
);
const handleDeleteChat = useCallback(async () => {
if (!currentChatSession) return;
try {
const response = await deleteChatSession(currentChatSession.id);
if (!response.ok) {
throw new Error("Failed to delete chat session");
}
await Promise.all([refreshChatSessions(), fetchProjects()]);
router.replace("/chat");
setDeleteModalOpen(false);
} catch (error) {
console.error("Failed to delete chat:", error);
showErrorNotification(
setPopup,
"Failed to delete chat. Please try again."
);
}
}, [
currentChatSession,
refreshChatSessions,
fetchProjects,
router,
setPopup,
]);
const setDeleteConfirmationModalOpen = useCallback((open: boolean) => {
setDeleteModalOpen(open);
if (open) {
setPopoverOpen(false);
}
}, []);
useEffect(() => {
const items = showMoveOptions
? [
<PopoverSearchInput
key="search"
setShowMoveOptions={setShowMoveOptions}
onSearch={setSearchTerm}
/>,
...filteredProjects.map((project) => (
<LineItem
key={project.id}
icon={SvgFolderIn}
onClick={noProp(() => handleMoveClick(project.id))}
>
{project.name}
</LineItem>
)),
]
: [
<LineItem
key="move"
icon={SvgFolderIn}
onClick={noProp(() => setShowMoveOptions(true))}
>
Move to Project
</LineItem>,
<LineItem
key="delete"
icon={SvgTrash}
onClick={noProp(() => setDeleteConfirmationModalOpen(true))}
danger
>
Delete
</LineItem>,
];
setPopoverItems(items);
}, [
showMoveOptions,
filteredProjects,
currentChatSession,
setDeleteConfirmationModalOpen,
handleMoveClick,
]);
// Don't render if no chat session
if (!currentChatSessionId) return null;
return (
<>
{popup}
{showShareModal && currentChatSession && (
<ShareChatSessionModal
chatSession={currentChatSession}
onClose={() => setShowShareModal(false)}
/>
)}
{showMoveCustomAgentModal && (
<MoveCustomAgentChatModal
onCancel={resetMoveState}
onConfirm={async (doNotShowAgain: boolean) => {
if (doNotShowAgain && typeof window !== "undefined") {
window.localStorage.setItem(
LOCAL_STORAGE_KEYS.HIDE_MOVE_CUSTOM_AGENT_MODAL,
"true"
);
}
if (pendingMoveProjectId != null) {
await performMove(pendingMoveProjectId);
}
}}
/>
)}
{deleteModalOpen && (
<ConfirmationModalLayout
title="Delete Chat"
icon={SvgTrash}
onClose={() => setDeleteModalOpen(false)}
submit={
<Button danger onClick={handleDeleteChat}>
Delete
</Button>
}
>
Are you sure you want to delete this chat? This action cannot be
undone.
</ConfirmationModalLayout>
)}
<div className="w-full flex flex-row justify-center items-center py-3 px-4 h-16 bg-background-tint-01 xl:bg-transparent">
{/* Left - contains the icon-button to fold the AppSidebar on mobile */}
<div className="flex-1">
<IconButton
icon={SvgSidebar}
onClick={() => setFolded(false)}
className={cn(!isMobile && "invisible")}
internal
/>
</div>
{/* Center - contains the custom-header-content */}
<div className="flex-1 flex flex-col items-center overflow-hidden">
<Text
as="p"
text03
mainUiBody
className="text-center break-words w-full"
>
{customHeaderContent}
</Text>
</div>
{/* Right - contains the share and more-options buttons */}
<div className="flex-1 flex flex-row items-center justify-end px-1">
<Button
leftIcon={SvgShare}
transient={showShareModal}
tertiary
onClick={() => setShowShareModal(true)}
>
Share Chat
</Button>
<SimplePopover
trigger={
<IconButton
icon={SvgMoreHorizontal}
className="ml-2"
transient={popoverOpen}
tertiary
/>
}
onOpenChange={(state) => {
setPopoverOpen(state);
if (!state) setShowMoveOptions(false);
}}
side="bottom"
align="end"
>
<PopoverMenu>{popoverItems}</PopoverMenu>
</SimplePopover>
</div>
</div>
</>
);
}

View File

@@ -51,7 +51,10 @@ import {
useDocumentSidebarVisible,
} from "@/app/chat/stores/useChatSessionStore";
import FederatedOAuthModal from "@/components/chat/FederatedOAuthModal";
import ChatUI, { ChatUIHandle } from "@/sections/ChatUI";
import ChatScrollContainer, {
ChatScrollContainerHandle,
} from "@/components/chat/ChatScrollContainer";
import MessageList from "@/components/chat/MessageList";
import WelcomeMessage from "@/app/chat/components/WelcomeMessage";
import ProjectContextPanel from "@/app/chat/components/projects/ProjectContextPanel";
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
@@ -66,7 +69,9 @@ import OnboardingFlow from "@/refresh-components/onboarding/OnboardingFlow";
import { OnboardingStep } from "@/refresh-components/onboarding/types";
import { useShowOnboarding } from "@/hooks/useShowOnboarding";
import * as AppLayouts from "@/layouts/app-layouts";
import { SvgFileText } from "@opal/icons";
import { SvgChevronDown, SvgFileText } from "@opal/icons";
import ChatHeader from "@/app/chat/components/ChatHeader";
import IconButton from "@/refresh-components/buttons/IconButton";
import Spacer from "@/refresh-components/Spacer";
import { DEFAULT_CONTEXT_TOKENS } from "@/lib/constants";
@@ -267,18 +272,17 @@ export default function ChatPage({ firstMessage }: ChatPageProps) {
settings,
});
const chatUiRef = useRef<ChatUIHandle>(null);
const autoScrollEnabled = user?.preferences?.auto_scroll ?? false;
const scrollContainerRef = useRef<ChatScrollContainerHandle>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
// Handle input bar height changes for scroll adjustment
const handleInputHeightChange = useCallback(
(delta: number) => {
if (autoScrollEnabled && delta > 0) {
chatUiRef.current?.scrollBy(delta);
}
},
[autoScrollEnabled]
);
// Reset scroll button when session changes
useEffect(() => {
setShowScrollButton(false);
}, [currentChatSessionId]);
const handleScrollToBottom = useCallback(() => {
scrollContainerRef.current?.scrollToBottom();
}, []);
const resetInputBar = useCallback(() => {
chatInputBarRef.current?.reset();
@@ -329,6 +333,15 @@ export default function ChatPage({ firstMessage }: ChatPageProps) {
);
const messageHistory = useCurrentMessageHistory();
// Determine anchor: second-to-last message (last user message before current response)
const anchorMessage = messageHistory.at(-2) ?? messageHistory[0];
const anchorNodeId = anchorMessage?.nodeId;
const anchorSelector = anchorNodeId ? `#message-${anchorNodeId}` : undefined;
// Auto-scroll preference from user settings
const autoScrollEnabled = user?.preferences?.auto_scroll !== false;
const isStreaming = currentChatState === "streaming";
const { onSubmit, stopGenerating, handleMessageSpecificFileUpload } =
useChatController({
filterManager,
@@ -580,7 +593,7 @@ export default function ChatPage({ firstMessage }: ChatPageProps) {
open
onOpenChange={() => updateCurrentDocumentSidebarVisible(false)}
>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgFileText}
title="Sources"
@@ -627,7 +640,7 @@ export default function ChatPage({ firstMessage }: ChatPageProps) {
>
{({ getRootProps }) => (
<div
className="h-full w-full flex flex-col items-center outline-none"
className="h-full w-full flex flex-col items-center outline-none relative"
{...getRootProps({ tabIndex: -1 })}
>
{/* ProjectUI */}
@@ -640,19 +653,31 @@ export default function ChatPage({ firstMessage }: ChatPageProps) {
)}
{/* ChatUI */}
{!!currentChatSessionId && (
<ChatUI
ref={chatUiRef}
liveAssistant={liveAssistant}
llmManager={llmManager}
deepResearchEnabled={deepResearchEnabled}
currentMessageFiles={currentMessageFiles}
setPresentingDocument={setPresentingDocument}
onSubmit={onSubmit}
onMessageSelection={onMessageSelection}
stopGenerating={stopGenerating}
handleResubmitLastMessage={handleResubmitLastMessage}
/>
{!!currentChatSessionId && liveAssistant && (
<ChatScrollContainer
ref={scrollContainerRef}
sessionId={currentChatSessionId}
anchorSelector={anchorSelector}
autoScroll={autoScrollEnabled}
isStreaming={isStreaming}
onScrollButtonVisibilityChange={setShowScrollButton}
>
<AppLayouts.StickyHeader>
<ChatHeader />
</AppLayouts.StickyHeader>
<MessageList
liveAssistant={liveAssistant}
llmManager={llmManager}
deepResearchEnabled={deepResearchEnabled}
currentMessageFiles={currentMessageFiles}
setPresentingDocument={setPresentingDocument}
onSubmit={onSubmit}
onMessageSelection={onMessageSelection}
stopGenerating={stopGenerating}
onResubmit={handleResubmitLastMessage}
anchorNodeId={anchorNodeId}
/>
</ChatScrollContainer>
)}
{!currentChatSessionId && !currentProjectId && (
@@ -665,58 +690,82 @@ export default function ChatPage({ firstMessage }: ChatPageProps) {
</div>
)}
{/* ChatInputBar container */}
<div className="w-[min(50rem,100%)] pointer-events-auto z-sticky flex flex-col px-4 justify-center items-center">
{(showOnboarding ||
(user?.role !== UserRole.ADMIN &&
!user?.personalization?.name)) &&
currentProjectId === null && (
<OnboardingFlow
handleHideOnboarding={hideOnboarding}
handleFinishOnboarding={finishOnboarding}
state={onboardingState}
actions={onboardingActions}
llmDescriptors={llmDescriptors}
/>
{/* ChatInputBar container - absolutely positioned when in chat, centered when no session */}
<div
className={cn(
"flex justify-center",
currentChatSessionId
? "absolute bottom-0 left-0 right-0 pointer-events-none"
: "w-full"
)}
>
<div
className={cn(
"w-[min(50rem,100%)] z-sticky flex flex-col px-4",
currentChatSessionId && "pointer-events-auto"
)}
>
{/* Scroll to bottom button - positioned above ChatInputBar */}
{showScrollButton && (
<div className="mb-2 self-center">
<IconButton
icon={SvgChevronDown}
onClick={handleScrollToBottom}
aria-label="Scroll to bottom"
/>
</div>
)}
<ChatInputBar
ref={chatInputBarRef}
deepResearchEnabled={deepResearchEnabled}
toggleDeepResearch={toggleDeepResearch}
toggleDocumentSidebar={toggleDocumentSidebar}
filterManager={filterManager}
llmManager={llmManager}
removeDocs={() => setSelectedDocuments([])}
retrievalEnabled={retrievalEnabled}
selectedDocuments={selectedDocuments}
initialMessage={
searchParams?.get(SEARCH_PARAM_NAMES.USER_PROMPT) || ""
}
stopGenerating={stopGenerating}
onSubmit={handleChatInputSubmit}
onHeightChange={handleInputHeightChange}
chatState={currentChatState}
currentSessionFileTokenCount={
currentChatSessionId
? currentSessionFileTokenCount
: projectContextTokenCount
}
availableContextTokens={availableContextTokens}
selectedAssistant={selectedAssistant || liveAssistant}
handleFileUpload={handleMessageSpecificFileUpload}
setPresentingDocument={setPresentingDocument}
disabled={
(!llmManager.isLoadingProviders &&
llmManager.hasAnyProvider === false) ||
(!isLoadingOnboarding &&
onboardingState.currentStep !== OnboardingStep.Complete)
}
/>
{(showOnboarding ||
(user?.role !== UserRole.ADMIN &&
!user?.personalization?.name)) &&
currentProjectId === null && (
<OnboardingFlow
handleHideOnboarding={hideOnboarding}
handleFinishOnboarding={finishOnboarding}
state={onboardingState}
actions={onboardingActions}
llmDescriptors={llmDescriptors}
/>
)}
<Spacer rem={0.5} />
<ChatInputBar
ref={chatInputBarRef}
deepResearchEnabled={deepResearchEnabled}
toggleDeepResearch={toggleDeepResearch}
toggleDocumentSidebar={toggleDocumentSidebar}
filterManager={filterManager}
llmManager={llmManager}
removeDocs={() => setSelectedDocuments([])}
retrievalEnabled={retrievalEnabled}
selectedDocuments={selectedDocuments}
initialMessage={
searchParams?.get(SEARCH_PARAM_NAMES.USER_PROMPT) || ""
}
stopGenerating={stopGenerating}
onSubmit={handleChatInputSubmit}
chatState={currentChatState}
currentSessionFileTokenCount={
currentChatSessionId
? currentSessionFileTokenCount
: projectContextTokenCount
}
availableContextTokens={availableContextTokens}
selectedAssistant={selectedAssistant || liveAssistant}
handleFileUpload={handleMessageSpecificFileUpload}
setPresentingDocument={setPresentingDocument}
disabled={
(!llmManager.isLoadingProviders &&
llmManager.hasAnyProvider === false) ||
(!isLoadingOnboarding &&
onboardingState.currentStep !== OnboardingStep.Complete)
}
/>
{!!currentProjectId && <ProjectChatSessionList />}
<Spacer rem={0.5} />
{!!currentProjectId && <ProjectChatSessionList />}
</div>
</div>
{/* SearchUI */}

View File

@@ -73,7 +73,7 @@ export function ChatPopup() {
return (
<Modal open onOpenChange={() => {}}>
<Modal.Content tall>
<Modal.Content width="sm" height="lg">
<Modal.Header
titleClassName="text-text-04"
icon={headerIcon}

View File

@@ -38,6 +38,8 @@ import {
} from "@/app/chat/services/actionUtils";
import { SvgArrowUp, SvgHourglass, SvgPlusCircle, SvgStop } from "@opal/icons";
const LINE_HEIGHT = 24;
const MIN_INPUT_HEIGHT = 44;
const MAX_INPUT_HEIGHT = 200;
export interface SourceChipProps {
@@ -90,7 +92,6 @@ export interface ChatInputBarProps {
initialMessage?: string;
stopGenerating: () => void;
onSubmit: (message: string) => void;
onHeightChange?: (delta: number) => void;
llmManager: LlmManager;
chatState: ChatState;
currentSessionFileTokenCount: number;
@@ -121,7 +122,6 @@ const ChatInputBar = React.memo(
initialMessage = "",
stopGenerating,
onSubmit,
onHeightChange,
chatState,
currentSessionFileTokenCount,
availableContextTokens,
@@ -141,9 +141,6 @@ const ChatInputBar = React.memo(
const [message, setMessage] = useState(initialMessage);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const previousHeightRef = useRef<number | null>(null);
const onHeightChangeRef = useRef(onHeightChange);
onHeightChangeRef.current = onHeightChange;
// Expose reset and focus methods to parent via ref
React.useImperativeHandle(ref, () => ({
@@ -198,15 +195,37 @@ const ChatInputBar = React.memo(
const combinedSettings = useContext(SettingsContext);
// Track previous message to detect when lines might decrease
const prevMessageRef = useRef("");
// Auto-resize textarea based on content
useEffect(() => {
const textarea = textAreaRef.current;
if (textarea) {
textarea.style.height = "0px"; // this is necessary in order to "reset" the scrollHeight
textarea.style.height = `${Math.min(
textarea.scrollHeight,
MAX_INPUT_HEIGHT
)}px`;
const prevLineCount = (prevMessageRef.current.match(/\n/g) || [])
.length;
const currLineCount = (message.match(/\n/g) || []).length;
const lineRemoved = currLineCount < prevLineCount;
prevMessageRef.current = message;
if (message.length === 0) {
textarea.style.height = `${MIN_INPUT_HEIGHT}px`;
return;
} else if (lineRemoved) {
const linesRemoved = prevLineCount - currLineCount;
textarea.style.height = `${Math.max(
MIN_INPUT_HEIGHT,
Math.min(
textarea.scrollHeight - LINE_HEIGHT * linesRemoved,
MAX_INPUT_HEIGHT
)
)}px`;
} else {
textarea.style.height = `${Math.min(
textarea.scrollHeight,
MAX_INPUT_HEIGHT
)}px`;
}
}
}, [message]);
@@ -216,27 +235,6 @@ const ChatInputBar = React.memo(
}
}, [initialMessage]);
// Detect height changes and notify parent for scroll adjustment
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const newHeight = entry.contentRect.height;
if (previousHeightRef.current !== null) {
const delta = newHeight - previousHeightRef.current;
if (delta !== 0) {
onHeightChangeRef.current?.(delta);
}
}
previousHeightRef.current = newHeight;
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
const handlePaste = (event: React.ClipboardEvent) => {
const items = event.clipboardData?.items;
if (items) {

View File

@@ -93,7 +93,7 @@ export default function FeedbackModal({
{popup}
<Modal open={modal.isOpen} onOpenChange={modal.toggle}>
<Modal.Content mini>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={icon}
title="Provide Additional Feedback"

View File

@@ -643,6 +643,7 @@ export function useChatController({
let toolCall: ToolCallMetadata | null = null;
let files = projectFilesToFileDescriptors(currentMessageFiles);
let packets: Packet[] = [];
let packetsVersion = 0;
let newUserMessageId: number | null = null;
let newAssistantMessageId: number | null = null;
@@ -729,7 +730,6 @@ export function useChatController({
if (!packet) {
continue;
}
console.debug("Packet:", JSON.stringify(packet));
// We've processed initial packets and are starting to stream content.
// Transition from 'loading' to 'streaming'.
@@ -800,8 +800,8 @@ export function useChatController({
updateCanContinue(true, frozenSessionId);
}
} else if (Object.hasOwn(packet, "obj")) {
console.debug("Object packet:", JSON.stringify(packet));
packets.push(packet as Packet);
packetsVersion++;
// Check if the packet contains document information
const packetObj = (packet as Packet).obj;
@@ -859,6 +859,7 @@ export function useChatController({
overridden_model: finalMessage?.overridden_model,
stopReason: stopReason,
packets: packets,
packetsVersion: packetsVersion,
},
],
// Pass the latest map state

View File

@@ -139,6 +139,8 @@ export interface Message {
// new gen
packets: Packet[];
// Version counter for efficient memo comparison (increments with each packet)
packetsVersion?: number;
// cached values for easy access
documents?: OnyxDocument[] | null;

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

@@ -10,8 +10,7 @@ import IconButton from "@/refresh-components/buttons/IconButton";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import Button from "@/refresh-components/buttons/Button";
import { SvgEdit } from "@opal/icons";
import FileDisplay from "@/app/chat/message/FileDisplay";
import { useTripleClickSelect } from "@/hooks/useTripleClickSelect";
import FileDisplay from "./FileDisplay";
interface MessageEditingProps {
content: string;
@@ -140,10 +139,6 @@ const HumanMessage = React.memo(function HumanMessage({
const [isEditing, setIsEditing] = useState(false);
// Ref for the text content element (for triple-click selection)
const textContentRef = useRef<HTMLDivElement>(null);
const handleTripleClick = useTripleClickSelect(textContentRef);
// Use nodeId for switching (finding position in siblings)
const indexInSiblings = otherMessagesCanSwitchTo?.indexOf(nodeId);
// indexOf returns -1 if not found, treat that as undefined
@@ -200,34 +195,18 @@ const HumanMessage = React.memo(function HumanMessage({
<>
<div className="md:max-w-[25rem] flex basis-[100%] md:basis-auto justify-end md:order-1">
<div
ref={textContentRef}
className={
"max-w-[25rem] whitespace-break-spaces rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3 cursor-text"
"max-w-[25rem] whitespace-break-spaces rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3"
}
onMouseDown={handleTripleClick}
onCopy={(e) => {
e.preventDefault();
const selection = window.getSelection();
if (!selection || !selection.rangeCount) {
e.clipboardData.setData("text/plain", content);
return;
}
const range = selection.getRangeAt(0);
const selectedText = selection
.toString()
.replace(/\n{2,}/g, "\n")
.trim();
// Check if selection is within this element using DOM containment
if (
textContentRef.current?.contains(
range.commonAncestorContainer
)
) {
e.clipboardData.setData("text/plain", selectedText);
} else {
e.clipboardData.setData("text/plain", content);
if (selection) {
e.preventDefault();
const text = selection
.toString()
.replace(/\n{2,}/g, "\n")
.trim();
e.clipboardData.setData("text/plain", text);
}
}}
>

View File

@@ -62,7 +62,6 @@ import { usePopup } from "@/components/admin/connectors/Popup";
import { useFeedbackController } from "../../hooks/useFeedbackController";
import { SvgThumbsDown, SvgThumbsUp } from "@opal/icons";
import Text from "@/refresh-components/texts/Text";
import { useTripleClickSelect } from "@/hooks/useTripleClickSelect";
// Type for the regeneration factory function passed from ChatUI
export type RegenerationFactory = (regenerationRequest: {
@@ -73,6 +72,8 @@ export type RegenerationFactory = (regenerationRequest: {
export interface AIMessageProps {
rawPackets: Packet[];
// Version counter for efficient memo comparison (avoids array copying)
packetsVersion?: number;
chatState: FullChatState;
nodeId: number;
messageId?: number;
@@ -87,8 +88,6 @@ export interface AIMessageProps {
}
// TODO: Consider more robust comparisons:
// - `rawPackets.length` assumes packets are append-only. Could compare the last
// packet or use a shallow comparison if packets can be modified in place.
// - `chatState.docs`, `chatState.citations`, and `otherMessagesCanSwitchTo` use
// reference equality. Shallow array/object comparison would be more robust if
// these are recreated with the same values.
@@ -97,7 +96,7 @@ function arePropsEqual(prev: AIMessageProps, next: AIMessageProps): boolean {
prev.nodeId === next.nodeId &&
prev.messageId === next.messageId &&
prev.currentFeedback === next.currentFeedback &&
prev.rawPackets.length === next.rawPackets.length &&
prev.packetsVersion === next.packetsVersion &&
prev.chatState.assistant?.id === next.chatState.assistant?.id &&
prev.chatState.docs === next.chatState.docs &&
prev.chatState.citations === next.chatState.citations &&
@@ -126,7 +125,6 @@ const AIMessage = React.memo(function AIMessage({
}: AIMessageProps) {
const markdownRef = useRef<HTMLDivElement>(null);
const finalAnswerRef = useRef<HTMLDivElement>(null);
const handleTripleClick = useTripleClickSelect(markdownRef);
const { popup, setPopup } = usePopup();
const { handleFeedbackChange } = useFeedbackController({ setPopup });
@@ -229,6 +227,14 @@ const AIMessage = React.memo(function AIMessage({
);
const stopReasonRef = useRef<StopReason | undefined>(undefined);
// Track specifically when MESSAGE_START arrives (for collapsing the tools header).
// This is separate from finalAnswerComing which can be set by onAllToolsDisplayed
// or by PYTHON/IMAGE tool packets.
const [hasTextMessageStarted, setHasTextMessageStarted] = useState(
rawPackets.some((p) => p.obj.type === PacketType.MESSAGE_START)
);
const hasTextMessageStartedRef = useRef(hasTextMessageStarted);
// Incremental packet processing state
const lastProcessedIndexRef = useRef<number>(0);
const citationsRef = useRef<StreamingCitation[]>([]);
@@ -267,6 +273,10 @@ const AIMessage = React.memo(function AIMessage({
seenGroupKeysRef.current = new Set();
groupKeysWithSectionEndRef.current = new Set();
expectedBranchesRef.current = new Map();
hasTextMessageStartedRef.current = rawPackets.some(
(p) => p.obj.type === PacketType.MESSAGE_START
);
setHasTextMessageStarted(hasTextMessageStartedRef.current);
};
useEffect(() => {
resetState();
@@ -425,6 +435,15 @@ const AIMessage = React.memo(function AIMessage({
finalAnswerComingRef.current = true;
}
// Track specifically when MESSAGE_START arrives (for collapsing tools header)
if (
packet.obj.type === PacketType.MESSAGE_START &&
!hasTextMessageStartedRef.current
) {
setHasTextMessageStarted(true);
hasTextMessageStartedRef.current = true;
}
if (packet.obj.type === PacketType.STOP && !stopPacketSeenRef.current) {
setStopPacketSeen(true);
// Extract and store the stop reason
@@ -538,8 +557,7 @@ const AIMessage = React.memo(function AIMessage({
<div className="max-w-message-max break-words pl-4 w-full">
<div
ref={markdownRef}
className="overflow-x-visible max-w-content-max focus:outline-none select-text cursor-text"
onMouseDown={handleTripleClick}
className="overflow-x-visible max-w-content-max focus:outline-none select-text"
onCopy={(e) => {
if (markdownRef.current) {
handleCopy(e, markdownRef as RefObject<HTMLDivElement>);
@@ -586,6 +604,7 @@ const AIMessage = React.memo(function AIMessage({
isStreaming={globalChatState === "streaming"}
onAllToolsDisplayed={() => setFinalAnswerComing(true)}
expectedBranchesPerTurn={expectedBranchesRef.current}
hasTextMessageStarted={hasTextMessageStarted}
/>
)}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect, JSX } from "react";
import { useState, useMemo, useEffect, useRef, JSX } from "react";
import {
FiCheckCircle,
FiChevronRight,
@@ -558,6 +558,7 @@ export default function MultiToolRenderer({
onAllToolsDisplayed,
isStreaming,
expectedBranchesPerTurn,
hasTextMessageStarted,
}: {
packetGroups: { turn_index: number; tab_index: number; packets: Packet[] }[];
chatState: FullChatState;
@@ -569,10 +570,24 @@ export default function MultiToolRenderer({
isStreaming?: boolean;
// Map of turn_index -> expected number of parallel branches (from TopLevelBranching packet)
expectedBranchesPerTurn?: Map<number, number>;
// True when MESSAGE_START packet has arrived (specifically for text messages,
// not PYTHON/IMAGE tools). Used to determine when to collapse the tools header.
hasTextMessageStarted?: boolean;
}) {
const [isExpanded, setIsExpanded] = useState(false);
const [isStreamingExpanded, setIsStreamingExpanded] = useState(false);
// Track if hasTextMessageStarted has ever been true.
// Once true, we always show the collapsed view and never revert to streaming view.
// This is latched because we want the user to control expansion after the initial collapse.
const hasEverSeenTextMessageRef = useRef(hasTextMessageStarted ?? false);
useEffect(() => {
if (hasTextMessageStarted) {
hasEverSeenTextMessageRef.current = true;
}
}, [hasTextMessageStarted]);
const toolGroups = useMemo(() => {
return packetGroups.filter(
(group) => group.packets[0] && isToolPacket(group.packets[0], false)
@@ -749,8 +764,13 @@ export default function MultiToolRenderer({
return uniqueTabIndices.size > 1;
};
// If still processing, show tools progressively with timing
if (!isComplete) {
// If the text message hasn't started yet, show tools progressively with timing.
// We use hasEverSeenTextMessageRef (latched) instead of isComplete directly to prevent
// flickering. isComplete (finalAnswerComing) can be set by onAllToolsDisplayed or by
// PYTHON/IMAGE tools before the actual text message starts. hasTextMessageStarted is
// only set when MESSAGE_START arrives. Once true, we switch to the collapsed view
// permanently and let the user control expansion.
if (!hasEverSeenTextMessageRef.current) {
// Filter display items to only show those whose (turn_index, tab_index) is visible
const itemsToDisplay = displayItems.filter((item) =>
visibleTools.has(`${item.turn_index}-${item.tab_index}`)

View File

@@ -29,7 +29,7 @@ import {
useCurrentChatState,
useCurrentMessageHistory,
} from "@/app/chat/stores/useChatSessionStore";
import ChatUI from "@/sections/ChatUI";
import MessageList from "@/components/chat/MessageList";
import useChatSessions from "@/hooks/useChatSessions";
import { cn } from "@/lib/utils";
import Logo from "@/refresh-components/Logo";
@@ -350,17 +350,19 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
{/* Scrollable messages area */}
<div className="nrf-messages-scroll">
<div className="nrf-messages-content">
<ChatUI
liveAssistant={resolvedAssistant}
llmManager={llmManager}
currentMessageFiles={currentMessageFiles}
setPresentingDocument={() => {}}
onSubmit={onSubmit}
onMessageSelection={() => {}}
stopGenerating={stopGenerating}
handleResubmitLastMessage={handleResubmitLastMessage}
deepResearchEnabled={deepResearchEnabled}
/>
{resolvedAssistant && (
<MessageList
liveAssistant={resolvedAssistant}
llmManager={llmManager}
currentMessageFiles={currentMessageFiles}
setPresentingDocument={() => {}}
onSubmit={onSubmit}
onMessageSelection={() => {}}
stopGenerating={stopGenerating}
onResubmit={handleResubmitLastMessage}
deepResearchEnabled={deepResearchEnabled}
/>
)}
</div>
</div>
@@ -461,7 +463,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
/>
<Modal open={showTurnOffModal} onOpenChange={setShowTurnOffModal}>
<Modal.Content mini>
<Modal.Content width="sm">
<Modal.Header
icon={SvgAlertTriangle}
title="Turn off Onyx new tab page?"
@@ -483,7 +485,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
{!user && authTypeMetadata.authType !== AuthType.DISABLED && (
<Modal open onOpenChange={() => {}}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header icon={SvgUser} title="Welcome to Onyx" />
<Modal.Body>
{authTypeMetadata.authType === AuthType.BASIC ? (

View File

@@ -4,8 +4,7 @@ import {
StreamStopInfo,
} from "@/lib/search/interfaces";
import { handleSSEStream } from "@/lib/search/streamingUtils";
import { ChatState, FeedbackType } from "@/app/chat/interfaces";
import { MutableRefObject, RefObject, useEffect, useRef } from "react";
import { FeedbackType } from "@/app/chat/interfaces";
import {
BackendMessage,
DocumentsResponse,
@@ -457,104 +456,3 @@ export async function uploadFilesForChat(
return [responseJson.files as FileDescriptor[], null];
}
export function useScrollonStream({
chatState,
scrollableDivRef,
scrollDist,
endDivRef,
debounceNumber,
mobile,
enableAutoScroll,
}: {
chatState: ChatState;
scrollableDivRef: RefObject<HTMLDivElement | null>;
scrollDist: MutableRefObject<number>;
endDivRef: RefObject<HTMLDivElement | null>;
debounceNumber: number;
mobile?: boolean;
enableAutoScroll?: boolean;
}) {
const mobileDistance = 900; // distance that should "engage" the scroll
const desktopDistance = 500; // distance that should "engage" the scroll
const distance = mobile ? mobileDistance : desktopDistance;
const preventScrollInterference = useRef<boolean>(false);
const preventScroll = useRef<boolean>(false);
const blockActionRef = useRef<boolean>(false);
const previousScroll = useRef<number>(0);
useEffect(() => {
if (!enableAutoScroll) {
return;
}
if (chatState != "input" && scrollableDivRef && scrollableDivRef.current) {
const newHeight: number = scrollableDivRef.current?.scrollTop!;
const heightDifference = newHeight - previousScroll.current;
previousScroll.current = newHeight;
// Prevent streaming scroll
if (heightDifference < 0 && !preventScroll.current) {
scrollableDivRef.current.style.scrollBehavior = "auto";
scrollableDivRef.current.scrollTop = scrollableDivRef.current.scrollTop;
scrollableDivRef.current.style.scrollBehavior = "smooth";
preventScrollInterference.current = true;
preventScroll.current = true;
setTimeout(() => {
preventScrollInterference.current = false;
}, 2000);
setTimeout(() => {
preventScroll.current = false;
}, 10000);
}
// Ensure can scroll if scroll down
else if (!preventScrollInterference.current) {
preventScroll.current = false;
}
if (
scrollDist.current < distance &&
!blockActionRef.current &&
!blockActionRef.current &&
!preventScroll.current &&
endDivRef &&
endDivRef.current
) {
// catch up if necessary!
const scrollAmount = scrollDist.current + (mobile ? 1000 : 10000);
if (scrollDist.current > 300) {
// if (scrollDist.current > 140) {
endDivRef.current.scrollIntoView();
} else {
blockActionRef.current = true;
scrollableDivRef?.current?.scrollBy({
left: 0,
top: Math.max(0, scrollAmount),
behavior: "smooth",
});
setTimeout(() => {
blockActionRef.current = false;
}, debounceNumber);
}
}
}
});
// scroll on end of stream if within distance
useEffect(() => {
if (scrollableDivRef?.current && chatState == "input" && enableAutoScroll) {
if (scrollDist.current < distance - 50) {
scrollableDivRef?.current?.scrollBy({
left: 0,
top: Math.max(scrollDist.current + 600, 0),
behavior: "smooth",
});
}
}
}, [chatState, distance, scrollDist, scrollableDivRef, enableAutoScroll]);
}

View File

@@ -35,7 +35,7 @@ export default function UserGroupCreationForm({
return (
<Modal open onOpenChange={onClose}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgUsers}
title={isUpdate ? "Update a User Group" : "Create a new User Group"}

View File

@@ -33,7 +33,7 @@ export default function AddConnectorForm({
return (
<Modal open onOpenChange={onClose}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgPlus}
title="Add New Connector"

View File

@@ -22,7 +22,7 @@ export default function AddMemberForm({
return (
<Modal open onOpenChange={onClose}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgUserPlus}
title="Add New User"

View File

@@ -4,8 +4,9 @@ import { use } from "react";
import { GroupDisplay } from "./GroupDisplay";
import { useSpecificUserGroup } from "./hook";
import { ThreeDotsLoader } from "@/components/Loading";
import { useConnectorStatus, useUsers } from "@/lib/hooks";
import { useConnectorStatus } from "@/lib/hooks";
import { useRouter } from "next/navigation";
import useUsers from "@/hooks/useUsers";
import BackButton from "@/refresh-components/buttons/BackButton";
import { AdminPageTitle } from "@/components/admin/Title";
import { SvgUsers } from "@opal/icons";

View File

@@ -5,8 +5,9 @@ import UserGroupCreationForm from "./UserGroupCreationForm";
import { usePopup } from "@/components/admin/connectors/Popup";
import { useState } from "react";
import { ThreeDotsLoader } from "@/components/Loading";
import { useConnectorStatus, useUserGroups, useUsers } from "@/lib/hooks";
import { useConnectorStatus, useUserGroups } from "@/lib/hooks";
import { AdminPageTitle } from "@/components/admin/Title";
import useUsers from "@/hooks/useUsers";
import { useUser } from "@/components/user/UserProvider";
import CreateButton from "@/refresh-components/buttons/CreateButton";

View File

@@ -178,7 +178,7 @@ function PreviousQueryHistoryExportsModal({
return (
<Modal open onOpenChange={() => setShowModal(false)}>
<Modal.Content large>
<Modal.Content width="lg" height="full">
<Modal.Header
icon={SvgFileText}
title="Previous Query History Exports"

View File

@@ -30,13 +30,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

@@ -56,7 +56,7 @@ export default function ResetPasswordModal({
return (
<Modal open onOpenChange={onClose}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgKey}
title="Reset Password"

View File

@@ -0,0 +1,420 @@
"use client";
import React, {
ForwardedRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
// Size constants
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 {
isAtBottom: boolean;
hasContentAbove: boolean;
hasContentBelow: boolean;
}
export interface ChatScrollContainerHandle {
scrollToBottom: (behavior?: ScrollBehavior) => void;
}
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
*/
anchorSelector?: string;
/** Enable auto-scroll behavior (follow new content) */
autoScroll?: boolean;
/** Whether content is currently streaming (affects scroll button visibility) */
isStreaming?: boolean;
/** Callback when scroll button visibility should change */
onScrollButtonVisibilityChange?: (visible: boolean) => void;
/** Session ID - resets scroll state when changed */
sessionId?: string;
}
const FadeOverlay = React.memo(
({ show, position }: { show: boolean; position: "top" | "bottom" }) => {
if (!show) return null;
const isTop = position === "top";
return (
<div
aria-hidden="true"
className={`absolute left-0 right-0 ${FADE_OVERLAY_HEIGHT} z-sticky pointer-events-none ${
isTop ? "top-0" : "bottom-0"
}`}
style={{
background: `linear-gradient(${
isTop ? "to bottom" : "to top"
}, var(--background-tint-01) 0%, transparent 100%)`,
}}
/>
);
}
);
FadeOverlay.displayName = "FadeOverlay";
const ChatScrollContainer = React.memo(
React.forwardRef(
(
{
children,
anchorSelector,
autoScroll = true,
isStreaming = false,
onScrollButtonVisibilityChange,
sessionId,
}: ChatScrollContainerProps,
ref: ForwardedRef<ChatScrollContainerHandle>
) => {
const anchorOffsetPx = DEFAULT_ANCHOR_OFFSET_PX;
const fadeThresholdPx = DEFAULT_FADE_THRESHOLD_PX;
const buttonThresholdPx = DEFAULT_BUTTON_THRESHOLD_PX;
const scrollContainerRef = useRef<HTMLDivElement>(null);
const endDivRef = useRef<HTMLDivElement>(null);
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);
const isAtBottomRef = useRef(true); // Ref for use in callbacks
const isAutoScrollingRef = useRef(false); // Prevent handleScroll from interfering during auto-scroll
const prevScrollTopRef = useRef(0); // Track scroll position to detect scroll direction
const [isScrollReady, setIsScrollReady] = useState(false);
// Use refs for values that change during streaming to prevent effect re-runs
const onScrollButtonVisibilityChangeRef = useRef(
onScrollButtonVisibilityChange
);
onScrollButtonVisibilityChangeRef.current =
onScrollButtonVisibilityChange;
const autoScrollRef = useRef(autoScroll);
autoScrollRef.current = autoScroll;
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;
if (!container || !endDivRef.current) {
return {
isAtBottom: true,
hasContentAbove: false,
hasContentBelow: false,
};
}
const contentEnd = endDivRef.current.offsetTop;
const viewportBottom = container.scrollTop + container.clientHeight;
const contentBelowViewport = contentEnd - viewportBottom;
return {
isAtBottom: contentBelowViewport <= buttonThresholdPx,
hasContentAbove: container.scrollTop > fadeThresholdPx,
hasContentBelow: contentBelowViewport > fadeThresholdPx,
};
}, [buttonThresholdPx, fadeThresholdPx]);
// Update scroll state and notify parent about button visibility
const updateScrollState = useCallback(() => {
const state = getScrollState();
setIsAtBottom(state.isAtBottom);
isAtBottomRef.current = state.isAtBottom; // Keep ref in sync
setHasContentAbove(state.hasContentAbove);
setHasContentBelow(state.hasContentBelow);
// Show button when user is not at bottom (e.g., scrolled up)
onScrollButtonVisibilityChangeRef.current?.(!state.isAtBottom);
}, [getScrollState]);
// Scroll to bottom of content
const scrollToBottom = useCallback(
(behavior: ScrollBehavior = "smooth") => {
const container = scrollContainerRef.current;
if (!container || !endDivRef.current) return;
// Mark as auto-scrolling to prevent handleScroll interference
isAutoScrollingRef.current = true;
// Use scrollTo instead of scrollIntoView for better cross-browser support
const targetScrollTop =
container.scrollHeight - container.clientHeight;
container.scrollTo({ top: targetScrollTop, behavior });
// Update tracking refs
prevScrollTopRef.current = targetScrollTop;
isAtBottomRef.current = true;
// For smooth scrolling, keep isAutoScrollingRef true longer
if (behavior === "smooth") {
// Clear after animation likely completes (Safari smooth scroll is ~500ms)
setTimeout(() => {
isAutoScrollingRef.current = false;
if (container) {
prevScrollTopRef.current = container.scrollTop;
}
}, 600);
} else {
isAutoScrollingRef.current = false;
}
},
[]
);
// Expose scrollToBottom via ref
useImperativeHandle(ref, () => ({ scrollToBottom }), [scrollToBottom]);
// Re-evaluate button visibility when at-bottom state changes
useEffect(() => {
onScrollButtonVisibilityChangeRef.current?.(!isAtBottom);
}, [isAtBottom]);
// Handle scroll events (user scrolls)
const handleScroll = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
// Skip if this scroll was triggered by auto-scroll
if (isAutoScrollingRef.current) return;
const currentScrollTop = container.scrollTop;
const scrolledUp = currentScrollTop < prevScrollTopRef.current - 5; // 5px threshold to ignore micro-movements
prevScrollTopRef.current = currentScrollTop;
// Only update isAtBottomRef when user explicitly scrolls UP
// This prevents content growth or programmatic scrolls from disabling auto-scroll
if (scrolledUp) {
updateScrollState();
} else {
// Still update fade overlays, but preserve isAtBottomRef
const state = getScrollState();
setHasContentAbove(state.hasContentAbove);
setHasContentBelow(state.hasContentBelow);
// 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]);
// Watch for content changes (MutationObserver + ResizeObserver)
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
let rafId: number | null = null;
const onContentChange = () => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = null;
// 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
scrollToBottom("instant");
}
updateScrollState();
});
};
// MutationObserver for content changes
const mutationObserver = new MutationObserver(onContentChange);
mutationObserver.observe(container, {
childList: true,
subtree: true,
characterData: true,
});
// ResizeObserver for container size changes
const resizeObserver = new ResizeObserver(onContentChange);
resizeObserver.observe(container);
return () => {
mutationObserver.disconnect();
resizeObserver.disconnect();
if (rafId) cancelAnimationFrame(rafId);
};
}, [anchorSelector, calcSpacerHeight, updateScrollState, scrollToBottom]);
// Handle session changes and anchor changes
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const isNewSession =
scrolledForSessionRef.current !== null &&
scrolledForSessionRef.current !== sessionId;
const isNewAnchor = prevAnchorSelectorRef.current !== anchorSelector;
// Reset on session change
if (isNewSession) {
scrolledForSessionRef.current = null;
setIsScrollReady(false);
prevScrollTopRef.current = 0;
isAtBottomRef.current = true;
}
const shouldScroll =
(scrolledForSessionRef.current !== sessionId || isNewAnchor) &&
anchorSelector;
if (!shouldScroll) {
prevAnchorSelectorRef.current = anchorSelector ?? null;
return;
}
const anchorElement = container.querySelector(
anchorSelector!
) as HTMLElement;
if (!anchorElement || !endDivRef.current) {
setIsScrollReady(true);
scrolledForSessionRef.current = sessionId ?? null;
prevAnchorSelectorRef.current = anchorSelector ?? null;
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 =
isNewSession || scrolledForSessionRef.current === null;
const behavior: ScrollBehavior = isLoadingExistingContent
? "instant"
: "smooth";
// Defer scroll to next tick so spacer height takes effect
const timeoutId = setTimeout(() => {
const targetScrollTop = Math.max(
0,
anchorElement.offsetTop - anchorOffsetPx
);
container.scrollTo({ top: targetScrollTop, behavior });
// Update prevScrollTopRef so scroll direction is measured from new position
prevScrollTopRef.current = targetScrollTop;
updateScrollState();
// When autoScroll is on, assume we're "at bottom" after positioning
// so that MutationObserver will continue auto-scrolling
if (autoScrollRef.current) {
isAtBottomRef.current = true;
}
setIsScrollReady(true);
scrolledForSessionRef.current = sessionId ?? null;
prevAnchorSelectorRef.current = anchorSelector ?? null;
}, 0);
return () => clearTimeout(timeoutId);
}, [
sessionId,
anchorSelector,
anchorOffsetPx,
calcSpacerHeight,
updateScrollState,
]);
return (
<div className="flex flex-col flex-1 min-h-0 w-full relative overflow-hidden mb-[7.5rem]">
<FadeOverlay show={hasContentAbove} position="top" />
<FadeOverlay show={hasContentBelow} position="bottom" />
<div
key={sessionId}
ref={scrollContainerRef}
className="flex flex-1 justify-center min-h-0 overflow-y-auto overflow-x-hidden default-scrollbar"
onScroll={handleScroll}
style={{
scrollbarGutter: "stable both-edges",
}}
>
<div
className="w-full flex flex-col items-center"
data-scroll-ready={isScrollReady}
style={{
visibility: isScrollReady ? "visible" : "hidden",
}}
>
{children}
{/* End marker - before spacer so we can 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>
);
}
)
);
ChatScrollContainer.displayName = "ChatScrollContainer";
export default ChatScrollContainer;

View File

@@ -128,7 +128,7 @@ export default function FederatedOAuthModal() {
return (
<Modal open>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgLink}
title="Connect Your Apps"

View File

@@ -153,7 +153,7 @@ export default function MCPApiKeyModal({
const credsType = isTemplateMode ? "Credentials" : "API Key";
return (
<Modal open={isOpen} onOpenChange={handleClose}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgKey}
title={isAuthenticated ? `Manage ${credsType}` : `Enter ${credsType}`}

View File

@@ -0,0 +1,229 @@
"use client";
import React, { useCallback, useMemo, useRef } from "react";
import { Message } from "@/app/chat/interfaces";
import { OnyxDocument, MinimalOnyxDocument } from "@/lib/search/interfaces";
import HumanMessage from "@/app/chat/message/HumanMessage";
import { ErrorBanner } from "@/app/chat/message/Resubmit";
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
import { LlmDescriptor, LlmManager } from "@/lib/hooks";
import AIMessage from "@/app/chat/message/messageComponents/AIMessage";
import Spacer from "@/refresh-components/Spacer";
import {
useCurrentMessageHistory,
useCurrentMessageTree,
useLoadingError,
useUncaughtError,
} from "@/app/chat/stores/useChatSessionStore";
export interface MessageListProps {
liveAssistant: MinimalPersonaSnapshot;
llmManager: LlmManager;
setPresentingDocument: (doc: MinimalOnyxDocument | null) => void;
onMessageSelection: (nodeId: number) => void;
stopGenerating: () => void;
// Submit handlers
onSubmit: (args: {
message: string;
messageIdToResend?: number;
currentMessageFiles: any[];
deepResearch: boolean;
modelOverride?: LlmDescriptor;
regenerationRequest?: {
messageId: number;
parentMessage: Message;
forceSearch?: boolean;
};
forceSearch?: boolean;
}) => Promise<void>;
deepResearchEnabled: boolean;
currentMessageFiles: any[];
onResubmit: () => void;
/**
* Node ID of the message to use as scroll anchor.
* This message will get a data-anchor attribute for ChatScrollContainer.
*/
anchorNodeId?: number;
}
const MessageList = React.memo(
({
liveAssistant,
llmManager,
setPresentingDocument,
onMessageSelection,
stopGenerating,
onSubmit,
deepResearchEnabled,
currentMessageFiles,
onResubmit,
anchorNodeId,
}: MessageListProps) => {
// Get messages and error state from store
const messages = useCurrentMessageHistory();
const messageTree = useCurrentMessageTree();
const error = useUncaughtError();
const loadError = useLoadingError();
// Stable fallbacks to avoid changing prop identities on each render
const emptyDocs = useMemo<OnyxDocument[]>(() => [], []);
const emptyChildrenIds = useMemo<number[]>(() => [], []);
// Use refs to keep callbacks stable while always using latest values
const onSubmitRef = useRef(onSubmit);
const deepResearchEnabledRef = useRef(deepResearchEnabled);
const currentMessageFilesRef = useRef(currentMessageFiles);
onSubmitRef.current = onSubmit;
deepResearchEnabledRef.current = deepResearchEnabled;
currentMessageFilesRef.current = currentMessageFiles;
const createRegenerator = useCallback(
(regenerationRequest: {
messageId: number;
parentMessage: Message;
forceSearch?: boolean;
}) => {
return async function (modelOverride: LlmDescriptor) {
return await onSubmitRef.current({
message: regenerationRequest.parentMessage.message,
currentMessageFiles: currentMessageFilesRef.current,
deepResearch: deepResearchEnabledRef.current,
modelOverride,
messageIdToResend: regenerationRequest.parentMessage.messageId,
regenerationRequest,
forceSearch: regenerationRequest.forceSearch,
});
};
},
[]
);
const handleEditWithMessageId = useCallback(
(editedContent: string, msgId: number) => {
onSubmitRef.current({
message: editedContent,
messageIdToResend: msgId,
currentMessageFiles: [],
deepResearch: deepResearchEnabledRef.current,
});
},
[]
);
return (
<div className="w-[min(50rem,100%)] px-6">
<Spacer />
{messages.map((message, i) => {
const messageReactComponentKey = `message-${message.nodeId}`;
const parentMessage = message.parentNodeId
? messageTree?.get(message.parentNodeId)
: null;
const isAnchor = message.nodeId === anchorNodeId;
if (message.type === "user") {
const nextMessage =
messages.length > i + 1 ? messages[i + 1] : null;
return (
<div
id={messageReactComponentKey}
key={messageReactComponentKey}
data-anchor={isAnchor ? "true" : undefined}
>
<HumanMessage
disableSwitchingForStreaming={
(nextMessage && nextMessage.is_generating) || false
}
stopGenerating={stopGenerating}
content={message.message}
files={message.files}
messageId={message.messageId}
nodeId={message.nodeId}
onEdit={handleEditWithMessageId}
otherMessagesCanSwitchTo={
parentMessage?.childrenNodeIds ?? emptyChildrenIds
}
onMessageSelection={onMessageSelection}
/>
</div>
);
} else if (message.type === "assistant") {
if ((error || loadError) && i === messages.length - 1) {
return (
<div key={`error-${message.nodeId}`} className="p-4">
<ErrorBanner
resubmit={onResubmit}
error={error || loadError || ""}
errorCode={message.errorCode || undefined}
isRetryable={message.isRetryable ?? true}
details={message.errorDetails || undefined}
stackTrace={message.stackTrace || undefined}
/>
</div>
);
}
const previousMessage = i !== 0 ? messages[i - 1] : null;
const chatStateData = {
assistant: liveAssistant,
docs: message.documents ?? emptyDocs,
citations: message.citations,
setPresentingDocument,
overriddenModel: llmManager.currentLlm?.modelName,
researchType: message.researchType,
};
return (
<div
id={`message-${message.nodeId}`}
key={messageReactComponentKey}
data-anchor={isAnchor ? "true" : undefined}
>
<AIMessage
rawPackets={message.packets}
packetsVersion={message.packetsVersion}
chatState={chatStateData}
nodeId={message.nodeId}
messageId={message.messageId}
currentFeedback={message.currentFeedback}
llmManager={llmManager}
otherMessagesCanSwitchTo={
parentMessage?.childrenNodeIds ?? emptyChildrenIds
}
onMessageSelection={onMessageSelection}
onRegenerate={createRegenerator}
parentMessage={previousMessage}
/>
</div>
);
}
return null;
})}
{/* Error banner when last message is user message or error type */}
{(((error !== null || loadError !== null) &&
messages[messages.length - 1]?.type === "user") ||
messages[messages.length - 1]?.type === "error") && (
<div className="p-4">
<ErrorBanner
resubmit={onResubmit}
error={error || loadError || ""}
errorCode={messages[messages.length - 1]?.errorCode || undefined}
isRetryable={messages[messages.length - 1]?.isRetryable ?? true}
details={messages[messages.length - 1]?.errorDetails || undefined}
stackTrace={
messages[messages.length - 1]?.stackTrace || undefined
}
/>
</div>
)}
</div>
);
}
);
MessageList.displayName = "MessageList";
export default MessageList;

View File

@@ -201,7 +201,8 @@ export default function TextView({
}}
>
<Modal.Content
large
width="lg"
height="full"
preventAccidentalClose={false}
onOpenAutoFocus={(e) => e.preventDefault()}
>

View File

@@ -244,7 +244,7 @@ export default function CredentialSection({
{showModifyCredential && (
<Modal open onOpenChange={closeModifyCredential}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgEdit}
title="Update Credentials"
@@ -272,7 +272,7 @@ export default function CredentialSection({
{editingCredential && (
<Modal open onOpenChange={closeEditingCredential}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgEdit}
title="Edit Credential"
@@ -292,7 +292,7 @@ export default function CredentialSection({
{showCreateCredential && (
<Modal open onOpenChange={closeCreateCredential}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgKey}
title={`Create ${getSourceDisplayName(sourceType)} Credential`}

View File

@@ -190,7 +190,7 @@ export default function ModifyCredential({
<>
{confirmDeletionCredential != null && (
<Modal open onOpenChange={() => setConfirmDeletionCredential(null)}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgAlertTriangle}
title="Confirm Deletion"

View File

@@ -185,7 +185,7 @@ export const HealthCheckBanner = () => {
if (showLoggedOutModal) {
return (
<Modal open>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header icon={SvgLogOut} title="You Have Been Logged Out" />
<Modal.Body>
<p className="text-sm">

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

@@ -31,7 +31,7 @@ export default function AddInstructionModal() {
return (
<Modal open={modal.isOpen} onOpenChange={modal.toggle}>
<Modal.Content mini>
<Modal.Content width="sm">
<Modal.Header
icon={SvgAddLines}
title="Set Project Instructions"

View File

@@ -35,7 +35,7 @@ export default function CreateProjectModal() {
return (
<Modal open={modal.isOpen} onOpenChange={modal.toggle}>
<Modal.Content mini>
<Modal.Content width="sm">
<Modal.Header
icon={SvgFolderPlus}
title="Create New Project"

View File

@@ -24,7 +24,7 @@ export default function EditPropertyModal({
}: EditPropertyModalProps) {
return (
<Modal open onOpenChange={onClose}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgEdit}
title={`Edit ${propertyTitle}`}

View File

@@ -16,7 +16,7 @@ export default function ExceptionTraceModal({
return (
<Modal open onOpenChange={onOutsideClick}>
<Modal.Content large>
<Modal.Content width="lg" height="full">
<Modal.Header
icon={SvgAlertTriangle}
title="Full Exception Trace"

View File

@@ -19,7 +19,7 @@ export default function GenericConfirmModal({
}: GenericConfirmModalProps) {
return (
<Modal open onOpenChange={onClose}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header icon={SvgCheck} title={title} onClose={onClose} />
<Modal.Body>
<Text as="p">{message}</Text>

View File

@@ -11,7 +11,7 @@ export default function NoAssistantModal() {
return (
<Modal open>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header icon={SvgUser} title="No Assistant Available" />
<Modal.Body>
<Text as="p">

View File

@@ -64,7 +64,7 @@ export default function ProviderModal({
return (
<Modal open={open} onOpenChange={handleOpenChange}>
<Modal.Content tall onKeyDown={handleKeyDown}>
<Modal.Content width="sm" height="lg" onKeyDown={handleKeyDown}>
<Modal.Header
icon={icon}
title={title}

View File

@@ -169,7 +169,8 @@ export default function UserFilesModal({
<Modal open={isOpen} onOpenChange={toggle}>
<Modal.Content
tall
width="sm"
height="lg"
onOpenAutoFocus={(e) => {
e.preventDefault();
searchInputRef.current?.focus();

View File

@@ -7,7 +7,7 @@ import {
FullPersona,
} from "@/app/admin/assistants/interfaces";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { pinAgents } from "../lib/assistants/orderAssistants";
import { pinAgents } from "@/lib/agents";
import { useUser } from "@/components/user/UserProvider";
import { useSearchParams } from "next/navigation";
import { SEARCH_PARAM_NAMES } from "@/app/chat/services/searchParams";
@@ -75,7 +75,7 @@ export function useAgents() {
* return <AgentEditor agent={agent} />;
*/
export function useAgent(agentId: number | null) {
const { data, error, mutate } = useSWR<FullPersona>(
const { data, error, isLoading, mutate } = useSWR<FullPersona>(
agentId ? `/api/persona/${agentId}` : null,
errorHandlingFetcher,
{
@@ -86,7 +86,7 @@ export function useAgent(agentId: number | null) {
return {
agent: data ?? null,
isLoading: !error && !data && agentId !== null,
isLoading,
error,
refresh: mutate,
};

View File

@@ -0,0 +1,64 @@
"use client";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { UserGroup } from "@/lib/types";
import { useContext } from "react";
import { SettingsContext } from "@/components/settings/SettingsProvider";
/**
* Fetches all user groups in the organization.
*
* Returns group information including group members, curators, and associated resources.
* Use this for displaying group lists in sharing dialogs, admin panels, or permission
* management interfaces.
*
* Note: This hook only returns data if enterprise features are enabled. In non-enterprise
* environments, it returns an empty array.
*
* @returns Object containing:
* - data: Array of UserGroup objects, or undefined while loading
* - isLoading: Boolean indicating if data is being fetched
* - error: Any error that occurred during fetch
* - refreshGroups: Function to manually revalidate the data
*
* @example
* // Fetch groups for sharing dialogs
* const { data: groupsData, isLoading } = useGroups();
* if (isLoading) return <Spinner />;
* return <GroupList groups={groupsData ?? []} />;
*
* @example
* // Fetch groups with manual refresh
* const { data: groupsData, refreshGroups } = useGroups();
* // Later...
* await createNewGroup(...);
* refreshGroups(); // Refresh the group list
*/
export default function useGroups() {
const combinedSettings = useContext(SettingsContext);
const isPaidEnterpriseFeaturesEnabled =
combinedSettings && combinedSettings.enterpriseSettings !== null;
const { data, error, mutate, isLoading } = useSWR<UserGroup[]>(
isPaidEnterpriseFeaturesEnabled ? "/api/manage/admin/user-group" : null,
errorHandlingFetcher
);
// If enterprise features are not enabled, return empty array
if (!isPaidEnterpriseFeaturesEnabled) {
return {
data: [],
isLoading: false,
error: undefined,
refreshGroups: () => {},
};
}
return {
data,
isLoading,
error,
refreshGroups: mutate,
};
}

View File

@@ -1,43 +0,0 @@
"use client";
import { useCallback } from "react";
/**
* Hook that implements standard triple-click text selection behavior:
* - Single click: place cursor (browser default)
* - Double click: select word (browser default)
* - Triple click: select entire content of the target element
*
* Uses onMouseDown with event.detail to detect click count and preventDefault
* on triple-click to avoid the native line selection flashing before our selection.
*
* @param elementRef - Ref to the element whose content should be selected on triple-click
* @returns onMouseDown handler to attach to the element
*/
export function useTripleClickSelect(
elementRef: React.RefObject<HTMLElement | null>
) {
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
// event.detail gives the click count (1, 2, 3, etc.)
if (e.detail === 3) {
// Prevent native triple-click (line/paragraph selection)
e.preventDefault();
const element = elementRef.current;
if (!element) return;
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
range.selectNodeContents(element);
selection.removeAllRanges();
selection.addRange(range);
}
},
[elementRef]
);
return handleMouseDown;
}

52
web/src/hooks/useUsers.ts Normal file
View File

@@ -0,0 +1,52 @@
"use client";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { AllUsersResponse } from "@/lib/types";
export interface UseUsersParams {
includeApiKeys: boolean;
}
/**
* Fetches all users in the organization.
*
* Returns user information including accepted users, invited users, and optionally
* API key users. Use this for displaying user lists in sharing dialogs, admin panels,
* or permission management interfaces.
*
* @param params - Configuration object
* @param params.includeApiKeys - Whether to include API key users in the response
*
* @returns Object containing:
* - data: AllUsersResponse containing accepted, invited, and API key users, or undefined while loading
* - isLoading: Boolean indicating if data is being fetched
* - error: Any error that occurred during fetch
* - refreshUsers: Function to manually revalidate the data
*
* @example
* // Fetch users without API keys (for sharing dialogs)
* const { data: usersData, isLoading } = useUsers({ includeApiKeys: false });
* if (isLoading) return <Spinner />;
* return <UserList users={usersData?.accepted ?? []} />;
*
* @example
* // Fetch all users including API keys (for admin panel)
* const { data: usersData, refreshUsers } = useUsers({ includeApiKeys: true });
* // Later...
* await createNewUser(...);
* refreshUsers(); // Refresh the user list
*/
export default function useUsers({ includeApiKeys }: UseUsersParams) {
const { data, error, mutate, isLoading } = useSWR<AllUsersResponse>(
`/api/manage/users?include_api_keys=${includeApiKeys}`,
errorHandlingFetcher
);
return {
data,
isLoading,
error,
refreshUsers: mutate,
};
}

View File

@@ -60,29 +60,19 @@ import React, {
useContext,
useState,
useMemo,
useRef,
useLayoutEffect,
Dispatch,
SetStateAction,
useCallback,
} from "react";
import { cn } from "@/lib/utils";
import type { IconProps } from "@opal/types";
import Truncated from "@/refresh-components/texts/Truncated";
import { WithoutStyles } from "@/types";
import Text from "@/refresh-components/texts/Text";
import { SvgMcp } from "@opal/icons";
import ShadowDiv from "@/refresh-components/ShadowDiv";
import { Section, SectionProps } from "@/layouts/general-layouts";
/**
* Actions Layout Context
*
* Provides folding state management for action cards without prop drilling.
*/
interface ActionsLayoutContextValue {
isFolded: boolean;
setIsFolded: Dispatch<SetStateAction<boolean>>;
}
const ActionsLayoutContext = createContext<
ActionsLayoutContextValue | undefined
>(undefined);
@@ -94,6 +84,7 @@ const ActionsLayoutContext = createContext<
* - Provider: Context provider component to wrap action card
* - isFolded: Current folding state
* - setIsFolded: Function to update folding state
* - hasContent: Whether an ActionsContent is currently mounted (read-only)
*
* @example
* ```tsx
@@ -121,25 +112,56 @@ const ActionsLayoutContext = createContext<
*/
export function useActionsLayout() {
const [isFolded, setIsFolded] = useState(false);
const contextValue = useMemo(() => ({ isFolded, setIsFolded }), [isFolded]);
const [hasContent, setHasContent] = useState(false);
// Wrap children directly, no component creation
// Registration function for ActionsContent to announce its presence
const registerContent = useMemo(
() => () => {
setHasContent(true);
return () => setHasContent(false);
},
[]
);
// Use a ref to hold the context value so Provider can be stable.
// Without this, changing contextValue would create a new Provider function,
// which React treats as a different component type, causing unmount/remount
// of all children (and losing focus on inputs).
const contextValueRef = useRef<ActionsLayoutContextValue>(null!);
contextValueRef.current = {
isFolded,
setIsFolded,
hasContent,
registerContent,
};
// Stable Provider - reads from ref on each render, so the function
// reference never changes but the provided value stays current.
const Provider = useMemo(
() =>
({ children }: { children: React.ReactNode }) => (
<ActionsLayoutContext.Provider value={contextValue}>
<ActionsLayoutContext.Provider value={contextValueRef.current}>
{children}
</ActionsLayoutContext.Provider>
),
[contextValue]
[]
);
return { Provider, isFolded, setIsFolded };
return { Provider, isFolded, setIsFolded, hasContent };
}
/**
* Internal hook to access the ActionsLayout context.
* Actions Layout Context
*
* Provides folding state management for action cards without prop drilling.
* Also tracks whether content is present via self-registration.
*/
interface ActionsLayoutContextValue {
isFolded: boolean;
setIsFolded: Dispatch<SetStateAction<boolean>>;
hasContent: boolean;
registerContent: () => () => void;
}
function useActionsLayoutContext() {
const context = useContext(ActionsLayoutContext);
if (!context) {
@@ -164,9 +186,7 @@ function useActionsLayoutContext() {
* </ActionsLayouts.Root>
* ```
*/
export type ActionsRootProps = SectionProps;
function ActionsRoot(props: ActionsRootProps) {
function ActionsRoot(props: SectionProps) {
return <Section gap={0} padding={0} {...props} />;
}
@@ -204,19 +224,17 @@ function ActionsRoot(props: ActionsRootProps) {
* />
* ```
*/
export type ActionsHeaderProps = WithoutStyles<
{
// Core content
name?: string;
title: string;
description: string;
icon: React.FunctionComponent<IconProps>;
// Custom content
rightChildren?: React.ReactNode;
} & HtmlHTMLAttributes<HTMLDivElement>
>;
export interface ActionsHeaderProps
extends WithoutStyles<HtmlHTMLAttributes<HTMLDivElement>> {
// Core content
name?: string;
title: string;
description: string;
icon: React.FunctionComponent<IconProps>;
// Custom content
rightChildren?: React.ReactNode;
}
function ActionsHeader({
name,
title,
@@ -226,13 +244,16 @@ function ActionsHeader({
...props
}: ActionsHeaderProps) {
const { isFolded } = useActionsLayoutContext();
const { isFolded, hasContent } = useActionsLayoutContext();
// Round all corners if there's no content, or if content exists but is folded
const shouldFullyRound = !hasContent || isFolded;
return (
<div
className={cn(
"flex flex-col border bg-background-neutral-00 w-full gap-2 pt-4 pb-2",
isFolded ? "rounded-16" : "rounded-t-16"
shouldFullyRound ? "rounded-16" : "rounded-t-16"
)}
>
<label
@@ -269,6 +290,13 @@ function ActionsHeader({
* Use this to wrap tools, settings, or other expandable content.
* Features a maximum height with scrollable overflow.
*
* IMPORTANT: Only ONE ActionsContent should be used within a single ActionsRoot.
* This component self-registers with the ActionsLayout context to inform
* ActionsHeader whether content exists (for border-radius styling). Using
* multiple ActionsContent components will cause incorrect unmount behavior -
* when any one unmounts, it will incorrectly signal that no content exists,
* even if other ActionsContent components remain mounted.
*
* @example
* ```tsx
* <ActionsLayouts.Content>
@@ -277,19 +305,22 @@ function ActionsHeader({
* </ActionsLayouts.Content>
* ```
*/
export type ActionsContentProps = WithoutStyles<
React.HTMLAttributes<HTMLDivElement>
>;
function ActionsContent(
props: WithoutStyles<React.HTMLAttributes<HTMLDivElement>>
) {
const { isFolded, registerContent } = useActionsLayoutContext();
function ActionsContent(props: ActionsContentProps) {
const { isFolded } = useActionsLayoutContext();
// Self-register with context to inform Header that content exists
useLayoutEffect(() => {
return registerContent();
}, [registerContent]);
if (isFolded) {
return null;
}
return (
<div className="border-x border-b rounded-b-16 overflow-hidden">
<div className="border-x border-b rounded-b-16 overflow-hidden w-full">
<ShadowDiv
className="flex flex-col gap-2 rounded-b-16 max-h-[20rem] p-2"
{...props}
@@ -358,7 +389,6 @@ export type ActionsToolProps = WithoutStyles<{
disabled?: boolean;
rightChildren?: React.ReactNode;
}>;
function ActionsTool({
name,
title,
@@ -401,34 +431,6 @@ function ActionsTool({
);
}
/**
* Actions No Tools Found Component
*
* A simple empty state component that displays when no tools are found.
* Shows the MCP icon with "No tools found" message.
*
* @example
* ```tsx
* <ActionsLayouts.Content>
* {tools.length === 0 ? (
* <ActionsLayouts.NoToolsFound />
* ) : (
* tools.map(tool => <ActionsLayouts.Tool key={tool.id} {...tool} />)
* )}
* </ActionsLayouts.Content>
* ```
*/
function ActionsNoToolsFound() {
return (
<div className="flex items-center justify-center gap-2 p-4">
<SvgMcp className="stroke-text-04" size={18} />
<Text as="p" text03>
No tools found
</Text>
</div>
);
}
/**
* Actions Tool Skeleton Component
*
@@ -486,6 +488,5 @@ export {
ActionsHeader as Header,
ActionsContent as Content,
ActionsTool as Tool,
ActionsNoToolsFound as NoToolsFound,
ActionsToolSkeleton as ToolSkeleton,
};

View File

@@ -28,39 +28,14 @@
"use client";
import { cn, ensureHrefProtocol, noProp } from "@/lib/utils";
import { cn, ensureHrefProtocol } from "@/lib/utils";
import type { Components } from "react-markdown";
import Text from "@/refresh-components/texts/Text";
import Button from "@/refresh-components/buttons/Button";
import { useCallback, useMemo, useState, useEffect } from "react";
import ShareChatSessionModal from "@/app/chat/components/modal/ShareChatSessionModal";
import IconButton from "@/refresh-components/buttons/IconButton";
import LineItem from "@/refresh-components/buttons/LineItem";
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
import useChatSessions from "@/hooks/useChatSessions";
import { usePopup } from "@/components/admin/connectors/Popup";
import {
handleMoveOperation,
shouldShowMoveModal,
showErrorNotification,
} from "@/sections/sidebar/sidebarUtils";
import { LOCAL_STORAGE_KEYS } from "@/sections/sidebar/constants";
import { deleteChatSession } from "@/app/chat/services/lib";
import { useRouter } from "next/navigation";
import MoveCustomAgentChatModal from "@/components/modals/MoveCustomAgentChatModal";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
import { PopoverMenu } from "@/refresh-components/Popover";
import { PopoverSearchInput } from "@/sections/sidebar/ChatButton";
import SimplePopover from "@/refresh-components/SimplePopover";
import { useAppSidebarContext } from "@/refresh-components/contexts/AppSidebarContext";
import useScreenSize from "@/hooks/useScreenSize";
import {
SvgFolderIn,
SvgMoreHorizontal,
SvgShare,
SvgSidebar,
SvgTrash,
} from "@opal/icons";
import { SvgSidebar } from "@opal/icons";
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import { useSettingsContext } from "@/components/settings/SettingsProvider";
@@ -93,278 +68,44 @@ function AppHeader() {
const settings = useSettingsContext();
const { isMobile } = useScreenSize();
const { setFolded } = useAppSidebarContext();
const [showShareModal, setShowShareModal] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [showMoveCustomAgentModal, setShowMoveCustomAgentModal] =
useState(false);
const [pendingMoveProjectId, setPendingMoveProjectId] = useState<
number | null
>(null);
const [showMoveOptions, setShowMoveOptions] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [popoverOpen, setPopoverOpen] = useState(false);
const [popoverItems, setPopoverItems] = useState<React.ReactNode[]>([]);
const {
projects,
fetchProjects,
refreshCurrentProjectDetails,
currentProjectId,
} = useProjectsContext();
const { currentChatSession, refreshChatSessions, currentChatSessionId } =
useChatSessions();
const { popup, setPopup } = usePopup();
const router = useRouter();
const { currentChatSessionId } = useChatSessions();
const customHeaderContent =
settings?.enterpriseSettings?.custom_header_content;
const availableProjects = useMemo(() => {
if (!projects) return [];
return projects.filter((project) => project.id !== currentProjectId);
}, [projects, currentProjectId]);
// Don't render when there's a chat session - ChatHeader handles that
if (currentChatSessionId) return null;
const filteredProjects = useMemo(() => {
if (!searchTerm) return availableProjects;
const term = searchTerm.toLowerCase();
return availableProjects.filter((project) =>
project.name.toLowerCase().includes(term)
);
}, [availableProjects, searchTerm]);
const resetMoveState = useCallback(() => {
setShowMoveOptions(false);
setSearchTerm("");
setPendingMoveProjectId(null);
setShowMoveCustomAgentModal(false);
}, []);
const performMove = useCallback(
async (targetProjectId: number) => {
if (!currentChatSession) return;
try {
await handleMoveOperation(
{
chatSession: currentChatSession,
targetProjectId,
refreshChatSessions,
refreshCurrentProjectDetails,
fetchProjects,
currentProjectId,
},
setPopup
);
resetMoveState();
setPopoverOpen(false);
} catch (error) {
console.error("Failed to move chat session:", error);
}
},
[
currentChatSession,
refreshChatSessions,
refreshCurrentProjectDetails,
fetchProjects,
currentProjectId,
setPopup,
resetMoveState,
]
);
const handleMoveClick = useCallback(
(projectId: number) => {
if (!currentChatSession) return;
if (shouldShowMoveModal(currentChatSession)) {
setPendingMoveProjectId(projectId);
setShowMoveCustomAgentModal(true);
return;
}
void performMove(projectId);
},
[currentChatSession, performMove]
);
const handleDeleteChat = useCallback(async () => {
if (!currentChatSession) return;
try {
const response = await deleteChatSession(currentChatSession.id);
if (!response.ok) {
throw new Error("Failed to delete chat session");
}
await Promise.all([refreshChatSessions(), fetchProjects()]);
router.replace("/chat");
setDeleteModalOpen(false);
} catch (error) {
console.error("Failed to delete chat:", error);
showErrorNotification(
setPopup,
"Failed to delete chat. Please try again."
);
}
}, [
currentChatSession,
refreshChatSessions,
fetchProjects,
router,
setPopup,
]);
const setDeleteConfirmationModalOpen = useCallback((open: boolean) => {
setDeleteModalOpen(open);
if (open) {
setPopoverOpen(false);
}
}, []);
useEffect(() => {
const items = showMoveOptions
? [
<PopoverSearchInput
key="search"
setShowMoveOptions={setShowMoveOptions}
onSearch={setSearchTerm}
/>,
...filteredProjects.map((project) => (
<LineItem
key={project.id}
icon={SvgFolderIn}
onClick={noProp(() => handleMoveClick(project.id))}
>
{project.name}
</LineItem>
)),
]
: [
<LineItem
key="move"
icon={SvgFolderIn}
onClick={noProp(() => setShowMoveOptions(true))}
>
Move to Project
</LineItem>,
<LineItem
key="delete"
icon={SvgTrash}
onClick={noProp(() => setDeleteConfirmationModalOpen(true))}
danger
>
Delete
</LineItem>,
];
setPopoverItems(items);
}, [
showMoveOptions,
filteredProjects,
currentChatSession,
setDeleteConfirmationModalOpen,
handleMoveClick,
]);
// Only render when on mobile or there's custom header content
if (!isMobile && !customHeaderContent) return null;
return (
<>
{popup}
{showShareModal && currentChatSession && (
<ShareChatSessionModal
chatSession={currentChatSession}
onClose={() => setShowShareModal(false)}
<header className="w-full flex flex-row justify-center items-center py-3 px-4 h-16">
{/* Left - contains the icon-button to fold the AppSidebar on mobile */}
<div className="flex-1">
<IconButton
icon={SvgSidebar}
onClick={() => setFolded(false)}
className={cn(!isMobile && "invisible")}
internal
/>
)}
</div>
{showMoveCustomAgentModal && (
<MoveCustomAgentChatModal
onCancel={resetMoveState}
onConfirm={async (doNotShowAgain: boolean) => {
if (doNotShowAgain && typeof window !== "undefined") {
window.localStorage.setItem(
LOCAL_STORAGE_KEYS.HIDE_MOVE_CUSTOM_AGENT_MODAL,
"true"
);
}
if (pendingMoveProjectId != null) {
await performMove(pendingMoveProjectId);
}
}}
/>
)}
{deleteModalOpen && (
<ConfirmationModalLayout
title="Delete Chat"
icon={SvgTrash}
onClose={() => setDeleteModalOpen(false)}
submit={
<Button danger onClick={handleDeleteChat}>
Delete
</Button>
}
{/* Center - contains the custom-header-content */}
<div className="flex-1 flex flex-col items-center overflow-hidden">
<Text
as="p"
text03
mainUiBody
className="text-center break-words w-full"
>
Are you sure you want to delete this chat? This action cannot be
undone.
</ConfirmationModalLayout>
)}
{customHeaderContent}
</Text>
</div>
{(isMobile || customHeaderContent || currentChatSessionId) && (
<header className="w-full flex flex-row justify-center items-center py-3 px-4 h-16">
{/* Left - contains the icon-button to fold the AppSidebar on mobile */}
<div className="flex-1">
<IconButton
icon={SvgSidebar}
onClick={() => setFolded(false)}
className={cn(!isMobile && "invisible")}
internal
/>
</div>
{/* Center - contains the custom-header-content */}
<div className="flex-1 flex flex-col items-center overflow-hidden">
<Text
as="p"
text03
mainUiBody
className="text-center break-words w-full"
>
{customHeaderContent}
</Text>
</div>
{/* Right - contains the share and more-options buttons */}
<div
className={cn(
"flex-1 flex flex-row items-center justify-end px-1",
!currentChatSessionId && "invisible"
)}
>
<Button
leftIcon={SvgShare}
transient={showShareModal}
tertiary
onClick={() => setShowShareModal(true)}
>
Share Chat
</Button>
<SimplePopover
trigger={
<IconButton
icon={SvgMoreHorizontal}
className="ml-2"
transient={popoverOpen}
tertiary
/>
}
onOpenChange={(state) => {
setPopoverOpen(state);
if (!state) setShowMoveOptions(false);
}}
side="bottom"
align="end"
>
<PopoverMenu>{popoverItems}</PopoverMenu>
</SimplePopover>
</div>
</header>
)}
</>
{/* Right - empty placeholder for layout balance */}
<div className="flex-1" />
</header>
);
}
@@ -455,4 +196,33 @@ function AppRoot({ children }: AppRootProps) {
);
}
export { AppRoot as Root };
/**
* Sticky Header Wrapper
*
* A layout component that provides sticky positioning for header content.
* Use this to wrap any header content that should stick to the top of a scroll container.
*
* @example
* ```tsx
* <ChatScrollContainer>
* <AppLayouts.StickyHeader>
* <ChatHeader />
* </AppLayouts.StickyHeader>
* <MessageList />
* </ChatScrollContainer>
* ```
*/
export interface StickyHeaderProps {
children?: React.ReactNode;
className?: string;
}
function StickyHeader({ children, className }: StickyHeaderProps) {
return (
<header className={cn("sticky top-0 z-sticky w-full", className)}>
{children}
</header>
);
}
export { AppRoot as Root, StickyHeader };

View File

@@ -134,10 +134,10 @@ function HorizontalInputLayout({
alignment
)}
>
<div className="w-[70%]">
<div className="min-w-[70%]">
<LabelLayout {...fieldLabelProps} />
</div>
<div className="flex flex-col items-end min-w-[12rem]">{children}</div>
<div className="flex flex-col items-end">{children}</div>
</label>
{name && <ErrorLayout name={name} />}
</div>

168
web/src/lib/agents.ts Normal file
View File

@@ -0,0 +1,168 @@
import {
MinimalPersonaSnapshot,
Persona,
} from "@/app/admin/assistants/interfaces";
import { User } from "./types";
import { checkUserIsNoAuthUser } from "./user";
import { personaComparator } from "@/app/admin/assistants/lib";
/**
* Checks if the given user owns the specified assistant.
*
* @param user - The user to check ownership for, or null if no user is logged in
* @param assistant - The assistant to check ownership of
* @returns true if the user owns the assistant (or no auth is required), false otherwise
*/
export function checkUserOwnsAssistant(
user: User | null,
assistant: MinimalPersonaSnapshot | Persona
) {
return checkUserIdOwnsAssistant(user?.id, assistant);
}
/**
* Checks if the given user ID owns the specified assistant.
*
* Returns true if a valid user ID is provided and any of the following conditions
* are met (and the assistant is not built-in):
* - The user is a no-auth user (authentication is disabled)
* - The user ID matches the assistant owner's ID
*
* Returns false if userId is undefined (e.g., user is loading or unauthenticated)
* to prevent granting ownership access prematurely.
*
* @param userId - The user ID to check ownership for
* @param assistant - The assistant to check ownership of
* @returns true if the user owns the assistant, false otherwise
*/
export function checkUserIdOwnsAssistant(
userId: string | undefined,
assistant: MinimalPersonaSnapshot | Persona
) {
return (
!!userId &&
(checkUserIsNoAuthUser(userId) || assistant.owner?.id === userId) &&
!assistant.builtin_persona
);
}
/**
* Updates the user's pinned assistants with the given ordered list of agent IDs.
*
* @param pinnedAgentIds - Array of agent IDs in the desired pinned order
* @throws Error if the API request fails
*/
export async function pinAgents(pinnedAgentIds: number[]) {
const response = await fetch(`/api/user/pinned-assistants`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
ordered_assistant_ids: pinnedAgentIds,
}),
});
if (!response.ok) {
throw new Error("Failed to update pinned assistants");
}
}
/**
* Filters and sorts assistants based on visibility.
*
* Only returns assistants that are marked as visible, sorted using the persona comparator.
*
* @param assistants - Array of assistants to filter
* @returns Filtered and sorted array of visible assistants
*/
export function filterAssistants(
assistants: MinimalPersonaSnapshot[]
): MinimalPersonaSnapshot[] {
let filteredAssistants = assistants.filter(
(assistant) => assistant.is_visible
);
return filteredAssistants.sort(personaComparator);
}
/**
* Deletes an agent by its ID.
*
* @param agentId - The ID of the agent to delete
* @returns null on success, or an error message string on failure
*/
export async function deleteAgent(agentId: number): Promise<string | null> {
try {
const response = await fetch(`/api/persona/${agentId}`, {
method: "DELETE",
});
if (response.ok) {
return null;
}
const errorMessage = (await response.json()).detail || "Unknown error";
return errorMessage;
} catch (error) {
console.error("deleteAgent: Network error", error);
return "Network error. Please check your connection and try again.";
}
}
/**
* Updates agent sharing settings.
*
* For MIT versions, group_ids should not be sent since group-based sharing
* is an EE-only feature.
*
* @param agentId - The ID of the agent to update
* @param userIds - Array of user IDs to share with
* @param groupIds - Array of group IDs to share with (ignored when isPaidEnterpriseFeaturesEnabled is false)
* @param isPublic - Whether the agent should be public
* @param isPaidEnterpriseFeaturesEnabled - Whether enterprise features are enabled
* @returns null on success, or an error message string on failure
*
* @example
* const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
* const error = await updateAgentSharedStatus(agentId, userIds, groupIds, isPublic, isPaidEnterpriseFeaturesEnabled);
* if (error) console.error(error);
*/
export async function updateAgentSharedStatus(
agentId: number,
userIds: string[],
groupIds: number[],
isPublic: boolean | undefined,
isPaidEnterpriseFeaturesEnabled: boolean
): Promise<null | string> {
// MIT versions should not send group_ids - warn if caller provided non-empty groups
if (!isPaidEnterpriseFeaturesEnabled && groupIds.length > 0) {
console.error(
"updateAgentSharedStatus: groupIds provided but enterprise features are disabled. " +
"Group sharing is an EE-only feature. Discarding groupIds."
);
}
try {
const response = await fetch(`/api/persona/${agentId}/share`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
user_ids: userIds,
// Only include group_ids for enterprise versions
group_ids: isPaidEnterpriseFeaturesEnabled ? groupIds : undefined,
is_public: isPublic,
}),
});
if (response.ok) {
return null;
}
const errorMessage = (await response.json()).detail || "Unknown error";
return errorMessage;
} catch (error) {
console.error("updateAgentSharedStatus: Network error", error);
return "Network error. Please check your connection and try again.";
}
}

View File

@@ -1,8 +1,9 @@
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
import { fetchSS } from "../utilsSS";
import { fetchSS } from "./utilsSS";
export type FetchAssistantsResponse = [MinimalPersonaSnapshot[], string | null];
// Fetch assistants server-side
export async function fetchAssistantsSS(): Promise<FetchAssistantsResponse> {
const response = await fetchSS("/persona");
if (response.ok) {

View File

@@ -1,25 +0,0 @@
import {
MinimalPersonaSnapshot,
Persona,
} from "@/app/admin/assistants/interfaces";
import { User } from "../types";
import { checkUserIsNoAuthUser } from "../user";
export function checkUserOwnsAssistant(
user: User | null,
assistant: MinimalPersonaSnapshot | Persona
) {
return checkUserIdOwnsAssistant(user?.id, assistant);
}
export function checkUserIdOwnsAssistant(
userId: string | undefined,
assistant: MinimalPersonaSnapshot | Persona
) {
return (
(!userId ||
checkUserIsNoAuthUser(userId) ||
assistant.owner?.id === userId) &&
!assistant.builtin_persona
);
}

View File

@@ -1,15 +0,0 @@
// Helper to persist pinned agents to the server
export async function pinAgents(pinnedAgentIds: number[]) {
const response = await fetch(`/api/user/pinned-assistants`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
ordered_assistant_ids: pinnedAgentIds,
}),
});
if (!response.ok) {
throw new Error("Failed to update pinned assistants");
}
}

View File

@@ -1,59 +0,0 @@
import { Persona } from "@/app/admin/assistants/interfaces";
interface ShareAssistantRequest {
userIds: string[];
assistantId: number;
}
async function updateAssistantSharedStatus(
request: ShareAssistantRequest
): Promise<null | string> {
const response = await fetch(`/api/persona/${request.assistantId}/share`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
user_ids: request.userIds,
}),
});
if (response.ok) {
return null;
}
const errorMessage = (await response.json()).detail || "Unknown error";
return errorMessage;
}
export async function addUsersToAssistantSharedList(
existingAssistant: Persona,
newUserIds: string[]
): Promise<null | string> {
// Merge existing user IDs with new user IDs, ensuring no duplicates
const updatedUserIds = Array.from(
new Set([...existingAssistant.users.map((user) => user.id), ...newUserIds])
);
// Update the assistant's shared status with the new user list
return updateAssistantSharedStatus({
userIds: updatedUserIds,
assistantId: existingAssistant.id,
});
}
export async function removeUsersFromAssistantSharedList(
existingAssistant: Persona,
userIdsToRemove: string[]
): Promise<null | string> {
// Filter out the user IDs to be removed from the existing user list
const updatedUserIds = existingAssistant.users
.map((user) => user.id)
.filter((id) => !userIdsToRemove.includes(id));
// Update the assistant's shared status with the new user list
return updateAssistantSharedStatus({
userIds: updatedUserIds,
assistantId: existingAssistant.id,
});
}

Some files were not shown because too many files have changed in this diff Show More