Compare commits
33 Commits
csv_render
...
v2.12.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2032b76fbf | ||
|
|
055b30b00e | ||
|
|
360a4cf591 | ||
|
|
3d3cab9f91 | ||
|
|
6120d012ba | ||
|
|
3e7e2e93f2 | ||
|
|
ccf482fa3b | ||
|
|
fd45a612da | ||
|
|
c444d8883b | ||
|
|
9947837f9f | ||
|
|
bc324a8070 | ||
|
|
26f648c24a | ||
|
|
638f20f5f3 | ||
|
|
f6ee57f523 | ||
|
|
aae6fc7aac | ||
|
|
5d7a664250 | ||
|
|
e7386490bf | ||
|
|
106e10a143 | ||
|
|
513f430a1b | ||
|
|
696d73822f | ||
|
|
bfcc5a20a2 | ||
|
|
efe3613354 | ||
|
|
62405bdc42 | ||
|
|
8f505dc45f | ||
|
|
75f0db4fe5 | ||
|
|
f0a5c579a3 | ||
|
|
293bf30847 | ||
|
|
8774ca3b0f | ||
|
|
016a73f85f | ||
|
|
2eddb4e23e | ||
|
|
0a61660a59 | ||
|
|
a10599e76e | ||
|
|
b3d3f7af76 |
151
.github/workflows/nightly-scan-licenses.yml
vendored
@@ -1,151 +0,0 @@
|
||||
# Scan for problematic software licenses
|
||||
|
||||
# trivy has their own rate limiting issues causing this action to flake
|
||||
# we worked around it by hardcoding to different db repos in env
|
||||
# can re-enable when they figure it out
|
||||
# https://github.com/aquasecurity/trivy/discussions/7538
|
||||
# https://github.com/aquasecurity/trivy-action/issues/389
|
||||
|
||||
name: 'Nightly - Scan licenses'
|
||||
on:
|
||||
# schedule:
|
||||
# - cron: '0 14 * * *' # Runs every day at 6 AM PST / 7 AM PDT / 2 PM UTC
|
||||
workflow_dispatch: # Allows manual triggering
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
scan-licenses:
|
||||
# See https://runs-on.com/runners/linux/
|
||||
runs-on: [runs-on,runner=2cpu-linux-x64,"run-id=${{ github.run_id }}-scan-licenses"]
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # ratchet:actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: |
|
||||
backend/requirements/default.txt
|
||||
backend/requirements/dev.txt
|
||||
backend/requirements/model_server.txt
|
||||
|
||||
- name: Get explicit and transitive dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install --retries 5 --timeout 30 -r backend/requirements/default.txt
|
||||
pip install --retries 5 --timeout 30 -r backend/requirements/dev.txt
|
||||
pip install --retries 5 --timeout 30 -r backend/requirements/model_server.txt
|
||||
pip freeze > requirements-all.txt
|
||||
|
||||
- name: Check python
|
||||
id: license_check_report
|
||||
uses: pilosus/action-pip-license-checker@e909b0226ff49d3235c99c4585bc617f49fff16a # ratchet:pilosus/action-pip-license-checker@v3
|
||||
with:
|
||||
requirements: 'requirements-all.txt'
|
||||
fail: 'Copyleft'
|
||||
exclude: '(?i)^(pylint|aio[-_]*).*'
|
||||
|
||||
- name: Print report
|
||||
if: always()
|
||||
env:
|
||||
REPORT: ${{ steps.license_check_report.outputs.report }}
|
||||
run: echo "$REPORT"
|
||||
|
||||
- name: Install npm dependencies
|
||||
working-directory: ./web
|
||||
run: npm ci
|
||||
|
||||
# be careful enabling the sarif and upload as it may spam the security tab
|
||||
# with a huge amount of items. Work out the issues before enabling upload.
|
||||
# - name: Run Trivy vulnerability scanner in repo mode
|
||||
# if: always()
|
||||
# uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # ratchet:aquasecurity/trivy-action@0.33.1
|
||||
# with:
|
||||
# scan-type: fs
|
||||
# scan-ref: .
|
||||
# scanners: license
|
||||
# format: table
|
||||
# severity: HIGH,CRITICAL
|
||||
# # format: sarif
|
||||
# # output: trivy-results.sarif
|
||||
#
|
||||
# # - name: Upload Trivy scan results to GitHub Security tab
|
||||
# # uses: github/codeql-action/upload-sarif@v3
|
||||
# # with:
|
||||
# # sarif_file: trivy-results.sarif
|
||||
|
||||
scan-trivy:
|
||||
# See https://runs-on.com/runners/linux/
|
||||
runs-on: [runs-on,runner=2cpu-linux-x64,"run-id=${{ github.run_id }}-scan-trivy"]
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
# Backend
|
||||
- name: Pull backend docker image
|
||||
run: docker pull onyxdotapp/onyx-backend:latest
|
||||
|
||||
- name: Run Trivy vulnerability scanner on backend
|
||||
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # ratchet:aquasecurity/trivy-action@0.33.1
|
||||
env:
|
||||
TRIVY_DB_REPOSITORY: 'public.ecr.aws/aquasecurity/trivy-db:2'
|
||||
TRIVY_JAVA_DB_REPOSITORY: 'public.ecr.aws/aquasecurity/trivy-java-db:1'
|
||||
with:
|
||||
image-ref: onyxdotapp/onyx-backend:latest
|
||||
scanners: license
|
||||
severity: HIGH,CRITICAL
|
||||
vuln-type: library
|
||||
exit-code: 0 # Set to 1 if we want a failed scan to fail the workflow
|
||||
|
||||
# Web server
|
||||
- name: Pull web server docker image
|
||||
run: docker pull onyxdotapp/onyx-web-server:latest
|
||||
|
||||
- name: Run Trivy vulnerability scanner on web server
|
||||
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # ratchet:aquasecurity/trivy-action@0.33.1
|
||||
env:
|
||||
TRIVY_DB_REPOSITORY: 'public.ecr.aws/aquasecurity/trivy-db:2'
|
||||
TRIVY_JAVA_DB_REPOSITORY: 'public.ecr.aws/aquasecurity/trivy-java-db:1'
|
||||
with:
|
||||
image-ref: onyxdotapp/onyx-web-server:latest
|
||||
scanners: license
|
||||
severity: HIGH,CRITICAL
|
||||
vuln-type: library
|
||||
exit-code: 0
|
||||
|
||||
# Model server
|
||||
- name: Pull model server docker image
|
||||
run: docker pull onyxdotapp/onyx-model-server:latest
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # ratchet:aquasecurity/trivy-action@0.33.1
|
||||
env:
|
||||
TRIVY_DB_REPOSITORY: 'public.ecr.aws/aquasecurity/trivy-db:2'
|
||||
TRIVY_JAVA_DB_REPOSITORY: 'public.ecr.aws/aquasecurity/trivy-java-db:1'
|
||||
with:
|
||||
image-ref: onyxdotapp/onyx-model-server:latest
|
||||
scanners: license
|
||||
severity: HIGH,CRITICAL
|
||||
vuln-type: library
|
||||
exit-code: 0
|
||||
3
.github/workflows/pr-database-tests.yml
vendored
@@ -40,6 +40,9 @@ jobs:
|
||||
|
||||
- name: Generate OpenAPI schema and Python client
|
||||
shell: bash
|
||||
# TODO(Nik): https://linear.app/onyx-app/issue/ENG-1/update-test-infra-to-use-test-license
|
||||
env:
|
||||
LICENSE_ENFORCEMENT_ENABLED: "false"
|
||||
run: |
|
||||
ods openapi all
|
||||
|
||||
|
||||
3
.github/workflows/pr-integration-tests.yml
vendored
@@ -302,6 +302,8 @@ jobs:
|
||||
cat <<EOF > deployment/docker_compose/.env
|
||||
COMPOSE_PROFILES=s3-filestore
|
||||
ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true
|
||||
# TODO(Nik): https://linear.app/onyx-app/issue/ENG-1/update-test-infra-to-use-test-license
|
||||
LICENSE_ENFORCEMENT_ENABLED=false
|
||||
AUTH_TYPE=basic
|
||||
POSTGRES_POOL_PRE_PING=true
|
||||
POSTGRES_USE_NULL_POOL=true
|
||||
@@ -478,6 +480,7 @@ jobs:
|
||||
run: |
|
||||
cd deployment/docker_compose
|
||||
ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true \
|
||||
LICENSE_ENFORCEMENT_ENABLED=false \
|
||||
MULTI_TENANT=true \
|
||||
AUTH_TYPE=cloud \
|
||||
REQUIRE_EMAIL_VERIFICATION=false \
|
||||
|
||||
5
.github/workflows/pr-playwright-tests.yml
vendored
@@ -22,6 +22,9 @@ env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
GEN_AI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
EXA_API_KEY: ${{ secrets.EXA_API_KEY }}
|
||||
FIRECRAWL_API_KEY: ${{ secrets.FIRECRAWL_API_KEY }}
|
||||
GOOGLE_PSE_API_KEY: ${{ secrets.GOOGLE_PSE_API_KEY }}
|
||||
GOOGLE_PSE_SEARCH_ENGINE_ID: ${{ secrets.GOOGLE_PSE_SEARCH_ENGINE_ID }}
|
||||
|
||||
# for federated slack tests
|
||||
SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }}
|
||||
@@ -291,6 +294,8 @@ jobs:
|
||||
cat <<EOF > deployment/docker_compose/.env
|
||||
COMPOSE_PROFILES=s3-filestore
|
||||
ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true
|
||||
# TODO(Nik): https://linear.app/onyx-app/issue/ENG-1/update-test-infra-to-use-test-license
|
||||
LICENSE_ENFORCEMENT_ENABLED=false
|
||||
AUTH_TYPE=basic
|
||||
GEN_AI_API_KEY=${OPENAI_API_KEY_VALUE}
|
||||
EXA_API_KEY=${EXA_API_KEY_VALUE}
|
||||
|
||||
3
.github/workflows/pr-python-checks.yml
vendored
@@ -42,6 +42,9 @@ jobs:
|
||||
|
||||
- name: Generate OpenAPI schema and Python client
|
||||
shell: bash
|
||||
# TODO(Nik): https://linear.app/onyx-app/issue/ENG-1/update-test-infra-to-use-test-license
|
||||
env:
|
||||
LICENSE_ENFORCEMENT_ENABLED: "false"
|
||||
run: |
|
||||
ods openapi all
|
||||
|
||||
|
||||
2
.github/workflows/pr-python-tests.yml
vendored
@@ -27,6 +27,8 @@ jobs:
|
||||
PYTHONPATH: ./backend
|
||||
REDIS_CLOUD_PYTEST_PASSWORD: ${{ secrets.REDIS_CLOUD_PYTEST_PASSWORD }}
|
||||
DISABLE_TELEMETRY: "true"
|
||||
# TODO(Nik): https://linear.app/onyx-app/issue/ENG-1/update-test-infra-to-use-test-license
|
||||
LICENSE_ENFORCEMENT_ENABLED: "false"
|
||||
|
||||
steps:
|
||||
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
|
||||
|
||||
5
LICENSE
@@ -2,7 +2,10 @@ Copyright (c) 2023-present DanswerAI, Inc.
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
- All content that resides under "ee" directories of this repository, if that directory exists, is licensed under the license defined in "backend/ee/LICENSE". Specifically all content under "backend/ee" and "web/src/app/ee" is licensed under the license defined in "backend/ee/LICENSE".
|
||||
- All content that resides under "ee" directories of this repository is licensed under the Onyx Enterprise License. Each ee directory contains an identical copy of this license at its root:
|
||||
- backend/ee/LICENSE
|
||||
- web/src/app/ee/LICENSE
|
||||
- web/src/ee/LICENSE
|
||||
- All third party components incorporated into the Onyx Software are licensed under the original license provided by the owner of the applicable component.
|
||||
- Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below.
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ COPY --chown=onyx:onyx ./alembic_tenants /app/alembic_tenants
|
||||
COPY --chown=onyx:onyx ./alembic.ini /app/alembic.ini
|
||||
COPY supervisord.conf /usr/etc/supervisord.conf
|
||||
COPY --chown=onyx:onyx ./static /app/static
|
||||
COPY --chown=onyx:onyx ./keys /app/keys
|
||||
|
||||
# Escape hatch scripts
|
||||
COPY --chown=onyx:onyx ./scripts/debugging /app/scripts/debugging
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
The DanswerAI Enterprise license (the “Enterprise License”)
|
||||
The Onyx Enterprise License (the "Enterprise License")
|
||||
Copyright (c) 2023-present DanswerAI, Inc.
|
||||
|
||||
With regard to the Onyx Software:
|
||||
|
||||
This software and associated documentation files (the "Software") may only be
|
||||
used in production, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, the DanswerAI Subscription Terms of Service, available
|
||||
at https://onyx.app/terms (the “Enterprise Terms”), or other
|
||||
and are in compliance with, the Onyx Subscription Terms of Service, available
|
||||
at https://www.onyx.app/legal/self-host (the "Enterprise Terms"), or other
|
||||
agreement governing the use of the Software, as agreed by you and DanswerAI,
|
||||
and otherwise have a valid Onyx Enterprise license for the
|
||||
and otherwise have a valid Onyx Enterprise License for the
|
||||
correct number of user seats. Subject to the foregoing sentence, you are free to
|
||||
modify this Software and publish patches to the Software. You agree that DanswerAI
|
||||
and/or its licensors (as applicable) retain all right, title and interest in and
|
||||
to all such modifications and/or patches, and all such modifications and/or
|
||||
patches may only be used, copied, modified, displayed, distributed, or otherwise
|
||||
exploited with a valid Onyx Enterprise license for the correct
|
||||
exploited with a valid Onyx Enterprise License for the correct
|
||||
number of user seats. Notwithstanding the foregoing, you may copy and modify
|
||||
the Software for development and testing purposes, without requiring a
|
||||
subscription. You agree that DanswerAI and/or its licensors (as applicable) retain
|
||||
|
||||
@@ -134,7 +134,7 @@ GATED_TENANTS_KEY = "gated_tenants"
|
||||
|
||||
# License enforcement - when True, blocks API access for gated/expired licenses
|
||||
LICENSE_ENFORCEMENT_ENABLED = (
|
||||
os.environ.get("LICENSE_ENFORCEMENT_ENABLED", "").lower() == "true"
|
||||
os.environ.get("LICENSE_ENFORCEMENT_ENABLED", "true").lower() == "true"
|
||||
)
|
||||
|
||||
# Cloud data plane URL - self-hosted instances call this to reach cloud proxy endpoints
|
||||
|
||||
@@ -263,9 +263,15 @@ def refresh_license_cache(
|
||||
|
||||
try:
|
||||
payload = verify_license_signature(license_record.license_data)
|
||||
# Derive source from payload: manual licenses lack stripe_customer_id
|
||||
source: LicenseSource = (
|
||||
LicenseSource.AUTO_FETCH
|
||||
if payload.stripe_customer_id
|
||||
else LicenseSource.MANUAL_UPLOAD
|
||||
)
|
||||
return update_license_cache(
|
||||
payload,
|
||||
source=LicenseSource.AUTO_FETCH,
|
||||
source=source,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -109,7 +109,9 @@ async def _make_billing_request(
|
||||
headers = _get_headers(license_data)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_REQUEST_TIMEOUT, follow_redirects=True
|
||||
) as client:
|
||||
if method == "GET":
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
else:
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"""EE Settings API - provides license-aware settings override."""
|
||||
|
||||
from redis.exceptions import RedisError
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from ee.onyx.configs.app_configs import LICENSE_ENFORCEMENT_ENABLED
|
||||
from ee.onyx.db.license import get_cached_license_metadata
|
||||
from ee.onyx.db.license import refresh_license_cache
|
||||
from onyx.configs.app_configs import ENTERPRISE_EDITION_ENABLED
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.server.settings.models import ApplicationStatus
|
||||
from onyx.server.settings.models import Settings
|
||||
from onyx.utils.logger import setup_logger
|
||||
@@ -40,6 +44,14 @@ def check_ee_features_enabled() -> bool:
|
||||
tenant_id = get_current_tenant_id()
|
||||
try:
|
||||
metadata = get_cached_license_metadata(tenant_id)
|
||||
if not metadata:
|
||||
# Cache miss — warm from DB so cold-start doesn't block EE features
|
||||
try:
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
metadata = refresh_license_cache(db_session, tenant_id)
|
||||
except SQLAlchemyError as db_error:
|
||||
logger.warning(f"Failed to load license from DB: {db_error}")
|
||||
|
||||
if metadata and metadata.status != _BLOCKING_STATUS:
|
||||
# Has a valid license (GRACE_PERIOD/PAYMENT_REMINDER still allow EE features)
|
||||
return True
|
||||
@@ -81,6 +93,18 @@ def apply_license_status_to_settings(settings: Settings) -> Settings:
|
||||
tenant_id = get_current_tenant_id()
|
||||
try:
|
||||
metadata = get_cached_license_metadata(tenant_id)
|
||||
if not metadata:
|
||||
# Cache miss (e.g. after TTL expiry). Fall back to DB so
|
||||
# the /settings request doesn't falsely return GATED_ACCESS
|
||||
# while the cache is cold.
|
||||
try:
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
metadata = refresh_license_cache(db_session, tenant_id)
|
||||
except SQLAlchemyError as db_error:
|
||||
logger.warning(
|
||||
f"Failed to load license from DB for settings: {db_error}"
|
||||
)
|
||||
|
||||
if metadata:
|
||||
if metadata.status == _BLOCKING_STATUS:
|
||||
settings.application_status = metadata.status
|
||||
@@ -89,7 +113,11 @@ def apply_license_status_to_settings(settings: Settings) -> Settings:
|
||||
# Has a valid license (GRACE_PERIOD/PAYMENT_REMINDER still allow EE features)
|
||||
settings.ee_features_enabled = True
|
||||
else:
|
||||
# No license = community edition, disable EE features
|
||||
# No license found in cache or DB.
|
||||
if ENTERPRISE_EDITION_ENABLED:
|
||||
# Legacy EE flag is set → prior EE usage (e.g. permission
|
||||
# syncing) means indexed data may need protection.
|
||||
settings.application_status = _BLOCKING_STATUS
|
||||
settings.ee_features_enabled = False
|
||||
except RedisError as e:
|
||||
logger.warning(f"Failed to check license metadata for settings: {e}")
|
||||
|
||||
@@ -177,7 +177,7 @@ async def forward_to_control_plane(
|
||||
url = f"{CONTROL_PLANE_API_BASE_URL}{path}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
if method == "GET":
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
elif method == "POST":
|
||||
|
||||
@@ -12,12 +12,14 @@ from ee.onyx.db.user_group import prepare_user_group_for_deletion
|
||||
from ee.onyx.db.user_group import update_user_curator_relationship
|
||||
from ee.onyx.db.user_group import update_user_group
|
||||
from ee.onyx.server.user_group.models import AddUsersToUserGroupRequest
|
||||
from ee.onyx.server.user_group.models import MinimalUserGroupSnapshot
|
||||
from ee.onyx.server.user_group.models import SetCuratorRequest
|
||||
from ee.onyx.server.user_group.models import UserGroup
|
||||
from ee.onyx.server.user_group.models import UserGroupCreate
|
||||
from ee.onyx.server.user_group.models import UserGroupUpdate
|
||||
from onyx.auth.users import current_admin_user
|
||||
from onyx.auth.users import current_curator_or_admin_user
|
||||
from onyx.auth.users import current_user
|
||||
from onyx.configs.constants import PUBLIC_API_TAGS
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.models import User
|
||||
@@ -45,6 +47,23 @@ def list_user_groups(
|
||||
return [UserGroup.from_model(user_group) for user_group in user_groups]
|
||||
|
||||
|
||||
@router.get("/user-groups/minimal")
|
||||
def list_minimal_user_groups(
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[MinimalUserGroupSnapshot]:
|
||||
if user.role == UserRole.ADMIN:
|
||||
user_groups = fetch_user_groups(db_session, only_up_to_date=False)
|
||||
else:
|
||||
user_groups = fetch_user_groups_for_user(
|
||||
db_session=db_session,
|
||||
user_id=user.id,
|
||||
)
|
||||
return [
|
||||
MinimalUserGroupSnapshot.from_model(user_group) for user_group in user_groups
|
||||
]
|
||||
|
||||
|
||||
@router.post("/admin/user-group")
|
||||
def create_user_group(
|
||||
user_group: UserGroupCreate,
|
||||
|
||||
@@ -76,6 +76,18 @@ class UserGroup(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class MinimalUserGroupSnapshot(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, user_group_model: UserGroupModel) -> "MinimalUserGroupSnapshot":
|
||||
return cls(
|
||||
id=user_group_model.id,
|
||||
name=user_group_model.name,
|
||||
)
|
||||
|
||||
|
||||
class UserGroupCreate(BaseModel):
|
||||
name: str
|
||||
user_ids: list[UUID]
|
||||
|
||||
@@ -60,6 +60,7 @@ from sqlalchemy import nulls_last
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.api_key import get_hashed_api_key_from_request
|
||||
from onyx.auth.disposable_email_validator import is_disposable_email
|
||||
@@ -110,6 +111,7 @@ from onyx.db.auth import get_user_db
|
||||
from onyx.db.auth import SQLAlchemyUserAdminDB
|
||||
from onyx.db.engine.async_sql_engine import get_async_session
|
||||
from onyx.db.engine.async_sql_engine import get_async_session_context_manager
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.db.engine.sql_engine import get_session_with_tenant
|
||||
from onyx.db.models import AccessToken
|
||||
from onyx.db.models import OAuthAccount
|
||||
@@ -272,6 +274,22 @@ def verify_email_domain(email: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def enforce_seat_limit(db_session: Session, seats_needed: int = 1) -> None:
|
||||
"""Raise HTTPException(402) if adding users would exceed the seat limit.
|
||||
|
||||
No-op for multi-tenant or CE deployments.
|
||||
"""
|
||||
if MULTI_TENANT:
|
||||
return
|
||||
|
||||
result = fetch_ee_implementation_or_noop(
|
||||
"onyx.db.license", "check_seat_availability", None
|
||||
)(db_session, seats_needed=seats_needed)
|
||||
|
||||
if result is not None and not result.available:
|
||||
raise HTTPException(status_code=402, detail=result.error_message)
|
||||
|
||||
|
||||
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||
reset_password_token_secret = USER_AUTH_SECRET
|
||||
verification_token_secret = USER_AUTH_SECRET
|
||||
@@ -401,6 +419,12 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||
):
|
||||
user_create.role = UserRole.ADMIN
|
||||
|
||||
# Check seat availability for new users (single-tenant only)
|
||||
with get_session_with_current_tenant() as sync_db:
|
||||
existing = get_user_by_email(user_create.email, sync_db)
|
||||
if existing is None:
|
||||
enforce_seat_limit(sync_db)
|
||||
|
||||
user_created = False
|
||||
try:
|
||||
user = await super().create(user_create, safe=safe, request=request)
|
||||
@@ -610,6 +634,10 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||
raise exceptions.UserNotExists()
|
||||
|
||||
except exceptions.UserNotExists:
|
||||
# Check seat availability before creating (single-tenant only)
|
||||
with get_session_with_current_tenant() as sync_db:
|
||||
enforce_seat_limit(sync_db)
|
||||
|
||||
password = self.password_helper.generate()
|
||||
user_dict = {
|
||||
"email": account_email,
|
||||
|
||||
@@ -28,6 +28,7 @@ from onyx.configs.constants import MessageType
|
||||
from onyx.context.search.models import SearchDoc
|
||||
from onyx.context.search.models import SearchDocsResponse
|
||||
from onyx.db.models import Persona
|
||||
from onyx.llm.constants import LlmProviderNames
|
||||
from onyx.llm.interfaces import LLM
|
||||
from onyx.llm.interfaces import LLMUserIdentity
|
||||
from onyx.llm.interfaces import ToolChoiceOptions
|
||||
@@ -59,6 +60,28 @@ from shared_configs.contextvars import get_current_tenant_id
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
def _should_keep_bedrock_tool_definitions(
|
||||
llm: object, simple_chat_history: list[ChatMessageSimple]
|
||||
) -> bool:
|
||||
"""Bedrock requires tool config when history includes toolUse/toolResult blocks."""
|
||||
model_provider = getattr(getattr(llm, "config", None), "model_provider", None)
|
||||
if model_provider not in {
|
||||
LlmProviderNames.BEDROCK,
|
||||
LlmProviderNames.BEDROCK_CONVERSE,
|
||||
}:
|
||||
return False
|
||||
|
||||
return any(
|
||||
(
|
||||
msg.message_type == MessageType.ASSISTANT
|
||||
and msg.tool_calls
|
||||
and len(msg.tool_calls) > 0
|
||||
)
|
||||
or msg.message_type == MessageType.TOOL_CALL_RESPONSE
|
||||
for msg in simple_chat_history
|
||||
)
|
||||
|
||||
|
||||
def _try_fallback_tool_extraction(
|
||||
llm_step_result: LlmStepResult,
|
||||
tool_choice: ToolChoiceOptions,
|
||||
@@ -454,7 +477,12 @@ def run_llm_loop(
|
||||
elif out_of_cycles or ran_image_gen:
|
||||
# Last cycle, no tools allowed, just answer!
|
||||
tool_choice = ToolChoiceOptions.NONE
|
||||
final_tools = []
|
||||
# Bedrock requires tool config in requests that include toolUse/toolResult history.
|
||||
final_tools = (
|
||||
tools
|
||||
if _should_keep_bedrock_tool_definitions(llm, simple_chat_history)
|
||||
else []
|
||||
)
|
||||
else:
|
||||
tool_choice = ToolChoiceOptions.AUTO
|
||||
final_tools = tools
|
||||
|
||||
@@ -75,7 +75,7 @@ WEB_DOMAIN = os.environ.get("WEB_DOMAIN") or "http://localhost:3000"
|
||||
# Auth Configs
|
||||
#####
|
||||
# Upgrades users from disabled auth to basic auth and shows warning.
|
||||
_auth_type_str = (os.environ.get("AUTH_TYPE") or "").lower()
|
||||
_auth_type_str = (os.environ.get("AUTH_TYPE") or "basic").lower()
|
||||
if _auth_type_str == "disabled":
|
||||
logger.warning(
|
||||
"AUTH_TYPE='disabled' is no longer supported. "
|
||||
@@ -900,6 +900,9 @@ MANAGED_VESPA = os.environ.get("MANAGED_VESPA", "").lower() == "true"
|
||||
|
||||
ENABLE_EMAIL_INVITES = os.environ.get("ENABLE_EMAIL_INVITES", "").lower() == "true"
|
||||
|
||||
# Limit on number of users a free trial tenant can invite (cloud only)
|
||||
NUM_FREE_TRIAL_USER_INVITES = int(os.environ.get("NUM_FREE_TRIAL_USER_INVITES", "10"))
|
||||
|
||||
# Security and authentication
|
||||
DATA_PLANE_SECRET = os.environ.get(
|
||||
"DATA_PLANE_SECRET", ""
|
||||
|
||||
@@ -428,7 +428,7 @@ def fetch_existing_models(
|
||||
|
||||
def fetch_existing_llm_providers(
|
||||
db_session: Session,
|
||||
flow_types: list[LLMModelFlowType],
|
||||
flow_type_filter: list[LLMModelFlowType],
|
||||
only_public: bool = False,
|
||||
exclude_image_generation_providers: bool = True,
|
||||
) -> list[LLMProviderModel]:
|
||||
@@ -436,30 +436,27 @@ def fetch_existing_llm_providers(
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
flow_types: List of flow types to filter by
|
||||
flow_type_filter: List of flow types to filter by, empty list for no filter
|
||||
only_public: If True, only return public providers
|
||||
exclude_image_generation_providers: If True, exclude providers that are
|
||||
used for image generation configs
|
||||
"""
|
||||
providers_with_flows = (
|
||||
select(ModelConfiguration.llm_provider_id)
|
||||
.join(LLMModelFlow)
|
||||
.where(LLMModelFlow.llm_model_flow_type.in_(flow_types))
|
||||
.distinct()
|
||||
)
|
||||
stmt = select(LLMProviderModel)
|
||||
|
||||
if flow_type_filter:
|
||||
providers_with_flows = (
|
||||
select(ModelConfiguration.llm_provider_id)
|
||||
.join(LLMModelFlow)
|
||||
.where(LLMModelFlow.llm_model_flow_type.in_(flow_type_filter))
|
||||
.distinct()
|
||||
)
|
||||
stmt = stmt.where(LLMProviderModel.id.in_(providers_with_flows))
|
||||
|
||||
if exclude_image_generation_providers:
|
||||
stmt = select(LLMProviderModel).where(
|
||||
LLMProviderModel.id.in_(providers_with_flows)
|
||||
)
|
||||
else:
|
||||
image_gen_provider_ids = select(ModelConfiguration.llm_provider_id).join(
|
||||
ImageGenerationConfig
|
||||
)
|
||||
stmt = select(LLMProviderModel).where(
|
||||
LLMProviderModel.id.in_(providers_with_flows)
|
||||
| LLMProviderModel.id.in_(image_gen_provider_ids)
|
||||
)
|
||||
stmt = stmt.where(~LLMProviderModel.id.in_(image_gen_provider_ids))
|
||||
|
||||
stmt = stmt.options(
|
||||
selectinload(LLMProviderModel.model_configurations),
|
||||
@@ -722,13 +719,15 @@ def sync_auto_mode_models(
|
||||
changes += 1
|
||||
else:
|
||||
# Add new model - all models from GitHub config are visible
|
||||
new_model = ModelConfiguration(
|
||||
insert_new_model_configuration__no_commit(
|
||||
db_session=db_session,
|
||||
llm_provider_id=provider.id,
|
||||
name=model_config.name,
|
||||
display_name=model_config.display_name,
|
||||
model_name=model_config.name,
|
||||
supported_flows=[LLMModelFlowType.CHAT],
|
||||
is_visible=True,
|
||||
max_input_tokens=None,
|
||||
display_name=model_config.display_name,
|
||||
)
|
||||
db_session.add(new_model)
|
||||
changes += 1
|
||||
|
||||
# In Auto mode, default model is always set from GitHub config
|
||||
|
||||
@@ -255,7 +255,7 @@ def list_llm_providers(
|
||||
llm_provider_list: list[LLMProviderView] = []
|
||||
for llm_provider_model in fetch_existing_llm_providers(
|
||||
db_session=db_session,
|
||||
flow_types=[LLMModelFlowType.CHAT, LLMModelFlowType.VISION],
|
||||
flow_type_filter=[],
|
||||
exclude_image_generation_providers=not include_image_gen,
|
||||
):
|
||||
from_model_start = datetime.now(timezone.utc)
|
||||
@@ -503,9 +503,7 @@ def list_llm_provider_basics(
|
||||
start_time = datetime.now(timezone.utc)
|
||||
logger.debug("Starting to fetch user-accessible LLM providers")
|
||||
|
||||
all_providers = fetch_existing_llm_providers(
|
||||
db_session, [LLMModelFlowType.CHAT, LLMModelFlowType.VISION]
|
||||
)
|
||||
all_providers = fetch_existing_llm_providers(db_session, [])
|
||||
user_group_ids = fetch_user_group_ids(db_session, user)
|
||||
is_admin = user.role == UserRole.ADMIN
|
||||
|
||||
|
||||
@@ -30,12 +30,14 @@ from onyx.auth.users import anonymous_user_enabled
|
||||
from onyx.auth.users import current_admin_user
|
||||
from onyx.auth.users import current_curator_or_admin_user
|
||||
from onyx.auth.users import current_user
|
||||
from onyx.auth.users import enforce_seat_limit
|
||||
from onyx.auth.users import optional_user
|
||||
from onyx.configs.app_configs import AUTH_BACKEND
|
||||
from onyx.configs.app_configs import AUTH_TYPE
|
||||
from onyx.configs.app_configs import AuthBackend
|
||||
from onyx.configs.app_configs import DEV_MODE
|
||||
from onyx.configs.app_configs import ENABLE_EMAIL_INVITES
|
||||
from onyx.configs.app_configs import NUM_FREE_TRIAL_USER_INVITES
|
||||
from onyx.configs.app_configs import REDIS_AUTH_KEY_PREFIX
|
||||
from onyx.configs.app_configs import SESSION_EXPIRE_TIME_SECONDS
|
||||
from onyx.configs.app_configs import USER_AUTH_SECRET
|
||||
@@ -90,6 +92,7 @@ from onyx.server.manage.models import UserSpecificAssistantPreferences
|
||||
from onyx.server.models import FullUserSnapshot
|
||||
from onyx.server.models import InvitedUserSnapshot
|
||||
from onyx.server.models import MinimalUserSnapshot
|
||||
from onyx.server.usage_limits import is_tenant_on_trial_fn
|
||||
from onyx.server.utils import BasicAuthenticationError
|
||||
from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
|
||||
@@ -391,14 +394,20 @@ def bulk_invite_users(
|
||||
if e not in existing_users and e not in already_invited
|
||||
]
|
||||
|
||||
# Limit bulk invites for trial tenants to prevent email spam
|
||||
# Only count new invites, not re-invites of existing users
|
||||
if MULTI_TENANT and is_tenant_on_trial_fn(tenant_id):
|
||||
current_invited = len(already_invited)
|
||||
if current_invited + len(emails_needing_seats) > NUM_FREE_TRIAL_USER_INVITES:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You have hit your invite limit. "
|
||||
"Please upgrade for unlimited invites.",
|
||||
)
|
||||
|
||||
# Check seat availability for new users
|
||||
# Only for self-hosted (non-multi-tenant) deployments
|
||||
if not MULTI_TENANT and emails_needing_seats:
|
||||
result = fetch_ee_implementation_or_noop(
|
||||
"onyx.db.license", "check_seat_availability", None
|
||||
)(db_session, seats_needed=len(emails_needing_seats))
|
||||
if result is not None and not result.available:
|
||||
raise HTTPException(status_code=402, detail=result.error_message)
|
||||
if emails_needing_seats:
|
||||
enforce_seat_limit(db_session, seats_needed=len(emails_needing_seats))
|
||||
|
||||
if MULTI_TENANT:
|
||||
try:
|
||||
@@ -414,10 +423,10 @@ def bulk_invite_users(
|
||||
all_emails = list(set(new_invited_emails) | set(initial_invited_users))
|
||||
number_of_invited_users = write_invited_users(all_emails)
|
||||
|
||||
# send out email invitations if enabled
|
||||
# send out email invitations only to new users (not already invited or existing)
|
||||
if ENABLE_EMAIL_INVITES:
|
||||
try:
|
||||
for email in new_invited_emails:
|
||||
for email in emails_needing_seats:
|
||||
send_user_email_invite(email, current_user, AUTH_TYPE)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending email invite to invited users: {e}")
|
||||
@@ -564,12 +573,7 @@ def activate_user_api(
|
||||
|
||||
# Check seat availability before activating
|
||||
# Only for self-hosted (non-multi-tenant) deployments
|
||||
if not MULTI_TENANT:
|
||||
result = fetch_ee_implementation_or_noop(
|
||||
"onyx.db.license", "check_seat_availability", None
|
||||
)(db_session, seats_needed=1)
|
||||
if result is not None and not result.available:
|
||||
raise HTTPException(status_code=402, detail=result.error_message)
|
||||
enforce_seat_limit(db_session)
|
||||
|
||||
activate_user(user_to_activate, db_session)
|
||||
|
||||
@@ -593,11 +597,17 @@ def get_valid_domains(
|
||||
|
||||
@router.get("/users", tags=PUBLIC_API_TAGS)
|
||||
def list_all_users_basic_info(
|
||||
include_api_keys: bool = False,
|
||||
_: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[MinimalUserSnapshot]:
|
||||
users = get_all_users(db_session)
|
||||
return [MinimalUserSnapshot(id=user.id, email=user.email) for user in users]
|
||||
return [
|
||||
MinimalUserSnapshot(id=user.id, email=user.email)
|
||||
for user in users
|
||||
if user.role != UserRole.SLACK_USER
|
||||
and (include_api_keys or not is_api_key_email_address(user.email))
|
||||
]
|
||||
|
||||
|
||||
@router.get("/get-user-role", tags=PUBLIC_API_TAGS)
|
||||
|
||||
@@ -47,6 +47,7 @@ from onyx.tools.tool_implementations.web_search.utils import (
|
||||
from onyx.tools.tool_implementations.web_search.utils import MAX_CHARS_PER_URL
|
||||
from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.threadpool_concurrency import run_functions_tuples_in_parallel
|
||||
from onyx.utils.url import normalize_url as normalize_web_content_url
|
||||
from shared_configs.configs import MULTI_TENANT
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
|
||||
@@ -791,7 +792,9 @@ class OpenURLTool(Tool[OpenURLToolOverrideKwargs]):
|
||||
for url in all_urls:
|
||||
doc_id = url_to_doc_id.get(url)
|
||||
indexed_section = indexed_by_doc_id.get(doc_id) if doc_id else None
|
||||
crawled_section = crawled_by_url.get(url)
|
||||
# WebContent.link is normalized (query/fragment stripped). Match on the
|
||||
# same normalized form to avoid dropping successful crawl results.
|
||||
crawled_section = crawled_by_url.get(normalize_web_content_url(url))
|
||||
|
||||
if indexed_section and indexed_section.combined_content:
|
||||
# Prefer indexed
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from fastapi import HTTPException
|
||||
|
||||
from onyx.tools.tool_implementations.web_search.models import (
|
||||
WebSearchProvider,
|
||||
)
|
||||
from onyx.tools.tool_implementations.web_search.models import WebSearchResult
|
||||
from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.retry_wrapper import retry_builder
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
BRAVE_WEB_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search"
|
||||
BRAVE_MAX_RESULTS_PER_REQUEST = 20
|
||||
BRAVE_SAFESEARCH_OPTIONS = {"off", "moderate", "strict"}
|
||||
BRAVE_FRESHNESS_OPTIONS = {"pd", "pw", "pm", "py"}
|
||||
|
||||
|
||||
class RetryableBraveSearchError(Exception):
|
||||
"""Error type used to trigger retry for transient Brave search failures."""
|
||||
|
||||
|
||||
class BraveClient(WebSearchProvider):
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
*,
|
||||
num_results: int = 10,
|
||||
timeout_seconds: int = 10,
|
||||
country: str | None = None,
|
||||
search_lang: str | None = None,
|
||||
ui_lang: str | None = None,
|
||||
safesearch: str | None = None,
|
||||
freshness: str | None = None,
|
||||
) -> None:
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("Brave provider config 'timeout_seconds' must be > 0.")
|
||||
|
||||
self._headers = {
|
||||
"Accept": "application/json",
|
||||
"X-Subscription-Token": api_key,
|
||||
}
|
||||
logger.debug(f"Count of results passed to BraveClient: {num_results}")
|
||||
self._num_results = max(1, min(num_results, BRAVE_MAX_RESULTS_PER_REQUEST))
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._country = _normalize_country(country)
|
||||
self._search_lang = _normalize_language_code(
|
||||
search_lang, field_name="search_lang"
|
||||
)
|
||||
self._ui_lang = _normalize_language_code(ui_lang, field_name="ui_lang")
|
||||
self._safesearch = _normalize_option(
|
||||
safesearch,
|
||||
field_name="safesearch",
|
||||
allowed_values=BRAVE_SAFESEARCH_OPTIONS,
|
||||
)
|
||||
self._freshness = _normalize_option(
|
||||
freshness,
|
||||
field_name="freshness",
|
||||
allowed_values=BRAVE_FRESHNESS_OPTIONS,
|
||||
)
|
||||
|
||||
def _build_search_params(self, query: str) -> dict[str, str]:
|
||||
params = {
|
||||
"q": query,
|
||||
"count": str(self._num_results),
|
||||
}
|
||||
if self._country:
|
||||
params["country"] = self._country
|
||||
if self._search_lang:
|
||||
params["search_lang"] = self._search_lang
|
||||
if self._ui_lang:
|
||||
params["ui_lang"] = self._ui_lang
|
||||
if self._safesearch:
|
||||
params["safesearch"] = self._safesearch
|
||||
if self._freshness:
|
||||
params["freshness"] = self._freshness
|
||||
return params
|
||||
|
||||
@retry_builder(
|
||||
tries=3,
|
||||
delay=1,
|
||||
backoff=2,
|
||||
exceptions=(RetryableBraveSearchError,),
|
||||
)
|
||||
def _search_with_retries(self, query: str) -> list[WebSearchResult]:
|
||||
params = self._build_search_params(query)
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
BRAVE_WEB_SEARCH_URL,
|
||||
headers=self._headers,
|
||||
params=params,
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise RetryableBraveSearchError(
|
||||
f"Brave search request failed: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
error_msg = _build_error_message(response)
|
||||
if _is_retryable_status(response.status_code):
|
||||
raise RetryableBraveSearchError(error_msg) from exc
|
||||
raise ValueError(error_msg) from exc
|
||||
|
||||
data = response.json()
|
||||
web_results = (data.get("web") or {}).get("results") or []
|
||||
|
||||
results: list[WebSearchResult] = []
|
||||
for result in web_results:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
|
||||
link = _clean_string(result.get("url"))
|
||||
if not link:
|
||||
continue
|
||||
|
||||
title = _clean_string(result.get("title"))
|
||||
description = _clean_string(result.get("description"))
|
||||
|
||||
results.append(
|
||||
WebSearchResult(
|
||||
title=title,
|
||||
link=link,
|
||||
snippet=description,
|
||||
author=None,
|
||||
published_date=None,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def search(self, query: str) -> list[WebSearchResult]:
|
||||
try:
|
||||
return self._search_with_retries(query)
|
||||
except RetryableBraveSearchError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
def test_connection(self) -> dict[str, str]:
|
||||
try:
|
||||
test_results = self.search("test")
|
||||
if not test_results or not any(result.link for result in test_results):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Brave API key validation failed: search returned no results.",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except (ValueError, requests.RequestException) as e:
|
||||
error_msg = str(e)
|
||||
lower = error_msg.lower()
|
||||
if (
|
||||
"status 401" in lower
|
||||
or "status 403" in lower
|
||||
or "api key" in lower
|
||||
or "auth" in lower
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid Brave API key: {error_msg}",
|
||||
) from e
|
||||
if "status 429" in lower or "rate limit" in lower:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Brave API rate limit exceeded: {error_msg}",
|
||||
) from e
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Brave API key validation failed: {error_msg}",
|
||||
) from e
|
||||
|
||||
logger.info("Web search provider test succeeded for Brave.")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def _build_error_message(response: requests.Response) -> str:
|
||||
return (
|
||||
"Brave search failed "
|
||||
f"(status {response.status_code}): {_extract_error_detail(response)}"
|
||||
)
|
||||
|
||||
|
||||
def _extract_error_detail(response: requests.Response) -> str:
|
||||
try:
|
||||
payload: Any = response.json()
|
||||
except Exception:
|
||||
text = response.text.strip()
|
||||
return text[:200] if text else "No error details"
|
||||
|
||||
if isinstance(payload, dict):
|
||||
error = payload.get("error")
|
||||
if isinstance(error, dict):
|
||||
detail = error.get("detail") or error.get("message")
|
||||
if isinstance(detail, str):
|
||||
return detail
|
||||
if isinstance(error, str):
|
||||
return error
|
||||
|
||||
message = payload.get("message")
|
||||
if isinstance(message, str):
|
||||
return message
|
||||
|
||||
return str(payload)[:200]
|
||||
|
||||
|
||||
def _is_retryable_status(status_code: int) -> bool:
|
||||
return status_code == 429 or status_code >= 500
|
||||
|
||||
|
||||
def _clean_string(value: Any) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _normalize_country(country: str | None) -> str | None:
|
||||
if country is None:
|
||||
return None
|
||||
normalized = country.strip().upper()
|
||||
if not normalized:
|
||||
return None
|
||||
if len(normalized) != 2 or not normalized.isalpha():
|
||||
raise ValueError(
|
||||
"Brave provider config 'country' must be a 2-letter ISO country code."
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_language_code(value: str | None, *, field_name: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return None
|
||||
if len(normalized) > 20:
|
||||
raise ValueError(f"Brave provider config '{field_name}' is too long.")
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_option(
|
||||
value: str | None,
|
||||
*,
|
||||
field_name: str,
|
||||
allowed_values: set[str],
|
||||
) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip().lower()
|
||||
if not normalized:
|
||||
return None
|
||||
if normalized not in allowed_values:
|
||||
allowed = ", ".join(sorted(allowed_values))
|
||||
raise ValueError(
|
||||
f"Brave provider config '{field_name}' must be one of: {allowed}."
|
||||
)
|
||||
return normalized
|
||||
@@ -13,6 +13,9 @@ from onyx.tools.tool_implementations.open_url.onyx_web_crawler import (
|
||||
DEFAULT_MAX_PDF_SIZE_BYTES,
|
||||
)
|
||||
from onyx.tools.tool_implementations.open_url.onyx_web_crawler import OnyxWebCrawler
|
||||
from onyx.tools.tool_implementations.web_search.clients.brave_client import (
|
||||
BraveClient,
|
||||
)
|
||||
from onyx.tools.tool_implementations.web_search.clients.exa_client import (
|
||||
ExaClient,
|
||||
)
|
||||
@@ -35,16 +38,76 @@ from shared_configs.enums import WebSearchProviderType
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
def _parse_positive_int_config(
|
||||
*,
|
||||
raw_value: str | None,
|
||||
default: int,
|
||||
provider_name: str,
|
||||
config_key: str,
|
||||
) -> int:
|
||||
if not raw_value:
|
||||
return default
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"{provider_name} provider config '{config_key}' must be an integer."
|
||||
) from exc
|
||||
if value <= 0:
|
||||
raise ValueError(
|
||||
f"{provider_name} provider config '{config_key}' must be greater than 0."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def provider_requires_api_key(provider_type: WebSearchProviderType) -> bool:
|
||||
"""Return True if the given provider type requires an API key.
|
||||
This list is most likely just going to contain SEARXNG. The way it works is that it uses public search engines that do not
|
||||
require an API key. You can also set it up in a way which requires a key but SearXNG itself does not require a key.
|
||||
"""
|
||||
return provider_type != WebSearchProviderType.SEARXNG
|
||||
|
||||
|
||||
def build_search_provider_from_config(
|
||||
provider_type: WebSearchProviderType,
|
||||
api_key: str,
|
||||
api_key: str | None,
|
||||
config: dict[str, str] | None, # TODO use a typed object
|
||||
) -> WebSearchProvider:
|
||||
config = config or {}
|
||||
num_results = int(config.get("num_results") or DEFAULT_MAX_RESULTS)
|
||||
|
||||
# SearXNG does not require an API key
|
||||
if provider_type == WebSearchProviderType.SEARXNG:
|
||||
searxng_base_url = config.get("searxng_base_url")
|
||||
if not searxng_base_url:
|
||||
raise ValueError("Please provide a URL for your private SearXNG instance.")
|
||||
return SearXNGClient(
|
||||
searxng_base_url,
|
||||
num_results=num_results,
|
||||
)
|
||||
|
||||
# All other providers require an API key
|
||||
if not api_key:
|
||||
raise ValueError(f"API key is required for {provider_type.value} provider.")
|
||||
|
||||
if provider_type == WebSearchProviderType.EXA:
|
||||
return ExaClient(api_key=api_key, num_results=num_results)
|
||||
if provider_type == WebSearchProviderType.BRAVE:
|
||||
return BraveClient(
|
||||
api_key=api_key,
|
||||
num_results=num_results,
|
||||
timeout_seconds=_parse_positive_int_config(
|
||||
raw_value=config.get("timeout_seconds"),
|
||||
default=10,
|
||||
provider_name="Brave",
|
||||
config_key="timeout_seconds",
|
||||
),
|
||||
country=config.get("country"),
|
||||
search_lang=config.get("search_lang"),
|
||||
ui_lang=config.get("ui_lang"),
|
||||
safesearch=config.get("safesearch"),
|
||||
freshness=config.get("freshness"),
|
||||
)
|
||||
if provider_type == WebSearchProviderType.SERPER:
|
||||
return SerperClient(api_key=api_key, num_results=num_results)
|
||||
if provider_type == WebSearchProviderType.GOOGLE_PSE:
|
||||
@@ -64,20 +127,13 @@ def build_search_provider_from_config(
|
||||
num_results=num_results,
|
||||
timeout_seconds=int(config.get("timeout_seconds") or 10),
|
||||
)
|
||||
if provider_type == WebSearchProviderType.SEARXNG:
|
||||
searxng_base_url = config.get("searxng_base_url")
|
||||
if not searxng_base_url:
|
||||
raise ValueError("Please provide a URL for your private SearXNG instance.")
|
||||
return SearXNGClient(
|
||||
searxng_base_url,
|
||||
num_results=num_results,
|
||||
)
|
||||
raise ValueError(f"Unknown provider type: {provider_type.value}")
|
||||
|
||||
|
||||
def _build_search_provider(provider_model: InternetSearchProvider) -> WebSearchProvider:
|
||||
return build_search_provider_from_config(
|
||||
provider_type=WebSearchProviderType(provider_model.provider_type),
|
||||
api_key=provider_model.api_key or "",
|
||||
api_key=provider_model.api_key,
|
||||
config=provider_model.config or {},
|
||||
)
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ global_version = OnyxVersion()
|
||||
# Eventually, ENABLE_PAID_ENTERPRISE_EDITION_FEATURES will be removed
|
||||
# and license enforcement will be the only mechanism for EE features.
|
||||
_LICENSE_ENFORCEMENT_ENABLED = (
|
||||
os.environ.get("LICENSE_ENFORCEMENT_ENABLED", "").lower() == "true"
|
||||
os.environ.get("LICENSE_ENFORCEMENT_ENABLED", "true").lower() == "true"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ class WebSearchProviderType(str, Enum):
|
||||
SERPER = "serper"
|
||||
EXA = "exa"
|
||||
SEARXNG = "searxng"
|
||||
BRAVE = "brave"
|
||||
|
||||
|
||||
class WebContentProviderType(str, Enum):
|
||||
|
||||
@@ -553,7 +553,7 @@ class TestDefaultProviderEndpoint:
|
||||
|
||||
try:
|
||||
existing_providers = fetch_existing_llm_providers(
|
||||
db_session, flow_types=[LLMModelFlowType.CHAT]
|
||||
db_session, flow_type_filter=[LLMModelFlowType.CHAT]
|
||||
)
|
||||
provider_names_to_restore: list[str] = []
|
||||
|
||||
|
||||
@@ -14,9 +14,12 @@ from uuid import uuid4
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.db.enums import LLMModelFlowType
|
||||
from onyx.db.llm import fetch_default_llm_model
|
||||
from onyx.db.llm import fetch_existing_llm_provider
|
||||
from onyx.db.llm import fetch_existing_llm_providers
|
||||
from onyx.db.llm import remove_llm_provider
|
||||
from onyx.db.llm import sync_auto_mode_models
|
||||
from onyx.db.llm import update_default_provider
|
||||
from onyx.db.models import UserRole
|
||||
from onyx.llm.constants import LlmProviderNames
|
||||
@@ -606,3 +609,95 @@ class TestAutoModeSyncFeature:
|
||||
db_session.rollback()
|
||||
_cleanup_provider(db_session, provider_1_name)
|
||||
_cleanup_provider(db_session, provider_2_name)
|
||||
|
||||
|
||||
class TestAutoModeMissingFlows:
|
||||
"""Regression test: sync_auto_mode_models must create LLMModelFlow rows
|
||||
for every ModelConfiguration it inserts, otherwise the provider vanishes
|
||||
from listing queries that join through LLMModelFlow."""
|
||||
|
||||
def test_sync_auto_mode_creates_flow_rows(
|
||||
self,
|
||||
db_session: Session,
|
||||
provider_name: str,
|
||||
) -> None:
|
||||
"""
|
||||
Steps:
|
||||
1. Create a provider with no model configs (empty shell).
|
||||
2. Call sync_auto_mode_models to add models from a mock config.
|
||||
3. Assert every new ModelConfiguration has at least one LLMModelFlow.
|
||||
4. Assert fetch_existing_llm_providers (which joins through
|
||||
LLMModelFlow) returns the provider.
|
||||
"""
|
||||
mock_recommendations = _create_mock_llm_recommendations(
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
default_model_name="gpt-4o",
|
||||
additional_models=["gpt-4o-mini"],
|
||||
)
|
||||
|
||||
try:
|
||||
# Step 1: Create provider with no model configs
|
||||
put_llm_provider(
|
||||
llm_provider_upsert_request=LLMProviderUpsertRequest(
|
||||
name=provider_name,
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
api_key="sk-test-key-00000000000000000000000000000000000",
|
||||
api_key_changed=True,
|
||||
is_auto_mode=True,
|
||||
default_model_name="gpt-4o",
|
||||
model_configurations=[],
|
||||
),
|
||||
is_creation=True,
|
||||
_=_create_mock_admin(),
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
# Step 2: Run sync_auto_mode_models (simulating the periodic sync)
|
||||
db_session.expire_all()
|
||||
provider = fetch_existing_llm_provider(
|
||||
name=provider_name, db_session=db_session
|
||||
)
|
||||
assert provider is not None
|
||||
|
||||
sync_auto_mode_models(
|
||||
db_session=db_session,
|
||||
provider=provider,
|
||||
llm_recommendations=mock_recommendations,
|
||||
)
|
||||
|
||||
# Step 3: Every ModelConfiguration must have at least one LLMModelFlow
|
||||
db_session.expire_all()
|
||||
provider = fetch_existing_llm_provider(
|
||||
name=provider_name, db_session=db_session
|
||||
)
|
||||
assert provider is not None
|
||||
|
||||
synced_model_names = {mc.name for mc in provider.model_configurations}
|
||||
assert "gpt-4o" in synced_model_names
|
||||
assert "gpt-4o-mini" in synced_model_names
|
||||
|
||||
for mc in provider.model_configurations:
|
||||
assert len(mc.llm_model_flows) > 0, (
|
||||
f"ModelConfiguration '{mc.name}' (id={mc.id}) has no "
|
||||
f"LLMModelFlow rows — it will be invisible to listing queries"
|
||||
)
|
||||
|
||||
flow_types = {f.llm_model_flow_type for f in mc.llm_model_flows}
|
||||
assert (
|
||||
LLMModelFlowType.CHAT in flow_types
|
||||
), f"ModelConfiguration '{mc.name}' is missing a CHAT flow"
|
||||
|
||||
# Step 4: The provider must appear in fetch_existing_llm_providers
|
||||
listed_providers = fetch_existing_llm_providers(
|
||||
db_session=db_session,
|
||||
flow_type_filter=[LLMModelFlowType.CHAT],
|
||||
)
|
||||
listed_provider_names = {p.name for p in listed_providers}
|
||||
assert provider_name in listed_provider_names, (
|
||||
f"Provider '{provider_name}' not returned by "
|
||||
f"fetch_existing_llm_providers — models are missing flow rows"
|
||||
)
|
||||
|
||||
finally:
|
||||
db_session.rollback()
|
||||
_cleanup_provider(db_session, provider_name)
|
||||
|
||||
@@ -17,7 +17,8 @@ COPY ./tests/* /app/tests/
|
||||
|
||||
FROM base AS openapi-schema
|
||||
COPY ./scripts/onyx_openapi_schema.py /app/scripts/onyx_openapi_schema.py
|
||||
RUN python scripts/onyx_openapi_schema.py --filename openapi.json
|
||||
# TODO(Nik): https://linear.app/onyx-app/issue/ENG-1/update-test-infra-to-use-test-license
|
||||
RUN LICENSE_ENFORCEMENT_ENABLED=false python scripts/onyx_openapi_schema.py --filename openapi.json
|
||||
|
||||
FROM openapitools/openapi-generator-cli:latest AS openapi-client
|
||||
WORKDIR /local
|
||||
|
||||
155
backend/tests/integration/tests/users/test_seat_limit.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Integration tests for seat limit enforcement on user creation paths.
|
||||
|
||||
Verifies that when a license with a seat limit is active, new user
|
||||
creation (registration, invite, reactivation) is blocked with HTTP 402.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
import redis
|
||||
import requests
|
||||
|
||||
from ee.onyx.server.license.models import LicenseMetadata
|
||||
from ee.onyx.server.license.models import LicenseSource
|
||||
from ee.onyx.server.license.models import PlanType
|
||||
from onyx.configs.app_configs import REDIS_DB_NUMBER
|
||||
from onyx.configs.app_configs import REDIS_HOST
|
||||
from onyx.configs.app_configs import REDIS_PORT
|
||||
from onyx.server.settings.models import ApplicationStatus
|
||||
from tests.integration.common_utils.constants import API_SERVER_URL
|
||||
from tests.integration.common_utils.constants import GENERAL_HEADERS
|
||||
from tests.integration.common_utils.managers.user import UserManager
|
||||
|
||||
# TenantRedis prefixes every key with "{tenant_id}:".
|
||||
# Single-tenant deployments use "public" as the tenant id.
|
||||
_LICENSE_REDIS_KEY = "public:license:metadata"
|
||||
|
||||
|
||||
def _seed_license(r: redis.Redis, seats: int) -> None:
|
||||
"""Write a LicenseMetadata entry into Redis with the given seat cap."""
|
||||
now = datetime.utcnow()
|
||||
metadata = LicenseMetadata(
|
||||
tenant_id="public",
|
||||
organization_name="Test Org",
|
||||
seats=seats,
|
||||
used_seats=0, # check_seat_availability recalculates from DB
|
||||
plan_type=PlanType.ANNUAL,
|
||||
issued_at=now,
|
||||
expires_at=now + timedelta(days=365),
|
||||
status=ApplicationStatus.ACTIVE,
|
||||
source=LicenseSource.MANUAL_UPLOAD,
|
||||
)
|
||||
r.set(_LICENSE_REDIS_KEY, metadata.model_dump_json(), ex=300)
|
||||
|
||||
|
||||
def _clear_license(r: redis.Redis) -> None:
|
||||
r.delete(_LICENSE_REDIS_KEY)
|
||||
|
||||
|
||||
def _redis() -> redis.Redis:
|
||||
return redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB_NUMBER)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Registration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registration_blocked_when_seats_full(reset: None) -> None: # noqa: ARG001
|
||||
"""POST /auth/register returns 402 when the seat limit is reached."""
|
||||
r = _redis()
|
||||
|
||||
# First user is admin — occupies 1 seat
|
||||
UserManager.create(name="admin_user")
|
||||
|
||||
# License allows exactly 1 seat → already full
|
||||
_seed_license(r, seats=1)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
url=f"{API_SERVER_URL}/auth/register",
|
||||
json={
|
||||
"email": "blocked@example.com",
|
||||
"username": "blocked@example.com",
|
||||
"password": "TestPassword123!",
|
||||
},
|
||||
headers=GENERAL_HEADERS,
|
||||
)
|
||||
assert response.status_code == 402
|
||||
finally:
|
||||
_clear_license(r)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Invitation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_invite_blocked_when_seats_full(reset: None) -> None: # noqa: ARG001
|
||||
"""PUT /manage/admin/users returns 402 when the seat limit is reached."""
|
||||
r = _redis()
|
||||
|
||||
admin_user = UserManager.create(name="admin_user")
|
||||
|
||||
_seed_license(r, seats=1)
|
||||
|
||||
try:
|
||||
response = requests.put(
|
||||
url=f"{API_SERVER_URL}/manage/admin/users",
|
||||
json={"emails": ["newuser@example.com"]},
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 402
|
||||
finally:
|
||||
_clear_license(r)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reactivation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reactivation_blocked_when_seats_full(reset: None) -> None: # noqa: ARG001
|
||||
"""PATCH /manage/admin/activate-user returns 402 when seats are full."""
|
||||
r = _redis()
|
||||
|
||||
admin_user = UserManager.create(name="admin_user")
|
||||
basic_user = UserManager.create(name="basic_user")
|
||||
|
||||
# Deactivate the basic user (frees a seat in the DB count)
|
||||
UserManager.set_status(
|
||||
basic_user, target_status=False, user_performing_action=admin_user
|
||||
)
|
||||
|
||||
# Set license to 1 seat — only admin counts now
|
||||
_seed_license(r, seats=1)
|
||||
|
||||
try:
|
||||
response = requests.patch(
|
||||
url=f"{API_SERVER_URL}/manage/admin/activate-user",
|
||||
json={"user_email": basic_user.email},
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 402
|
||||
finally:
|
||||
_clear_license(r)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# No license → no enforcement
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registration_allowed_without_license(reset: None) -> None: # noqa: ARG001
|
||||
"""Without a license in Redis, registration is unrestricted."""
|
||||
r = _redis()
|
||||
|
||||
# Make sure there is no cached license
|
||||
_clear_license(r)
|
||||
|
||||
UserManager.create(name="admin_user")
|
||||
|
||||
# Second user should register without issue
|
||||
second_user = UserManager.create(name="second_user")
|
||||
assert second_user is not None
|
||||
@@ -17,6 +17,7 @@ class TestOnyxWebCrawler:
|
||||
content from public websites correctly.
|
||||
"""
|
||||
|
||||
@pytest.mark.skip(reason="Temporarily disabled")
|
||||
def test_fetches_public_url_successfully(self, admin_user: DATestUser) -> None:
|
||||
"""Test that the crawler can fetch content from a public URL."""
|
||||
response = requests.post(
|
||||
@@ -40,6 +41,7 @@ class TestOnyxWebCrawler:
|
||||
assert "This domain is for use in" in content
|
||||
assert "documentation" in content or "illustrative" in content
|
||||
|
||||
@pytest.mark.skip(reason="Temporarily disabled")
|
||||
def test_fetches_multiple_urls(self, admin_user: DATestUser) -> None:
|
||||
"""Test that the crawler can fetch multiple URLs in one request."""
|
||||
response = requests.post(
|
||||
|
||||
@@ -101,6 +101,33 @@ class TestMakeBillingRequest:
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "Bad request" in exc_info.value.message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("ee.onyx.server.billing.service._get_headers")
|
||||
@patch("ee.onyx.server.billing.service._get_base_url")
|
||||
async def test_follows_redirects(
|
||||
self,
|
||||
mock_base_url: MagicMock,
|
||||
mock_headers: MagicMock,
|
||||
) -> None:
|
||||
"""AsyncClient must be created with follow_redirects=True.
|
||||
|
||||
The target server (cloud data plane for self-hosted, control
|
||||
plane for cloud) may sit behind nginx that returns 308
|
||||
(HTTP→HTTPS). httpx does not follow redirects by default,
|
||||
so we must explicitly opt in.
|
||||
"""
|
||||
from ee.onyx.server.billing.service import _make_billing_request
|
||||
|
||||
mock_base_url.return_value = "http://api.example.com"
|
||||
mock_headers.return_value = {"Authorization": "Bearer token"}
|
||||
mock_response = make_mock_response({"ok": True})
|
||||
mock_client = make_mock_http_client("get", response=mock_response)
|
||||
|
||||
with patch("httpx.AsyncClient", mock_client):
|
||||
await _make_billing_request(method="GET", path="/test")
|
||||
|
||||
mock_client.assert_called_once_with(timeout=30.0, follow_redirects=True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("ee.onyx.server.billing.service._get_headers")
|
||||
@patch("ee.onyx.server.billing.service._get_base_url")
|
||||
|
||||
@@ -51,7 +51,6 @@ class TestApplyLicenseStatusToSettings:
|
||||
@pytest.mark.parametrize(
|
||||
"license_status,expected_app_status,expected_ee_enabled",
|
||||
[
|
||||
(None, ApplicationStatus.ACTIVE, False),
|
||||
(ApplicationStatus.GATED_ACCESS, ApplicationStatus.GATED_ACCESS, False),
|
||||
(ApplicationStatus.ACTIVE, ApplicationStatus.ACTIVE, True),
|
||||
],
|
||||
@@ -84,6 +83,56 @@ class TestApplyLicenseStatusToSettings:
|
||||
assert result.application_status == expected_app_status
|
||||
assert result.ee_features_enabled is expected_ee_enabled
|
||||
|
||||
@patch("ee.onyx.server.settings.api.ENTERPRISE_EDITION_ENABLED", True)
|
||||
@patch("ee.onyx.server.settings.api.LICENSE_ENFORCEMENT_ENABLED", True)
|
||||
@patch("ee.onyx.server.settings.api.MULTI_TENANT", False)
|
||||
@patch("ee.onyx.server.settings.api.refresh_license_cache", return_value=None)
|
||||
@patch("ee.onyx.server.settings.api.get_session_with_current_tenant")
|
||||
@patch("ee.onyx.server.settings.api.get_current_tenant_id")
|
||||
@patch("ee.onyx.server.settings.api.get_cached_license_metadata")
|
||||
def test_no_license_with_ee_flag_gates_access(
|
||||
self,
|
||||
mock_get_metadata: MagicMock,
|
||||
mock_get_tenant: MagicMock,
|
||||
_mock_get_session: MagicMock,
|
||||
_mock_refresh: MagicMock,
|
||||
base_settings: Settings,
|
||||
) -> None:
|
||||
"""No license + ENTERPRISE_EDITION_ENABLED=true → GATED_ACCESS."""
|
||||
from ee.onyx.server.settings.api import apply_license_status_to_settings
|
||||
|
||||
mock_get_tenant.return_value = "test_tenant"
|
||||
mock_get_metadata.return_value = None
|
||||
|
||||
result = apply_license_status_to_settings(base_settings)
|
||||
assert result.application_status == ApplicationStatus.GATED_ACCESS
|
||||
assert result.ee_features_enabled is False
|
||||
|
||||
@patch("ee.onyx.server.settings.api.ENTERPRISE_EDITION_ENABLED", False)
|
||||
@patch("ee.onyx.server.settings.api.LICENSE_ENFORCEMENT_ENABLED", True)
|
||||
@patch("ee.onyx.server.settings.api.MULTI_TENANT", False)
|
||||
@patch("ee.onyx.server.settings.api.refresh_license_cache", return_value=None)
|
||||
@patch("ee.onyx.server.settings.api.get_session_with_current_tenant")
|
||||
@patch("ee.onyx.server.settings.api.get_current_tenant_id")
|
||||
@patch("ee.onyx.server.settings.api.get_cached_license_metadata")
|
||||
def test_no_license_without_ee_flag_allows_community(
|
||||
self,
|
||||
mock_get_metadata: MagicMock,
|
||||
mock_get_tenant: MagicMock,
|
||||
_mock_get_session: MagicMock,
|
||||
_mock_refresh: MagicMock,
|
||||
base_settings: Settings,
|
||||
) -> None:
|
||||
"""No license + ENTERPRISE_EDITION_ENABLED=false → community mode (no gating)."""
|
||||
from ee.onyx.server.settings.api import apply_license_status_to_settings
|
||||
|
||||
mock_get_tenant.return_value = "test_tenant"
|
||||
mock_get_metadata.return_value = None
|
||||
|
||||
result = apply_license_status_to_settings(base_settings)
|
||||
assert result.application_status == ApplicationStatus.ACTIVE
|
||||
assert result.ee_features_enabled is False
|
||||
|
||||
@patch("ee.onyx.server.settings.api.LICENSE_ENFORCEMENT_ENABLED", True)
|
||||
@patch("ee.onyx.server.settings.api.MULTI_TENANT", False)
|
||||
@patch("ee.onyx.server.settings.api.get_current_tenant_id")
|
||||
|
||||
@@ -427,6 +427,37 @@ class TestForwardToControlPlane:
|
||||
assert exc_info.value.status_code == 502
|
||||
assert "Failed to connect to control plane" in str(exc_info.value.detail)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_follows_redirects(self) -> None:
|
||||
"""Test that AsyncClient is created with follow_redirects=True.
|
||||
|
||||
The control plane may sit behind a reverse proxy that returns
|
||||
308 (HTTP→HTTPS). httpx does not follow redirects by default,
|
||||
so we must explicitly opt in.
|
||||
"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"ok": True}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"ee.onyx.server.tenants.proxy.generate_data_plane_token"
|
||||
) as mock_token,
|
||||
patch("ee.onyx.server.tenants.proxy.httpx.AsyncClient") as mock_client,
|
||||
patch(
|
||||
"ee.onyx.server.tenants.proxy.CONTROL_PLANE_API_BASE_URL",
|
||||
"http://control.example.com",
|
||||
),
|
||||
):
|
||||
mock_token.return_value = "cp_token"
|
||||
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
await forward_to_control_plane("GET", "/test")
|
||||
|
||||
mock_client.assert_called_once_with(timeout=30.0, follow_redirects=True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_method(self) -> None:
|
||||
"""Test that unsupported HTTP methods raise ValueError."""
|
||||
|
||||
@@ -384,6 +384,29 @@ class TestWhitelistBehavior:
|
||||
verify_email_is_invited("Allowed@Example.Com")
|
||||
|
||||
|
||||
class TestSeatLimitEnforcement:
|
||||
"""Seat limits block new user creation on self-hosted deployments."""
|
||||
|
||||
def test_adding_user_fails_when_seats_full(self) -> None:
|
||||
from onyx.auth.users import enforce_seat_limit
|
||||
|
||||
seat_result = MagicMock(available=False, error_message="Seat limit reached")
|
||||
with patch(
|
||||
"onyx.auth.users.fetch_ee_implementation_or_noop",
|
||||
return_value=lambda *_a, **_kw: seat_result,
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
enforce_seat_limit(MagicMock())
|
||||
|
||||
assert exc.value.status_code == 402
|
||||
|
||||
def test_seat_limit_only_enforced_for_self_hosted(self) -> None:
|
||||
from onyx.auth.users import enforce_seat_limit
|
||||
|
||||
with patch("onyx.auth.users.MULTI_TENANT", True):
|
||||
enforce_seat_limit(MagicMock()) # should not raise
|
||||
|
||||
|
||||
class TestCaseInsensitiveEmailMatching:
|
||||
"""Test case-insensitive email matching for existing user checks."""
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.chat.llm_loop import _should_keep_bedrock_tool_definitions
|
||||
from onyx.chat.llm_loop import construct_message_history
|
||||
from onyx.chat.models import ChatLoadedFile
|
||||
from onyx.chat.models import ChatMessageSimple
|
||||
@@ -10,6 +11,17 @@ from onyx.chat.models import ProjectFileMetadata
|
||||
from onyx.chat.models import ToolCallSimple
|
||||
from onyx.configs.constants import MessageType
|
||||
from onyx.file_store.models import ChatFileType
|
||||
from onyx.llm.constants import LlmProviderNames
|
||||
|
||||
|
||||
class _StubConfig:
|
||||
def __init__(self, model_provider: str) -> None:
|
||||
self.model_provider = model_provider
|
||||
|
||||
|
||||
class _StubLLM:
|
||||
def __init__(self, model_provider: str) -> None:
|
||||
self.config = _StubConfig(model_provider=model_provider)
|
||||
|
||||
|
||||
def create_message(
|
||||
@@ -568,3 +580,34 @@ class TestConstructMessageHistory:
|
||||
assert '"contents"' in project_message.message
|
||||
assert "Project file 0 content" in project_message.message
|
||||
assert "Project file 1 content" in project_message.message
|
||||
|
||||
|
||||
class TestBedrockToolConfigGuard:
|
||||
def test_bedrock_with_tool_history_keeps_tool_definitions(self) -> None:
|
||||
llm = _StubLLM(LlmProviderNames.BEDROCK)
|
||||
history = [
|
||||
create_message("Question", MessageType.USER, 5),
|
||||
create_assistant_with_tool_call("tc_1", "search", 5),
|
||||
create_tool_response("tc_1", "Tool output", 5),
|
||||
]
|
||||
|
||||
assert _should_keep_bedrock_tool_definitions(llm, history) is True
|
||||
|
||||
def test_bedrock_without_tool_history_does_not_keep_tool_definitions(self) -> None:
|
||||
llm = _StubLLM(LlmProviderNames.BEDROCK)
|
||||
history = [
|
||||
create_message("Question", MessageType.USER, 5),
|
||||
create_message("Answer", MessageType.ASSISTANT, 5),
|
||||
]
|
||||
|
||||
assert _should_keep_bedrock_tool_definitions(llm, history) is False
|
||||
|
||||
def test_non_bedrock_with_tool_history_does_not_keep_tool_definitions(self) -> None:
|
||||
llm = _StubLLM(LlmProviderNames.OPENAI)
|
||||
history = [
|
||||
create_message("Question", MessageType.USER, 5),
|
||||
create_assistant_with_tool_call("tc_1", "search", 5),
|
||||
create_tool_response("tc_1", "Tool output", 5),
|
||||
]
|
||||
|
||||
assert _should_keep_bedrock_tool_definitions(llm, history) is False
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Test bulk invite limit for free trial tenants."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from onyx.server.manage.users import bulk_invite_users
|
||||
|
||||
|
||||
@patch("onyx.server.manage.users.MULTI_TENANT", True)
|
||||
@patch("onyx.server.manage.users.is_tenant_on_trial_fn", return_value=True)
|
||||
@patch("onyx.server.manage.users.get_current_tenant_id", return_value="test_tenant")
|
||||
@patch("onyx.server.manage.users.get_invited_users", return_value=[])
|
||||
@patch("onyx.server.manage.users.get_all_users", return_value=[])
|
||||
@patch("onyx.server.manage.users.NUM_FREE_TRIAL_USER_INVITES", 5)
|
||||
def test_trial_tenant_cannot_exceed_invite_limit(*_mocks: None) -> None:
|
||||
"""Trial tenants cannot invite more users than the configured limit."""
|
||||
emails = [f"user{i}@example.com" for i in range(6)]
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
bulk_invite_users(emails=emails)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "invite limit" in exc_info.value.detail.lower()
|
||||
|
||||
|
||||
@patch("onyx.server.manage.users.MULTI_TENANT", True)
|
||||
@patch("onyx.server.manage.users.DEV_MODE", True)
|
||||
@patch("onyx.server.manage.users.ENABLE_EMAIL_INVITES", False)
|
||||
@patch("onyx.server.manage.users.is_tenant_on_trial_fn", return_value=True)
|
||||
@patch("onyx.server.manage.users.get_current_tenant_id", return_value="test_tenant")
|
||||
@patch("onyx.server.manage.users.get_invited_users", return_value=[])
|
||||
@patch("onyx.server.manage.users.get_all_users", return_value=[])
|
||||
@patch("onyx.server.manage.users.write_invited_users", return_value=3)
|
||||
@patch("onyx.server.manage.users.NUM_FREE_TRIAL_USER_INVITES", 5)
|
||||
@patch(
|
||||
"onyx.server.manage.users.fetch_ee_implementation_or_noop",
|
||||
return_value=lambda *_args: None,
|
||||
)
|
||||
def test_trial_tenant_can_invite_within_limit(*_mocks: None) -> None:
|
||||
"""Trial tenants can invite users when under the limit."""
|
||||
emails = ["user1@example.com", "user2@example.com", "user3@example.com"]
|
||||
|
||||
result = bulk_invite_users(emails=emails)
|
||||
|
||||
assert result == 3
|
||||
@@ -0,0 +1,206 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from fastapi import HTTPException
|
||||
|
||||
import onyx.tools.tool_implementations.web_search.clients.brave_client as brave_module
|
||||
from onyx.tools.tool_implementations.web_search.clients.brave_client import (
|
||||
BraveClient,
|
||||
)
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status_code: int,
|
||||
payload: dict[str, Any] | None = None,
|
||||
text: str = "",
|
||||
) -> None:
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.text = text
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if self.status_code >= 400:
|
||||
http_error = requests.HTTPError(f"{self.status_code} Client Error")
|
||||
http_error.response = cast(requests.Response, self)
|
||||
raise http_error
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
if self._payload is None:
|
||||
raise ValueError("No JSON payload")
|
||||
return self._payload
|
||||
|
||||
|
||||
def test_search_maps_brave_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = BraveClient(api_key="test-key", num_results=5)
|
||||
|
||||
def _mock_get(*args: Any, **kwargs: Any) -> DummyResponse: # noqa: ARG001
|
||||
return DummyResponse(
|
||||
status_code=200,
|
||||
payload={
|
||||
"web": {
|
||||
"results": [
|
||||
{
|
||||
"title": "Result 1",
|
||||
"url": "https://example.com/one",
|
||||
"description": "Snippet 1",
|
||||
},
|
||||
{
|
||||
"title": "Result without URL",
|
||||
"description": "Should be skipped",
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(brave_module.requests, "get", _mock_get)
|
||||
|
||||
results = client.search("onyx")
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].title == "Result 1"
|
||||
assert results[0].link == "https://example.com/one"
|
||||
assert results[0].snippet == "Snippet 1"
|
||||
|
||||
|
||||
def test_search_caps_count_to_brave_max(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = BraveClient(api_key="test-key", num_results=100)
|
||||
captured_count: str | None = None
|
||||
|
||||
def _mock_get(*args: Any, **kwargs: Any) -> DummyResponse: # noqa: ARG001
|
||||
nonlocal captured_count
|
||||
captured_count = kwargs["params"]["count"]
|
||||
return DummyResponse(status_code=200, payload={"web": {"results": []}})
|
||||
|
||||
monkeypatch.setattr(brave_module.requests, "get", _mock_get)
|
||||
|
||||
client.search("onyx")
|
||||
|
||||
assert captured_count == "20"
|
||||
|
||||
|
||||
def test_search_includes_optional_params(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = BraveClient(
|
||||
api_key="test-key",
|
||||
num_results=5,
|
||||
country="us",
|
||||
search_lang="en",
|
||||
ui_lang="en-US",
|
||||
safesearch="moderate",
|
||||
freshness="pw",
|
||||
)
|
||||
captured_params: dict[str, str] | None = None
|
||||
|
||||
def _mock_get(*args: Any, **kwargs: Any) -> DummyResponse: # noqa: ARG001
|
||||
nonlocal captured_params
|
||||
captured_params = kwargs["params"]
|
||||
return DummyResponse(status_code=200, payload={"web": {"results": []}})
|
||||
|
||||
monkeypatch.setattr(brave_module.requests, "get", _mock_get)
|
||||
|
||||
client.search("onyx")
|
||||
|
||||
assert captured_params is not None
|
||||
assert captured_params["country"] == "US"
|
||||
assert captured_params["search_lang"] == "en"
|
||||
assert captured_params["ui_lang"] == "en-US"
|
||||
assert captured_params["safesearch"] == "moderate"
|
||||
assert captured_params["freshness"] == "pw"
|
||||
|
||||
|
||||
def test_search_raises_descriptive_error_on_http_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client = BraveClient(api_key="test-key", num_results=5)
|
||||
|
||||
def _mock_get(*args: Any, **kwargs: Any) -> DummyResponse: # noqa: ARG001
|
||||
return DummyResponse(
|
||||
status_code=401,
|
||||
payload={"error": {"message": "Unauthorized"}},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(brave_module.requests, "get", _mock_get)
|
||||
|
||||
with pytest.raises(ValueError, match="status 401"):
|
||||
client.search("onyx")
|
||||
|
||||
|
||||
def test_search_does_not_retry_non_retryable_http_errors(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client = BraveClient(api_key="test-key", num_results=5)
|
||||
calls = 0
|
||||
|
||||
def _mock_get(*args: Any, **kwargs: Any) -> DummyResponse: # noqa: ARG001
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return DummyResponse(
|
||||
status_code=401,
|
||||
payload={"error": {"message": "Unauthorized"}},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(brave_module.requests, "get", _mock_get)
|
||||
|
||||
with pytest.raises(ValueError, match="status 401"):
|
||||
client.search("onyx")
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "expected_error"),
|
||||
[
|
||||
({"country": "USA"}, "country"),
|
||||
({"safesearch": "invalid"}, "safesearch"),
|
||||
({"freshness": "invalid"}, "freshness"),
|
||||
({"timeout_seconds": 0}, "timeout_seconds"),
|
||||
],
|
||||
)
|
||||
def test_constructor_rejects_invalid_config_values(
|
||||
kwargs: dict[str, Any],
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
with pytest.raises(ValueError, match=expected_error):
|
||||
BraveClient(api_key="test-key", **kwargs)
|
||||
|
||||
|
||||
def test_test_connection_maps_invalid_key_errors() -> None:
|
||||
client = BraveClient(api_key="test-key")
|
||||
|
||||
def _mock_search(query: str) -> list[Any]: # noqa: ARG001
|
||||
raise ValueError("Brave search failed (status 401): Unauthorized")
|
||||
|
||||
client.search = _mock_search # type: ignore[method-assign]
|
||||
|
||||
with pytest.raises(HTTPException, match="Invalid Brave API key"):
|
||||
client.test_connection()
|
||||
|
||||
|
||||
def test_test_connection_maps_rate_limit_errors() -> None:
|
||||
client = BraveClient(api_key="test-key")
|
||||
|
||||
def _mock_search(query: str) -> list[Any]: # noqa: ARG001
|
||||
raise ValueError("Brave search failed (status 429): Too many requests")
|
||||
|
||||
client.search = _mock_search # type: ignore[method-assign]
|
||||
|
||||
with pytest.raises(HTTPException, match="rate limit exceeded"):
|
||||
client.test_connection()
|
||||
|
||||
|
||||
def test_test_connection_propagates_unexpected_errors() -> None:
|
||||
client = BraveClient(api_key="test-key")
|
||||
|
||||
def _mock_search(query: str) -> list[Any]: # noqa: ARG001
|
||||
raise RuntimeError("unexpected parsing bug")
|
||||
|
||||
client.search = _mock_search # type: ignore[method-assign]
|
||||
|
||||
with pytest.raises(RuntimeError, match="unexpected parsing bug"):
|
||||
client.test_connection()
|
||||
@@ -0,0 +1,122 @@
|
||||
import pytest
|
||||
|
||||
from onyx.tools.tool_implementations.web_search.clients.brave_client import (
|
||||
BraveClient,
|
||||
)
|
||||
from onyx.tools.tool_implementations.web_search.providers import (
|
||||
build_search_provider_from_config,
|
||||
)
|
||||
from onyx.tools.tool_implementations.web_search.providers import (
|
||||
provider_requires_api_key,
|
||||
)
|
||||
from shared_configs.enums import WebSearchProviderType
|
||||
|
||||
|
||||
def test_provider_requires_api_key() -> None:
|
||||
"""Test that provider_requires_api_key correctly identifies which providers need API keys."""
|
||||
assert provider_requires_api_key(WebSearchProviderType.EXA) is True
|
||||
assert provider_requires_api_key(WebSearchProviderType.BRAVE) is True
|
||||
assert provider_requires_api_key(WebSearchProviderType.SERPER) is True
|
||||
assert provider_requires_api_key(WebSearchProviderType.GOOGLE_PSE) is True
|
||||
assert provider_requires_api_key(WebSearchProviderType.SEARXNG) is False
|
||||
|
||||
|
||||
def test_build_searxng_provider_without_api_key() -> None:
|
||||
"""Test that SearXNG provider can be built without an API key."""
|
||||
provider = build_search_provider_from_config(
|
||||
provider_type=WebSearchProviderType.SEARXNG,
|
||||
api_key=None,
|
||||
config={"searxng_base_url": "http://localhost:8080"},
|
||||
)
|
||||
assert provider is not None
|
||||
|
||||
|
||||
def test_build_searxng_provider_requires_base_url() -> None:
|
||||
"""Test that SearXNG provider requires a base URL."""
|
||||
with pytest.raises(ValueError, match="Please provide a URL"):
|
||||
build_search_provider_from_config(
|
||||
provider_type=WebSearchProviderType.SEARXNG,
|
||||
api_key=None,
|
||||
config={},
|
||||
)
|
||||
|
||||
|
||||
def test_build_exa_provider_requires_api_key() -> None:
|
||||
"""Test that Exa provider requires an API key."""
|
||||
with pytest.raises(ValueError, match="API key is required"):
|
||||
build_search_provider_from_config(
|
||||
provider_type=WebSearchProviderType.EXA,
|
||||
api_key=None,
|
||||
config={},
|
||||
)
|
||||
|
||||
|
||||
def test_build_brave_provider_requires_api_key() -> None:
|
||||
"""Test that Brave provider requires an API key."""
|
||||
with pytest.raises(ValueError, match="API key is required"):
|
||||
build_search_provider_from_config(
|
||||
provider_type=WebSearchProviderType.BRAVE,
|
||||
api_key=None,
|
||||
config={},
|
||||
)
|
||||
|
||||
|
||||
def test_build_brave_provider_with_optional_config() -> None:
|
||||
provider = build_search_provider_from_config(
|
||||
provider_type=WebSearchProviderType.BRAVE,
|
||||
api_key="test-api-key",
|
||||
config={
|
||||
"country": "us",
|
||||
"search_lang": "en",
|
||||
"ui_lang": "en-US",
|
||||
"safesearch": "strict",
|
||||
"freshness": "pm",
|
||||
"timeout_seconds": "12",
|
||||
},
|
||||
)
|
||||
assert isinstance(provider, BraveClient)
|
||||
assert provider._country == "US" # noqa: SLF001
|
||||
assert provider._search_lang == "en" # noqa: SLF001
|
||||
assert provider._ui_lang == "en-US" # noqa: SLF001
|
||||
assert provider._safesearch == "strict" # noqa: SLF001
|
||||
assert provider._freshness == "pm" # noqa: SLF001
|
||||
assert provider._timeout_seconds == 12 # noqa: SLF001
|
||||
|
||||
|
||||
def test_build_brave_provider_rejects_invalid_timeout() -> None:
|
||||
with pytest.raises(ValueError, match="timeout_seconds"):
|
||||
build_search_provider_from_config(
|
||||
provider_type=WebSearchProviderType.BRAVE,
|
||||
api_key="test-api-key",
|
||||
config={"timeout_seconds": "not-an-int"},
|
||||
)
|
||||
|
||||
|
||||
def test_build_serper_provider_requires_api_key() -> None:
|
||||
"""Test that Serper provider requires an API key."""
|
||||
with pytest.raises(ValueError, match="API key is required"):
|
||||
build_search_provider_from_config(
|
||||
provider_type=WebSearchProviderType.SERPER,
|
||||
api_key=None,
|
||||
config={},
|
||||
)
|
||||
|
||||
|
||||
def test_build_google_pse_provider_requires_api_key() -> None:
|
||||
"""Test that Google PSE provider requires an API key."""
|
||||
with pytest.raises(ValueError, match="API key is required"):
|
||||
build_search_provider_from_config(
|
||||
provider_type=WebSearchProviderType.GOOGLE_PSE,
|
||||
api_key=None,
|
||||
config={"search_engine_id": "test-cx"},
|
||||
)
|
||||
|
||||
|
||||
def test_build_google_pse_provider_requires_search_engine_id() -> None:
|
||||
"""Test that Google PSE provider requires a search engine ID."""
|
||||
with pytest.raises(ValueError, match="search engine id"):
|
||||
build_search_provider_from_config(
|
||||
provider_type=WebSearchProviderType.GOOGLE_PSE,
|
||||
api_key="test-api-key",
|
||||
config={},
|
||||
)
|
||||
@@ -29,6 +29,8 @@ services:
|
||||
- MODEL_SERVER_PORT=${MODEL_SERVER_PORT:-}
|
||||
- ENV_SEED_CONFIGURATION=${ENV_SEED_CONFIGURATION:-}
|
||||
- ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=True
|
||||
# TODO(Nik): https://linear.app/onyx-app/issue/ENG-1/update-test-infra-to-use-test-license
|
||||
- LICENSE_ENFORCEMENT_ENABLED=false
|
||||
# MinIO configuration
|
||||
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
|
||||
- S3_AWS_ACCESS_KEY_ID=${S3_AWS_ACCESS_KEY_ID:-minioadmin}
|
||||
@@ -66,6 +68,8 @@ services:
|
||||
- INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server}
|
||||
- ENV_SEED_CONFIGURATION=${ENV_SEED_CONFIGURATION:-}
|
||||
- ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=True
|
||||
# TODO(Nik): https://linear.app/onyx-app/issue/ENG-1/update-test-infra-to-use-test-license
|
||||
- LICENSE_ENFORCEMENT_ENABLED=false
|
||||
# MinIO configuration
|
||||
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
|
||||
- S3_AWS_ACCESS_KEY_ID=${S3_AWS_ACCESS_KEY_ID:-minioadmin}
|
||||
|
||||
@@ -20,9 +20,9 @@ use tauri::Wry;
|
||||
use tauri::{
|
||||
webview::PageLoadPayload, AppHandle, Manager, Webview, WebviewUrl, WebviewWindowBuilder,
|
||||
};
|
||||
use url::Url;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tokio::time::sleep;
|
||||
use url::Url;
|
||||
#[cfg(target_os = "macos")]
|
||||
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
|
||||
|
||||
@@ -40,6 +40,136 @@ const TRAY_MENU_OPEN_APP_ID: &str = "tray_open_app";
|
||||
const TRAY_MENU_OPEN_CHAT_ID: &str = "tray_open_chat";
|
||||
const TRAY_MENU_SHOW_IN_BAR_ID: &str = "tray_show_in_menu_bar";
|
||||
const TRAY_MENU_QUIT_ID: &str = "tray_quit";
|
||||
const CHAT_LINK_INTERCEPT_SCRIPT: &str = r##"
|
||||
(() => {
|
||||
if (window.__ONYX_CHAT_LINK_INTERCEPT_INSTALLED__) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.__ONYX_CHAT_LINK_INTERCEPT_INSTALLED__ = true;
|
||||
|
||||
function isChatSessionPage() {
|
||||
try {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
return (
|
||||
currentUrl.pathname.startsWith("/app") &&
|
||||
currentUrl.searchParams.has("chatId")
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getAllowedNavigationUrl(rawUrl) {
|
||||
try {
|
||||
const parsed = new URL(String(rawUrl), window.location.href);
|
||||
const scheme = parsed.protocol.toLowerCase();
|
||||
if (!["http:", "https:", "mailto:", "tel:"].includes(scheme)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function openWithTauri(url) {
|
||||
try {
|
||||
const invoke =
|
||||
window.__TAURI__?.core?.invoke || window.__TAURI_INTERNALS__?.invoke;
|
||||
if (typeof invoke !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
await invoke("open_in_browser", { url });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleChatNavigation(rawUrl) {
|
||||
const parsedUrl = getAllowedNavigationUrl(rawUrl);
|
||||
if (!parsedUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const safeUrl = parsedUrl.toString();
|
||||
const scheme = parsedUrl.protocol.toLowerCase();
|
||||
if (scheme === "mailto:" || scheme === "tel:") {
|
||||
void openWithTauri(safeUrl).then((opened) => {
|
||||
if (!opened) {
|
||||
window.location.assign(safeUrl);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
window.location.assign(safeUrl);
|
||||
return true;
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
if (!isChatSessionPage() || event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = event.target;
|
||||
if (!(element instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = element.closest("a");
|
||||
if (!(anchor instanceof HTMLAnchorElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = (anchor.getAttribute("target") || "").toLowerCase();
|
||||
if (target !== "_blank") {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = anchor.getAttribute("href");
|
||||
if (!href || href.startsWith("#")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!handleChatNavigation(href)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
const nativeWindowOpen = window.open;
|
||||
window.open = function(url, target, features) {
|
||||
const resolvedTarget = typeof target === "string" ? target.toLowerCase() : "";
|
||||
const shouldNavigateInPlace = resolvedTarget === "" || resolvedTarget === "_blank";
|
||||
|
||||
if (
|
||||
isChatSessionPage() &&
|
||||
shouldNavigateInPlace &&
|
||||
url != null &&
|
||||
String(url).length > 0
|
||||
) {
|
||||
if (!handleChatNavigation(url)) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof nativeWindowOpen === "function") {
|
||||
return nativeWindowOpen.call(window, url, target, features);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
})();
|
||||
"##;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
@@ -177,22 +307,7 @@ fn trigger_new_window(app: &AppHandle) {
|
||||
}
|
||||
|
||||
fn open_docs() {
|
||||
let url = "https://docs.onyx.app";
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = Command::new("open").arg(url).status();
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = Command::new("xdg-open").arg(url).status();
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = Command::new("rundll32")
|
||||
.arg("url.dll,FileProtocolHandler")
|
||||
.arg(url)
|
||||
.status();
|
||||
}
|
||||
let _ = open_in_default_browser("https://docs.onyx.app");
|
||||
}
|
||||
|
||||
fn open_settings(app: &AppHandle) {
|
||||
@@ -219,6 +334,68 @@ fn open_settings(app: &AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
fn same_origin(left: &Url, right: &Url) -> bool {
|
||||
left.scheme() == right.scheme()
|
||||
&& left.host_str() == right.host_str()
|
||||
&& left.port_or_known_default() == right.port_or_known_default()
|
||||
}
|
||||
|
||||
fn is_chat_session_url(url: &Url) -> bool {
|
||||
url.path().starts_with("/app") && url.query_pairs().any(|(key, _)| key == "chatId")
|
||||
}
|
||||
|
||||
fn should_open_in_external_browser(current_url: &Url, destination_url: &Url) -> bool {
|
||||
if !is_chat_session_url(current_url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
match destination_url.scheme() {
|
||||
"mailto" | "tel" => true,
|
||||
"http" | "https" => !same_origin(current_url, destination_url),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_in_default_browser(url: &str) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return Command::new("open").arg(url).status().is_ok();
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
return Command::new("xdg-open").arg(url).status().is_ok();
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return Command::new("rundll32")
|
||||
.arg("url.dll,FileProtocolHandler")
|
||||
.arg(url)
|
||||
.status()
|
||||
.is_ok();
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
false
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_in_browser(url: String) -> Result<(), String> {
|
||||
let parsed_url = Url::parse(&url).map_err(|_| "Invalid URL".to_string())?;
|
||||
match parsed_url.scheme() {
|
||||
"http" | "https" | "mailto" | "tel" => {}
|
||||
_ => return Err("Unsupported URL scheme".to_string()),
|
||||
}
|
||||
|
||||
if open_in_default_browser(parsed_url.as_str()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Failed to open URL in default browser".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn inject_chat_link_intercept(webview: &Webview) {
|
||||
let _ = webview.eval(CHAT_LINK_INTERCEPT_SCRIPT);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tauri Commands
|
||||
// ============================================================================
|
||||
@@ -240,8 +417,8 @@ struct BootstrapState {
|
||||
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);
|
||||
let config_exists =
|
||||
config_initialized && get_config_path().map(|path| path.exists()).unwrap_or(false);
|
||||
|
||||
BootstrapState {
|
||||
server_url,
|
||||
@@ -462,7 +639,13 @@ 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 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
|
||||
@@ -501,13 +684,7 @@ fn setup_app_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
}
|
||||
|
||||
fn build_tray_menu(app: &AppHandle) -> tauri::Result<Menu<Wry>> {
|
||||
let open_app = MenuItem::with_id(
|
||||
app,
|
||||
TRAY_MENU_OPEN_APP_ID,
|
||||
"Open Onyx",
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let open_app = MenuItem::with_id(app, TRAY_MENU_OPEN_APP_ID, "Open Onyx", true, None::<&str>)?;
|
||||
let open_chat = MenuItem::with_id(
|
||||
app,
|
||||
TRAY_MENU_OPEN_CHAT_ID,
|
||||
@@ -598,6 +775,27 @@ fn main() {
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(
|
||||
tauri::plugin::Builder::<Wry>::new("chat-external-navigation-handler")
|
||||
.on_navigation(|webview, destination_url| {
|
||||
let Ok(current_url) = webview.url() else {
|
||||
return true;
|
||||
};
|
||||
|
||||
if should_open_in_external_browser(¤t_url, destination_url) {
|
||||
if !open_in_default_browser(destination_url.as_str()) {
|
||||
eprintln!(
|
||||
"Failed to open external URL in default browser: {}",
|
||||
destination_url
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.manage(ConfigState {
|
||||
config: RwLock::new(config),
|
||||
@@ -609,6 +807,7 @@ fn main() {
|
||||
get_bootstrap_state,
|
||||
set_server_url,
|
||||
get_config_path_cmd,
|
||||
open_in_browser,
|
||||
open_config_file,
|
||||
open_config_directory,
|
||||
navigate_to,
|
||||
@@ -661,10 +860,12 @@ fn main() {
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_page_load(|_webview: &Webview, _payload: &PageLoadPayload| {
|
||||
.on_page_load(|webview: &Webview, _payload: &PageLoadPayload| {
|
||||
inject_chat_link_intercept(webview);
|
||||
|
||||
// Re-inject titlebar after every navigation/page load (macOS only)
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = _webview.eval(TITLEBAR_SCRIPT);
|
||||
let _ = webview.eval(TITLEBAR_SCRIPT);
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -10,7 +10,7 @@ const SvgCircle = ({ size, ...props }: IconProps) => (
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<circle cx="8" cy="8" r="6" strokeWidth={1.5} />
|
||||
<circle cx="8" cy="8" r="4" strokeWidth={1.5} />
|
||||
</svg>
|
||||
);
|
||||
export default SvgCircle;
|
||||
|
||||
21
web/lib/opal/src/icons/dashboard.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgDashboard = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M14 6V3.33333C14 2.59695 13.403 2 12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V6M14 6V12.6667C14 13.403 13.403 14 12.6667 14H6M14 6H6M2 6V12.6667C2 13.403 2.59695 14 3.33333 14H6M2 6H6M6 6V14"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgDashboard;
|
||||
21
web/lib/opal/src/icons/history.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgHistory = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M7.99998 4.00001V8.00001L11 9.50003M1.33332 1.40151V5.23535M1.33332 5.23535H4.99998M1.33332 5.23535L3.28593 3.28597C4.49236 2.07954 6.15903 1.33334 7.99998 1.33334C11.6819 1.33334 14.6667 4.31811 14.6667 8.00001C14.6667 11.6819 11.6819 14.6667 7.99998 14.6667C4.83386 14.6667 2.18324 12.4596 1.50274 9.50003"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgHistory;
|
||||
@@ -50,6 +50,7 @@ export { default as SvgCode } from "@opal/icons/code";
|
||||
export { default as SvgCopy } from "@opal/icons/copy";
|
||||
export { default as SvgCornerRightUpDot } from "@opal/icons/corner-right-up-dot";
|
||||
export { default as SvgCpu } from "@opal/icons/cpu";
|
||||
export { default as SvgDashboard } from "@opal/icons/dashboard";
|
||||
export { default as SvgDevKit } from "@opal/icons/dev-kit";
|
||||
export { default as SvgDownload } from "@opal/icons/download";
|
||||
export { default as SvgDiscordMono } from "@opal/icons/DiscordMono";
|
||||
@@ -76,6 +77,7 @@ export { default as SvgHardDrive } from "@opal/icons/hard-drive";
|
||||
export { default as SvgHashSmall } from "@opal/icons/hash-small";
|
||||
export { default as SvgHash } from "@opal/icons/hash";
|
||||
export { default as SvgHeadsetMic } from "@opal/icons/headset-mic";
|
||||
export { default as SvgHistory } from "@opal/icons/history";
|
||||
export { default as SvgHourglass } from "@opal/icons/hourglass";
|
||||
export { default as SvgImage } from "@opal/icons/image";
|
||||
export { default as SvgImageSmall } from "@opal/icons/image-small";
|
||||
@@ -155,6 +157,7 @@ export { default as SvgTwoLineSmall } from "@opal/icons/two-line-small";
|
||||
export { default as SvgUnplug } from "@opal/icons/unplug";
|
||||
export { default as SvgUploadCloud } from "@opal/icons/upload-cloud";
|
||||
export { default as SvgUser } from "@opal/icons/user";
|
||||
export { default as SvgUserManage } from "@opal/icons/user-manage";
|
||||
export { default as SvgUserPlus } from "@opal/icons/user-plus";
|
||||
export { default as SvgUsers } from "@opal/icons/users";
|
||||
export { default as SvgWallet } from "@opal/icons/wallet";
|
||||
|
||||
21
web/lib/opal/src/icons/user-manage.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgUserManage = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 15 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M0.75 12.75C0.75 12.4167 0.75 12.0833 0.75 11.75C0.750002 10.0931 2.09316 8.75 3.75002 8.75L5.75 8.75M12.25 11.25L13.2981 12.2981M12.25 11.25C12.5916 10.9084 12.7499 10.4481 12.75 10.0004M12.25 11.25C11.9083 11.5917 11.4479 11.75 11 11.75M9.75 11.25L8.7019 12.2981M9.75 11.25C10.0917 11.5917 10.5521 11.75 11 11.75M9.75 11.25C9.4084 10.9084 9.25011 10.4481 9.25 10.0004M9.75 8.75L8.7019 7.70193M9.75 8.75C10.0917 8.40829 10.5521 8.25 11 8.25M9.75 8.75C9.40818 9.09182 9.24989 9.55242 9.25 10.0004M12.25 8.75L13.2981 7.70193M12.25 8.75C12.5918 9.09182 12.7501 9.55242 12.75 10.0004M12.25 8.75C11.9083 8.40829 11.4479 8.25 11 8.25M12.75 10.0004L14.25 10M11 13.25V11.75M11 6.75V8.25M7.75 10L9.25 10.0004M8.5 3.5C8.5 5.01878 7.26878 6.25 5.75 6.25C4.23122 6.25 3 5.01878 3 3.5C3 1.98122 4.23122 0.75 5.75 0.75C7.26878 0.75 8.5 1.98122 8.5 3.5Z"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgUserManage;
|
||||
33
web/public/Brave.svg
Normal file
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="218px" height="256px" viewBox="0 0 218 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 51.2 (57519) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>build-icons/Stable Copy 3</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="50.7055163%" x2="100%" y2="50.7055163%" id="linearGradient-1">
|
||||
<stop stop-color="#FF5500" offset="0%"></stop>
|
||||
<stop stop-color="#FF5500" offset="40.9877232%"></stop>
|
||||
<stop stop-color="#FF2000" offset="58.1981215%"></stop>
|
||||
<stop stop-color="#FF2000" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient x1="2.1484375%" y1="50.7055163%" x2="100%" y2="50.7055163%" id="linearGradient-2">
|
||||
<stop stop-color="#FF452A" offset="0%"></stop>
|
||||
<stop stop-color="#FF2000" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
<path d="M170.272109,25.3359387 L147.968109,0.00010893617 L108.800109,0.00010893617 L69.6321088,0.00010893617 L47.3281088,25.3359387 C47.3281088,25.3359387 27.7441088,19.8891302 18.4961088,29.1487047 C18.4961088,29.1487047 44.6081088,26.7886026 53.5841088,41.4040238 C53.5841088,41.4040238 77.7921088,46.0338111 81.0561088,46.0338111 C84.3201088,46.0338111 91.3921088,43.3104068 97.9201088,41.1316834 C104.448109,38.95296 108.800109,38.9371643 108.800109,38.9371643 C108.800109,38.9371643 113.152109,38.95296 119.680109,41.1316834 C126.208109,43.3104068 133.280109,46.0338111 136.544109,46.0338111 C139.808109,46.0338111 164.016109,41.4040238 164.016109,41.4040238 C172.992109,26.7886026 199.104109,29.1487047 199.104109,29.1487047 C189.856109,19.8891302 170.272109,25.3359387 170.272109,25.3359387" id="path-3"></path>
|
||||
</defs>
|
||||
<g id="starting-collection" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Build-Icons" transform="translate(-70.000000, -350.000000)">
|
||||
<g id="build-icons/Stable" transform="translate(50.000000, 350.000000)">
|
||||
<g id="Logo" transform="translate(20.114286, 0.000000)">
|
||||
<path d="M209.984109,61.2848749 L215.968109,46.5784919 C215.968109,46.5784919 208.352109,38.4082791 199.104109,29.1487047 C189.856109,19.8891302 170.272109,25.3359387 170.272109,25.3359387 L147.968109,0.00010893617 L108.800109,0.00010893617 L69.6321088,0.00010893617 L47.3281088,25.3359387 C47.3281088,25.3359387 27.7441088,19.8891302 18.4961088,29.1487047 C9.2481088,38.4082791 1.6321088,46.5784919 1.6321088,46.5784919 L7.6161088,61.2848749 L0.0001088,83.0721089 C0.0001088,83.0721089 22.3993088,168.017811 25.0241088,178.391258 C30.1921088,198.81679 33.7281088,206.714662 48.4161088,217.063598 C63.1041088,227.412534 89.7601088,245.387003 94.1121088,248.110407 C98.4641088,250.833811 103.904109,255.472858 108.800109,255.472858 C113.696109,255.472858 119.136109,250.833811 123.488109,248.110407 C127.840109,245.387003 154.496109,227.412534 169.184109,217.063598 C183.872109,206.714662 187.408109,198.81679 192.576109,178.391258 C195.200365,168.017811 217.600109,83.0721089 217.600109,83.0721089 L209.984109,61.2848749 Z" id="Head" fill="url(#linearGradient-1)"></path>
|
||||
<path d="M164.016109,41.4040238 C164.016109,41.4040238 192.704493,76.1274281 192.704493,83.5487047 C192.704493,90.9699813 189.095597,92.9286536 185.467117,96.7866281 C181.838637,100.644603 165.991373,117.49376 163.956269,119.657232 C161.921165,121.821249 157.684493,125.101862 160.176557,131.006747 C162.668621,136.911632 166.344973,144.425505 162.256813,152.046679 C158.168109,159.667854 151.164109,164.754628 146.676109,163.913641 C142.188109,163.072109 131.648109,157.557215 127.772109,155.038066 C123.896109,152.518917 111.611501,142.374781 111.611501,138.493386 C111.611501,134.612534 124.310093,127.643888 126.655821,126.0605 C129.002637,124.477658 139.703117,118.349454 139.922349,115.944143 C140.141037,113.538288 140.057805,112.832926 136.899885,106.889369 C133.741965,100.945266 128.054989,93.0136238 129.001549,87.7362111 C129.948109,82.459343 139.119949,79.7152409 145.665357,77.2402111 C152.209677,74.765726 164.811437,70.0918196 166.385229,69.3652153 C167.960109,68.6380664 167.553197,67.945777 162.783949,67.4931472 C158.015789,67.0405174 144.483245,65.2419813 138.382285,66.9446536 C132.281325,68.647326 121.858285,71.2378281 121.013997,72.6115132 C120.169709,73.9851983 119.424973,74.0314962 120.292109,78.7702196 C121.158701,83.508943 125.622765,106.24719 126.055789,110.285998 C126.489357,114.324807 127.336909,116.994832 122.987629,117.990509 C118.637805,118.986186 111.316109,120.715003 108.800109,120.715003 C106.284109,120.715003 98.9618688,118.986186 94.6125888,117.990509 C90.2627648,116.994832 91.1103168,114.324807 91.5438848,110.285998 C91.9774528,106.24719 96.4409728,83.508943 97.3081088,78.7702196 C98.1747008,74.0314962 97.4299648,73.9851983 96.5862208,72.6115132 C95.7419328,71.2378281 85.3183488,68.647326 79.2173888,66.9446536 C73.1164288,65.2419813 59.5844288,67.0405174 54.8157248,67.4931472 C50.0470208,67.945777 49.6401088,68.6380664 51.2144448,69.3652153 C52.7887808,70.0918196 65.3905408,74.765726 71.9348608,77.2402111 C78.4797248,79.7152409 87.6521088,82.459343 88.5986688,87.7362111 C89.5452288,93.0136238 83.8577088,100.945266 80.7003328,106.889369 C77.5424128,112.832926 77.4586368,113.538288 77.6778688,115.944143 C77.8965568,118.349454 88.5975808,124.477658 90.9438528,126.0605 C93.2901248,127.643888 105.988173,134.612534 105.988173,138.493386 C105.988173,142.374781 93.7041088,152.518917 89.8281088,155.038066 C85.9521088,157.557215 75.4121088,163.072109 70.9241088,163.913641 C66.4361088,164.754628 59.4321088,159.667854 55.3434048,152.046679 C51.2552448,144.425505 54.9315968,136.911632 57.4231168,131.006747 C59.9151808,125.101862 55.6790528,121.821249 53.6434048,119.657232 C51.6088448,117.49376 35.7610368,100.644603 32.1325568,96.7866281 C28.5040768,92.9286536 24.8957248,90.9699813 24.8957248,83.5487047 C24.8957248,76.1274281 53.5841088,41.4040238 53.5841088,41.4040238 C53.5841088,41.4040238 77.7921088,46.0338111 81.0561088,46.0338111 C84.3201088,46.0338111 91.3921088,43.3104068 97.9201088,41.1316834 C104.448109,38.95296 108.800109,38.9371643 108.800109,38.9371643 C108.800109,38.9371643 113.152109,38.95296 119.680109,41.1316834 C126.208109,43.3104068 133.280109,46.0338111 136.544109,46.0338111 C139.808109,46.0338111 164.016109,41.4040238 164.016109,41.4040238 Z M142.509504,174.227935 C144.28512,175.341263 143.202016,177.439918 141.584704,178.584837 C139.966848,179.729757 118.228064,196.584361 116.118432,198.447169 C114.008256,200.310523 110.908,203.387425 108.8,203.387425 C106.692,203.387425 103.5912,200.310523 101.481568,198.447169 C99.371392,196.584361 77.633152,179.729757 76.015296,178.584837 C74.39744,177.439918 73.31488,175.341263 75.090496,174.227935 C76.866656,173.115152 82.422528,170.306233 90.08912,166.333876 C97.754624,162.362063 107.308896,158.985042 108.8,158.985042 C110.291104,158.985042 119.844832,162.362063 127.511424,166.333876 C135.177472,170.306233 140.733344,173.115152 142.509504,174.227935 Z" id="Face" fill="#FFFFFF"></path>
|
||||
<mask id="mask-4" fill="white">
|
||||
<use xlink:href="#path-3"></use>
|
||||
</mask>
|
||||
<use id="Top-Head" fill="url(#linearGradient-2)" xlink:href="#path-3"></use>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.3 KiB |
BIN
web/public/chat-backgrounds/clouds.jpg
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
web/public/chat-backgrounds/hills.jpg
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
web/public/chat-backgrounds/mountains.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
web/public/chat-backgrounds/night.jpg
Normal file
|
After Width: | Height: | Size: 374 KiB |
BIN
web/public/chat-backgrounds/plant.jpg
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
web/public/chat-backgrounds/thumbnails/clouds.jpg
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
web/public/chat-backgrounds/thumbnails/hills.jpg
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
web/public/chat-backgrounds/thumbnails/mountains.jpg
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
web/public/chat-backgrounds/thumbnails/night.jpg
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
web/public/chat-backgrounds/thumbnails/plant.jpg
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
@@ -1,13 +1,17 @@
|
||||
import { Form, Formik } from "formik";
|
||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||
import { SelectorFormField, TextFormField } from "@/components/Field";
|
||||
import { createApiKey, updateApiKey } from "./lib";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import InputComboBox from "@/refresh-components/inputs/InputComboBox";
|
||||
import { FormikField } from "@/refresh-components/form/FormikField";
|
||||
import { FormField } from "@/refresh-components/form/FormField";
|
||||
import { USER_ROLE_LABELS, UserRole } from "@/lib/types";
|
||||
import { APIKey } from "./types";
|
||||
import { SvgKey } from "@opal/icons";
|
||||
|
||||
export interface OnyxApiKeyFormProps {
|
||||
onClose: () => void;
|
||||
setPopup: (popupSpec: PopupSpec | null) => void;
|
||||
@@ -31,91 +35,120 @@ export default function OnyxApiKeyForm({
|
||||
title={isUpdate ? "Update API Key" : "Create a new API Key"}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<Modal.Body>
|
||||
<Formik
|
||||
initialValues={{
|
||||
name: apiKey?.api_key_name || "",
|
||||
role: apiKey?.api_key_role || UserRole.BASIC.toString(),
|
||||
}}
|
||||
onSubmit={async (values, formikHelpers) => {
|
||||
formikHelpers.setSubmitting(true);
|
||||
<Formik
|
||||
initialValues={{
|
||||
name: apiKey?.api_key_name || "",
|
||||
role: apiKey?.api_key_role || UserRole.BASIC.toString(),
|
||||
}}
|
||||
onSubmit={async (values, formikHelpers) => {
|
||||
formikHelpers.setSubmitting(true);
|
||||
|
||||
// Prepare the payload with the UserRole
|
||||
const payload = {
|
||||
...values,
|
||||
role: values.role as UserRole, // Assign the role directly as a UserRole type
|
||||
};
|
||||
// Prepare the payload with the UserRole
|
||||
const payload = {
|
||||
...values,
|
||||
role: values.role as UserRole, // Assign the role directly as a UserRole type
|
||||
};
|
||||
|
||||
let response;
|
||||
if (isUpdate) {
|
||||
response = await updateApiKey(apiKey.api_key_id, payload);
|
||||
} else {
|
||||
response = await createApiKey(payload);
|
||||
let response;
|
||||
if (isUpdate) {
|
||||
response = await updateApiKey(apiKey.api_key_id, payload);
|
||||
} else {
|
||||
response = await createApiKey(payload);
|
||||
}
|
||||
formikHelpers.setSubmitting(false);
|
||||
if (response.ok) {
|
||||
setPopup({
|
||||
message: isUpdate
|
||||
? "Successfully updated API key!"
|
||||
: "Successfully created API key!",
|
||||
type: "success",
|
||||
});
|
||||
if (!isUpdate) {
|
||||
onCreateApiKey(await response.json());
|
||||
}
|
||||
formikHelpers.setSubmitting(false);
|
||||
if (response.ok) {
|
||||
setPopup({
|
||||
message: isUpdate
|
||||
? "Successfully updated API key!"
|
||||
: "Successfully created API key!",
|
||||
type: "success",
|
||||
});
|
||||
if (!isUpdate) {
|
||||
onCreateApiKey(await response.json());
|
||||
}
|
||||
onClose();
|
||||
} else {
|
||||
const responseJson = await response.json();
|
||||
const errorMsg = responseJson.detail || responseJson.message;
|
||||
setPopup({
|
||||
message: isUpdate
|
||||
? `Error updating API key - ${errorMsg}`
|
||||
: `Error creating API key - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form className="w-full overflow-visible">
|
||||
onClose();
|
||||
} else {
|
||||
const responseJson = await response.json();
|
||||
const errorMsg = responseJson.detail || responseJson.message;
|
||||
setPopup({
|
||||
message: isUpdate
|
||||
? `Error updating API key - ${errorMsg}`
|
||||
: `Error creating API key - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form className="w-full overflow-visible">
|
||||
<Modal.Body>
|
||||
<Text as="p">
|
||||
Choose a memorable name for your API key. This is optional and
|
||||
can be added or changed later!
|
||||
</Text>
|
||||
|
||||
<TextFormField name="name" label="Name (optional):" />
|
||||
|
||||
<SelectorFormField
|
||||
// defaultValue is managed by Formik
|
||||
label="Role:"
|
||||
subtext="Select the role for this API key.
|
||||
Limited has access to simple public API's.
|
||||
Basic has access to regular user API's.
|
||||
Admin has access to admin level APIs."
|
||||
name="role"
|
||||
options={[
|
||||
{
|
||||
name: USER_ROLE_LABELS[UserRole.LIMITED],
|
||||
value: UserRole.LIMITED.toString(),
|
||||
},
|
||||
{
|
||||
name: USER_ROLE_LABELS[UserRole.BASIC],
|
||||
value: UserRole.BASIC.toString(),
|
||||
},
|
||||
{
|
||||
name: USER_ROLE_LABELS[UserRole.ADMIN],
|
||||
value: UserRole.ADMIN.toString(),
|
||||
},
|
||||
]}
|
||||
<FormikField<string>
|
||||
name="name"
|
||||
render={(field, helper, _meta, state) => (
|
||||
<FormField name="name" state={state} className="w-full">
|
||||
<FormField.Label>Name (optional):</FormField.Label>
|
||||
<FormField.Control>
|
||||
<InputTypeIn
|
||||
{...field}
|
||||
placeholder=""
|
||||
onClear={() => helper.setValue("")}
|
||||
showClearButton={false}
|
||||
/>
|
||||
</FormField.Control>
|
||||
</FormField>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormikField<string>
|
||||
name="role"
|
||||
render={(field, helper, _meta, state) => (
|
||||
<FormField name="role" state={state} className="w-full">
|
||||
<FormField.Label>Role:</FormField.Label>
|
||||
<FormField.Control>
|
||||
<InputComboBox
|
||||
value={field.value}
|
||||
onValueChange={(value) => helper.setValue(value)}
|
||||
options={[
|
||||
{
|
||||
label: USER_ROLE_LABELS[UserRole.LIMITED],
|
||||
value: UserRole.LIMITED.toString(),
|
||||
},
|
||||
{
|
||||
label: USER_ROLE_LABELS[UserRole.BASIC],
|
||||
value: UserRole.BASIC.toString(),
|
||||
},
|
||||
{
|
||||
label: USER_ROLE_LABELS[UserRole.ADMIN],
|
||||
value: UserRole.ADMIN.toString(),
|
||||
},
|
||||
]}
|
||||
placeholder="Select a role"
|
||||
strict
|
||||
/>
|
||||
</FormField.Control>
|
||||
<FormField.Description>
|
||||
Select the role for this API key. Limited has access to
|
||||
simple public APIs. Basic has access to regular user
|
||||
APIs. Admin has access to admin level APIs.
|
||||
</FormField.Description>
|
||||
</FormField>
|
||||
)}
|
||||
/>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isUpdate ? "Update" : "Create"}
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Modal.Body>
|
||||
</Modal.Footer>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -281,8 +281,10 @@ function SeatsCard({
|
||||
});
|
||||
|
||||
const totalSeats = billing?.seats ?? license?.seats ?? 0;
|
||||
const acceptedUsers = usersData?.accepted?.length ?? 0;
|
||||
const slackUsers = usersData?.slack_users?.length ?? 0;
|
||||
const acceptedUsers =
|
||||
usersData?.accepted?.filter((u) => u.is_active).length ?? 0;
|
||||
const slackUsers =
|
||||
usersData?.slack_users?.filter((u) => u.is_active).length ?? 0;
|
||||
const usedSeats = acceptedUsers + slackUsers;
|
||||
const pendingSeats = usersData?.invited?.length ?? 0;
|
||||
const remainingSeats = Math.max(0, totalSeats - usedSeats - pendingSeats);
|
||||
|
||||
@@ -99,9 +99,11 @@ export default function CheckoutView({ onAdjustPlan }: CheckoutViewProps) {
|
||||
const { user } = useUser();
|
||||
const { data: usersData } = useUsers({ includeApiKeys: false });
|
||||
|
||||
// Calculate minimum required seats based on current users
|
||||
const acceptedUsers = usersData?.accepted?.length ?? 0;
|
||||
const slackUsers = usersData?.slack_users?.length ?? 0;
|
||||
// Calculate minimum required seats based on current active users
|
||||
const acceptedUsers =
|
||||
usersData?.accepted?.filter((u) => u.is_active).length ?? 0;
|
||||
const slackUsers =
|
||||
usersData?.slack_users?.filter((u) => u.is_active).length ?? 0;
|
||||
const minRequiredSeats = Math.max(1, acceptedUsers + slackUsers);
|
||||
|
||||
const [billingPeriod, setBillingPeriod] = useState<PlanType>("annual");
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SvgArrowUpCircle,
|
||||
SvgBarChart,
|
||||
SvgFileText,
|
||||
SvgDashboard,
|
||||
SvgHistory,
|
||||
SvgFiles,
|
||||
SvgGlobe,
|
||||
SvgHardDrive,
|
||||
SvgHeadsetMic,
|
||||
SvgKey,
|
||||
SvgLock,
|
||||
SvgOrganization,
|
||||
SvgPaintBrush,
|
||||
SvgSearch,
|
||||
SvgOrganization,
|
||||
SvgServer,
|
||||
SvgShield,
|
||||
SvgSliders,
|
||||
SvgUserManage,
|
||||
SvgUsers,
|
||||
} from "@opal/icons";
|
||||
import "./billing.css";
|
||||
import "@/app/admin/billing/billing.css";
|
||||
import type { IconProps } from "@opal/types";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
@@ -52,20 +55,23 @@ interface PlanConfig {
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const BUSINESS_FEATURES: PlanFeature[] = [
|
||||
{ icon: SvgSearch, text: "Enterprise Search" },
|
||||
{ icon: SvgBarChart, text: "Query History & Usage Dashboard" },
|
||||
{ icon: SvgServer, text: "On-Premise Deployments" },
|
||||
{ icon: SvgGlobe, text: "Region-Specific Deployments" },
|
||||
{ icon: SvgUsers, text: "RBAC Support" },
|
||||
{ icon: SvgOrganization, text: "Permission Inheritance" },
|
||||
{ icon: SvgKey, text: "OIDC/SAML SSO" },
|
||||
{ icon: SvgFiles, text: "Inherit Document Permissions" },
|
||||
{ icon: SvgHistory, text: "Query History and Usage Dashboard" },
|
||||
{ icon: SvgShield, text: "Role Based Access Control (RBAC)" },
|
||||
{ icon: SvgLock, text: "Encryption of Secrets" },
|
||||
{ icon: SvgKey, text: "Service Account API Keys" },
|
||||
{ icon: SvgHardDrive, text: "Self-hosting (Optional)" },
|
||||
{ icon: SvgPaintBrush, text: "Custom Theming" },
|
||||
];
|
||||
|
||||
const ENTERPRISE_FEATURES: PlanFeature[] = [
|
||||
{ icon: SvgHeadsetMic, text: "Priority Support" },
|
||||
{ icon: SvgPaintBrush, text: "White-labeling" },
|
||||
{ icon: SvgFileText, text: "Enterprise SLAs" },
|
||||
{ icon: SvgUsers, text: "SCIM / Group Sync" },
|
||||
{ icon: SvgDashboard, text: "Full White-labeling" },
|
||||
{ icon: SvgUserManage, text: "Custom Roles and Permissions" },
|
||||
{ icon: SvgSliders, text: "Configurable Usage Limits" },
|
||||
{ icon: SvgServer, text: "Custom Deployments" },
|
||||
{ icon: SvgGlobe, text: "Region-Specific Data Processing" },
|
||||
{ icon: SvgHeadsetMic, text: "Enterprise SLAs and Priority Support" },
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -126,7 +132,13 @@ function PlanCard({
|
||||
{pricing}
|
||||
</Text>
|
||||
)}
|
||||
<Text mainUiBody text03>
|
||||
<Text
|
||||
secondaryBody
|
||||
text03
|
||||
className={
|
||||
pricing ? "whitespace-pre-line" : "whitespace-pre-line min-h-9"
|
||||
}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
</Section>
|
||||
@@ -149,10 +161,16 @@ function PlanCard({
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
) : (
|
||||
) : onClick ? (
|
||||
<Button main primary onClick={onClick} leftIcon={ButtonIcon}>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<Button tertiary transient className="pointer-events-none">
|
||||
<Text mainUiAction text03>
|
||||
Included in your plan
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
@@ -189,7 +207,7 @@ function PlanCard({
|
||||
height="auto"
|
||||
>
|
||||
<div className="plan-card-feature-icon">
|
||||
<feature.icon size={16} />
|
||||
<feature.icon size={16} className="stroke-text-03" />
|
||||
</div>
|
||||
<Text mainUiBody text03>
|
||||
{feature.text}
|
||||
@@ -208,21 +226,18 @@ function PlanCard({
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
interface PlansViewProps {
|
||||
currentPlan?: string;
|
||||
hasSubscription?: boolean;
|
||||
hasLicense?: boolean;
|
||||
onCheckout: () => void;
|
||||
hideFeatures?: boolean;
|
||||
}
|
||||
|
||||
export default function PlansView({
|
||||
currentPlan,
|
||||
hasSubscription,
|
||||
hasLicense,
|
||||
onCheckout,
|
||||
hideFeatures,
|
||||
}: PlansViewProps) {
|
||||
const isBusinessPlan =
|
||||
currentPlan?.toLowerCase() === "business" || hasSubscription;
|
||||
|
||||
const plans: PlanConfig[] = [
|
||||
{
|
||||
icon: SvgUsers,
|
||||
@@ -230,24 +245,24 @@ export default function PlansView({
|
||||
pricing: "$20",
|
||||
description:
|
||||
"per seat/month billed annually\nor $25 per seat if billed monthly",
|
||||
buttonLabel: isBusinessPlan ? "Get Business Plan" : "Upgrade Plan",
|
||||
buttonLabel: "Get Business Plan",
|
||||
buttonVariant: "primary",
|
||||
buttonIcon: isBusinessPlan ? undefined : SvgArrowUpCircle,
|
||||
onClick: onCheckout,
|
||||
onClick: hasLicense ? undefined : onCheckout,
|
||||
features: BUSINESS_FEATURES,
|
||||
featuresPrefix: "Get more work done with AI for your team.",
|
||||
isCurrentPlan: isBusinessPlan,
|
||||
isCurrentPlan: !!hasSubscription,
|
||||
},
|
||||
{
|
||||
icon: SvgOrganization,
|
||||
title: "Enterprise",
|
||||
description:
|
||||
"Flexible pricing & deployment options for large organizations",
|
||||
"Flexible pricing & deployment options\nfor large organizations",
|
||||
buttonLabel: "Contact Sales",
|
||||
buttonVariant: "secondary",
|
||||
href: SALES_URL,
|
||||
features: ENTERPRISE_FEATURES,
|
||||
featuresPrefix: "Everything in Business Plan, plus:",
|
||||
isCurrentPlan: !!hasLicense && !hasSubscription,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { SvgWallet } from "@opal/icons";
|
||||
import { SvgArrowUpCircle, SvgWallet } from "@opal/icons";
|
||||
import type { IconProps } from "@opal/types";
|
||||
import {
|
||||
useBillingInformation,
|
||||
useLicense,
|
||||
@@ -30,8 +31,8 @@ import "./billing.css";
|
||||
type BillingView = "plans" | "details" | "checkout" | null;
|
||||
|
||||
interface ViewConfig {
|
||||
icon: React.FunctionComponent<IconProps>;
|
||||
title: string;
|
||||
description: string;
|
||||
showBackButton: boolean;
|
||||
}
|
||||
|
||||
@@ -66,7 +67,7 @@ function FooterLinks({
|
||||
Have a license key?
|
||||
</Text>
|
||||
<Button action tertiary onClick={onActivateLicense}>
|
||||
<Text secondaryBody text03 className="underline">
|
||||
<Text secondaryBody text05 className="underline">
|
||||
{licenseText}
|
||||
</Text>
|
||||
</Button>
|
||||
@@ -118,7 +119,6 @@ export default function BillingPage() {
|
||||
const isLoading = billingLoading || licenseLoading;
|
||||
const hasSubscription = billingData && hasActiveSubscription(billingData);
|
||||
const billing = hasSubscription ? (billingData as BillingInformation) : null;
|
||||
const currentPlan = billing?.plan_type ?? licenseData?.plan_type ?? undefined;
|
||||
const isSelfHosted = !NEXT_PUBLIC_CLOUD_ENABLED;
|
||||
|
||||
// User is only air-gapped if they have a manual license AND Stripe is not connected
|
||||
@@ -227,30 +227,28 @@ export default function BillingPage() {
|
||||
const getViewConfig = (): ViewConfig => {
|
||||
if (isLoading || view === null) {
|
||||
return {
|
||||
icon: SvgWallet,
|
||||
title: "Plans & Billing",
|
||||
description: "Loading billing information...",
|
||||
showBackButton: false,
|
||||
};
|
||||
}
|
||||
switch (view) {
|
||||
case "checkout":
|
||||
return {
|
||||
icon: SvgArrowUpCircle,
|
||||
title: "Upgrade Plan",
|
||||
description: "Configure your Business Plan subscription",
|
||||
showBackButton: false,
|
||||
};
|
||||
case "plans":
|
||||
return {
|
||||
icon: hasSubscription ? SvgWallet : SvgArrowUpCircle,
|
||||
title: hasSubscription ? "View Plans" : "Upgrade Plan",
|
||||
description: hasSubscription
|
||||
? "Compare and manage your subscription plan"
|
||||
: "Choose a plan to unlock premium features",
|
||||
showBackButton: !!hasSubscription,
|
||||
};
|
||||
case "details":
|
||||
return {
|
||||
icon: SvgWallet,
|
||||
title: "Plans & Billing",
|
||||
description: "Manage your subscription and billing settings",
|
||||
showBackButton: false,
|
||||
};
|
||||
}
|
||||
@@ -294,8 +292,8 @@ export default function BillingPage() {
|
||||
checkout: <CheckoutView onAdjustPlan={() => changeView("plans")} />,
|
||||
plans: (
|
||||
<PlansView
|
||||
currentPlan={currentPlan}
|
||||
hasSubscription={!!hasSubscription}
|
||||
hasLicense={!!licenseData?.has_license}
|
||||
onCheckout={() => changeView("checkout")}
|
||||
hideFeatures={showLicenseActivationInput}
|
||||
/>
|
||||
@@ -355,9 +353,8 @@ export default function BillingPage() {
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgWallet}
|
||||
icon={viewConfig.icon}
|
||||
title={viewConfig.title}
|
||||
description={viewConfig.description}
|
||||
backButton={viewConfig.showBackButton}
|
||||
onBack={handleBack}
|
||||
separator
|
||||
|
||||
@@ -160,6 +160,7 @@ export default function ImageGenerationContent() {
|
||||
info
|
||||
static
|
||||
large
|
||||
close={false}
|
||||
text="Connect an image generation model to use in chat."
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
@@ -125,6 +125,7 @@ export const WebProviderSetupModal = memo(
|
||||
<FormField.Label>API Key</FormField.Label>
|
||||
<FormField.Control asChild>
|
||||
<PasswordInputTypeIn
|
||||
data-testid="web-provider-api-key-input"
|
||||
placeholder="Enter API key"
|
||||
value={apiKeyValue}
|
||||
autoFocus={apiKeyAutoFocus}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export type WebSearchProviderType = "google_pse" | "serper" | "exa" | "searxng";
|
||||
export type WebSearchProviderType =
|
||||
| "google_pse"
|
||||
| "serper"
|
||||
| "exa"
|
||||
| "searxng"
|
||||
| "brave";
|
||||
|
||||
export const SEARCH_PROVIDERS_URL = "/api/admin/web-search/search-providers";
|
||||
|
||||
@@ -26,6 +31,14 @@ export const SEARCH_PROVIDER_DETAILS: Record<
|
||||
logoSrc: "/Serper.svg",
|
||||
apiKeyUrl: "https://serper.dev/api-key",
|
||||
},
|
||||
brave: {
|
||||
label: "Brave",
|
||||
subtitle: "Brave Search API",
|
||||
helper: "Connect to Brave Search API to set up web search.",
|
||||
logoSrc: "/Brave.svg",
|
||||
apiKeyUrl:
|
||||
"https://api-dashboard.search.brave.com/app/documentation/web-search/get-started",
|
||||
},
|
||||
google_pse: {
|
||||
label: "Google PSE",
|
||||
subtitle: "Google",
|
||||
@@ -93,6 +106,10 @@ const SEARCH_PROVIDER_CAPABILITIES: Record<
|
||||
requiresApiKey: true,
|
||||
requiredConfigKeys: [],
|
||||
},
|
||||
brave: {
|
||||
requiresApiKey: true,
|
||||
requiredConfigKeys: [],
|
||||
},
|
||||
google_pse: {
|
||||
requiresApiKey: true,
|
||||
requiredConfigKeys: ["search_engine_id"],
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { ValidSources } from "@/lib/types";
|
||||
import { EditIcon } from "@/components/icons/icons";
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronUpIcon } from "lucide-react";
|
||||
import { ChevronDownIcon } from "@/components/icons/icons";
|
||||
|
||||
import { ValidSources } from "@/lib/types";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import { SvgChevronUp, SvgChevronDown, SvgEdit } from "@opal/icons";
|
||||
import Truncated from "@/refresh-components/texts/Truncated";
|
||||
|
||||
function convertObjectToString(obj: any): string | any {
|
||||
// Check if obj is an object and not an array or null
|
||||
if (typeof obj === "object" && obj !== null) {
|
||||
if (!Array.isArray(obj)) {
|
||||
return JSON.stringify(obj);
|
||||
@@ -28,8 +33,6 @@ export function buildConfigEntries(
|
||||
sourceType: ValidSources
|
||||
): { [key: string]: string } {
|
||||
if (sourceType === ValidSources.File) {
|
||||
// File connectors show files in the InlineFileManagement component
|
||||
// Don't show file_names or file_locations in the config display
|
||||
return {};
|
||||
} else if (sourceType === ValidSources.GoogleSites) {
|
||||
return {
|
||||
@@ -39,15 +42,13 @@ export function buildConfigEntries(
|
||||
return obj;
|
||||
}
|
||||
|
||||
function ConfigItem({
|
||||
label,
|
||||
value,
|
||||
onEdit,
|
||||
}: {
|
||||
interface ConfigItemProps {
|
||||
label: string;
|
||||
value: any;
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
}
|
||||
|
||||
function ConfigItem({ label, value, onEdit }: ConfigItemProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const isExpandable = Array.isArray(value) && value.length > 5;
|
||||
|
||||
@@ -55,69 +56,82 @@ function ConfigItem({
|
||||
if (Array.isArray(value)) {
|
||||
const displayedItems = isExpanded ? value : value.slice(0, 5);
|
||||
return (
|
||||
<ul className="list-disc pl-4 overflow-x-auto">
|
||||
{displayedItems.map((item, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="mb-1 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{convertObjectToString(item)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Section
|
||||
flexDirection="row"
|
||||
gap={0.25}
|
||||
justifyContent="end"
|
||||
alignItems="center"
|
||||
height="fit"
|
||||
>
|
||||
<Text secondaryBody text03 className="break-words">
|
||||
{displayedItems
|
||||
.map((item) => convertObjectToString(item))
|
||||
.join(", ")}
|
||||
</Text>
|
||||
</Section>
|
||||
);
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Section gap={0.25} alignItems="end" height="fit">
|
||||
{Object.entries(value).map(([key, val]) => (
|
||||
<div key={key} className="mb-1">
|
||||
<span className="font-semibold">{key}:</span>{" "}
|
||||
<Text key={key} secondaryBody text03 className="break-words">
|
||||
<Text mainContentEmphasis text03>
|
||||
{key}:
|
||||
</Text>{" "}
|
||||
{convertObjectToString(val)}
|
||||
</div>
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
} else if (typeof value === "boolean") {
|
||||
return (
|
||||
<Text secondaryBody text03 className="text-right">
|
||||
{value ? "True" : "False"}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
// TODO: figure out a nice way to display boolean values
|
||||
else if (typeof value === "boolean") {
|
||||
return value ? "True" : "False";
|
||||
}
|
||||
return convertObjectToString(value) || "-";
|
||||
return (
|
||||
<Truncated secondaryBody text03 className="text-right">
|
||||
{convertObjectToString(value) || "-"}
|
||||
</Truncated>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="w-full py-4 px-1">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-sm">{label}</span>
|
||||
<div className="text-right overflow-x-auto max-w-[60%] text-sm font-normal ml-auto">
|
||||
{renderValue()}
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="between"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
>
|
||||
<Section alignItems="start">
|
||||
<Text mainUiBody text04>
|
||||
{label}
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="end"
|
||||
alignItems="center"
|
||||
gap={0.5}
|
||||
>
|
||||
{renderValue()}
|
||||
|
||||
{isExpandable && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="mt-2 text-sm text-text-600 hover:text-text-800 flex items-center ml-auto"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUpIcon className="h-4 w-4 mr-1" />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDownIcon className="h-4 w-4 mr-1" />
|
||||
Show all ({value.length} items)
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onEdit && (
|
||||
<button onClick={onEdit} className="ml-4">
|
||||
<EditIcon size={12} />
|
||||
</button>
|
||||
{isExpandable && (
|
||||
<Button
|
||||
tertiary
|
||||
size="md"
|
||||
leftIcon={isExpanded ? SvgChevronUp : SvgChevronDown}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? "Show less" : `Show all (${value.length} items)`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
{onEdit && (
|
||||
<IconButton icon={SvgEdit} tertiary onClick={onEdit} tooltip="Edit" />
|
||||
)}
|
||||
</Section>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -182,31 +196,38 @@ export function AdvancedConfigDisplay({
|
||||
});
|
||||
};
|
||||
|
||||
const items = [
|
||||
pruneFreq !== null && {
|
||||
label: "Pruning Frequency",
|
||||
value: formatPruneFrequency(pruneFreq),
|
||||
onEdit: onPruningEdit,
|
||||
},
|
||||
refreshFreq && {
|
||||
label: "Refresh Frequency",
|
||||
value: formatRefreshFrequency(refreshFreq),
|
||||
onEdit: onRefreshEdit,
|
||||
},
|
||||
indexingStart && {
|
||||
label: "Indexing Start",
|
||||
value: formatDate(indexingStart),
|
||||
},
|
||||
].filter(Boolean) as ConfigItemProps[];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul className="w-full divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
{pruneFreq !== null && (
|
||||
<ConfigItem
|
||||
label="Pruning Frequency"
|
||||
value={formatPruneFrequency(pruneFreq)}
|
||||
onEdit={onPruningEdit}
|
||||
/>
|
||||
)}
|
||||
{refreshFreq && (
|
||||
<ConfigItem
|
||||
label="Refresh Frequency"
|
||||
value={formatRefreshFrequency(refreshFreq)}
|
||||
onEdit={onRefreshEdit}
|
||||
/>
|
||||
)}
|
||||
{indexingStart && (
|
||||
<ConfigItem
|
||||
label="Indexing Start"
|
||||
value={formatDate(indexingStart)}
|
||||
/>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<Section gap={0} height="fit">
|
||||
{items.map((item, index) => (
|
||||
<div key={item.label} className="w-full">
|
||||
<div className="py-4">
|
||||
<ConfigItem
|
||||
label={item.label}
|
||||
value={item.value}
|
||||
onEdit={item.onEdit}
|
||||
/>
|
||||
</div>
|
||||
{index < items.length - 1 && <Separator noPadding />}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,16 +238,22 @@ export function ConfigDisplay({
|
||||
configEntries: { [key: string]: string };
|
||||
onEdit?: (key: string) => void;
|
||||
}) {
|
||||
const entries = Object.entries(configEntries);
|
||||
|
||||
return (
|
||||
<ul className="w-full divide-y divide-background-200 dark:divide-background-700">
|
||||
{Object.entries(configEntries).map(([key, value]) => (
|
||||
<ConfigItem
|
||||
key={key}
|
||||
label={key}
|
||||
value={value}
|
||||
onEdit={onEdit ? () => onEdit(key) : undefined}
|
||||
/>
|
||||
<Section gap={0} height="fit">
|
||||
{entries.map(([key, value], index) => (
|
||||
<div key={key} className="w-full">
|
||||
<div className="py-4">
|
||||
<ConfigItem
|
||||
label={key}
|
||||
value={value}
|
||||
onEdit={onEdit ? () => onEdit(key) : undefined}
|
||||
/>
|
||||
</div>
|
||||
{index < entries.length - 1 && <Separator noPadding />}
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
23
web/src/app/app/components/AgentDescription.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
|
||||
|
||||
export interface AgentDescriptionProps {
|
||||
agent?: MinimalPersonaSnapshot;
|
||||
}
|
||||
|
||||
export default function AgentDescription({ agent }: AgentDescriptionProps) {
|
||||
if (!agent?.description) return null;
|
||||
|
||||
return (
|
||||
<Text
|
||||
as="p"
|
||||
secondaryBody
|
||||
text03
|
||||
className="w-full min-w-0 text-center break-words"
|
||||
>
|
||||
{agent.description}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import ChatScrollContainer, {
|
||||
} from "@/components/chat/ChatScrollContainer";
|
||||
import MessageList from "@/components/chat/MessageList";
|
||||
import WelcomeMessage from "@/app/app/components/WelcomeMessage";
|
||||
import AgentDescription from "@/app/app/components/AgentDescription";
|
||||
import ProjectContextPanel from "@/app/app/components/projects/ProjectContextPanel";
|
||||
import { useProjectsContext } from "@/providers/ProjectsContext";
|
||||
import {
|
||||
@@ -746,6 +747,15 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Agent description below input */}
|
||||
{(appFocus.isNewSession() || appFocus.isAgent()) &&
|
||||
!isDefaultAgent && (
|
||||
<>
|
||||
<AgentDescription agent={liveAssistant} />
|
||||
<Spacer rem={1.5} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ProjectChatSessionsUI */}
|
||||
{appFocus.isProject() && <ProjectChatSessionList />}
|
||||
</div>
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import Truncated from "@/refresh-components/texts/Truncated";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SourceChip2Props {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
onRemove?: () => void;
|
||||
onClick?: () => void;
|
||||
includeAnimation?: boolean;
|
||||
}
|
||||
|
||||
export function SourceChip2({
|
||||
icon,
|
||||
title,
|
||||
onRemove,
|
||||
onClick,
|
||||
includeAnimation,
|
||||
}: SourceChip2Props) {
|
||||
const [isNew, setIsNew] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsNew(false), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"max-w-[15rem] p-1 bg-background-tint-03 hover:bg-background-tint-04 rounded-full justify-center items-center flex flex-row transition-colors duration-200",
|
||||
includeAnimation && isNew && "animate-fade-in-scale"
|
||||
)}
|
||||
>
|
||||
{icon && (
|
||||
<div className="w-[17px] h-4 p-[3px] flex-col justify-center items-center gap-2.5 inline-flex">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<Truncated text03 secondaryBody>
|
||||
{title}
|
||||
</Truncated>
|
||||
{onRemove && (
|
||||
<XIcon
|
||||
size={12}
|
||||
className="ml-2 cursor-pointer"
|
||||
onClick={(e: React.MouseEvent<SVGSVGElement>) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -58,11 +58,6 @@ export default function WelcomeMessage({
|
||||
{agent.name}
|
||||
</Text>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<Text as="p" secondaryBody text03>
|
||||
{agent.description}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ export const MemoizedLink = memo(
|
||||
|
||||
return (
|
||||
<SourceTag
|
||||
inlineCitation
|
||||
variant="inlineCitation"
|
||||
displayName={displayName}
|
||||
sources={[sourceInfo]}
|
||||
onSourceClick={handleSourceClick}
|
||||
|
||||
@@ -77,6 +77,7 @@ const SourcesTagWrapper = React.memo(function SourcesTagWrapper({
|
||||
|
||||
return (
|
||||
<SourceTag
|
||||
variant="button"
|
||||
displayName="Sources"
|
||||
sources={sources}
|
||||
onSourceClick={handleSourceClick}
|
||||
|
||||
@@ -58,6 +58,7 @@ export const ParallelStreamingHeader = React.memo(
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
className="bg-transparent"
|
||||
>
|
||||
{steps.map((step) => (
|
||||
<Tabs.Trigger
|
||||
|
||||
@@ -414,6 +414,177 @@ describe("usePacedTurnGroups", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool-after-message transition", () => {
|
||||
test("resets toolPacingComplete when finalAnswerComing goes true → false with new tool step", () => {
|
||||
const displayGroup = createDisplayGroup(0);
|
||||
|
||||
// Step 1: Render with finalAnswerComing=true, no tool steps
|
||||
// No tools = pacing complete immediately → display groups shown
|
||||
const { result, rerender } = renderHook(
|
||||
({ turnGroups, finalAnswerComing }) =>
|
||||
usePacedTurnGroups(
|
||||
turnGroups,
|
||||
[displayGroup],
|
||||
false,
|
||||
1,
|
||||
finalAnswerComing
|
||||
),
|
||||
{
|
||||
initialProps: {
|
||||
turnGroups: [] as TurnGroup[],
|
||||
finalAnswerComing: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.current.pacedDisplayGroups.length).toBe(1);
|
||||
expect(result.current.pacedFinalAnswerComing).toBe(true);
|
||||
|
||||
// Step 2: finalAnswerComing goes false + new tool step arrives
|
||||
// This simulates the agent switching from message streaming back to tools
|
||||
const step1 = createStep(0, 0);
|
||||
rerender({
|
||||
turnGroups: [createTurnGroup([step1])],
|
||||
finalAnswerComing: false,
|
||||
});
|
||||
|
||||
// toolPacingComplete was reset, so display groups should be hidden
|
||||
// (first tool step is revealed immediately, but pacing just re-started)
|
||||
expect(result.current.pacedTurnGroups.length).toBe(1);
|
||||
expect(result.current.pacedDisplayGroups.length).toBe(0);
|
||||
|
||||
// Step 3: Add a second tool step so pacing is not yet complete
|
||||
const step2 = createStep(1, 0);
|
||||
rerender({
|
||||
turnGroups: [createTurnGroup([step1]), createTurnGroup([step2])],
|
||||
finalAnswerComing: false,
|
||||
});
|
||||
|
||||
// Display groups still hidden (pacing incomplete)
|
||||
expect(result.current.pacedDisplayGroups.length).toBe(0);
|
||||
|
||||
// Step 4: Advance timer to complete pacing
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
// Now pacing is complete → display groups shown again
|
||||
expect(result.current.pacedTurnGroups.length).toBe(2);
|
||||
expect(result.current.pacedDisplayGroups.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("referential stability", () => {
|
||||
test("returns same array reference when turn groups have not changed", () => {
|
||||
const step1 = createStep(0, 0);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ turnGroups }) => usePacedTurnGroups(turnGroups, [], false, 1, false),
|
||||
{ initialProps: { turnGroups: [createTurnGroup([step1])] } }
|
||||
);
|
||||
|
||||
// First step revealed immediately
|
||||
expect(result.current.pacedTurnGroups.length).toBe(1);
|
||||
|
||||
// Add second step and reveal it via pacing
|
||||
const step2 = createStep(1, 0);
|
||||
rerender({
|
||||
turnGroups: [createTurnGroup([step1]), createTurnGroup([step2])],
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
expect(result.current.pacedTurnGroups.length).toBe(2);
|
||||
|
||||
const stableRef = result.current.pacedTurnGroups;
|
||||
|
||||
// Re-render with new array containing structurally identical turn groups
|
||||
rerender({
|
||||
turnGroups: [createTurnGroup([step1]), createTurnGroup([step2])],
|
||||
});
|
||||
|
||||
// Should be the exact same array reference (nothing changed)
|
||||
expect(result.current.pacedTurnGroups).toBe(stableRef);
|
||||
});
|
||||
|
||||
test("preserves completed group references when streaming group changes", () => {
|
||||
const step1 = createStep(0, 0);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ turnGroups, stopPacketSeen }) =>
|
||||
usePacedTurnGroups(turnGroups, [], stopPacketSeen, 1, false),
|
||||
{
|
||||
initialProps: {
|
||||
turnGroups: [createTurnGroup([step1])],
|
||||
stopPacketSeen: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// First step revealed immediately
|
||||
expect(result.current.pacedTurnGroups.length).toBe(1);
|
||||
|
||||
// Add second step and advance timer to reveal it
|
||||
const step2 = createStep(1, 0);
|
||||
rerender({
|
||||
turnGroups: [createTurnGroup([step1]), createTurnGroup([step2])],
|
||||
stopPacketSeen: false,
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
expect(result.current.pacedTurnGroups.length).toBe(2);
|
||||
|
||||
const firstGroupRef = result.current.pacedTurnGroups[0];
|
||||
|
||||
// Simulate streaming: step2 gets more packets (new object with longer packets array)
|
||||
const step2Updated: TransformedStep = {
|
||||
...step2,
|
||||
packets: [
|
||||
...step2.packets,
|
||||
{
|
||||
placement: { turn_index: 1, tab_index: 0 },
|
||||
obj: { type: PacketType.SEARCH_TOOL_START },
|
||||
} as Packet,
|
||||
],
|
||||
};
|
||||
rerender({
|
||||
turnGroups: [createTurnGroup([step1]), createTurnGroup([step2Updated])],
|
||||
stopPacketSeen: false,
|
||||
});
|
||||
|
||||
// First group (completed) should keep the same object reference
|
||||
expect(result.current.pacedTurnGroups[0]).toBe(firstGroupRef);
|
||||
// Second group changed (packets.length differs) — new reference
|
||||
expect(result.current.pacedTurnGroups.length).toBe(2);
|
||||
});
|
||||
|
||||
test("returns new array reference when a new step is revealed", () => {
|
||||
const step1 = createStep(0, 0);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ turnGroups }) => usePacedTurnGroups(turnGroups, [], false, 1, false),
|
||||
{ initialProps: { turnGroups: [createTurnGroup([step1])] } }
|
||||
);
|
||||
|
||||
const firstResult = result.current.pacedTurnGroups;
|
||||
expect(firstResult.length).toBe(1);
|
||||
|
||||
// Add second step and reveal it
|
||||
const step2 = createStep(1, 0);
|
||||
rerender({
|
||||
turnGroups: [createTurnGroup([step1]), createTurnGroup([step2])],
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
// Array reference must differ (length changed)
|
||||
expect(result.current.pacedTurnGroups).not.toBe(firstResult);
|
||||
expect(result.current.pacedTurnGroups.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timer cleanup", () => {
|
||||
test("clears timer on unmount", () => {
|
||||
const step1 = createStep(0, 0);
|
||||
|
||||
@@ -100,6 +100,10 @@ export function usePacedTurnGroups(
|
||||
// Track previous finalAnswerComing to detect tool-after-message transitions
|
||||
const prevFinalAnswerComingRef = useRef(finalAnswerComing);
|
||||
|
||||
// Cache previous pacedTurnGroups to preserve referential equality
|
||||
// for completed turn groups that haven't changed
|
||||
const prevPacedRef = useRef<TurnGroup[]>([]);
|
||||
|
||||
// Trigger re-render when content should update
|
||||
// Used in useMemo dependencies since state.revealedStepKeys is stored in a ref
|
||||
const [revealTrigger, setRevealTrigger] = useState(0);
|
||||
@@ -114,6 +118,7 @@ export function usePacedTurnGroups(
|
||||
}
|
||||
stateRef.current = createInitialPacingState();
|
||||
stateRef.current.nodeId = nodeIdStr;
|
||||
prevPacedRef.current = [];
|
||||
}
|
||||
|
||||
const state = stateRef.current;
|
||||
@@ -293,6 +298,38 @@ export function usePacedTurnGroups(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Stabilize: reuse previous TurnGroup objects when their content hasn't changed.
|
||||
// This preserves referential equality for completed groups, preventing
|
||||
// unnecessary re-renders in downstream components (e.g. SearchChipList).
|
||||
const prev = prevPacedRef.current;
|
||||
if (prev.length === result.length) {
|
||||
let allMatch = true;
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const oldGroup = prev[i]!;
|
||||
const newGroup = result[i]!;
|
||||
if (
|
||||
oldGroup.turnIndex === newGroup.turnIndex &&
|
||||
oldGroup.steps.length === newGroup.steps.length &&
|
||||
oldGroup.steps.every(
|
||||
(s, j) =>
|
||||
s.key === newGroup.steps[j]!.key &&
|
||||
s.packets.length === newGroup.steps[j]!.packets.length
|
||||
)
|
||||
) {
|
||||
// Reuse old object reference for this group
|
||||
result[i] = oldGroup;
|
||||
} else {
|
||||
allMatch = false;
|
||||
}
|
||||
}
|
||||
if (allMatch) {
|
||||
// Every group matched — return the exact same array reference
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
|
||||
prevPacedRef.current = result;
|
||||
return result;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [toolTurnGroups, revealTrigger, shouldBypassPacing]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { JSX, useState, useEffect, useRef } from "react";
|
||||
import React, { JSX, useState, useEffect, useRef, useMemo } from "react";
|
||||
import { SourceTag, SourceInfo } from "@/refresh-components/buttons/source-tag";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -35,55 +35,33 @@ export function SearchChipList<T>({
|
||||
showDetailsCard,
|
||||
isQuery,
|
||||
}: SearchChipListProps<T>): JSX.Element {
|
||||
const [displayList, setDisplayList] = useState<DisplayEntry<T>[]>([]);
|
||||
const [batchId, setBatchId] = useState(0);
|
||||
const [visibleCount, setVisibleCount] = useState(initialCount);
|
||||
const animatedKeysRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const getEntryKey = (entry: DisplayEntry<T>): string => {
|
||||
if (entry.type === "more") return `more-button-${entry.batchId}`;
|
||||
if (entry.type === "more") return `more-button`;
|
||||
return String(getKey(entry.item, entry.index));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const initial: DisplayEntry<T>[] = items
|
||||
.slice(0, initialCount)
|
||||
const effectiveCount = Math.min(visibleCount, items.length);
|
||||
|
||||
const displayList: DisplayEntry<T>[] = useMemo(() => {
|
||||
const chips: DisplayEntry<T>[] = items
|
||||
.slice(0, effectiveCount)
|
||||
.map((item, i) => ({ type: "chip" as const, item, index: i }));
|
||||
|
||||
if (items.length > initialCount) {
|
||||
initial.push({ type: "more", batchId: 0 });
|
||||
if (effectiveCount < items.length) {
|
||||
chips.push({ type: "more", batchId: 0 });
|
||||
}
|
||||
return chips;
|
||||
}, [items, effectiveCount]);
|
||||
|
||||
setDisplayList(initial);
|
||||
setBatchId(0);
|
||||
}, [items, initialCount]);
|
||||
|
||||
const chipCount = displayList.filter((e) => e.type === "chip").length;
|
||||
const chipCount = effectiveCount;
|
||||
const remainingCount = items.length - chipCount;
|
||||
const remainingItems = items.slice(chipCount);
|
||||
|
||||
const handleShowMore = () => {
|
||||
const nextBatchId = batchId + 1;
|
||||
|
||||
setDisplayList((prev) => {
|
||||
const withoutButton = prev.filter((e) => e.type !== "more");
|
||||
const currentCount = withoutButton.length;
|
||||
const newCount = Math.min(currentCount + expansionCount, items.length);
|
||||
const newItems: DisplayEntry<T>[] = items
|
||||
.slice(currentCount, newCount)
|
||||
.map((item, i) => ({
|
||||
type: "chip" as const,
|
||||
item,
|
||||
index: currentCount + i,
|
||||
}));
|
||||
|
||||
const updated = [...withoutButton, ...newItems];
|
||||
if (newCount < items.length) {
|
||||
updated.push({ type: "more", batchId: nextBatchId });
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
setBatchId(nextBatchId);
|
||||
setVisibleCount((prev) => prev + expansionCount);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -189,7 +189,7 @@ export const SettingsPanel = ({
|
||||
key={bg.id}
|
||||
thumbnailUrl={bg.thumbnail}
|
||||
label={bg.label}
|
||||
isNone={bg.url === CHAT_BACKGROUND_NONE}
|
||||
isNone={bg.src === CHAT_BACKGROUND_NONE}
|
||||
isSelected={currentBackgroundId === bg.id}
|
||||
onClick={() => handleBackgroundChange(bg.id)}
|
||||
/>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
The DanswerAI Enterprise license (the “Enterprise License”)
|
||||
The Onyx Enterprise License (the "Enterprise License")
|
||||
Copyright (c) 2023-present DanswerAI, Inc.
|
||||
|
||||
With regard to the Onyx Software:
|
||||
|
||||
This software and associated documentation files (the "Software") may only be
|
||||
used in production, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, the DanswerAI Subscription Terms of Service, available
|
||||
at https://onyx.app/terms (the “Enterprise Terms”), or other
|
||||
and are in compliance with, the Onyx Subscription Terms of Service, available
|
||||
at https://www.onyx.app/legal/self-host (the "Enterprise Terms"), or other
|
||||
agreement governing the use of the Software, as agreed by you and DanswerAI,
|
||||
and otherwise have a valid Onyx Enterprise license for the
|
||||
and otherwise have a valid Onyx Enterprise License for the
|
||||
correct number of user seats. Subject to the foregoing sentence, you are free to
|
||||
modify this Software and publish patches to the Software. You agree that DanswerAI
|
||||
and/or its licensors (as applicable) retain all right, title and interest in and
|
||||
to all such modifications and/or patches, and all such modifications and/or
|
||||
patches may only be used, copied, modified, displayed, distributed, or otherwise
|
||||
exploited with a valid Onyx Enterprise license for the correct
|
||||
exploited with a valid Onyx Enterprise License for the correct
|
||||
number of user seats. Notwithstanding the foregoing, you may copy and modify
|
||||
the Software for development and testing purposes, without requiring a
|
||||
subscription. You agree that DanswerAI and/or its licensors (as applicable) retain
|
||||
|
||||
@@ -24,6 +24,13 @@ export default async function AdminLayout({
|
||||
if (settingsResponse?.ok) {
|
||||
const settings = await settingsResponse.json();
|
||||
if (settings.ee_features_enabled === false) {
|
||||
// When the app is in GATED_ACCESS (expired or missing license), defer
|
||||
// to the root layout's GatedContentWrapper which handles path-based
|
||||
// exemptions (e.g. allowing /admin/billing for license management).
|
||||
if (settings.application_status === "gated_access") {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<div className="mx-auto my-auto text-lg font-bold text-red-500">
|
||||
|
||||
@@ -186,7 +186,12 @@ export const DefaultDropdown = forwardRef<HTMLDivElement, DefaultDropdownProps>(
|
||||
<FiChevronDown className="my-auto ml-auto" />
|
||||
</div>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="start" side={side} sideOffset={5}>
|
||||
<Popover.Content
|
||||
align="start"
|
||||
side={side}
|
||||
sideOffset={5}
|
||||
width="trigger"
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className={`
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
import AccessRestrictedPage from "@/components/errorPages/AccessRestrictedPage";
|
||||
|
||||
// Paths accessible even when gated - allows users to manage billing/license
|
||||
const ALLOWED_GATED_PATHS = ["/admin/billing", "/ee/admin/billing"];
|
||||
// Paths accessible even when gated - allows users to manage billing updates and seat counts
|
||||
const ALLOWED_GATED_PATHS = ["/admin/billing", "/admin/users"];
|
||||
|
||||
/**
|
||||
* Check if pathname matches an allowed path exactly or is a subpath.
|
||||
|
||||
@@ -8,6 +8,7 @@ import InlineExternalLink from "@/refresh-components/InlineExternalLink";
|
||||
import { logout } from "@/lib/user";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
import { useLicense } from "@/hooks/useLicense";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { SvgLock } from "@opal/icons";
|
||||
|
||||
@@ -38,6 +39,10 @@ const fetchResubscriptionSession = async () => {
|
||||
export default function AccessRestricted() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: license } = useLicense();
|
||||
|
||||
// Distinguish between "never had a license" vs "license lapsed"
|
||||
const hasLicenseLapsed = license?.has_license === true;
|
||||
|
||||
const handleResubscribe = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -68,8 +73,9 @@ export default function AccessRestricted() {
|
||||
</div>
|
||||
|
||||
<Text text03>
|
||||
Your access to Onyx has been temporarily suspended due to a lapse in
|
||||
your subscription.
|
||||
{hasLicenseLapsed
|
||||
? "Your access to Onyx has been temporarily suspended due to a lapse in your subscription."
|
||||
: "An Enterprise license is required to use Onyx. Your data is protected and will be available once a license is activated."}
|
||||
</Text>
|
||||
|
||||
{NEXT_PUBLIC_CLOUD_ENABLED ? (
|
||||
@@ -105,20 +111,22 @@ export default function AccessRestricted() {
|
||||
) : (
|
||||
<>
|
||||
<Text text03>
|
||||
To reinstate your access and continue using Onyx, please contact
|
||||
your system administrator to renew your license.
|
||||
{hasLicenseLapsed
|
||||
? "To reinstate your access and continue using Onyx, please contact your system administrator to renew your license."
|
||||
: "To get started, please contact your system administrator to obtain an Enterprise license."}
|
||||
</Text>
|
||||
|
||||
<Text text03>
|
||||
If you are the administrator, please visit the{" "}
|
||||
<Link className={linkClassName} href="/ee/admin/billing">
|
||||
<Link className={linkClassName} href="/admin/billing">
|
||||
Admin Billing
|
||||
</Link>{" "}
|
||||
page to update your license, or reach out to{" "}
|
||||
page to {hasLicenseLapsed ? "renew" : "activate"} your license, sign
|
||||
up through Stripe or reach out to{" "}
|
||||
<a className={linkClassName} href="mailto:support@onyx.app">
|
||||
support@onyx.app
|
||||
</a>{" "}
|
||||
to renew your subscription.
|
||||
</a>
|
||||
for billing assistance.
|
||||
</Text>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
|
||||
@@ -82,7 +82,7 @@ export function AnnouncementBanner() {
|
||||
Your trial is ending soon - submit your billing information to
|
||||
continue using Onyx.{" "}
|
||||
<Link
|
||||
href={"/ee/admin/billing" as Route}
|
||||
href={"/admin/billing" as Route}
|
||||
className="ml-2 underline cursor-pointer"
|
||||
>
|
||||
Update here
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function EditPropertyModal({
|
||||
}: EditPropertyModalProps) {
|
||||
return (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content>
|
||||
<Modal.Content width="sm">
|
||||
<Modal.Header
|
||||
icon={SvgEdit}
|
||||
title={`Edit ${propertyTitle}`}
|
||||
@@ -43,7 +43,7 @@ export default function EditPropertyModal({
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting, isValid, values }) => (
|
||||
<Form className="items-stretch">
|
||||
<Form className="w-full">
|
||||
<TextFormField
|
||||
vertical
|
||||
label={propertyDetails || ""}
|
||||
|
||||
36
web/src/ee/LICENSE
Normal file
@@ -0,0 +1,36 @@
|
||||
The Onyx Enterprise License (the "Enterprise License")
|
||||
Copyright (c) 2023-present DanswerAI, Inc.
|
||||
|
||||
With regard to the Onyx Software:
|
||||
|
||||
This software and associated documentation files (the "Software") may only be
|
||||
used in production, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, the Onyx Subscription Terms of Service, available
|
||||
at https://www.onyx.app/legal/self-host (the "Enterprise Terms"), or other
|
||||
agreement governing the use of the Software, as agreed by you and DanswerAI,
|
||||
and otherwise have a valid Onyx Enterprise License for the
|
||||
correct number of user seats. Subject to the foregoing sentence, you are free to
|
||||
modify this Software and publish patches to the Software. You agree that DanswerAI
|
||||
and/or its licensors (as applicable) retain all right, title and interest in and
|
||||
to all such modifications and/or patches, and all such modifications and/or
|
||||
patches may only be used, copied, modified, displayed, distributed, or otherwise
|
||||
exploited with a valid Onyx Enterprise License for the correct
|
||||
number of user seats. Notwithstanding the foregoing, you may copy and modify
|
||||
the Software for development and testing purposes, without requiring a
|
||||
subscription. You agree that DanswerAI and/or its licensors (as applicable) retain
|
||||
all right, title and interest in and to all such modifications. You are not
|
||||
granted any other rights beyond what is expressly stated herein. Subject to the
|
||||
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
|
||||
and/or sell the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
For all third party components incorporated into the Onyx Software, those
|
||||
components are licensed under the original license provided by the owner of the
|
||||
applicable component.
|
||||
41
web/src/hooks/useShareableGroups.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { useContext } from "react";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { SettingsContext } from "@/providers/SettingsProvider";
|
||||
|
||||
export interface MinimalUserGroupSnapshot {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// TODO (@raunakab):
|
||||
// Refactor this hook to live inside of a special `ee` directory.
|
||||
|
||||
export default function useShareableGroups() {
|
||||
const combinedSettings = useContext(SettingsContext);
|
||||
const isPaidEnterpriseFeaturesEnabled =
|
||||
combinedSettings && combinedSettings.enterpriseSettings !== null;
|
||||
|
||||
const { data, error, mutate, isLoading } = useSWR<MinimalUserGroupSnapshot[]>(
|
||||
isPaidEnterpriseFeaturesEnabled ? "/api/manage/user-groups/minimal" : null,
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
if (!isPaidEnterpriseFeaturesEnabled) {
|
||||
return {
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
refreshShareableGroups: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refreshShareableGroups: mutate,
|
||||
};
|
||||
}
|
||||
25
web/src/hooks/useShareableUsers.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { MinimalUserSnapshot } from "@/lib/types";
|
||||
|
||||
export interface UseShareableUsersParams {
|
||||
includeApiKeys: boolean;
|
||||
}
|
||||
|
||||
export default function useShareableUsers({
|
||||
includeApiKeys,
|
||||
}: UseShareableUsersParams) {
|
||||
const { data, error, mutate, isLoading } = useSWR<MinimalUserSnapshot[]>(
|
||||
`/api/users?include_api_keys=${includeApiKeys}`,
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refreshShareableUsers: mutate,
|
||||
};
|
||||
}
|
||||
@@ -1,60 +1,50 @@
|
||||
// Default chat background images
|
||||
// Using high-quality Unsplash images optimized for different themes
|
||||
|
||||
export const CHAT_BACKGROUND_NONE = "none";
|
||||
|
||||
export interface ChatBackgroundOption {
|
||||
id: string;
|
||||
url: string;
|
||||
src: string;
|
||||
thumbnail: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// Unsplash URL parameters:
|
||||
// - Full images: w=1920, q=80, auto=format (webp when supported)
|
||||
// - Thumbnails: w=200, h=150, fit=crop, q=70, auto=format
|
||||
|
||||
// Curated collection of scenic backgrounds that work well as chat backgrounds
|
||||
export const CHAT_BACKGROUND_OPTIONS: ChatBackgroundOption[] = [
|
||||
{
|
||||
id: "none",
|
||||
url: CHAT_BACKGROUND_NONE,
|
||||
src: CHAT_BACKGROUND_NONE,
|
||||
thumbnail: CHAT_BACKGROUND_NONE,
|
||||
label: "None",
|
||||
},
|
||||
{
|
||||
id: "clouds",
|
||||
url: "https://images.unsplash.com/photo-1610888814579-ff6913173733?w=1920&q=80&auto=format",
|
||||
thumbnail:
|
||||
"https://images.unsplash.com/photo-1610888814579-ff6913173733?w=200&h=150&fit=crop&q=70&auto=format",
|
||||
src: "/chat-backgrounds/clouds.jpg",
|
||||
thumbnail: "/chat-backgrounds/thumbnails/clouds.jpg",
|
||||
label: "Clouds",
|
||||
},
|
||||
{
|
||||
id: "hills",
|
||||
url: "https://images.unsplash.com/photo-1532019333101-b0f43c16a912?w=1920&q=80&auto=format",
|
||||
thumbnail:
|
||||
"https://images.unsplash.com/photo-1532019333101-b0f43c16a912?w=200&h=150&fit=crop&q=70&auto=format",
|
||||
src: "/chat-backgrounds/hills.jpg",
|
||||
thumbnail: "/chat-backgrounds/thumbnails/hills.jpg",
|
||||
label: "Hills",
|
||||
},
|
||||
{
|
||||
id: "plant",
|
||||
url: "https://images.unsplash.com/photo-1692520883599-d543cfe6d43d?w=1920&q=80&auto=format",
|
||||
thumbnail:
|
||||
"https://images.unsplash.com/photo-1692520883599-d543cfe6d43d?w=200&h=150&fit=crop&q=70&auto=format",
|
||||
src: "/chat-backgrounds/plant.jpg",
|
||||
thumbnail: "/chat-backgrounds/thumbnails/plant.jpg",
|
||||
label: "Plants",
|
||||
},
|
||||
{
|
||||
id: "mountains",
|
||||
url: "https://images.unsplash.com/photo-1496361751588-bdd9a3fcdd6f?w=1920&q=80&auto=format",
|
||||
thumbnail:
|
||||
"https://images.unsplash.com/photo-1496361751588-bdd9a3fcdd6f?w=200&h=150&fit=crop&q=70&auto=format",
|
||||
src: "/chat-backgrounds/mountains.jpg",
|
||||
thumbnail: "/chat-backgrounds/thumbnails/mountains.jpg",
|
||||
label: "Mountains",
|
||||
},
|
||||
{
|
||||
id: "night",
|
||||
url: "https://images.unsplash.com/photo-1520330461350-508fab483d6a?w=1920&q=80&auto=format",
|
||||
thumbnail:
|
||||
"https://images.unsplash.com/photo-1520330461350-508fab483d6a?w=200&h=150&fit=crop&q=70&auto=format",
|
||||
src: "/chat-backgrounds/night.jpg",
|
||||
thumbnail: "/chat-backgrounds/thumbnails/night.jpg",
|
||||
label: "Night",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -32,8 +32,8 @@ export function AppBackgroundProvider({
|
||||
const chatBackgroundId = user?.preferences?.chat_background;
|
||||
const appBackground = getBackgroundById(chatBackgroundId ?? null);
|
||||
const hasBackground =
|
||||
!!appBackground && appBackground.url !== CHAT_BACKGROUND_NONE;
|
||||
const appBackgroundUrl = hasBackground ? appBackground.url : null;
|
||||
!!appBackground && appBackground.src !== CHAT_BACKGROUND_NONE;
|
||||
const appBackgroundUrl = hasBackground ? appBackground.src : null;
|
||||
|
||||
return {
|
||||
appBackground,
|
||||
|
||||
@@ -107,12 +107,13 @@ const PopoverClose = PopoverPrimitive.Close;
|
||||
* </Popover.Content>
|
||||
* ```
|
||||
*/
|
||||
type PopoverWidths = "fit" | "md" | "lg" | "xl";
|
||||
type PopoverWidths = "fit" | "md" | "lg" | "xl" | "trigger";
|
||||
const widthClasses: Record<PopoverWidths, string> = {
|
||||
fit: "w-fit",
|
||||
md: "w-[12rem]",
|
||||
lg: "w-[15rem]",
|
||||
xl: "w-[18rem]",
|
||||
trigger: "w-[var(--radix-popover-trigger-width)]",
|
||||
};
|
||||
interface PopoverContentProps
|
||||
extends WithoutStyles<
|
||||
|
||||
@@ -615,7 +615,13 @@ const TabsTrigger = React.forwardRef<
|
||||
<Icon size={14} className={cn(iconVariants[variant])} />
|
||||
</div>
|
||||
)}
|
||||
{typeof children === "string" ? <Text>{children}</Text> : children}
|
||||
{typeof children === "string" ? (
|
||||
<div className="px-0.5">
|
||||
<Text>{children}</Text>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{isLoading && (
|
||||
<span
|
||||
className="inline-block w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin ml-1"
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface SidebarTabProps {
|
||||
transient?: boolean;
|
||||
focused?: boolean;
|
||||
lowlight?: boolean;
|
||||
nested?: boolean;
|
||||
|
||||
// Button properties:
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||
@@ -29,6 +30,7 @@ export default function SidebarTab({
|
||||
transient,
|
||||
focused,
|
||||
lowlight,
|
||||
nested,
|
||||
|
||||
onClick,
|
||||
href,
|
||||
@@ -65,6 +67,9 @@ export default function SidebarTab({
|
||||
!focused && "pointer-events-none"
|
||||
)}
|
||||
>
|
||||
{nested && !LeftIcon && (
|
||||
<div className="w-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
{LeftIcon && (
|
||||
<div className="w-[1rem] h-[1rem] flex items-center justify-center pointer-events-auto">
|
||||
<LeftIcon
|
||||
|
||||
@@ -34,6 +34,9 @@ const sizeClasses = {
|
||||
tag: {
|
||||
container: "rounded-08 p-1 gap-1",
|
||||
},
|
||||
button: {
|
||||
container: "rounded-08 h-[2.25rem] min-w-[2.25rem] p-2 gap-1",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -271,8 +274,8 @@ const QueryText = ({
|
||||
* Props for the SourceTag component.
|
||||
*/
|
||||
export interface SourceTagProps {
|
||||
/** Use inline citation size (smaller, for use within text) */
|
||||
inlineCitation?: boolean;
|
||||
/** Sizing variant: "inlineCitation" for compact in-text use, "button" for interactive contexts, "tag" (default) for standard display */
|
||||
variant?: "inlineCitation" | "tag" | "button";
|
||||
|
||||
/** Display name shown on the tag (e.g., "Google Drive", "Business Insider") */
|
||||
displayName: string;
|
||||
@@ -314,7 +317,7 @@ export interface SourceTagProps {
|
||||
* - Shows stacked source icons + display name
|
||||
* - Hovering opens a details card with source navigation
|
||||
*
|
||||
* **Inline Citation** (`inlineCitation`):
|
||||
* **Inline Citation** (`variant="inlineCitation"`):
|
||||
* - Compact size for use within text content
|
||||
* - Shows "+N" count for multiple sources
|
||||
*
|
||||
@@ -341,7 +344,7 @@ export interface SourceTagProps {
|
||||
*
|
||||
* // Inline citation within text
|
||||
* <SourceTag
|
||||
* inlineCitation
|
||||
* variant="inlineCitation"
|
||||
* displayName="Source 1"
|
||||
* sources={multipleSources}
|
||||
* />
|
||||
@@ -355,7 +358,7 @@ export interface SourceTagProps {
|
||||
* ```
|
||||
*/
|
||||
const SourceTagInner = ({
|
||||
inlineCitation,
|
||||
variant = "tag",
|
||||
displayName,
|
||||
displayUrl,
|
||||
sources,
|
||||
@@ -367,6 +370,8 @@ const SourceTagInner = ({
|
||||
toggleSource,
|
||||
tooltipText,
|
||||
}: SourceTagProps) => {
|
||||
const inlineCitation = variant === "inlineCitation";
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -383,7 +388,7 @@ const SourceTagInner = ({
|
||||
|
||||
const extraCount = sources.length - 1;
|
||||
|
||||
const size = inlineCitation ? "inlineCitation" : "tag";
|
||||
const size = variant;
|
||||
const styles = sizeClasses[size];
|
||||
|
||||
// Shared text styling props
|
||||
|
||||
@@ -171,9 +171,11 @@ const SourceTagDetailsCardInner = ({
|
||||
|
||||
{/* Description */}
|
||||
{currentSource.description && (
|
||||
<Text secondaryBody text03 as="span" className="line-clamp-4">
|
||||
{currentSource.description}
|
||||
</Text>
|
||||
<div className="px-1.5 pb-1">
|
||||
<Text secondaryBody text03 as="span" className="line-clamp-4">
|
||||
{currentSource.description}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,7 @@ const NameStep = React.memo(
|
||||
value={userName || ""}
|
||||
onChange={(e) => updateName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-[26%] min-w-40"
|
||||
className="max-w-60"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -32,7 +32,6 @@ export interface ActionItemProps {
|
||||
onForceToggle: () => void;
|
||||
onSourceManagementOpen?: () => void;
|
||||
hasNoConnectors?: boolean;
|
||||
hasNoKnowledgeSources?: boolean;
|
||||
toolAuthStatus?: ToolAuthStatus;
|
||||
onOAuthAuthenticate?: () => void;
|
||||
onClose?: () => void;
|
||||
@@ -55,7 +54,6 @@ export default function ActionLineItem({
|
||||
onForceToggle,
|
||||
onSourceManagementOpen,
|
||||
hasNoConnectors = false,
|
||||
hasNoKnowledgeSources = false,
|
||||
toolAuthStatus,
|
||||
onOAuthAuthenticate,
|
||||
onClose,
|
||||
@@ -77,11 +75,6 @@ export default function ActionLineItem({
|
||||
tool?.in_code_tool_id === SEARCH_TOOL_ID &&
|
||||
hasNoConnectors;
|
||||
|
||||
const isSearchToolWithNoKnowledgeSources =
|
||||
!currentProjectId &&
|
||||
tool?.in_code_tool_id === SEARCH_TOOL_ID &&
|
||||
hasNoKnowledgeSources;
|
||||
|
||||
const isSearchToolAndNotInProject =
|
||||
tool?.in_code_tool_id === SEARCH_TOOL_ID && !currentProjectId;
|
||||
|
||||
@@ -94,22 +87,14 @@ export default function ActionLineItem({
|
||||
sourceCounts.enabled > 0 &&
|
||||
sourceCounts.enabled < sourceCounts.total;
|
||||
|
||||
const tooltipText = isSearchToolWithNoKnowledgeSources
|
||||
? "No knowledge sources are available. Contact your admin to add a knowledge source to this agent."
|
||||
: isUnavailable
|
||||
? unavailableReason
|
||||
: tool?.description;
|
||||
const tooltipText = isUnavailable ? unavailableReason : tool?.description;
|
||||
|
||||
return (
|
||||
<SimpleTooltip tooltip={tooltipText} className="max-w-[30rem]">
|
||||
<div data-testid={`tool-option-${toolName}`}>
|
||||
<LineItem
|
||||
onClick={() => {
|
||||
if (
|
||||
isSearchToolWithNoConnectors ||
|
||||
isSearchToolWithNoKnowledgeSources
|
||||
)
|
||||
return;
|
||||
if (isSearchToolWithNoConnectors) return;
|
||||
if (isUnavailable) {
|
||||
if (isForced) onForceToggle();
|
||||
return;
|
||||
@@ -122,10 +107,7 @@ export default function ActionLineItem({
|
||||
}}
|
||||
selected={isForced}
|
||||
strikethrough={
|
||||
disabled ||
|
||||
isSearchToolWithNoConnectors ||
|
||||
isSearchToolWithNoKnowledgeSources ||
|
||||
isUnavailable
|
||||
disabled || isSearchToolWithNoConnectors || isUnavailable
|
||||
}
|
||||
icon={Icon}
|
||||
rightChildren={
|
||||
@@ -198,31 +180,28 @@ export default function ActionLineItem({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isSearchToolAndNotInProject &&
|
||||
!isSearchToolWithNoKnowledgeSources && (
|
||||
<IconButton
|
||||
icon={
|
||||
isSearchToolWithNoConnectors
|
||||
? SvgSettings
|
||||
: SvgChevronRight
|
||||
}
|
||||
onClick={noProp(() => {
|
||||
if (isSearchToolWithNoConnectors)
|
||||
router.push("/admin/add-connector");
|
||||
else onSourceManagementOpen?.();
|
||||
})}
|
||||
internal
|
||||
className={cn(
|
||||
isSearchToolWithNoConnectors &&
|
||||
"invisible group-hover/LineItem:visible"
|
||||
)}
|
||||
tooltip={
|
||||
isSearchToolWithNoConnectors
|
||||
? "Add Connectors"
|
||||
: "Configure Connectors"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isSearchToolAndNotInProject && (
|
||||
<IconButton
|
||||
icon={
|
||||
isSearchToolWithNoConnectors ? SvgSettings : SvgChevronRight
|
||||
}
|
||||
onClick={noProp(() => {
|
||||
if (isSearchToolWithNoConnectors)
|
||||
router.push("/admin/add-connector");
|
||||
else onSourceManagementOpen?.();
|
||||
})}
|
||||
internal
|
||||
className={cn(
|
||||
isSearchToolWithNoConnectors &&
|
||||
"invisible group-hover/LineItem:visible"
|
||||
)}
|
||||
tooltip={
|
||||
isSearchToolWithNoConnectors
|
||||
? "Add Connectors"
|
||||
: "Configure Connectors"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -209,6 +209,9 @@ export default function ActionsPopover({
|
||||
sourceSet.add(normalized);
|
||||
});
|
||||
|
||||
// No specific sources selected means everything is searchable
|
||||
if (sourceSet.size === 0) return null;
|
||||
|
||||
return sourceSet;
|
||||
}, [
|
||||
isDefaultAgent,
|
||||
@@ -216,16 +219,6 @@ export default function ActionsPopover({
|
||||
selectedAssistant.knowledge_sources,
|
||||
]);
|
||||
|
||||
// Check if non-default agent has no knowledge sources (Internal Search should be disabled)
|
||||
// Knowledge sources include document sets and hierarchy nodes (folders, spaces, channels)
|
||||
// Check if non-default agent has no knowledge sources (Internal Search should be disabled)
|
||||
// Knowledge sources include document sets, hierarchy nodes, and attached documents
|
||||
const hasNoKnowledgeSources =
|
||||
!isDefaultAgent &&
|
||||
selectedAssistant.document_sets.length === 0 &&
|
||||
(selectedAssistant.hierarchy_node_count ?? 0) === 0 &&
|
||||
(selectedAssistant.attached_document_count ?? 0) === 0;
|
||||
|
||||
// Store MCP server auth/loading state (tools are part of selectedAssistant.tools)
|
||||
const [mcpServerData, setMcpServerData] = useState<{
|
||||
[serverId: number]: {
|
||||
@@ -894,7 +887,6 @@ export default function ActionsPopover({
|
||||
setSecondaryView({ type: "sources" })
|
||||
}
|
||||
hasNoConnectors={hasNoConnectors}
|
||||
hasNoKnowledgeSources={hasNoKnowledgeSources}
|
||||
toolAuthStatus={getToolAuthStatus(tool)}
|
||||
onOAuthAuthenticate={() => authenticateTool(tool)}
|
||||
onClose={() => setOpen(false)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef, useLayoutEffect, useEffect } from "react";
|
||||
import { useState, useMemo, useRef, useEffect, useLayoutEffect } from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
@@ -81,6 +81,34 @@ function downloadAsTxt(content: string, filename: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Block-level HTML tags used by the snap algorithm to recurse into containers. */
|
||||
const CONTAINER_TAGS = new Set([
|
||||
"UL",
|
||||
"OL",
|
||||
"LI",
|
||||
"BLOCKQUOTE",
|
||||
"DIV",
|
||||
"DL",
|
||||
"DD",
|
||||
"TABLE",
|
||||
"TBODY",
|
||||
"THEAD",
|
||||
"TR",
|
||||
"TH",
|
||||
"TD",
|
||||
"SECTION",
|
||||
"DETAILS",
|
||||
"PRE",
|
||||
"FIGURE",
|
||||
"FIGCAPTION",
|
||||
"ARTICLE",
|
||||
"ASIDE",
|
||||
"HEADER",
|
||||
"FOOTER",
|
||||
"MAIN",
|
||||
"NAV",
|
||||
]);
|
||||
|
||||
export default function ExpandableTextDisplay({
|
||||
title,
|
||||
content,
|
||||
@@ -94,45 +122,89 @@ export default function ExpandableTextDisplay({
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const prevIsStreamingRef = useRef(isStreaming);
|
||||
const contentInnerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const lineCount = useMemo(() => getLineCount(content), [content]);
|
||||
const contentSize = useMemo(() => getContentSize(content), [content]);
|
||||
const displaySubtitle = subtitle ?? contentSize;
|
||||
|
||||
// Detect truncation for renderContent mode, streaming, and plain text static
|
||||
useLayoutEffect(() => {
|
||||
// Truncation detection (read-only, doesn't need to block paint)
|
||||
useEffect(() => {
|
||||
if (renderContent && scrollRef.current) {
|
||||
// For renderContent mode (streaming or static), use scroll-based detection
|
||||
// CSS line-clamp handles visual truncation, we just need to detect if it happened
|
||||
setIsTruncated(
|
||||
scrollRef.current.scrollHeight > scrollRef.current.clientHeight
|
||||
);
|
||||
} else if (isStreaming) {
|
||||
// For plain text streaming, use line-based detection (still works for plain text)
|
||||
const textToCheck = displayContent ?? content;
|
||||
const lineCount = getLineCount(textToCheck);
|
||||
setIsTruncated(lineCount > maxLines);
|
||||
setIsTruncated(getLineCount(textToCheck) > maxLines);
|
||||
} else if (scrollRef.current) {
|
||||
// For plain text static, use scroll-based detection with line-clamp
|
||||
setIsTruncated(
|
||||
scrollRef.current.scrollHeight > scrollRef.current.clientHeight
|
||||
);
|
||||
}
|
||||
}, [isStreaming, renderContent, content, displayContent, maxLines]);
|
||||
|
||||
// Scroll to bottom during streaming for renderContent mode
|
||||
// This creates a "scrolling from bottom" effect showing the latest content
|
||||
// Shift content upward during streaming for renderContent mode,
|
||||
// snapping to element boundaries so blocks are never partially clipped.
|
||||
// Must block paint to avoid flicker.
|
||||
useLayoutEffect(() => {
|
||||
if (isStreaming && renderContent && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
if (
|
||||
!isStreaming ||
|
||||
!renderContent ||
|
||||
!scrollRef.current ||
|
||||
!contentInnerRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}, [isStreaming, renderContent, content, displayContent]);
|
||||
|
||||
// Track streaming state transitions (no longer need scroll management with top-truncation)
|
||||
useEffect(() => {
|
||||
prevIsStreamingRef.current = isStreaming;
|
||||
}, [isStreaming]);
|
||||
const containerHeight = scrollRef.current.clientHeight;
|
||||
const contentHeight = contentInnerRef.current.scrollHeight;
|
||||
let overflow = Math.max(0, contentHeight - containerHeight);
|
||||
|
||||
if (overflow > 0) {
|
||||
let blockParent: Element = contentInnerRef.current;
|
||||
while (
|
||||
blockParent.children.length === 1 &&
|
||||
blockParent.children[0]!.children.length > 0
|
||||
) {
|
||||
blockParent = blockParent.children[0]!;
|
||||
}
|
||||
|
||||
contentInnerRef.current.style.transform = "translateY(0)";
|
||||
const refTop = contentInnerRef.current.getBoundingClientRect().top;
|
||||
|
||||
let snapParent: Element = blockParent;
|
||||
let snap = overflow;
|
||||
while (true) {
|
||||
let found = false;
|
||||
for (let i = 0; i < snapParent.children.length; i++) {
|
||||
const child = snapParent.children[i] as HTMLElement;
|
||||
const rect = child.getBoundingClientRect();
|
||||
const top = rect.top - refTop;
|
||||
const bottom = top + rect.height;
|
||||
if (top < snap && snap < bottom) {
|
||||
if (
|
||||
child.children.length > 0 &&
|
||||
CONTAINER_TAGS.has(child.tagName)
|
||||
) {
|
||||
snapParent = child;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
snap = bottom;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) break;
|
||||
if (snap !== overflow) break;
|
||||
}
|
||||
overflow = snap;
|
||||
}
|
||||
|
||||
contentInnerRef.current.style.transform =
|
||||
overflow > 0 ? `translateY(-${overflow}px)` : "translateY(0)";
|
||||
}, [isStreaming, renderContent, content, displayContent, maxLines]);
|
||||
|
||||
const handleDownload = () => {
|
||||
const sanitizedTitle = title.replace(/[^a-z0-9]/gi, "_").toLowerCase();
|
||||
@@ -160,25 +232,25 @@ export default function ExpandableTextDisplay({
|
||||
const textToDisplay = displayContent ?? content;
|
||||
|
||||
if (isStreaming) {
|
||||
// During streaming: use max-height with overflow-auto to create scrollable container,
|
||||
// then scroll to bottom to show latest content (handled by useLayoutEffect above).
|
||||
// We can't use line-clamp here because it sets overflow:hidden and shows from top,
|
||||
// but we need scrollable overflow to show the latest (bottom) content.
|
||||
// During streaming: use max-height with overflow-hidden and CSS transform to shift
|
||||
// content upward, showing the latest content from the bottom without scroll jitter.
|
||||
// Line height is approximately 1.5rem (24px) for body text.
|
||||
// We show a top ellipsis indicator when content is truncated.
|
||||
return (
|
||||
<div>
|
||||
{isTruncated && (
|
||||
<Text as="span" mainUiMuted text03>
|
||||
<Text as="p" text03 mainUiMuted className="!my-0">
|
||||
…
|
||||
</Text>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="overflow-auto no-scrollbar"
|
||||
className="overflow-hidden"
|
||||
style={{ maxHeight: `calc(${maxLines} * 1.5rem)` }}
|
||||
>
|
||||
{renderContent!(textToDisplay, false)}
|
||||
<div ref={contentInnerRef}>
|
||||
{renderContent!(textToDisplay, false)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -234,7 +306,7 @@ export default function ExpandableTextDisplay({
|
||||
|
||||
{/* Expand button - only show when content is truncated */}
|
||||
|
||||
<div className="flex justify-end items-end mt-1 w-8">
|
||||
<div className="flex justify-end self-end mt-1 w-8">
|
||||
{isTruncated && (
|
||||
<IconButton
|
||||
internal
|
||||
|
||||
@@ -426,7 +426,7 @@ export default function AgentsNavigationPage() {
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgOnyxOctagon}
|
||||
title="Agents & Assistants"
|
||||
description="Customize AI behavior and knowledge for you and your team’s use cases."
|
||||
description="Customize AI behavior and knowledge for you and your team's use cases."
|
||||
rightChildren={
|
||||
<div data-testid="AgentsPage/new-agent-button">
|
||||
<Button href="/app/agents/create" leftIcon={SvgPlus}>
|
||||
|
||||