mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-24 19:25:46 +00:00
Compare commits
38 Commits
csv_render
...
v2.9.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3af8c6c8a | ||
|
|
d6e46ed792 | ||
|
|
4ce1f4ecdd | ||
|
|
a4678884d7 | ||
|
|
c861ba68f1 | ||
|
|
b1d0e0bb0b | ||
|
|
0d78bf52e3 | ||
|
|
bd743282e6 | ||
|
|
d44d1d92b3 | ||
|
|
4cedcfee59 | ||
|
|
90a721a76e | ||
|
|
3ccd99e931 | ||
|
|
9076bf603f | ||
|
|
8c6e0a70c3 | ||
|
|
bebe9555d4 | ||
|
|
c530722c9f | ||
|
|
68380b4ddb | ||
|
|
b3380746ab | ||
|
|
56be114c87 | ||
|
|
54f467da5c | ||
|
|
8726b112fe | ||
|
|
92181d07b2 | ||
|
|
3a73f7fab2 | ||
|
|
7dabaca7cd | ||
|
|
dec4748825 | ||
|
|
072836cd86 | ||
|
|
2705b5fb0e | ||
|
|
37dcde4226 | ||
|
|
a765b5f622 | ||
|
|
5e093368d1 | ||
|
|
f945ab6b05 | ||
|
|
11b7a22404 | ||
|
|
8e34f944cc | ||
|
|
32606dc752 | ||
|
|
1f6c4b40bf | ||
|
|
1943f1c745 | ||
|
|
82460729a6 | ||
|
|
c445e6a8c0 |
387
.github/workflows/deployment.yml
vendored
387
.github/workflows/deployment.yml
vendored
@@ -8,7 +8,9 @@ on:
|
||||
|
||||
# Set restrictive default permissions for all jobs. Jobs that need more permissions
|
||||
# should explicitly declare them.
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Required for OIDC authentication with AWS
|
||||
id-token: write # zizmor: ignore[excessive-permissions]
|
||||
|
||||
env:
|
||||
EDGE_TAG: ${{ startsWith(github.ref_name, 'nightly-latest') }}
|
||||
@@ -150,16 +152,30 @@ jobs:
|
||||
if: always() && needs.check-version-tag.result == 'failure' && github.event_name != 'workflow_dispatch'
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
environment: release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # ratchet:actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
MONITOR_DEPLOYMENTS_WEBHOOK, deploy/monitor-deployments-webhook
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Send Slack notification
|
||||
uses: ./.github/actions/slack-notify
|
||||
with:
|
||||
webhook-url: ${{ secrets.MONITOR_DEPLOYMENTS_WEBHOOK }}
|
||||
webhook-url: ${{ env.MONITOR_DEPLOYMENTS_WEBHOOK }}
|
||||
failed-jobs: "• check-version-tag"
|
||||
title: "🚨 Version Tag Check Failed"
|
||||
ref-name: ${{ github.ref_name }}
|
||||
@@ -168,6 +184,7 @@ jobs:
|
||||
needs: determine-builds
|
||||
if: needs.determine-builds.outputs.build-desktop == 'true'
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
actions: read
|
||||
strategy:
|
||||
@@ -185,12 +202,33 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # ratchet:actions/checkout@v6.0.1
|
||||
with:
|
||||
# NOTE: persist-credentials is needed for tauri-action to create GitHub releases.
|
||||
persist-credentials: true # zizmor: ignore[artipacked]
|
||||
|
||||
- name: Configure AWS credentials
|
||||
if: startsWith(matrix.platform, 'macos-')
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
if: startsWith(matrix.platform, 'macos-')
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
APPLE_ID, deploy/apple-id
|
||||
APPLE_PASSWORD, deploy/apple-password
|
||||
APPLE_CERTIFICATE, deploy/apple-certificate
|
||||
APPLE_CERTIFICATE_PASSWORD, deploy/apple-certificate-password
|
||||
KEYCHAIN_PASSWORD, deploy/keychain-password
|
||||
APPLE_TEAM_ID, deploy/apple-team-id
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: startsWith(matrix.platform, 'ubuntu-')
|
||||
run: |
|
||||
@@ -285,15 +323,40 @@ jobs:
|
||||
|
||||
Write-Host "Versions set to: $VERSION"
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: startsWith(matrix.platform, 'macos-')
|
||||
run: |
|
||||
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security set-keychain-settings -t 3600 -u build.keychain
|
||||
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security find-identity -v -p codesigning build.keychain
|
||||
|
||||
- name: Verify Certificate
|
||||
if: startsWith(matrix.platform, 'macos-')
|
||||
run: |
|
||||
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep -E "(Developer ID Application|Apple Distribution|Apple Development)" | head -n 1)
|
||||
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
|
||||
echo "Certificate imported."
|
||||
|
||||
- uses: tauri-apps/tauri-action@73fb865345c54760d875b94642314f8c0c894afa # ratchet:tauri-apps/tauri-action@action-v0.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_ID: ${{ env.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ env.APPLE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
|
||||
APPLE_TEAM_ID: ${{ env.APPLE_TEAM_ID }}
|
||||
with:
|
||||
tagName: ${{ needs.determine-builds.outputs.is-test-run != 'true' && 'v__VERSION__' || format('v0.0.0-dev+{0}', needs.determine-builds.outputs.short-sha) }}
|
||||
releaseName: ${{ needs.determine-builds.outputs.is-test-run != 'true' && 'v__VERSION__' || format('v0.0.0-dev+{0}', needs.determine-builds.outputs.short-sha) }}
|
||||
releaseBody: "See the assets to download this version and install."
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
assetNamePattern: "[name]_[arch][ext]"
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
build-web-amd64:
|
||||
@@ -305,6 +368,7 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-web-amd64
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
env:
|
||||
@@ -317,6 +381,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
|
||||
@@ -331,8 +409,8 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push AMD64
|
||||
id: build
|
||||
@@ -363,6 +441,7 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-web-arm64
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
env:
|
||||
@@ -375,6 +454,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
|
||||
@@ -389,8 +482,8 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push ARM64
|
||||
id: build
|
||||
@@ -423,19 +516,34 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-merge-web
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
env:
|
||||
REGISTRY_IMAGE: onyxdotapp/onyx-web-server
|
||||
steps:
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # ratchet:docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
@@ -471,6 +579,7 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-web-cloud-amd64
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
env:
|
||||
@@ -483,6 +592,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
|
||||
@@ -497,8 +620,8 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push AMD64
|
||||
id: build
|
||||
@@ -537,6 +660,7 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-web-cloud-arm64
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
env:
|
||||
@@ -549,6 +673,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
|
||||
@@ -563,8 +701,8 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push ARM64
|
||||
id: build
|
||||
@@ -605,19 +743,34 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-merge-web-cloud
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
env:
|
||||
REGISTRY_IMAGE: onyxdotapp/onyx-web-server-cloud
|
||||
steps:
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # ratchet:docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
@@ -650,6 +803,7 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-backend-amd64
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
env:
|
||||
@@ -662,6 +816,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
|
||||
@@ -676,8 +844,8 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push AMD64
|
||||
id: build
|
||||
@@ -707,6 +875,7 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-backend-arm64
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
env:
|
||||
@@ -719,6 +888,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
|
||||
@@ -733,8 +916,8 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push ARM64
|
||||
id: build
|
||||
@@ -766,19 +949,34 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-merge-backend
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
env:
|
||||
REGISTRY_IMAGE: ${{ contains(github.ref_name, 'cloud') && 'onyxdotapp/onyx-backend-cloud' || 'onyxdotapp/onyx-backend' }}
|
||||
steps:
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # ratchet:docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
@@ -815,6 +1013,7 @@ jobs:
|
||||
- volume=40gb
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
env:
|
||||
@@ -827,6 +1026,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
|
||||
@@ -843,8 +1056,8 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push AMD64
|
||||
id: build
|
||||
@@ -879,6 +1092,7 @@ jobs:
|
||||
- volume=40gb
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
env:
|
||||
@@ -891,6 +1105,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # ratchet:docker/metadata-action@v5
|
||||
@@ -907,8 +1135,8 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push ARM64
|
||||
id: build
|
||||
@@ -944,19 +1172,34 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-merge-model-server
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
env:
|
||||
REGISTRY_IMAGE: ${{ contains(github.ref_name, 'cloud') && 'onyxdotapp/onyx-model-server-cloud' || 'onyxdotapp/onyx-model-server' }}
|
||||
steps:
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # ratchet:docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
@@ -994,11 +1237,26 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-trivy-scan-web
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
env:
|
||||
REGISTRY_IMAGE: onyxdotapp/onyx-web-server
|
||||
steps:
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # ratchet:nick-fields/retry@v3
|
||||
with:
|
||||
@@ -1014,8 +1272,8 @@ jobs:
|
||||
docker run --rm -v $HOME/.cache/trivy:/root/.cache/trivy \
|
||||
-e TRIVY_DB_REPOSITORY="public.ecr.aws/aquasecurity/trivy-db:2" \
|
||||
-e TRIVY_JAVA_DB_REPOSITORY="public.ecr.aws/aquasecurity/trivy-java-db:1" \
|
||||
-e TRIVY_USERNAME="${{ secrets.DOCKER_USERNAME }}" \
|
||||
-e TRIVY_PASSWORD="${{ secrets.DOCKER_TOKEN }}" \
|
||||
-e TRIVY_USERNAME="${{ env.DOCKER_USERNAME }}" \
|
||||
-e TRIVY_PASSWORD="${{ env.DOCKER_TOKEN }}" \
|
||||
aquasec/trivy@sha256:a22415a38938a56c379387a8163fcb0ce38b10ace73e593475d3658d578b2436 \
|
||||
image \
|
||||
--skip-version-check \
|
||||
@@ -1034,11 +1292,26 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-trivy-scan-web-cloud
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
env:
|
||||
REGISTRY_IMAGE: onyxdotapp/onyx-web-server-cloud
|
||||
steps:
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # ratchet:nick-fields/retry@v3
|
||||
with:
|
||||
@@ -1054,8 +1327,8 @@ jobs:
|
||||
docker run --rm -v $HOME/.cache/trivy:/root/.cache/trivy \
|
||||
-e TRIVY_DB_REPOSITORY="public.ecr.aws/aquasecurity/trivy-db:2" \
|
||||
-e TRIVY_JAVA_DB_REPOSITORY="public.ecr.aws/aquasecurity/trivy-java-db:1" \
|
||||
-e TRIVY_USERNAME="${{ secrets.DOCKER_USERNAME }}" \
|
||||
-e TRIVY_PASSWORD="${{ secrets.DOCKER_TOKEN }}" \
|
||||
-e TRIVY_USERNAME="${{ env.DOCKER_USERNAME }}" \
|
||||
-e TRIVY_PASSWORD="${{ env.DOCKER_TOKEN }}" \
|
||||
aquasec/trivy@sha256:a22415a38938a56c379387a8163fcb0ce38b10ace73e593475d3658d578b2436 \
|
||||
image \
|
||||
--skip-version-check \
|
||||
@@ -1074,6 +1347,7 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-trivy-scan-backend
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
env:
|
||||
REGISTRY_IMAGE: ${{ contains(github.ref_name, 'cloud') && 'onyxdotapp/onyx-backend-cloud' || 'onyxdotapp/onyx-backend' }}
|
||||
steps:
|
||||
@@ -1084,6 +1358,20 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # ratchet:nick-fields/retry@v3
|
||||
with:
|
||||
@@ -1100,8 +1388,8 @@ jobs:
|
||||
-v ${{ github.workspace }}/backend/.trivyignore:/tmp/.trivyignore:ro \
|
||||
-e TRIVY_DB_REPOSITORY="public.ecr.aws/aquasecurity/trivy-db:2" \
|
||||
-e TRIVY_JAVA_DB_REPOSITORY="public.ecr.aws/aquasecurity/trivy-java-db:1" \
|
||||
-e TRIVY_USERNAME="${{ secrets.DOCKER_USERNAME }}" \
|
||||
-e TRIVY_PASSWORD="${{ secrets.DOCKER_TOKEN }}" \
|
||||
-e TRIVY_USERNAME="${{ env.DOCKER_USERNAME }}" \
|
||||
-e TRIVY_PASSWORD="${{ env.DOCKER_TOKEN }}" \
|
||||
aquasec/trivy@sha256:a22415a38938a56c379387a8163fcb0ce38b10ace73e593475d3658d578b2436 \
|
||||
image \
|
||||
--skip-version-check \
|
||||
@@ -1121,11 +1409,26 @@ jobs:
|
||||
- run-id=${{ github.run_id }}-trivy-scan-model-server
|
||||
- extras=ecr-cache
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
env:
|
||||
REGISTRY_IMAGE: ${{ contains(github.ref_name, 'cloud') && 'onyxdotapp/onyx-model-server-cloud' || 'onyxdotapp/onyx-model-server' }}
|
||||
steps:
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
DOCKER_USERNAME, deploy/docker-username
|
||||
DOCKER_TOKEN, deploy/docker-token
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # ratchet:nick-fields/retry@v3
|
||||
with:
|
||||
@@ -1141,8 +1444,8 @@ jobs:
|
||||
docker run --rm -v $HOME/.cache/trivy:/root/.cache/trivy \
|
||||
-e TRIVY_DB_REPOSITORY="public.ecr.aws/aquasecurity/trivy-db:2" \
|
||||
-e TRIVY_JAVA_DB_REPOSITORY="public.ecr.aws/aquasecurity/trivy-java-db:1" \
|
||||
-e TRIVY_USERNAME="${{ secrets.DOCKER_USERNAME }}" \
|
||||
-e TRIVY_PASSWORD="${{ secrets.DOCKER_TOKEN }}" \
|
||||
-e TRIVY_USERNAME="${{ env.DOCKER_USERNAME }}" \
|
||||
-e TRIVY_PASSWORD="${{ env.DOCKER_TOKEN }}" \
|
||||
aquasec/trivy@sha256:a22415a38938a56c379387a8163fcb0ce38b10ace73e593475d3658d578b2436 \
|
||||
image \
|
||||
--skip-version-check \
|
||||
@@ -1170,12 +1473,26 @@ jobs:
|
||||
# NOTE: Github-hosted runners have about 20s faster queue times and are preferred here.
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 90
|
||||
environment: release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # ratchet:actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Get AWS Secrets
|
||||
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
|
||||
with:
|
||||
secret-ids: |
|
||||
MONITOR_DEPLOYMENTS_WEBHOOK, deploy/monitor-deployments-webhook
|
||||
parse-json-secrets: true
|
||||
|
||||
- name: Determine failed jobs
|
||||
id: failed-jobs
|
||||
shell: bash
|
||||
@@ -1241,7 +1558,7 @@ jobs:
|
||||
- name: Send Slack notification
|
||||
uses: ./.github/actions/slack-notify
|
||||
with:
|
||||
webhook-url: ${{ secrets.MONITOR_DEPLOYMENTS_WEBHOOK }}
|
||||
webhook-url: ${{ env.MONITOR_DEPLOYMENTS_WEBHOOK }}
|
||||
failed-jobs: ${{ steps.failed-jobs.outputs.jobs }}
|
||||
title: "🚨 Deployment Workflow Failed"
|
||||
ref-name: ${{ github.ref_name }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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"] = (
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
{
|
||||
"title": "Onyx",
|
||||
"label": "main",
|
||||
"url": "https://cloud.onyx.app",
|
||||
"url": "index.html",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
@@ -52,7 +52,7 @@
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "cloud.onyx.app",
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": "-",
|
||||
"signingIdentity": null,
|
||||
"dmg": {
|
||||
"windowSize": {
|
||||
"width": 660,
|
||||
|
||||
@@ -4,28 +4,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>
|
||||
|
||||
@@ -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 |
@@ -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"}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"} ${
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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?"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}?`}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
323
web/src/app/chat/components/ChatHeader.tsx
Normal file
323
web/src/app/chat/components/ChatHeader.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
420
web/src/components/chat/ChatScrollContainer.tsx
Normal file
420
web/src/components/chat/ChatScrollContainer.tsx
Normal 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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
229
web/src/components/chat/MessageList.tsx
Normal file
229
web/src/components/chat/MessageList.tsx
Normal 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;
|
||||
@@ -201,7 +201,8 @@ export default function TextView({
|
||||
}}
|
||||
>
|
||||
<Modal.Content
|
||||
large
|
||||
width="lg"
|
||||
height="full"
|
||||
preventAccidentalClose={false}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
64
web/src/hooks/useGroups.ts
Normal file
64
web/src/hooks/useGroups.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
52
web/src/hooks/useUsers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
168
web/src/lib/agents.ts
Normal 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.";
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user