mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-18 00:05:47 +00:00
Compare commits
24 Commits
misc_ux
...
improved-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cf5cdba2c | ||
|
|
bdaa293ae4 | ||
|
|
5a131f4547 | ||
|
|
ffb7d5b85b | ||
|
|
fe8a5d671a | ||
|
|
6de53ebf60 | ||
|
|
61d536c782 | ||
|
|
e1ff9086a4 | ||
|
|
ba21bacbbf | ||
|
|
158bccc3fc | ||
|
|
599b7705c2 | ||
|
|
4958a5355d | ||
|
|
c4b8519381 | ||
|
|
8b4413694a | ||
|
|
57cf7d9fac | ||
|
|
ad4efb5f20 | ||
|
|
e304ec4ab6 | ||
|
|
1690dc45ba | ||
|
|
7582ba1640 | ||
|
|
99fc546943 | ||
|
|
353c185856 | ||
|
|
7c96b7f24e | ||
|
|
31524a3eff | ||
|
|
c9f618798e |
5
.github/workflows/pr-integration-tests.yml
vendored
5
.github/workflows/pr-integration-tests.yml
vendored
@@ -145,7 +145,7 @@ jobs:
|
||||
run: |
|
||||
cd deployment/docker_compose
|
||||
docker compose -f docker-compose.multitenant-dev.yml -p onyx-stack down -v
|
||||
|
||||
|
||||
# NOTE: Use pre-ping/null pool to reduce flakiness due to dropped connections
|
||||
- name: Start Docker containers
|
||||
run: |
|
||||
@@ -157,6 +157,7 @@ jobs:
|
||||
REQUIRE_EMAIL_VERIFICATION=false \
|
||||
DISABLE_TELEMETRY=true \
|
||||
IMAGE_TAG=test \
|
||||
INTEGRATION_TESTS_MODE=true \
|
||||
docker compose -f docker-compose.dev.yml -p onyx-stack up -d
|
||||
id: start_docker
|
||||
|
||||
@@ -199,7 +200,7 @@ jobs:
|
||||
cd backend/tests/integration/mock_services
|
||||
docker compose -f docker-compose.mock-it-services.yml \
|
||||
-p mock-it-services-stack up -d
|
||||
|
||||
|
||||
# NOTE: Use pre-ping/null to reduce flakiness due to dropped connections
|
||||
- name: Run Standard Integration Tests
|
||||
run: |
|
||||
|
||||
@@ -74,7 +74,9 @@ jobs:
|
||||
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
|
||||
|
||||
playwright install chromium
|
||||
playwright install-deps chromium
|
||||
|
||||
- name: Run Tests
|
||||
shell: script -q -e -c "bash --noprofile --norc -eo pipefail {0}"
|
||||
run: py.test -o junit_family=xunit2 -xv --ff backend/tests/daily/connectors
|
||||
|
||||
77
.github/workflows/pr-python-model-tests.yml
vendored
77
.github/workflows/pr-python-model-tests.yml
vendored
@@ -1,10 +1,16 @@
|
||||
name: Connector Tests
|
||||
name: Model Server Tests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# This cron expression runs the job daily at 16:00 UTC (9am PT)
|
||||
- cron: "0 16 * * *"
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: 'Branch to run the workflow on'
|
||||
required: false
|
||||
default: 'main'
|
||||
|
||||
env:
|
||||
# Bedrock
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
@@ -26,6 +32,23 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
# tag every docker image with "test" so that we can spin up the correct set
|
||||
# of images during testing
|
||||
|
||||
# We don't need to build the Web Docker image since it's not yet used
|
||||
# in the integration tests. We have a separate action to verify that it builds
|
||||
# successfully.
|
||||
- name: Pull Model Server Docker image
|
||||
run: |
|
||||
docker pull onyxdotapp/onyx-model-server:latest
|
||||
docker tag onyxdotapp/onyx-model-server:latest onyxdotapp/onyx-model-server:test
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -41,6 +64,49 @@ jobs:
|
||||
pip install --retries 5 --timeout 30 -r backend/requirements/default.txt
|
||||
pip install --retries 5 --timeout 30 -r backend/requirements/dev.txt
|
||||
|
||||
- name: Start Docker containers
|
||||
run: |
|
||||
cd deployment/docker_compose
|
||||
ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true \
|
||||
AUTH_TYPE=basic \
|
||||
REQUIRE_EMAIL_VERIFICATION=false \
|
||||
DISABLE_TELEMETRY=true \
|
||||
IMAGE_TAG=test \
|
||||
docker compose -f docker-compose.dev.yml -p onyx-stack up -d indexing_model_server
|
||||
id: start_docker
|
||||
|
||||
- name: Wait for service to be ready
|
||||
run: |
|
||||
echo "Starting wait-for-service script..."
|
||||
|
||||
start_time=$(date +%s)
|
||||
timeout=300 # 5 minutes in seconds
|
||||
|
||||
while true; do
|
||||
current_time=$(date +%s)
|
||||
elapsed_time=$((current_time - start_time))
|
||||
|
||||
if [ $elapsed_time -ge $timeout ]; then
|
||||
echo "Timeout reached. Service did not become ready in 5 minutes."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use curl with error handling to ignore specific exit code 56
|
||||
response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:9000/api/health || echo "curl_error")
|
||||
|
||||
if [ "$response" = "200" ]; then
|
||||
echo "Service is ready!"
|
||||
break
|
||||
elif [ "$response" = "curl_error" ]; then
|
||||
echo "Curl encountered an error, possibly exit code 56. Continuing to retry..."
|
||||
else
|
||||
echo "Service not ready yet (HTTP status $response). Retrying in 5 seconds..."
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
done
|
||||
echo "Finished waiting for service."
|
||||
|
||||
- name: Run Tests
|
||||
shell: script -q -e -c "bash --noprofile --norc -eo pipefail {0}"
|
||||
run: |
|
||||
@@ -56,3 +122,10 @@ jobs:
|
||||
-H 'Content-type: application/json' \
|
||||
--data '{"text":"Scheduled Model Tests failed! Check the run at: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"}' \
|
||||
$SLACK_WEBHOOK
|
||||
|
||||
- name: Stop Docker containers
|
||||
if: always()
|
||||
run: |
|
||||
cd deployment/docker_compose
|
||||
docker compose -f docker-compose.dev.yml -p onyx-stack down -v
|
||||
|
||||
|
||||
23
README.md
23
README.md
@@ -26,12 +26,12 @@
|
||||
|
||||
<strong>[Onyx](https://www.onyx.app/)</strong> (formerly Danswer) is the AI platform connected to your company's docs, apps, and people.
|
||||
Onyx provides a feature rich Chat interface and plugs into any LLM of your choice.
|
||||
There are over 40 supported connectors such as Google Drive, Slack, Confluence, Salesforce, etc. which keep knowledge and permissions up to date.
|
||||
Create custom AI agents with unique prompts, knowledge, and actions the agents can take.
|
||||
Keep knowledge and access controls sync-ed across over 40 connectors like Google Drive, Slack, Confluence, Salesforce, etc.
|
||||
Create custom AI agents with unique prompts, knowledge, and actions that the agents can take.
|
||||
Onyx can be deployed securely anywhere and for any scale - on a laptop, on-premise, or to cloud.
|
||||
|
||||
|
||||
<h3>Feature Showcase</h3>
|
||||
<h3>Feature Highlights</h3>
|
||||
|
||||
**Deep research over your team's knowledge:**
|
||||
|
||||
@@ -63,22 +63,21 @@ We also have built-in support for high-availability/scalable deployment on Kuber
|
||||
References [here](https://github.com/onyx-dot-app/onyx/tree/main/deployment).
|
||||
|
||||
|
||||
## 🔍 Other Notable Benefits of Onyx
|
||||
- Custom deep learning models for indexing and inference time, only through Onyx + learning from user feedback.
|
||||
- Flexible security features like SSO (OIDC/SAML/OAuth2), RBAC, encryption of credentials, etc.
|
||||
- Knowledge curation features like document-sets, query history, usage analytics, etc.
|
||||
- Scalable deployment options tested up to many tens of thousands users and hundreds of millions of documents.
|
||||
|
||||
|
||||
## 🚧 Roadmap
|
||||
- Extensions to the Chrome Plugin
|
||||
- Latest methods in information retrieval (StructRAG, LightGraphRAG, etc.)
|
||||
- New methods in information retrieval (StructRAG, LightGraphRAG, etc.)
|
||||
- Personalized Search
|
||||
- Organizational understanding and ability to locate and suggest experts from your team.
|
||||
- Code Search
|
||||
- SQL and Structured Query Language
|
||||
|
||||
|
||||
## 🔍 Other Notable Benefits of Onyx
|
||||
- Custom deep learning models only through Onyx + learn from user feedback.
|
||||
- Flexible security features like SSO (OIDC/SAML/OAuth2), RBAC, encryption of credentials, etc.
|
||||
- Knowledge curation features like document-sets, query history, usage analytics, etc.
|
||||
- Scalable deployment options tested up to many tens of thousands users and hundreds of millions of documents.
|
||||
|
||||
|
||||
## 🔌 Connectors
|
||||
Keep knowledge and access up to sync across 40+ connectors:
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""remove inactive ccpair status on downgrade
|
||||
|
||||
Revision ID: acaab4ef4507
|
||||
Revises: b388730a2899
|
||||
Create Date: 2025-02-16 18:21:41.330212
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
from onyx.db.models import ConnectorCredentialPair
|
||||
from onyx.db.enums import ConnectorCredentialPairStatus
|
||||
from sqlalchemy import update
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "acaab4ef4507"
|
||||
down_revision = "b388730a2899"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
update(ConnectorCredentialPair)
|
||||
.where(ConnectorCredentialPair.status == ConnectorCredentialPairStatus.INVALID)
|
||||
.values(status=ConnectorCredentialPairStatus.ACTIVE)
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Add composite index for last_modified and last_synced to document
|
||||
|
||||
Revision ID: f13db29f3101
|
||||
Revises: b388730a2899
|
||||
Create Date: 2025-02-18 22:48:11.511389
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f13db29f3101"
|
||||
down_revision = "acaab4ef4507"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_index(
|
||||
"ix_document_sync_status",
|
||||
"document",
|
||||
["last_modified", "last_synced"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_document_sync_status", table_name="document")
|
||||
@@ -98,12 +98,17 @@ class CloudEmbedding:
|
||||
return final_embeddings
|
||||
except Exception as e:
|
||||
error_string = (
|
||||
f"Error embedding text with OpenAI: {str(e)} \n"
|
||||
f"Model: {model} \n"
|
||||
f"Provider: {self.provider} \n"
|
||||
f"Texts: {texts}"
|
||||
f"Exception embedding text with OpenAI - {type(e)}: "
|
||||
f"Model: {model} "
|
||||
f"Provider: {self.provider} "
|
||||
f"Exception: {e}"
|
||||
)
|
||||
logger.error(error_string)
|
||||
|
||||
# only log text when it's not an authentication error.
|
||||
if not isinstance(e, openai.AuthenticationError):
|
||||
logger.debug(f"Exception texts: {texts}")
|
||||
|
||||
raise RuntimeError(error_string)
|
||||
|
||||
async def _embed_cohere(
|
||||
|
||||
@@ -10,6 +10,7 @@ from onyx.configs.app_configs import SMTP_PORT
|
||||
from onyx.configs.app_configs import SMTP_SERVER
|
||||
from onyx.configs.app_configs import SMTP_USER
|
||||
from onyx.configs.app_configs import WEB_DOMAIN
|
||||
from onyx.configs.constants import AuthType
|
||||
from onyx.configs.constants import TENANT_ID_COOKIE_NAME
|
||||
from onyx.db.models import User
|
||||
|
||||
@@ -187,23 +188,51 @@ def send_subscription_cancellation_email(user_email: str) -> None:
|
||||
send_email(user_email, subject, html_content, text_content)
|
||||
|
||||
|
||||
def send_user_email_invite(user_email: str, current_user: User) -> None:
|
||||
def send_user_email_invite(
|
||||
user_email: str, current_user: User, auth_type: AuthType
|
||||
) -> None:
|
||||
subject = "Invitation to Join Onyx Organization"
|
||||
heading = "You've Been Invited!"
|
||||
message = (
|
||||
f"<p>You have been invited by {current_user.email} to join an organization on Onyx.</p>"
|
||||
"<p>To join the organization, please click the button below to set a password "
|
||||
"or login with Google and complete your registration.</p>"
|
||||
)
|
||||
|
||||
# the exact action taken by the user, and thus the message, depends on the auth type
|
||||
message = f"<p>You have been invited by {current_user.email} to join an organization on Onyx.</p>"
|
||||
if auth_type == AuthType.CLOUD:
|
||||
message += (
|
||||
"<p>To join the organization, please click the button below to set a password "
|
||||
"or login with Google and complete your registration.</p>"
|
||||
)
|
||||
elif auth_type == AuthType.BASIC:
|
||||
message += (
|
||||
"<p>To join the organization, please click the button below to set a password "
|
||||
"and complete your registration.</p>"
|
||||
)
|
||||
elif auth_type == AuthType.GOOGLE_OAUTH:
|
||||
message += (
|
||||
"<p>To join the organization, please click the button below to login with Google "
|
||||
"and complete your registration.</p>"
|
||||
)
|
||||
elif auth_type == AuthType.OIDC or auth_type == AuthType.SAML:
|
||||
message += (
|
||||
"<p>To join the organization, please click the button below to"
|
||||
" complete your registration.</p>"
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Invalid auth type: {auth_type}")
|
||||
|
||||
cta_text = "Join Organization"
|
||||
cta_link = f"{WEB_DOMAIN}/auth/signup?email={user_email}"
|
||||
html_content = build_html_email(heading, message, cta_text, cta_link)
|
||||
|
||||
# text content is the fallback for clients that don't support HTML
|
||||
# not as critical, so not having special cases for each auth type
|
||||
text_content = (
|
||||
f"You have been invited by {current_user.email} to join an organization on Onyx.\n"
|
||||
"To join the organization, please visit the following link:\n"
|
||||
f"{WEB_DOMAIN}/auth/signup?email={user_email}\n"
|
||||
"You'll be asked to set a password or login with Google to complete your registration."
|
||||
)
|
||||
if auth_type == AuthType.CLOUD:
|
||||
text_content += "You'll be asked to set a password or login with Google to complete your registration."
|
||||
|
||||
send_email(user_email, subject, html_content, text_content)
|
||||
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ from onyx.db.models import User
|
||||
from onyx.db.users import get_user_by_email
|
||||
from onyx.redis.redis_pool import get_async_redis_connection
|
||||
from onyx.redis.redis_pool import get_redis_client
|
||||
from onyx.server.utils import BasicAuthenticationError
|
||||
from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.telemetry import create_milestone_and_report
|
||||
from onyx.utils.telemetry import optional_telemetry
|
||||
@@ -109,11 +110,6 @@ from shared_configs.contextvars import get_current_tenant_id
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
class BasicAuthenticationError(HTTPException):
|
||||
def __init__(self, detail: str):
|
||||
super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
|
||||
|
||||
|
||||
def is_user_admin(user: User | None) -> bool:
|
||||
if AUTH_TYPE == AuthType.DISABLED:
|
||||
return True
|
||||
|
||||
@@ -140,7 +140,7 @@ def on_task_postrun(
|
||||
f"{f'for tenant_id={tenant_id}' if tenant_id else ''}"
|
||||
)
|
||||
|
||||
r = get_redis_client()
|
||||
r = get_redis_client(tenant_id=tenant_id)
|
||||
|
||||
if task_id.startswith(RedisConnectorCredentialPair.PREFIX):
|
||||
r.srem(RedisConnectorCredentialPair.get_taskset_key(), task_id)
|
||||
|
||||
@@ -361,6 +361,7 @@ def connector_external_group_sync_generator_task(
|
||||
cc_pair = get_connector_credential_pair_from_id(
|
||||
db_session=db_session,
|
||||
cc_pair_id=cc_pair_id,
|
||||
eager_load_credential=True,
|
||||
)
|
||||
if cc_pair is None:
|
||||
raise ValueError(
|
||||
|
||||
@@ -48,6 +48,7 @@ from onyx.configs.constants import OnyxCeleryTask
|
||||
from onyx.configs.constants import OnyxRedisConstants
|
||||
from onyx.configs.constants import OnyxRedisLocks
|
||||
from onyx.configs.constants import OnyxRedisSignals
|
||||
from onyx.connectors.interfaces import ConnectorValidationError
|
||||
from onyx.db.connector import mark_ccpair_with_indexing_trigger
|
||||
from onyx.db.connector_credential_pair import fetch_connector_credential_pairs
|
||||
from onyx.db.connector_credential_pair import get_connector_credential_pair_from_id
|
||||
@@ -107,6 +108,9 @@ class IndexingWatchdogTerminalStatus(str, Enum):
|
||||
"index_attempt_mismatch" # expected index attempt metadata not found in db
|
||||
)
|
||||
|
||||
CONNECTOR_VALIDATION_ERROR = (
|
||||
"connector_validation_error" # the connector validation failed
|
||||
)
|
||||
CONNECTOR_EXCEPTIONED = "connector_exceptioned" # the connector itself exceptioned
|
||||
WATCHDOG_EXCEPTIONED = "watchdog_exceptioned" # the watchdog exceptioned
|
||||
|
||||
@@ -127,6 +131,7 @@ class IndexingWatchdogTerminalStatus(str, Enum):
|
||||
_ENUM_TO_CODE: dict[IndexingWatchdogTerminalStatus, int] = {
|
||||
IndexingWatchdogTerminalStatus.PROCESS_SIGNAL_SIGKILL: -9,
|
||||
IndexingWatchdogTerminalStatus.OUT_OF_MEMORY: 137,
|
||||
IndexingWatchdogTerminalStatus.CONNECTOR_VALIDATION_ERROR: 247,
|
||||
IndexingWatchdogTerminalStatus.BLOCKED_BY_DELETION: 248,
|
||||
IndexingWatchdogTerminalStatus.BLOCKED_BY_STOP_SIGNAL: 249,
|
||||
IndexingWatchdogTerminalStatus.FENCE_NOT_FOUND: 250,
|
||||
@@ -144,6 +149,7 @@ class IndexingWatchdogTerminalStatus(str, Enum):
|
||||
_CODE_TO_ENUM: dict[int, IndexingWatchdogTerminalStatus] = {
|
||||
-9: IndexingWatchdogTerminalStatus.PROCESS_SIGNAL_SIGKILL,
|
||||
137: IndexingWatchdogTerminalStatus.OUT_OF_MEMORY,
|
||||
247: IndexingWatchdogTerminalStatus.CONNECTOR_VALIDATION_ERROR,
|
||||
248: IndexingWatchdogTerminalStatus.BLOCKED_BY_DELETION,
|
||||
249: IndexingWatchdogTerminalStatus.BLOCKED_BY_STOP_SIGNAL,
|
||||
250: IndexingWatchdogTerminalStatus.FENCE_NOT_FOUND,
|
||||
@@ -796,6 +802,15 @@ def connector_indexing_task(
|
||||
# get back the total number of indexed docs and return it
|
||||
n_final_progress = redis_connector_index.get_progress()
|
||||
redis_connector_index.set_generator_complete(HTTPStatus.OK.value)
|
||||
except ConnectorValidationError:
|
||||
raise SimpleJobException(
|
||||
f"Indexing task failed: attempt={index_attempt_id} "
|
||||
f"tenant={tenant_id} "
|
||||
f"cc_pair={cc_pair_id} "
|
||||
f"search_settings={search_settings_id}",
|
||||
code=IndexingWatchdogTerminalStatus.CONNECTOR_VALIDATION_ERROR.code,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Indexing spawned task failed: attempt={index_attempt_id} "
|
||||
@@ -803,8 +818,8 @@ def connector_indexing_task(
|
||||
f"cc_pair={cc_pair_id} "
|
||||
f"search_settings={search_settings_id}"
|
||||
)
|
||||
|
||||
raise e
|
||||
|
||||
finally:
|
||||
if lock.owned():
|
||||
lock.release()
|
||||
@@ -1064,9 +1079,13 @@ def connector_indexing_proxy_task(
|
||||
)
|
||||
)
|
||||
continue
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
result.status = IndexingWatchdogTerminalStatus.WATCHDOG_EXCEPTIONED
|
||||
result.exception_str = traceback.format_exc()
|
||||
if isinstance(e, ConnectorValidationError):
|
||||
# No need to expose full stack trace for validation errors
|
||||
result.exception_str = str(e)
|
||||
else:
|
||||
result.exception_str = traceback.format_exc()
|
||||
|
||||
# handle exit and reporting
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
@@ -431,6 +431,7 @@ def connector_pruning_generator_task(
|
||||
f"cc_pair={cc_pair_id} "
|
||||
f"connector_source={cc_pair.connector.source}"
|
||||
)
|
||||
|
||||
runnable_connector = instantiate_connector(
|
||||
db_session,
|
||||
cc_pair.connector.source,
|
||||
|
||||
@@ -15,12 +15,14 @@ from onyx.background.indexing.memory_tracer import MemoryTracer
|
||||
from onyx.configs.app_configs import INDEX_BATCH_SIZE
|
||||
from onyx.configs.app_configs import INDEXING_SIZE_WARNING_THRESHOLD
|
||||
from onyx.configs.app_configs import INDEXING_TRACER_INTERVAL
|
||||
from onyx.configs.app_configs import INTEGRATION_TESTS_MODE
|
||||
from onyx.configs.app_configs import LEAVE_CONNECTOR_ACTIVE_ON_INITIALIZATION_FAILURE
|
||||
from onyx.configs.app_configs import POLL_CONNECTOR_OFFSET
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.configs.constants import MilestoneRecordType
|
||||
from onyx.connectors.connector_runner import ConnectorRunner
|
||||
from onyx.connectors.factory import instantiate_connector
|
||||
from onyx.connectors.interfaces import ConnectorValidationError
|
||||
from onyx.connectors.models import ConnectorCheckpoint
|
||||
from onyx.connectors.models import ConnectorFailure
|
||||
from onyx.connectors.models import Document
|
||||
@@ -86,6 +88,11 @@ def _get_connector_runner(
|
||||
credential=attempt.connector_credential_pair.credential,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
# validate the connector settings
|
||||
if not INTEGRATION_TESTS_MODE:
|
||||
runnable_connector.validate_connector_settings()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Unable to instantiate connector due to {e}")
|
||||
|
||||
@@ -567,8 +574,28 @@ def _run_indexing(
|
||||
"Connector run exceptioned after elapsed time: "
|
||||
f"{time.monotonic() - start_time} seconds"
|
||||
)
|
||||
if isinstance(e, ConnectorValidationError):
|
||||
# On validation errors during indexing, we want to cancel the indexing attempt
|
||||
# and mark the CCPair as invalid. This prevents the connector from being
|
||||
# used in the future until the credentials are updated.
|
||||
with get_session_with_current_tenant() as db_session_temp:
|
||||
mark_attempt_canceled(
|
||||
index_attempt_id,
|
||||
db_session_temp,
|
||||
reason=str(e),
|
||||
)
|
||||
|
||||
if isinstance(e, ConnectorStopSignal):
|
||||
if ctx.is_primary:
|
||||
update_connector_credential_pair(
|
||||
db_session=db_session_temp,
|
||||
connector_id=ctx.connector_id,
|
||||
credential_id=ctx.credential_id,
|
||||
status=ConnectorCredentialPairStatus.INVALID,
|
||||
)
|
||||
memory_tracer.stop()
|
||||
raise e
|
||||
|
||||
elif isinstance(e, ConnectorStopSignal):
|
||||
with get_session_with_current_tenant() as db_session_temp:
|
||||
mark_attempt_canceled(
|
||||
index_attempt_id,
|
||||
|
||||
@@ -190,7 +190,8 @@ def create_chat_chain(
|
||||
and previous_message.message_type == MessageType.ASSISTANT
|
||||
and mainline_messages
|
||||
):
|
||||
mainline_messages[-1] = current_message
|
||||
if current_message.refined_answer_improvement:
|
||||
mainline_messages[-1] = current_message
|
||||
else:
|
||||
mainline_messages.append(current_message)
|
||||
|
||||
|
||||
@@ -142,6 +142,15 @@ class MessageResponseIDInfo(BaseModel):
|
||||
reserved_assistant_message_id: int
|
||||
|
||||
|
||||
class AgentMessageIDInfo(BaseModel):
|
||||
level: int
|
||||
message_id: int
|
||||
|
||||
|
||||
class AgenticMessageResponseIDInfo(BaseModel):
|
||||
agentic_message_ids: list[AgentMessageIDInfo]
|
||||
|
||||
|
||||
class StreamingError(BaseModel):
|
||||
error: str
|
||||
stack_trace: str | None = None
|
||||
|
||||
@@ -11,6 +11,8 @@ from onyx.agents.agent_search.orchestration.nodes.call_tool import ToolCallExcep
|
||||
from onyx.chat.answer import Answer
|
||||
from onyx.chat.chat_utils import create_chat_chain
|
||||
from onyx.chat.chat_utils import create_temporary_persona
|
||||
from onyx.chat.models import AgenticMessageResponseIDInfo
|
||||
from onyx.chat.models import AgentMessageIDInfo
|
||||
from onyx.chat.models import AgentSearchPacket
|
||||
from onyx.chat.models import AllCitations
|
||||
from onyx.chat.models import AnswerPostInfo
|
||||
@@ -308,6 +310,7 @@ ChatPacket = (
|
||||
| CustomToolResponse
|
||||
| MessageSpecificCitations
|
||||
| MessageResponseIDInfo
|
||||
| AgenticMessageResponseIDInfo
|
||||
| StreamStopInfo
|
||||
| AgentSearchPacket
|
||||
)
|
||||
@@ -1035,6 +1038,7 @@ def stream_chat_message_objects(
|
||||
next_level = 1
|
||||
prev_message = gen_ai_response_message
|
||||
agent_answers = answer.llm_answer_by_level()
|
||||
agentic_message_ids = []
|
||||
while next_level in agent_answers:
|
||||
next_answer = agent_answers[next_level]
|
||||
info = info_by_subq[
|
||||
@@ -1059,17 +1063,18 @@ def stream_chat_message_objects(
|
||||
refined_answer_improvement=refined_answer_improvement,
|
||||
is_agentic=True,
|
||||
)
|
||||
agentic_message_ids.append(
|
||||
AgentMessageIDInfo(level=next_level, message_id=next_answer_message.id)
|
||||
)
|
||||
next_level += 1
|
||||
prev_message = next_answer_message
|
||||
|
||||
logger.debug("Committing messages")
|
||||
db_session.commit() # actually save user / assistant message
|
||||
|
||||
msg_detail_response = translate_db_message_to_chat_message_detail(
|
||||
gen_ai_response_message
|
||||
)
|
||||
yield AgenticMessageResponseIDInfo(agentic_message_ids=agentic_message_ids)
|
||||
|
||||
yield msg_detail_response
|
||||
yield translate_db_message_to_chat_message_detail(gen_ai_response_message)
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.exception(error_msg)
|
||||
|
||||
@@ -158,7 +158,7 @@ POSTGRES_USER = os.environ.get("POSTGRES_USER") or "postgres"
|
||||
POSTGRES_PASSWORD = urllib.parse.quote_plus(
|
||||
os.environ.get("POSTGRES_PASSWORD") or "password"
|
||||
)
|
||||
POSTGRES_HOST = os.environ.get("POSTGRES_HOST") or "localhost"
|
||||
POSTGRES_HOST = os.environ.get("POSTGRES_HOST") or "127.0.0.1"
|
||||
POSTGRES_PORT = os.environ.get("POSTGRES_PORT") or "5432"
|
||||
POSTGRES_DB = os.environ.get("POSTGRES_DB") or "postgres"
|
||||
AWS_REGION_NAME = os.environ.get("AWS_REGION_NAME") or "us-east-2"
|
||||
@@ -626,6 +626,8 @@ POD_NAMESPACE = os.environ.get("POD_NAMESPACE")
|
||||
|
||||
DEV_MODE = os.environ.get("DEV_MODE", "").lower() == "true"
|
||||
|
||||
INTEGRATION_TESTS_MODE = os.environ.get("INTEGRATION_TESTS_MODE", "").lower() == "true"
|
||||
|
||||
MOCK_CONNECTOR_FILE_PATH = os.environ.get("MOCK_CONNECTOR_FILE_PATH")
|
||||
|
||||
TEST_ENV = os.environ.get("TEST_ENV", "").lower() == "true"
|
||||
|
||||
@@ -5,6 +5,8 @@ import requests
|
||||
|
||||
class BookStackClientRequestFailedError(ConnectionError):
|
||||
def __init__(self, status: int, error: str) -> None:
|
||||
self.status_code = status
|
||||
self.error = error
|
||||
super().__init__(
|
||||
"BookStack Client request failed with status {status}: {error}".format(
|
||||
status=status, error=error
|
||||
|
||||
@@ -7,8 +7,12 @@ from typing import Any
|
||||
from onyx.configs.app_configs import INDEX_BATCH_SIZE
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.bookstack.client import BookStackApiClient
|
||||
from onyx.connectors.bookstack.client import BookStackClientRequestFailedError
|
||||
from onyx.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc
|
||||
from onyx.connectors.interfaces import ConnectorValidationError
|
||||
from onyx.connectors.interfaces import CredentialExpiredError
|
||||
from onyx.connectors.interfaces import GenerateDocumentsOutput
|
||||
from onyx.connectors.interfaces import InsufficientPermissionsError
|
||||
from onyx.connectors.interfaces import LoadConnector
|
||||
from onyx.connectors.interfaces import PollConnector
|
||||
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
|
||||
@@ -214,3 +218,39 @@ class BookstackConnector(LoadConnector, PollConnector):
|
||||
break
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
|
||||
def validate_connector_settings(self) -> None:
|
||||
"""
|
||||
Validate that the BookStack credentials and connector settings are correct.
|
||||
Specifically checks that we can make an authenticated request to BookStack.
|
||||
"""
|
||||
if not self.bookstack_client:
|
||||
raise ConnectorMissingCredentialError(
|
||||
"BookStack credentials have not been loaded."
|
||||
)
|
||||
|
||||
try:
|
||||
# Attempt to fetch a small batch of books (arbitrary endpoint) to verify credentials
|
||||
_ = self.bookstack_client.get(
|
||||
"/books", params={"count": "1", "offset": "0"}
|
||||
)
|
||||
|
||||
except BookStackClientRequestFailedError as e:
|
||||
# Check for HTTP status codes
|
||||
if e.status_code == 401:
|
||||
raise CredentialExpiredError(
|
||||
"Your BookStack credentials appear to be invalid or expired (HTTP 401)."
|
||||
) from e
|
||||
elif e.status_code == 403:
|
||||
raise InsufficientPermissionsError(
|
||||
"The configured BookStack token does not have sufficient permissions (HTTP 403)."
|
||||
) from e
|
||||
else:
|
||||
raise ConnectorValidationError(
|
||||
f"Unexpected BookStack error (status={e.status_code}): {e}"
|
||||
) from e
|
||||
|
||||
except Exception as exc:
|
||||
raise ConnectorValidationError(
|
||||
f"Unexpected error while validating BookStack connector settings: {exc}"
|
||||
) from exc
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
@@ -24,16 +25,22 @@ def datetime_to_utc(dt: datetime) -> datetime:
|
||||
|
||||
|
||||
def time_str_to_utc(datetime_str: str) -> datetime:
|
||||
# Remove all timezone abbreviations in parentheses
|
||||
datetime_str = re.sub(r"\([A-Z]+\)", "", datetime_str).strip()
|
||||
|
||||
# Remove any remaining parentheses and their contents
|
||||
datetime_str = re.sub(r"\(.*?\)", "", datetime_str).strip()
|
||||
|
||||
try:
|
||||
dt = parse(datetime_str)
|
||||
except ValueError:
|
||||
# Handle malformed timezone by attempting to fix common format issues
|
||||
# Fix common format issues (e.g. "0000" => "+0000")
|
||||
if "0000" in datetime_str:
|
||||
# Convert "0000" to "+0000" for proper timezone parsing
|
||||
fixed_dt_str = datetime_str.replace(" 0000", " +0000")
|
||||
dt = parse(fixed_dt_str)
|
||||
datetime_str = datetime_str.replace(" 0000", " +0000")
|
||||
dt = parse(datetime_str)
|
||||
else:
|
||||
raise
|
||||
|
||||
return datetime_to_utc(dt)
|
||||
|
||||
|
||||
|
||||
@@ -4,12 +4,16 @@ from typing import Any
|
||||
|
||||
from dropbox import Dropbox # type: ignore
|
||||
from dropbox.exceptions import ApiError # type:ignore
|
||||
from dropbox.exceptions import AuthError # type:ignore
|
||||
from dropbox.files import FileMetadata # type:ignore
|
||||
from dropbox.files import FolderMetadata # type:ignore
|
||||
|
||||
from onyx.configs.app_configs import INDEX_BATCH_SIZE
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.interfaces import ConnectorValidationError
|
||||
from onyx.connectors.interfaces import CredentialInvalidError
|
||||
from onyx.connectors.interfaces import GenerateDocumentsOutput
|
||||
from onyx.connectors.interfaces import InsufficientPermissionsError
|
||||
from onyx.connectors.interfaces import LoadConnector
|
||||
from onyx.connectors.interfaces import PollConnector
|
||||
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
|
||||
@@ -141,6 +145,29 @@ class DropboxConnector(LoadConnector, PollConnector):
|
||||
|
||||
return None
|
||||
|
||||
def validate_connector_settings(self) -> None:
|
||||
if self.dropbox_client is None:
|
||||
raise ConnectorMissingCredentialError("Dropbox credentials not loaded.")
|
||||
|
||||
try:
|
||||
self.dropbox_client.files_list_folder(path="", limit=1)
|
||||
except AuthError as e:
|
||||
logger.exception("Failed to validate Dropbox credentials")
|
||||
raise CredentialInvalidError(f"Dropbox credential is invalid: {e.error}")
|
||||
except ApiError as e:
|
||||
if (
|
||||
e.error is not None
|
||||
and "insufficient_permissions" in str(e.error).lower()
|
||||
):
|
||||
raise InsufficientPermissionsError(
|
||||
"Your Dropbox token does not have sufficient permissions."
|
||||
)
|
||||
raise ConnectorValidationError(
|
||||
f"Unexpected Dropbox error during validation: {e.user_message_text or e}"
|
||||
)
|
||||
except Exception as e:
|
||||
raise Exception(f"Unexpected error during Dropbox settings validation: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Type
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.configs.app_configs import INTEGRATION_TESTS_MODE
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.configs.constants import DocumentSourceRequiringTenantContext
|
||||
from onyx.connectors.airtable.airtable_connector import AirtableConnector
|
||||
@@ -31,6 +32,7 @@ from onyx.connectors.guru.connector import GuruConnector
|
||||
from onyx.connectors.hubspot.connector import HubSpotConnector
|
||||
from onyx.connectors.interfaces import BaseConnector
|
||||
from onyx.connectors.interfaces import CheckpointConnector
|
||||
from onyx.connectors.interfaces import ConnectorValidationError
|
||||
from onyx.connectors.interfaces import EventConnector
|
||||
from onyx.connectors.interfaces import LoadConnector
|
||||
from onyx.connectors.interfaces import PollConnector
|
||||
@@ -52,8 +54,11 @@ from onyx.connectors.wikipedia.connector import WikipediaConnector
|
||||
from onyx.connectors.xenforo.connector import XenforoConnector
|
||||
from onyx.connectors.zendesk.connector import ZendeskConnector
|
||||
from onyx.connectors.zulip.connector import ZulipConnector
|
||||
from onyx.db.connector import fetch_connector_by_id
|
||||
from onyx.db.credentials import backend_update_credential_json
|
||||
from onyx.db.credentials import fetch_credential_by_id_for_user
|
||||
from onyx.db.models import Credential
|
||||
from onyx.db.models import User
|
||||
|
||||
|
||||
class ConnectorMissingException(Exception):
|
||||
@@ -174,3 +179,49 @@ def instantiate_connector(
|
||||
backend_update_credential_json(credential, new_credentials, db_session)
|
||||
|
||||
return connector
|
||||
|
||||
|
||||
def validate_ccpair_for_user(
|
||||
connector_id: int,
|
||||
credential_id: int,
|
||||
db_session: Session,
|
||||
user: User | None,
|
||||
tenant_id: str | None,
|
||||
) -> None:
|
||||
if INTEGRATION_TESTS_MODE:
|
||||
return
|
||||
|
||||
# Validate the connector settings
|
||||
connector = fetch_connector_by_id(connector_id, db_session)
|
||||
credential = fetch_credential_by_id_for_user(
|
||||
credential_id,
|
||||
user,
|
||||
db_session,
|
||||
get_editable=False,
|
||||
)
|
||||
|
||||
if not connector:
|
||||
raise ValueError("Connector not found")
|
||||
|
||||
if (
|
||||
connector.source == DocumentSource.INGESTION_API
|
||||
or connector.source == DocumentSource.MOCK_CONNECTOR
|
||||
):
|
||||
return
|
||||
|
||||
if not credential:
|
||||
raise ValueError("Credential not found")
|
||||
|
||||
try:
|
||||
runnable_connector = instantiate_connector(
|
||||
db_session=db_session,
|
||||
source=connector.source,
|
||||
input_type=connector.input_type,
|
||||
connector_specific_config=connector.connector_specific_config,
|
||||
credential=credential,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
except Exception as e:
|
||||
raise ConnectorValidationError(str(e))
|
||||
|
||||
runnable_connector.validate_connector_settings()
|
||||
|
||||
@@ -187,12 +187,12 @@ class FirefliesConnector(PollConnector, LoadConnector):
|
||||
return self._process_transcripts()
|
||||
|
||||
def poll_source(
|
||||
self, start_unixtime: SecondsSinceUnixEpoch, end_unixtime: SecondsSinceUnixEpoch
|
||||
self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch
|
||||
) -> GenerateDocumentsOutput:
|
||||
start_datetime = datetime.fromtimestamp(
|
||||
start_unixtime, tz=timezone.utc
|
||||
).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||
end_datetime = datetime.fromtimestamp(end_unixtime, tz=timezone.utc).strftime(
|
||||
start_datetime = datetime.fromtimestamp(start, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S.000Z"
|
||||
)
|
||||
end_datetime = datetime.fromtimestamp(end, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S.000Z"
|
||||
)
|
||||
|
||||
|
||||
@@ -229,16 +229,20 @@ class GitbookConnector(LoadConnector, PollConnector):
|
||||
|
||||
try:
|
||||
content = self.client.get(f"/spaces/{self.space_id}/content")
|
||||
pages = content.get("pages", [])
|
||||
|
||||
pages: list[dict[str, Any]] = content.get("pages", [])
|
||||
current_batch: list[Document] = []
|
||||
for page in pages:
|
||||
updated_at = datetime.fromisoformat(page["updatedAt"])
|
||||
|
||||
while pages:
|
||||
page = pages.pop(0)
|
||||
|
||||
updated_at_raw = page.get("updatedAt")
|
||||
if updated_at_raw is None:
|
||||
# if updatedAt is not present, that means the page has never been edited
|
||||
continue
|
||||
|
||||
updated_at = datetime.fromisoformat(updated_at_raw)
|
||||
if start and updated_at < start:
|
||||
if current_batch:
|
||||
yield current_batch
|
||||
return
|
||||
continue
|
||||
if end and updated_at > end:
|
||||
continue
|
||||
|
||||
@@ -250,6 +254,8 @@ class GitbookConnector(LoadConnector, PollConnector):
|
||||
yield current_batch
|
||||
current_batch = []
|
||||
|
||||
pages.extend(page.get("pages", []))
|
||||
|
||||
if current_batch:
|
||||
yield current_batch
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import cast
|
||||
from github import Github
|
||||
from github import RateLimitExceededException
|
||||
from github import Repository
|
||||
from github.GithubException import GithubException
|
||||
from github.Issue import Issue
|
||||
from github.PaginatedList import PaginatedList
|
||||
from github.PullRequest import PullRequest
|
||||
@@ -16,17 +17,20 @@ from github.PullRequest import PullRequest
|
||||
from onyx.configs.app_configs import GITHUB_CONNECTOR_BASE_URL
|
||||
from onyx.configs.app_configs import INDEX_BATCH_SIZE
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.interfaces import ConnectorValidationError
|
||||
from onyx.connectors.interfaces import CredentialExpiredError
|
||||
from onyx.connectors.interfaces import GenerateDocumentsOutput
|
||||
from onyx.connectors.interfaces import InsufficientPermissionsError
|
||||
from onyx.connectors.interfaces import LoadConnector
|
||||
from onyx.connectors.interfaces import PollConnector
|
||||
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
|
||||
from onyx.connectors.interfaces import UnexpectedError
|
||||
from onyx.connectors.models import ConnectorMissingCredentialError
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import Section
|
||||
from onyx.utils.batching import batch_generator
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
@@ -226,6 +230,48 @@ class GithubConnector(LoadConnector, PollConnector):
|
||||
|
||||
return self._fetch_from_github(adjusted_start_datetime, end_datetime)
|
||||
|
||||
def validate_connector_settings(self) -> None:
|
||||
if self.github_client is None:
|
||||
raise ConnectorMissingCredentialError("GitHub credentials not loaded.")
|
||||
|
||||
if not self.repo_owner or not self.repo_name:
|
||||
raise ConnectorValidationError(
|
||||
"Invalid connector settings: 'repo_owner' and 'repo_name' must be provided."
|
||||
)
|
||||
|
||||
try:
|
||||
test_repo = self.github_client.get_repo(
|
||||
f"{self.repo_owner}/{self.repo_name}"
|
||||
)
|
||||
test_repo.get_contents("")
|
||||
|
||||
except RateLimitExceededException:
|
||||
raise UnexpectedError(
|
||||
"Validation failed due to GitHub rate-limits being exceeded. Please try again later."
|
||||
)
|
||||
|
||||
except GithubException as e:
|
||||
if e.status == 401:
|
||||
raise CredentialExpiredError(
|
||||
"GitHub credential appears to be invalid or expired (HTTP 401)."
|
||||
)
|
||||
elif e.status == 403:
|
||||
raise InsufficientPermissionsError(
|
||||
"Your GitHub token does not have sufficient permissions for this repository (HTTP 403)."
|
||||
)
|
||||
elif e.status == 404:
|
||||
raise ConnectorValidationError(
|
||||
f"GitHub repository not found with name: {self.repo_owner}/{self.repo_name}"
|
||||
)
|
||||
else:
|
||||
raise ConnectorValidationError(
|
||||
f"Unexpected GitHub error (status={e.status}): {e.data}"
|
||||
)
|
||||
except Exception as exc:
|
||||
raise Exception(
|
||||
f"Unexpected error during GitHub settings validation: {exc}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
|
||||
@@ -297,6 +297,7 @@ class GmailConnector(LoadConnector, PollConnector, SlimConnector):
|
||||
userId=user_email,
|
||||
fields=THREAD_LIST_FIELDS,
|
||||
q=query,
|
||||
continue_on_404_or_403=True,
|
||||
):
|
||||
full_threads = execute_paginated_retrieval(
|
||||
retrieval_function=gmail_service.users().threads().get,
|
||||
|
||||
@@ -220,7 +220,14 @@ class GoogleDriveConnector(LoadConnector, PollConnector, SlimConnector):
|
||||
return self._creds
|
||||
|
||||
def load_credentials(self, credentials: dict[str, Any]) -> dict[str, str] | None:
|
||||
self._primary_admin_email = credentials[DB_CREDENTIALS_PRIMARY_ADMIN_KEY]
|
||||
try:
|
||||
self._primary_admin_email = credentials[DB_CREDENTIALS_PRIMARY_ADMIN_KEY]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
"Primary admin email missing, "
|
||||
"should not call this property "
|
||||
"before calling load_credentials"
|
||||
)
|
||||
|
||||
self._creds, new_creds_dict = get_google_creds(
|
||||
credentials=credentials,
|
||||
|
||||
@@ -12,7 +12,6 @@ from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import SlimDocument
|
||||
from onyx.indexing.indexing_heartbeat import IndexingHeartbeatInterface
|
||||
|
||||
|
||||
SecondsSinceUnixEpoch = float
|
||||
|
||||
GenerateDocumentsOutput = Iterator[list[Document]]
|
||||
@@ -45,6 +44,14 @@ class BaseConnector(abc.ABC):
|
||||
raise RuntimeError(custom_parser_req_msg)
|
||||
return metadata_lines
|
||||
|
||||
def validate_connector_settings(self) -> None:
|
||||
"""
|
||||
Override this if your connector needs to validate credentials or settings.
|
||||
Raise an exception if invalid, otherwise do nothing.
|
||||
|
||||
Default is a no-op (always successful).
|
||||
"""
|
||||
|
||||
|
||||
# Large set update or reindex, generally pulling a complete state or from a savestate file
|
||||
class LoadConnector(BaseConnector):
|
||||
@@ -139,3 +146,46 @@ class CheckpointConnector(BaseConnector):
|
||||
```
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ConnectorValidationError(Exception):
|
||||
"""General exception for connector validation errors."""
|
||||
|
||||
def __init__(self, message: str):
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class UnexpectedError(Exception):
|
||||
"""Raised when an unexpected error occurs during connector validation.
|
||||
|
||||
Unexpected errors don't necessarily mean the credential is invalid,
|
||||
but rather that there was an error during the validation process
|
||||
or we encountered a currently unhandled error case.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str = "Unexpected error during connector validation"):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class CredentialInvalidError(ConnectorValidationError):
|
||||
"""Raised when a connector's credential is invalid."""
|
||||
|
||||
def __init__(self, message: str = "Credential is invalid"):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class CredentialExpiredError(ConnectorValidationError):
|
||||
"""Raised when a connector's credential is expired."""
|
||||
|
||||
def __init__(self, message: str = "Credential has expired"):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class InsufficientPermissionsError(ConnectorValidationError):
|
||||
"""Raised when the credential does not have sufficient API permissions."""
|
||||
|
||||
def __init__(
|
||||
self, message: str = "Insufficient permissions for the requested operation"
|
||||
):
|
||||
super().__init__(message)
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import timezone
|
||||
from typing import Any
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from retry import retry
|
||||
|
||||
from onyx.configs.app_configs import INDEX_BATCH_SIZE
|
||||
@@ -15,10 +16,14 @@ from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.cross_connector_utils.rate_limit_wrapper import (
|
||||
rl_requests,
|
||||
)
|
||||
from onyx.connectors.interfaces import ConnectorValidationError
|
||||
from onyx.connectors.interfaces import CredentialExpiredError
|
||||
from onyx.connectors.interfaces import GenerateDocumentsOutput
|
||||
from onyx.connectors.interfaces import InsufficientPermissionsError
|
||||
from onyx.connectors.interfaces import LoadConnector
|
||||
from onyx.connectors.interfaces import PollConnector
|
||||
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
|
||||
from onyx.connectors.models import ConnectorMissingCredentialError
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import Section
|
||||
from onyx.utils.batching import batch_generator
|
||||
@@ -616,6 +621,64 @@ class NotionConnector(LoadConnector, PollConnector):
|
||||
else:
|
||||
break
|
||||
|
||||
def validate_connector_settings(self) -> None:
|
||||
if not self.headers.get("Authorization"):
|
||||
raise ConnectorMissingCredentialError("Notion credentials not loaded.")
|
||||
|
||||
try:
|
||||
# We'll do a minimal search call (page_size=1) to confirm accessibility
|
||||
if self.root_page_id:
|
||||
# If root_page_id is set, fetch the specific page
|
||||
res = rl_requests.get(
|
||||
f"https://api.notion.com/v1/pages/{self.root_page_id}",
|
||||
headers=self.headers,
|
||||
timeout=_NOTION_CALL_TIMEOUT,
|
||||
)
|
||||
else:
|
||||
# If root_page_id is not set, perform a minimal search
|
||||
test_query = {
|
||||
"filter": {"property": "object", "value": "page"},
|
||||
"page_size": 1,
|
||||
}
|
||||
res = rl_requests.post(
|
||||
"https://api.notion.com/v1/search",
|
||||
headers=self.headers,
|
||||
json=test_query,
|
||||
timeout=_NOTION_CALL_TIMEOUT,
|
||||
)
|
||||
res.raise_for_status()
|
||||
|
||||
except requests.exceptions.HTTPError as http_err:
|
||||
status_code = http_err.response.status_code if http_err.response else None
|
||||
|
||||
if status_code == 401:
|
||||
raise CredentialExpiredError(
|
||||
"Notion credential appears to be invalid or expired (HTTP 401)."
|
||||
)
|
||||
elif status_code == 403:
|
||||
raise InsufficientPermissionsError(
|
||||
"Your Notion token does not have sufficient permissions (HTTP 403)."
|
||||
)
|
||||
elif status_code == 404:
|
||||
# Typically means resource not found or not shared. Could be root_page_id is invalid.
|
||||
raise ConnectorValidationError(
|
||||
"Notion resource not found or not shared with the integration (HTTP 404)."
|
||||
)
|
||||
elif status_code == 429:
|
||||
raise ConnectorValidationError(
|
||||
"Validation failed due to Notion rate-limits being exceeded (HTTP 429). "
|
||||
"Please try again later."
|
||||
)
|
||||
else:
|
||||
raise Exception(
|
||||
f"Unexpected Notion HTTP error (status={status_code}): {http_err}"
|
||||
) from http_err
|
||||
|
||||
except Exception as exc:
|
||||
raise Exception(
|
||||
f"Unexpected error during Notion settings validation: {exc}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
|
||||
@@ -12,8 +12,11 @@ from onyx.configs.app_configs import JIRA_CONNECTOR_LABELS_TO_SKIP
|
||||
from onyx.configs.app_configs import JIRA_CONNECTOR_MAX_TICKET_SIZE
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc
|
||||
from onyx.connectors.interfaces import ConnectorValidationError
|
||||
from onyx.connectors.interfaces import CredentialExpiredError
|
||||
from onyx.connectors.interfaces import GenerateDocumentsOutput
|
||||
from onyx.connectors.interfaces import GenerateSlimDocumentOutput
|
||||
from onyx.connectors.interfaces import InsufficientPermissionsError
|
||||
from onyx.connectors.interfaces import LoadConnector
|
||||
from onyx.connectors.interfaces import PollConnector
|
||||
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
|
||||
@@ -272,6 +275,40 @@ class JiraConnector(LoadConnector, PollConnector, SlimConnector):
|
||||
|
||||
yield slim_doc_batch
|
||||
|
||||
def validate_connector_settings(self) -> None:
|
||||
if self._jira_client is None:
|
||||
raise ConnectorMissingCredentialError("Jira")
|
||||
|
||||
if not self._jira_project:
|
||||
raise ConnectorValidationError(
|
||||
"Invalid connector settings: 'jira_project' must be provided."
|
||||
)
|
||||
|
||||
try:
|
||||
self.jira_client.project(self._jira_project)
|
||||
|
||||
except Exception as e:
|
||||
status_code = getattr(e, "status_code", None)
|
||||
|
||||
if status_code == 401:
|
||||
raise CredentialExpiredError(
|
||||
"Jira credential appears to be expired or invalid (HTTP 401)."
|
||||
)
|
||||
elif status_code == 403:
|
||||
raise InsufficientPermissionsError(
|
||||
"Your Jira token does not have sufficient permissions for this project (HTTP 403)."
|
||||
)
|
||||
elif status_code == 404:
|
||||
raise ConnectorValidationError(
|
||||
f"Jira project not found with key: {self._jira_project}"
|
||||
)
|
||||
elif status_code == 429:
|
||||
raise ConnectorValidationError(
|
||||
"Validation failed due to Jira rate-limits being exceeded. Please try again later."
|
||||
)
|
||||
else:
|
||||
raise Exception(f"Unexpected Jira error during validation: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
|
||||
@@ -25,8 +25,12 @@ from onyx.configs.app_configs import WEB_CONNECTOR_OAUTH_CLIENT_SECRET
|
||||
from onyx.configs.app_configs import WEB_CONNECTOR_OAUTH_TOKEN_URL
|
||||
from onyx.configs.app_configs import WEB_CONNECTOR_VALIDATE_URLS
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.interfaces import ConnectorValidationError
|
||||
from onyx.connectors.interfaces import CredentialExpiredError
|
||||
from onyx.connectors.interfaces import GenerateDocumentsOutput
|
||||
from onyx.connectors.interfaces import InsufficientPermissionsError
|
||||
from onyx.connectors.interfaces import LoadConnector
|
||||
from onyx.connectors.interfaces import UnexpectedError
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import Section
|
||||
from onyx.file_processing.extract_file_text import read_pdf_file
|
||||
@@ -37,6 +41,8 @@ from shared_configs.configs import MULTI_TENANT
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
WEB_CONNECTOR_MAX_SCROLL_ATTEMPTS = 20
|
||||
|
||||
|
||||
class WEB_CONNECTOR_VALID_SETTINGS(str, Enum):
|
||||
# Given a base site, index everything under that path
|
||||
@@ -170,26 +176,35 @@ def start_playwright() -> Tuple[Playwright, BrowserContext]:
|
||||
|
||||
|
||||
def extract_urls_from_sitemap(sitemap_url: str) -> list[str]:
|
||||
response = requests.get(sitemap_url)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
response = requests.get(sitemap_url)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.content, "html.parser")
|
||||
urls = [
|
||||
_ensure_absolute_url(sitemap_url, loc_tag.text)
|
||||
for loc_tag in soup.find_all("loc")
|
||||
]
|
||||
soup = BeautifulSoup(response.content, "html.parser")
|
||||
urls = [
|
||||
_ensure_absolute_url(sitemap_url, loc_tag.text)
|
||||
for loc_tag in soup.find_all("loc")
|
||||
]
|
||||
|
||||
if len(urls) == 0 and len(soup.find_all("urlset")) == 0:
|
||||
# the given url doesn't look like a sitemap, let's try to find one
|
||||
urls = list_pages_for_site(sitemap_url)
|
||||
if len(urls) == 0 and len(soup.find_all("urlset")) == 0:
|
||||
# the given url doesn't look like a sitemap, let's try to find one
|
||||
urls = list_pages_for_site(sitemap_url)
|
||||
|
||||
if len(urls) == 0:
|
||||
raise ValueError(
|
||||
f"No URLs found in sitemap {sitemap_url}. Try using the 'single' or 'recursive' scraping options instead."
|
||||
if len(urls) == 0:
|
||||
raise ValueError(
|
||||
f"No URLs found in sitemap {sitemap_url}. Try using the 'single' or 'recursive' scraping options instead."
|
||||
)
|
||||
|
||||
return urls
|
||||
except requests.RequestException as e:
|
||||
raise RuntimeError(f"Failed to fetch sitemap from {sitemap_url}: {e}")
|
||||
except ValueError as e:
|
||||
raise RuntimeError(f"Error processing sitemap {sitemap_url}: {e}")
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"Unexpected error while processing sitemap {sitemap_url}: {e}"
|
||||
)
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
def _ensure_absolute_url(source_url: str, maybe_relative_url: str) -> str:
|
||||
if not urlparse(maybe_relative_url).netloc:
|
||||
@@ -225,10 +240,14 @@ class WebConnector(LoadConnector):
|
||||
web_connector_type: str = WEB_CONNECTOR_VALID_SETTINGS.RECURSIVE.value,
|
||||
mintlify_cleanup: bool = True, # Mostly ok to apply to other websites as well
|
||||
batch_size: int = INDEX_BATCH_SIZE,
|
||||
scroll_before_scraping: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self.mintlify_cleanup = mintlify_cleanup
|
||||
self.batch_size = batch_size
|
||||
self.recursive = False
|
||||
self.scroll_before_scraping = scroll_before_scraping
|
||||
self.web_connector_type = web_connector_type
|
||||
|
||||
if web_connector_type == WEB_CONNECTOR_VALID_SETTINGS.RECURSIVE.value:
|
||||
self.recursive = True
|
||||
@@ -344,6 +363,18 @@ class WebConnector(LoadConnector):
|
||||
continue
|
||||
visited_links.add(current_url)
|
||||
|
||||
if self.scroll_before_scraping:
|
||||
scroll_attempts = 0
|
||||
previous_height = page.evaluate("document.body.scrollHeight")
|
||||
while scroll_attempts < WEB_CONNECTOR_MAX_SCROLL_ATTEMPTS:
|
||||
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
||||
page.wait_for_load_state("networkidle", timeout=30000)
|
||||
new_height = page.evaluate("document.body.scrollHeight")
|
||||
if new_height == previous_height:
|
||||
break # Stop scrolling when no more content is loaded
|
||||
previous_height = new_height
|
||||
scroll_attempts += 1
|
||||
|
||||
content = page.content()
|
||||
soup = BeautifulSoup(content, "html.parser")
|
||||
|
||||
@@ -402,6 +433,53 @@ class WebConnector(LoadConnector):
|
||||
raise RuntimeError(last_error)
|
||||
raise RuntimeError("No valid pages found.")
|
||||
|
||||
def validate_connector_settings(self) -> None:
|
||||
# Make sure we have at least one valid URL to check
|
||||
if not self.to_visit_list:
|
||||
raise ConnectorValidationError(
|
||||
"No URL configured. Please provide at least one valid URL."
|
||||
)
|
||||
|
||||
if self.web_connector_type == WEB_CONNECTOR_VALID_SETTINGS.SITEMAP.value:
|
||||
return None
|
||||
|
||||
# We'll just test the first URL for connectivity and correctness
|
||||
test_url = self.to_visit_list[0]
|
||||
|
||||
# Check that the URL is allowed and well-formed
|
||||
try:
|
||||
protected_url_check(test_url)
|
||||
except ValueError as e:
|
||||
raise ConnectorValidationError(
|
||||
f"Protected URL check failed for '{test_url}': {e}"
|
||||
)
|
||||
except ConnectionError as e:
|
||||
# Typically DNS or other network issues
|
||||
raise ConnectorValidationError(str(e))
|
||||
|
||||
# Make a quick request to see if we get a valid response
|
||||
try:
|
||||
check_internet_connection(test_url)
|
||||
except Exception as e:
|
||||
err_str = str(e)
|
||||
if "401" in err_str:
|
||||
raise CredentialExpiredError(
|
||||
f"Unauthorized access to '{test_url}': {e}"
|
||||
)
|
||||
elif "403" in err_str:
|
||||
raise InsufficientPermissionsError(
|
||||
f"Forbidden access to '{test_url}': {e}"
|
||||
)
|
||||
elif "404" in err_str:
|
||||
raise ConnectorValidationError(f"Page not found for '{test_url}': {e}")
|
||||
elif "Max retries exceeded" in err_str and "NameResolutionError" in err_str:
|
||||
raise ConnectorValidationError(
|
||||
f"Unable to resolve hostname for '{test_url}'. Please check the URL and your internet connection."
|
||||
)
|
||||
else:
|
||||
# Could be a 5xx or another error, treat as unexpected
|
||||
raise UnexpectedError(f"Unexpected error validating '{test_url}': {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
connector = WebConnector("https://docs.onyx.app/")
|
||||
|
||||
@@ -194,9 +194,14 @@ def get_connector_credential_pair_from_id_for_user(
|
||||
def get_connector_credential_pair_from_id(
|
||||
db_session: Session,
|
||||
cc_pair_id: int,
|
||||
eager_load_credential: bool = False,
|
||||
) -> ConnectorCredentialPair | None:
|
||||
stmt = select(ConnectorCredentialPair).distinct()
|
||||
stmt = stmt.where(ConnectorCredentialPair.id == cc_pair_id)
|
||||
|
||||
if eager_load_credential:
|
||||
stmt = stmt.options(joinedload(ConnectorCredentialPair.credential))
|
||||
|
||||
result = db_session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.google_utils.shared_constants import (
|
||||
DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY,
|
||||
)
|
||||
from onyx.db.enums import ConnectorCredentialPairStatus
|
||||
from onyx.db.models import ConnectorCredentialPair
|
||||
from onyx.db.models import Credential
|
||||
from onyx.db.models import Credential__UserGroup
|
||||
@@ -245,6 +246,10 @@ def swap_credentials_connector(
|
||||
existing_pair.credential_id = new_credential_id
|
||||
existing_pair.credential = new_credential
|
||||
|
||||
# Update ccpair status if it's in INVALID state
|
||||
if existing_pair.status == ConnectorCredentialPairStatus.INVALID:
|
||||
existing_pair.status = ConnectorCredentialPairStatus.ACTIVE
|
||||
|
||||
# Commit the changes
|
||||
db_session.commit()
|
||||
|
||||
|
||||
@@ -60,9 +60,8 @@ def count_documents_by_needs_sync(session: Session) -> int:
|
||||
This function executes the query and returns the count of
|
||||
documents matching the criteria."""
|
||||
|
||||
count = (
|
||||
session.query(func.count(DbDocument.id.distinct()))
|
||||
.select_from(DbDocument)
|
||||
return (
|
||||
session.query(DbDocument.id)
|
||||
.join(
|
||||
DocumentByConnectorCredentialPair,
|
||||
DbDocument.id == DocumentByConnectorCredentialPair.id,
|
||||
@@ -73,63 +72,53 @@ def count_documents_by_needs_sync(session: Session) -> int:
|
||||
DbDocument.last_synced.is_(None),
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
.count()
|
||||
)
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def construct_document_select_for_connector_credential_pair_by_needs_sync(
|
||||
connector_id: int, credential_id: int
|
||||
) -> Select:
|
||||
initial_doc_ids_stmt = select(DocumentByConnectorCredentialPair.id).where(
|
||||
and_(
|
||||
DocumentByConnectorCredentialPair.connector_id == connector_id,
|
||||
DocumentByConnectorCredentialPair.credential_id == credential_id,
|
||||
)
|
||||
)
|
||||
|
||||
stmt = (
|
||||
return (
|
||||
select(DbDocument)
|
||||
.where(
|
||||
DbDocument.id.in_(initial_doc_ids_stmt),
|
||||
or_(
|
||||
DbDocument.last_modified
|
||||
> DbDocument.last_synced, # last_modified is newer than last_synced
|
||||
DbDocument.last_synced.is_(None), # never synced
|
||||
),
|
||||
.join(
|
||||
DocumentByConnectorCredentialPair,
|
||||
DbDocument.id == DocumentByConnectorCredentialPair.id,
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
DocumentByConnectorCredentialPair.connector_id == connector_id,
|
||||
DocumentByConnectorCredentialPair.credential_id == credential_id,
|
||||
or_(
|
||||
DbDocument.last_modified > DbDocument.last_synced,
|
||||
DbDocument.last_synced.is_(None),
|
||||
),
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
return stmt
|
||||
|
||||
|
||||
def construct_document_id_select_for_connector_credential_pair_by_needs_sync(
|
||||
connector_id: int, credential_id: int
|
||||
) -> Select:
|
||||
initial_doc_ids_stmt = select(DocumentByConnectorCredentialPair.id).where(
|
||||
and_(
|
||||
DocumentByConnectorCredentialPair.connector_id == connector_id,
|
||||
DocumentByConnectorCredentialPair.credential_id == credential_id,
|
||||
)
|
||||
)
|
||||
|
||||
stmt = (
|
||||
return (
|
||||
select(DbDocument.id)
|
||||
.where(
|
||||
DbDocument.id.in_(initial_doc_ids_stmt),
|
||||
or_(
|
||||
DbDocument.last_modified
|
||||
> DbDocument.last_synced, # last_modified is newer than last_synced
|
||||
DbDocument.last_synced.is_(None), # never synced
|
||||
),
|
||||
.join(
|
||||
DocumentByConnectorCredentialPair,
|
||||
DbDocument.id == DocumentByConnectorCredentialPair.id,
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
DocumentByConnectorCredentialPair.connector_id == connector_id,
|
||||
DocumentByConnectorCredentialPair.credential_id == credential_id,
|
||||
or_(
|
||||
DbDocument.last_modified > DbDocument.last_synced,
|
||||
DbDocument.last_synced.is_(None),
|
||||
),
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
return stmt
|
||||
|
||||
|
||||
def get_all_documents_needing_vespa_sync_for_cc_pair(
|
||||
db_session: Session, cc_pair_id: int
|
||||
|
||||
@@ -73,6 +73,7 @@ class ConnectorCredentialPairStatus(str, PyEnum):
|
||||
ACTIVE = "ACTIVE"
|
||||
PAUSED = "PAUSED"
|
||||
DELETING = "DELETING"
|
||||
INVALID = "INVALID"
|
||||
|
||||
def is_active(self) -> bool:
|
||||
return self == ConnectorCredentialPairStatus.ACTIVE
|
||||
|
||||
@@ -570,6 +570,14 @@ class Document(Base):
|
||||
back_populates="documents",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_document_sync_status",
|
||||
last_modified,
|
||||
last_synced,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Tag(Base):
|
||||
__tablename__ = "tag"
|
||||
|
||||
@@ -23,6 +23,7 @@ class PreviousMessage(BaseModel):
|
||||
message_type: MessageType
|
||||
files: list[InMemoryChatFile]
|
||||
tool_call: ToolCallFinalResult | None
|
||||
refined_answer_improvement: bool | None
|
||||
|
||||
@classmethod
|
||||
def from_chat_message(
|
||||
@@ -47,6 +48,7 @@ class PreviousMessage(BaseModel):
|
||||
)
|
||||
if chat_message.tool_call
|
||||
else None,
|
||||
refined_answer_improvement=chat_message.refined_answer_improvement,
|
||||
)
|
||||
|
||||
def to_langchain_msg(self) -> BaseMessage:
|
||||
|
||||
@@ -25,6 +25,9 @@ from onyx.background.celery.versioned_apps.primary import app as primary_app
|
||||
from onyx.background.indexing.models import IndexAttemptErrorPydantic
|
||||
from onyx.configs.constants import OnyxCeleryPriority
|
||||
from onyx.configs.constants import OnyxCeleryTask
|
||||
from onyx.connectors.factory import validate_ccpair_for_user
|
||||
from onyx.connectors.interfaces import ConnectorValidationError
|
||||
from onyx.db.connector import delete_connector
|
||||
from onyx.db.connector_credential_pair import add_credential_to_connector
|
||||
from onyx.db.connector_credential_pair import (
|
||||
get_connector_credential_pair_from_id_for_user,
|
||||
@@ -617,6 +620,10 @@ def associate_credential_to_connector(
|
||||
)
|
||||
|
||||
try:
|
||||
validate_ccpair_for_user(
|
||||
connector_id, credential_id, db_session, user, tenant_id
|
||||
)
|
||||
|
||||
response = add_credential_to_connector(
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
@@ -641,10 +648,27 @@ def associate_credential_to_connector(
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except ConnectorValidationError as e:
|
||||
# If validation fails, delete the connector and commit the changes
|
||||
# Ensures we don't leave invalid connectors in the database
|
||||
# NOTE: consensus is that it makes sense to unify connector and ccpair creation flows
|
||||
# which would rid us of needing to handle cases like these
|
||||
delete_connector(db_session, connector_id)
|
||||
db_session.commit()
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Connector validation error: " + str(e)
|
||||
)
|
||||
|
||||
except IntegrityError as e:
|
||||
logger.error(f"IntegrityError: {e}")
|
||||
raise HTTPException(status_code=400, detail="Name must be unique")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error: {e}")
|
||||
raise HTTPException(status_code=500, detail="Unexpected error")
|
||||
|
||||
|
||||
@router.delete("/connector/{connector_id}/credential/{credential_id}")
|
||||
def dissociate_credential_from_connector(
|
||||
|
||||
@@ -28,6 +28,7 @@ from onyx.configs.constants import FileOrigin
|
||||
from onyx.configs.constants import MilestoneRecordType
|
||||
from onyx.configs.constants import OnyxCeleryPriority
|
||||
from onyx.configs.constants import OnyxCeleryTask
|
||||
from onyx.connectors.factory import validate_ccpair_for_user
|
||||
from onyx.connectors.google_utils.google_auth import (
|
||||
get_google_oauth_creds,
|
||||
)
|
||||
@@ -61,6 +62,7 @@ from onyx.connectors.google_utils.shared_constants import DB_CREDENTIALS_DICT_TO
|
||||
from onyx.connectors.google_utils.shared_constants import (
|
||||
GoogleOAuthAuthenticationMethod,
|
||||
)
|
||||
from onyx.connectors.interfaces import ConnectorValidationError
|
||||
from onyx.db.connector import create_connector
|
||||
from onyx.db.connector import delete_connector
|
||||
from onyx.db.connector import fetch_connector_by_id
|
||||
@@ -844,11 +846,22 @@ def create_connector_with_mock_credential(
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
# Store the created connector and credential IDs
|
||||
connector_id = cast(int, connector_response.id)
|
||||
credential_id = credential.id
|
||||
|
||||
validate_ccpair_for_user(
|
||||
connector_id=connector_id,
|
||||
credential_id=credential_id,
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
response = add_credential_to_connector(
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
connector_id=cast(int, connector_response.id), # will aways be an int
|
||||
credential_id=credential.id,
|
||||
connector_id=connector_id,
|
||||
credential_id=credential_id,
|
||||
access_type=connector_data.access_type,
|
||||
cc_pair_name=connector_data.name,
|
||||
groups=connector_data.groups,
|
||||
@@ -873,9 +886,12 @@ def create_connector_with_mock_credential(
|
||||
properties=None,
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except ConnectorValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Connector validation error: " + str(e)
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
|
||||
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.connectors.factory import validate_ccpair_for_user
|
||||
from onyx.db.credentials import alter_credential
|
||||
from onyx.db.credentials import cleanup_gmail_credentials
|
||||
from onyx.db.credentials import create_credential
|
||||
@@ -17,6 +18,7 @@ from onyx.db.credentials import fetch_credentials_by_source_for_user
|
||||
from onyx.db.credentials import fetch_credentials_for_user
|
||||
from onyx.db.credentials import swap_credentials_connector
|
||||
from onyx.db.credentials import update_credential
|
||||
from onyx.db.engine import get_current_tenant_id
|
||||
from onyx.db.engine import get_session
|
||||
from onyx.db.models import DocumentSource
|
||||
from onyx.db.models import User
|
||||
@@ -98,7 +100,16 @@ def swap_credentials_for_connector(
|
||||
credential_swap_req: CredentialSwapRequest,
|
||||
user: User | None = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
tenant_id: str | None = Depends(get_current_tenant_id),
|
||||
) -> StatusResponse:
|
||||
validate_ccpair_for_user(
|
||||
credential_swap_req.connector_id,
|
||||
credential_swap_req.new_credential_id,
|
||||
db_session,
|
||||
user,
|
||||
tenant_id,
|
||||
)
|
||||
|
||||
connector_credential_pair = swap_credentials_connector(
|
||||
new_credential_id=credential_swap_req.new_credential_id,
|
||||
connector_id=credential_swap_req.connector_id,
|
||||
|
||||
@@ -311,19 +311,23 @@ 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
|
||||
if ENABLE_EMAIL_INVITES:
|
||||
try:
|
||||
for email in new_invited_emails:
|
||||
send_user_email_invite(email, current_user, AUTH_TYPE)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending email invite to invited users: {e}")
|
||||
|
||||
if not MULTI_TENANT:
|
||||
return number_of_invited_users
|
||||
|
||||
# for billing purposes, write to the control plane about the number of new users
|
||||
try:
|
||||
logger.info("Registering tenant users")
|
||||
fetch_ee_implementation_or_noop(
|
||||
"onyx.server.tenants.billing", "register_tenant_users", None
|
||||
)(tenant_id, get_total_users_count(db_session))
|
||||
if ENABLE_EMAIL_INVITES:
|
||||
try:
|
||||
for email in new_invited_emails:
|
||||
send_user_email_invite(email, current_user)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending email invite to invited users: {e}")
|
||||
|
||||
return number_of_invited_users
|
||||
except Exception as e:
|
||||
|
||||
@@ -45,7 +45,7 @@ class Settings(BaseModel):
|
||||
gpu_enabled: bool | None = None
|
||||
application_status: ApplicationStatus = ApplicationStatus.ACTIVE
|
||||
anonymous_user_enabled: bool | None = None
|
||||
pro_search_disabled: bool | None = None
|
||||
pro_search_enabled: bool | None = None
|
||||
|
||||
temperature_override_enabled: bool = False
|
||||
auto_scroll: bool = False
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
cohere==5.6.1
|
||||
posthog==3.7.4
|
||||
python3-saml==1.15.0
|
||||
xmlsec==1.3.14
|
||||
|
||||
@@ -3,6 +3,7 @@ import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from enum import Enum
|
||||
from logging import getLogger
|
||||
from typing import cast
|
||||
from uuid import UUID
|
||||
@@ -20,10 +21,13 @@ from onyx.configs.app_configs import REDIS_PORT
|
||||
from onyx.configs.app_configs import REDIS_SSL
|
||||
from onyx.db.engine import get_session_with_tenant
|
||||
from onyx.db.users import get_user_by_email
|
||||
from onyx.redis.redis_connector import RedisConnector
|
||||
from onyx.redis.redis_connector_index import RedisConnectorIndex
|
||||
from onyx.redis.redis_pool import RedisPool
|
||||
from shared_configs.configs import MULTI_TENANT
|
||||
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA
|
||||
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
|
||||
# Tool to run helpful operations on Redis in production
|
||||
# This is targeted for internal usage and may not have all the necessary parameters
|
||||
@@ -42,6 +46,19 @@ SCAN_ITER_COUNT = 10000
|
||||
BATCH_DEFAULT = 1000
|
||||
|
||||
|
||||
class OnyxRedisCommand(Enum):
|
||||
purge_connectorsync_taskset = "purge_connectorsync_taskset"
|
||||
purge_documentset_taskset = "purge_documentset_taskset"
|
||||
purge_usergroup_taskset = "purge_usergroup_taskset"
|
||||
purge_locks_blocking_deletion = "purge_locks_blocking_deletion"
|
||||
purge_vespa_syncing = "purge_vespa_syncing"
|
||||
get_user_token = "get_user_token"
|
||||
delete_user_token = "delete_user_token"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
def get_user_id(user_email: str) -> tuple[UUID, str]:
|
||||
tenant_id = (
|
||||
get_tenant_id_for_email(user_email) if MULTI_TENANT else POSTGRES_DEFAULT_SCHEMA
|
||||
@@ -55,50 +72,79 @@ def get_user_id(user_email: str) -> tuple[UUID, str]:
|
||||
|
||||
|
||||
def onyx_redis(
|
||||
command: str,
|
||||
command: OnyxRedisCommand,
|
||||
batch: int,
|
||||
dry_run: bool,
|
||||
ssl: bool,
|
||||
host: str,
|
||||
port: int,
|
||||
db: int,
|
||||
password: str | None,
|
||||
user_email: str | None = None,
|
||||
cc_pair_id: int | None = None,
|
||||
) -> int:
|
||||
# this is global and not tenant aware
|
||||
pool = RedisPool.create_pool(
|
||||
host=host,
|
||||
port=port,
|
||||
db=db,
|
||||
password=password if password else "",
|
||||
ssl=REDIS_SSL,
|
||||
ssl=ssl,
|
||||
ssl_cert_reqs="optional",
|
||||
ssl_ca_certs=None,
|
||||
)
|
||||
|
||||
r = Redis(connection_pool=pool)
|
||||
|
||||
logger.info("Redis ping starting. This may hang if your settings are incorrect.")
|
||||
|
||||
try:
|
||||
r.ping()
|
||||
except:
|
||||
logger.exception("Redis ping exceptioned")
|
||||
raise
|
||||
|
||||
if command == "purge_connectorsync_taskset":
|
||||
logger.info("Redis ping succeeded.")
|
||||
|
||||
if command == OnyxRedisCommand.purge_connectorsync_taskset:
|
||||
"""Purge connector tasksets. Used when the tasks represented in the tasksets
|
||||
have been purged."""
|
||||
return purge_by_match_and_type(
|
||||
"*connectorsync_taskset*", "set", batch, dry_run, r
|
||||
)
|
||||
elif command == "purge_documentset_taskset":
|
||||
elif command == OnyxRedisCommand.purge_documentset_taskset:
|
||||
return purge_by_match_and_type(
|
||||
"*documentset_taskset*", "set", batch, dry_run, r
|
||||
)
|
||||
elif command == "purge_usergroup_taskset":
|
||||
elif command == OnyxRedisCommand.purge_usergroup_taskset:
|
||||
return purge_by_match_and_type("*usergroup_taskset*", "set", batch, dry_run, r)
|
||||
elif command == "purge_vespa_syncing":
|
||||
elif command == OnyxRedisCommand.purge_locks_blocking_deletion:
|
||||
if cc_pair_id is None:
|
||||
logger.error("You must specify --cc-pair with purge_deletion_locks")
|
||||
return 1
|
||||
|
||||
tenant_id = get_current_tenant_id()
|
||||
logger.info(f"Purging locks associated with deleting cc_pair={cc_pair_id}.")
|
||||
redis_connector = RedisConnector(tenant_id, cc_pair_id)
|
||||
|
||||
match_pattern = f"{tenant_id}:{RedisConnectorIndex.FENCE_PREFIX}_{cc_pair_id}/*"
|
||||
purge_by_match_and_type(match_pattern, "string", batch, dry_run, r)
|
||||
|
||||
redis_delete_if_exists_helper(
|
||||
f"{tenant_id}:{redis_connector.prune.fence_key}", dry_run, r
|
||||
)
|
||||
redis_delete_if_exists_helper(
|
||||
f"{tenant_id}:{redis_connector.permissions.fence_key}", dry_run, r
|
||||
)
|
||||
redis_delete_if_exists_helper(
|
||||
f"{tenant_id}:{redis_connector.external_group_sync.fence_key}", dry_run, r
|
||||
)
|
||||
return 0
|
||||
elif command == OnyxRedisCommand.purge_vespa_syncing:
|
||||
return purge_by_match_and_type(
|
||||
"*connectorsync:vespa_syncing*", "string", batch, dry_run, r
|
||||
)
|
||||
elif command == "get_user_token":
|
||||
elif command == OnyxRedisCommand.get_user_token:
|
||||
if not user_email:
|
||||
logger.error("You must specify --user-email with get_user_token")
|
||||
return 1
|
||||
@@ -109,7 +155,7 @@ def onyx_redis(
|
||||
else:
|
||||
print(f"No token found for user {user_email}")
|
||||
return 2
|
||||
elif command == "delete_user_token":
|
||||
elif command == OnyxRedisCommand.delete_user_token:
|
||||
if not user_email:
|
||||
logger.error("You must specify --user-email with delete_user_token")
|
||||
return 1
|
||||
@@ -131,6 +177,25 @@ def flush_batch_delete(batch_keys: list[bytes], r: Redis) -> None:
|
||||
pipe.execute()
|
||||
|
||||
|
||||
def redis_delete_if_exists_helper(key: str, dry_run: bool, r: Redis) -> bool:
|
||||
"""Returns True if the key was found, False if not.
|
||||
This function exists for logging purposes as the delete operation itself
|
||||
doesn't really need to check the existence of the key.
|
||||
"""
|
||||
|
||||
if not r.exists(key):
|
||||
logger.info(f"Did not find {key}.")
|
||||
return False
|
||||
|
||||
if dry_run:
|
||||
logger.info(f"(DRY-RUN) Deleting {key}.")
|
||||
else:
|
||||
logger.info(f"Deleting {key}.")
|
||||
r.delete(key)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def purge_by_match_and_type(
|
||||
match_pattern: str, match_type: str, batch_size: int, dry_run: bool, r: Redis
|
||||
) -> int:
|
||||
@@ -138,6 +203,12 @@ def purge_by_match_and_type(
|
||||
match_type: https://redis.io/docs/latest/commands/type/
|
||||
"""
|
||||
|
||||
logger.info(
|
||||
f"purge_by_match_and_type start: "
|
||||
f"match_pattern={match_pattern} "
|
||||
f"match_type={match_type}"
|
||||
)
|
||||
|
||||
# cursor = "0"
|
||||
# while cursor != 0:
|
||||
# cursor, data = self.scan(
|
||||
@@ -164,13 +235,15 @@ def purge_by_match_and_type(
|
||||
logger.info(f"Deleting item {count}: {key_str}")
|
||||
|
||||
batch_keys.append(key)
|
||||
|
||||
# flush if batch size has been reached
|
||||
if len(batch_keys) >= batch_size:
|
||||
flush_batch_delete(batch_keys, r)
|
||||
batch_keys.clear()
|
||||
|
||||
if len(batch_keys) >= batch_size:
|
||||
flush_batch_delete(batch_keys, r)
|
||||
batch_keys.clear()
|
||||
# final flush
|
||||
flush_batch_delete(batch_keys, r)
|
||||
batch_keys.clear()
|
||||
|
||||
logger.info(f"Deleted {count} matches.")
|
||||
|
||||
@@ -279,7 +352,21 @@ def delete_user_token_from_redis(
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Onyx Redis Manager")
|
||||
parser.add_argument("--command", type=str, help="Operation to run", required=True)
|
||||
parser.add_argument(
|
||||
"--command",
|
||||
type=OnyxRedisCommand,
|
||||
help="The command to run",
|
||||
choices=list(OnyxRedisCommand),
|
||||
required=True,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--ssl",
|
||||
type=bool,
|
||||
default=REDIS_SSL,
|
||||
help="Use SSL when connecting to Redis. Usually True for prod and False for local testing",
|
||||
required=False,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
@@ -342,6 +429,13 @@ if __name__ == "__main__":
|
||||
required=False,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--cc-pair",
|
||||
type=int,
|
||||
help="A connector credential pair id. Used with the purge_deletion_locks command.",
|
||||
required=False,
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.tenant_id:
|
||||
@@ -368,10 +462,12 @@ if __name__ == "__main__":
|
||||
command=args.command,
|
||||
batch=args.batch,
|
||||
dry_run=args.dry_run,
|
||||
ssl=args.ssl,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
db=args.db,
|
||||
password=args.password,
|
||||
user_email=args.user_email,
|
||||
cc_pair_id=args.cc_pair,
|
||||
)
|
||||
sys.exit(exitcode)
|
||||
|
||||
44
backend/tests/daily/connectors/web/test_web_connector.py
Normal file
44
backend/tests/daily/connectors/web/test_web_connector.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import pytest
|
||||
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.web.connector import WEB_CONNECTOR_VALID_SETTINGS
|
||||
from onyx.connectors.web.connector import WebConnector
|
||||
|
||||
|
||||
# NOTE(rkuo): we will probably need to adjust this test to point at our own test site
|
||||
# to avoid depending on a third party site
|
||||
@pytest.fixture
|
||||
def web_connector(request: pytest.FixtureRequest) -> WebConnector:
|
||||
scroll_before_scraping = request.param
|
||||
connector = WebConnector(
|
||||
base_url="https://developer.onewelcome.com",
|
||||
web_connector_type=WEB_CONNECTOR_VALID_SETTINGS.SINGLE.value,
|
||||
scroll_before_scraping=scroll_before_scraping,
|
||||
)
|
||||
return connector
|
||||
|
||||
|
||||
@pytest.mark.parametrize("web_connector", [True], indirect=True)
|
||||
def test_web_connector_scroll(web_connector: WebConnector) -> None:
|
||||
all_docs: list[Document] = []
|
||||
document_batches = web_connector.load_from_state()
|
||||
for doc_batch in document_batches:
|
||||
for doc in doc_batch:
|
||||
all_docs.append(doc)
|
||||
|
||||
assert len(all_docs) == 1
|
||||
doc = all_docs[0]
|
||||
assert "Onegini Identity Cloud" in doc.sections[0].text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("web_connector", [False], indirect=True)
|
||||
def test_web_connector_no_scroll(web_connector: WebConnector) -> None:
|
||||
all_docs: list[Document] = []
|
||||
document_batches = web_connector.load_from_state()
|
||||
for doc_batch in document_batches:
|
||||
for doc in doc_batch:
|
||||
all_docs.append(doc)
|
||||
|
||||
assert len(all_docs) == 1
|
||||
doc = all_docs[0]
|
||||
assert "Onegini Identity Cloud" not in doc.sections[0].text
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
ADMIN_USER_NAME = "admin_user"
|
||||
|
||||
API_SERVER_PROTOCOL = os.getenv("API_SERVER_PROTOCOL") or "http"
|
||||
API_SERVER_HOST = os.getenv("API_SERVER_HOST") or "localhost"
|
||||
API_SERVER_HOST = os.getenv("API_SERVER_HOST") or "127.0.0.1"
|
||||
API_SERVER_PORT = os.getenv("API_SERVER_PORT") or "8080"
|
||||
API_SERVER_URL = f"{API_SERVER_PROTOCOL}://{API_SERVER_HOST}:{API_SERVER_PORT}"
|
||||
MAX_DELAY = 45
|
||||
|
||||
@@ -30,7 +30,10 @@ class ConnectorManager:
|
||||
name=name,
|
||||
source=source,
|
||||
input_type=input_type,
|
||||
connector_specific_config=connector_specific_config or {},
|
||||
connector_specific_config=(
|
||||
connector_specific_config
|
||||
or ({"file_locations": []} if source == DocumentSource.FILE else {})
|
||||
),
|
||||
access_type=access_type,
|
||||
groups=groups or [],
|
||||
)
|
||||
|
||||
@@ -88,8 +88,6 @@ class UserManager:
|
||||
if not session_cookie:
|
||||
raise Exception("Failed to login")
|
||||
|
||||
print(f"Logged in as {test_user.email}")
|
||||
|
||||
# Set cookies in the headers
|
||||
test_user.headers["Cookie"] = f"fastapiusersauth={session_cookie}; "
|
||||
test_user.cookies = {"fastapiusersauth": session_cookie}
|
||||
|
||||
@@ -4,6 +4,24 @@ log_format custom_main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for" '
|
||||
'rt=$request_time';
|
||||
|
||||
# Map X-Forwarded-Proto or fallback to $scheme
|
||||
map $http_x_forwarded_proto $forwarded_proto {
|
||||
default $http_x_forwarded_proto;
|
||||
"" $scheme;
|
||||
}
|
||||
|
||||
# Map X-Forwarded-Host or fallback to $host
|
||||
map $http_x_forwarded_host $forwarded_host {
|
||||
default $http_x_forwarded_host;
|
||||
"" $host;
|
||||
}
|
||||
|
||||
# Map X-Forwarded-Port or fallback to server port
|
||||
map $http_x_forwarded_port $forwarded_port {
|
||||
default $http_x_forwarded_port;
|
||||
"" $server_port;
|
||||
}
|
||||
|
||||
upstream api_server {
|
||||
# fail_timeout=0 means we always retry an upstream even if it failed
|
||||
# to return a good HTTP response
|
||||
@@ -21,8 +39,7 @@ upstream web_server {
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name ${DOMAIN};
|
||||
listen 80 default_server;
|
||||
|
||||
client_max_body_size 5G; # Maximum upload size
|
||||
|
||||
@@ -36,8 +53,9 @@ server {
|
||||
# misc headers
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Host $forwarded_host;
|
||||
proxy_set_header X-Forwarded-Port $forwarded_port;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
# need to use 1.1 to support chunked transfers
|
||||
@@ -54,8 +72,9 @@ server {
|
||||
# misc headers
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Host $forwarded_host;
|
||||
proxy_set_header X-Forwarded-Port $forwarded_port;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
@@ -72,14 +91,25 @@ server {
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name ${DOMAIN};
|
||||
listen 443 ssl default_server;
|
||||
|
||||
client_max_body_size 5G; # Maximum upload size
|
||||
|
||||
location / {
|
||||
# misc headers
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# don't use forwarded schema, host, or port here - this is the entry point
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
# we don't want nginx trying to do something clever with
|
||||
# redirects, we set the Host: header above already.
|
||||
proxy_redirect off;
|
||||
proxy_pass http://localhost:80;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,7 @@ upstream web_server {
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name ${DOMAIN};
|
||||
listen 80 default_server;
|
||||
|
||||
client_max_body_size 5G; # Maximum upload size
|
||||
|
||||
@@ -37,7 +36,8 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
# need to use 1.1 to support chunked transfers
|
||||
@@ -55,7 +55,8 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -4,6 +4,24 @@ log_format custom_main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for" '
|
||||
'rt=$request_time';
|
||||
|
||||
# Map X-Forwarded-Proto or fallback to $scheme
|
||||
map $http_x_forwarded_proto $forwarded_proto {
|
||||
default $http_x_forwarded_proto;
|
||||
"" $scheme;
|
||||
}
|
||||
|
||||
# Map X-Forwarded-Host or fallback to $host
|
||||
map $http_x_forwarded_host $forwarded_host {
|
||||
default $http_x_forwarded_host;
|
||||
"" $host;
|
||||
}
|
||||
|
||||
# Map X-Forwarded-Port or fallback to server port
|
||||
map $http_x_forwarded_port $forwarded_port {
|
||||
default $http_x_forwarded_port;
|
||||
"" $server_port;
|
||||
}
|
||||
|
||||
upstream api_server {
|
||||
# fail_timeout=0 means we always retry an upstream even if it failed
|
||||
# to return a good HTTP response
|
||||
@@ -21,8 +39,7 @@ upstream web_server {
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name ${DOMAIN};
|
||||
listen 80 default_server;
|
||||
|
||||
client_max_body_size 5G; # Maximum upload size
|
||||
|
||||
@@ -36,8 +53,9 @@ server {
|
||||
# misc headers
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Host $forwarded_host;
|
||||
proxy_set_header X-Forwarded-Port $forwarded_port;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
# need to use 1.1 to support chunked transfers
|
||||
@@ -54,8 +72,9 @@ server {
|
||||
# misc headers
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Host $forwarded_host;
|
||||
proxy_set_header X-Forwarded-Port $forwarded_port;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
@@ -68,14 +87,25 @@ server {
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name ${DOMAIN};
|
||||
listen 443 ssl default_server;
|
||||
|
||||
client_max_body_size 5G; # Maximum upload size
|
||||
|
||||
location / {
|
||||
# misc headers
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# don't use forwarded schema, host, or port here - this is the entry point
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
# we don't want nginx trying to do something clever with
|
||||
# redirects, we set the Host: header above already.
|
||||
proxy_redirect off;
|
||||
proxy_pass http://localhost:80;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ services:
|
||||
- OPENID_CONFIG_URL=${OPENID_CONFIG_URL:-}
|
||||
- TRACK_EXTERNAL_IDP_EXPIRY=${TRACK_EXTERNAL_IDP_EXPIRY:-}
|
||||
- CORS_ALLOWED_ORIGIN=${CORS_ALLOWED_ORIGIN:-}
|
||||
- INTEGRATION_TESTS_MODE=${INTEGRATION_TESTS_MODE:-}
|
||||
# Gen AI Settings
|
||||
- GEN_AI_MAX_TOKENS=${GEN_AI_MAX_TOKENS:-}
|
||||
- QA_TIMEOUT=${QA_TIMEOUT:-}
|
||||
|
||||
@@ -67,12 +67,13 @@ export default function UpgradingPage({
|
||||
};
|
||||
const statusOrder: Record<ValidStatuses, number> = useMemo(
|
||||
() => ({
|
||||
failed: 0,
|
||||
canceled: 1,
|
||||
completed_with_errors: 2,
|
||||
not_started: 3,
|
||||
in_progress: 4,
|
||||
success: 5,
|
||||
invalid: 0,
|
||||
failed: 1,
|
||||
canceled: 2,
|
||||
completed_with_errors: 3,
|
||||
not_started: 4,
|
||||
in_progress: 5,
|
||||
success: 6,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
@@ -4,9 +4,14 @@ import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Text from "@/components/ui/text";
|
||||
import { triggerIndexing } from "./lib";
|
||||
import { mutate } from "swr";
|
||||
import { buildCCPairInfoUrl, getTooltipMessage } from "./lib";
|
||||
import { useState } from "react";
|
||||
import { Modal } from "@/components/Modal";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ConnectorCredentialPairStatus } from "./types";
|
||||
import { CCPairStatus } from "@/components/Status";
|
||||
import { getCCPairStatusMessage } from "@/lib/ccPair";
|
||||
|
||||
function ReIndexPopup({
|
||||
connectorId,
|
||||
@@ -83,16 +88,16 @@ export function ReIndexButton({
|
||||
ccPairId,
|
||||
connectorId,
|
||||
credentialId,
|
||||
isDisabled,
|
||||
isIndexing,
|
||||
isDeleting,
|
||||
isDisabled,
|
||||
ccPairStatus,
|
||||
}: {
|
||||
ccPairId: number;
|
||||
connectorId: number;
|
||||
credentialId: number;
|
||||
isDisabled: boolean;
|
||||
isIndexing: boolean;
|
||||
isDeleting: boolean;
|
||||
isDisabled: boolean;
|
||||
ccPairStatus: ConnectorCredentialPairStatus;
|
||||
}) {
|
||||
const { popup, setPopup } = usePopup();
|
||||
const [reIndexPopupVisible, setReIndexPopupVisible] = useState(false);
|
||||
@@ -115,18 +120,14 @@ export function ReIndexButton({
|
||||
onClick={() => {
|
||||
setReIndexPopupVisible(true);
|
||||
}}
|
||||
disabled={isDisabled || isDeleting}
|
||||
tooltip={
|
||||
isDeleting
|
||||
? "Cannot index while connector is deleting"
|
||||
: isIndexing
|
||||
? "Indexing is already in progress"
|
||||
: isDisabled
|
||||
? "Connector must be re-enabled before indexing"
|
||||
: undefined
|
||||
disabled={
|
||||
isDisabled ||
|
||||
ccPairStatus == ConnectorCredentialPairStatus.DELETING ||
|
||||
ccPairStatus == ConnectorCredentialPairStatus.PAUSED
|
||||
}
|
||||
tooltip={getCCPairStatusMessage(isDisabled, isIndexing, ccPairStatus)}
|
||||
>
|
||||
Index
|
||||
Re-Index
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -40,3 +40,24 @@ export async function triggerIndexing(
|
||||
}
|
||||
mutate(buildCCPairInfoUrl(ccPairId));
|
||||
}
|
||||
|
||||
export function getTooltipMessage(
|
||||
isInvalid: boolean,
|
||||
isDeleting: boolean,
|
||||
isIndexing: boolean,
|
||||
isDisabled: boolean
|
||||
): string | undefined {
|
||||
if (isInvalid) {
|
||||
return "Connector is in an invalid state. Please update the credentials or configuration before re-indexing.";
|
||||
}
|
||||
if (isDeleting) {
|
||||
return "Cannot index while connector is deleting";
|
||||
}
|
||||
if (isIndexing) {
|
||||
return "Indexing is already in progress";
|
||||
}
|
||||
if (isDisabled) {
|
||||
return "Connector must be re-enabled before indexing";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import IndexAttemptErrorsModal from "./IndexAttemptErrorsModal";
|
||||
import usePaginatedFetch from "@/hooks/usePaginatedFetch";
|
||||
import { IndexAttemptSnapshot } from "@/lib/types";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { Callout } from "@/components/ui/callout";
|
||||
|
||||
// synchronize these validations with the SQLAlchemy connector class until we have a
|
||||
// centralized schema for both frontend and backend
|
||||
@@ -363,6 +364,7 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
<div className="ml-auto flex gap-x-2">
|
||||
<ReIndexButton
|
||||
ccPairId={ccPair.id}
|
||||
ccPairStatus={ccPair.status}
|
||||
connectorId={ccPair.connector.id}
|
||||
credentialId={ccPair.credential.id}
|
||||
isDisabled={
|
||||
@@ -370,7 +372,6 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
ccPair.status === ConnectorCredentialPairStatus.PAUSED
|
||||
}
|
||||
isIndexing={ccPair.indexing}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
|
||||
{!isDeleting && <ModifyStatusButtonCluster ccPair={ccPair} />}
|
||||
@@ -379,8 +380,7 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
</div>
|
||||
<CCPairStatus
|
||||
status={ccPair.last_index_attempt_status || "not_started"}
|
||||
disabled={ccPair.status === ConnectorCredentialPairStatus.PAUSED}
|
||||
isDeleting={isDeleting}
|
||||
ccPairStatus={ccPair.status}
|
||||
/>
|
||||
<div className="text-sm mt-1">
|
||||
Creator:{" "}
|
||||
@@ -424,6 +424,16 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{ccPair.status === ConnectorCredentialPairStatus.INVALID && (
|
||||
<div className="mt-2">
|
||||
<Callout type="warning" title="Invalid Connector State">
|
||||
This connector is in an invalid state. Please update your
|
||||
credentials or create a new connector before re-indexing.
|
||||
</Callout>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
<ConfigDisplay
|
||||
connectorSpecificConfig={ccPair.connector.connector_specific_config}
|
||||
|
||||
@@ -12,6 +12,7 @@ export enum ConnectorCredentialPairStatus {
|
||||
ACTIVE = "ACTIVE",
|
||||
PAUSED = "PAUSED",
|
||||
DELETING = "DELETING",
|
||||
INVALID = "INVALID",
|
||||
}
|
||||
|
||||
export interface CCPairFullInfo {
|
||||
|
||||
@@ -418,7 +418,7 @@ export default function AddConnector({
|
||||
} else {
|
||||
const errorData = await linkCredentialResponse.json();
|
||||
setPopup({
|
||||
message: errorData.message,
|
||||
message: errorData.message || errorData.detail,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,6 +159,19 @@ function ConnectorRow({
|
||||
Paused
|
||||
</Badge>
|
||||
);
|
||||
} else if (
|
||||
ccPairsIndexingStatus.cc_pair_status ===
|
||||
ConnectorCredentialPairStatus.INVALID
|
||||
) {
|
||||
return (
|
||||
<Badge
|
||||
tooltip="Connector is in an invalid state. Please update the credentials or create a new connector."
|
||||
circle
|
||||
variant="invalid"
|
||||
>
|
||||
Invalid
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ACTIVE case
|
||||
|
||||
@@ -240,11 +240,11 @@ export function SettingsForm() {
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label="Pro Search Disabled"
|
||||
sublabel="If set, users will not be able to use Pro Search."
|
||||
checked={settings.pro_search_disabled ?? false}
|
||||
label="Agent Search"
|
||||
sublabel="If set, users will be able to use Agent Search."
|
||||
checked={settings.pro_search_enabled ?? true}
|
||||
onChange={(e) =>
|
||||
handleToggleSettingsField("pro_search_disabled", e.target.checked)
|
||||
handleToggleSettingsField("pro_search_enabled", e.target.checked)
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface Settings {
|
||||
notifications: Notification[];
|
||||
needs_reindexing: boolean;
|
||||
gpu_enabled: boolean;
|
||||
pro_search_disabled: boolean | null;
|
||||
pro_search_enabled: boolean | null;
|
||||
application_status: ApplicationStatus;
|
||||
auto_scroll: boolean;
|
||||
temperature_override_enabled: boolean;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
SubQuestionDetail,
|
||||
constructSubQuestions,
|
||||
DocumentsResponse,
|
||||
AgenticMessageResponseIDInfo,
|
||||
} from "./interfaces";
|
||||
|
||||
import Prism from "prismjs";
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
removeMessage,
|
||||
sendMessage,
|
||||
setMessageAsLatest,
|
||||
updateLlmOverrideForChatSession,
|
||||
updateParentChildren,
|
||||
uploadFilesForChat,
|
||||
useScrollonStream,
|
||||
@@ -64,7 +66,7 @@ import {
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { SEARCH_PARAM_NAMES, shouldSubmitOnLoad } from "./searchParams";
|
||||
import { useDocumentSelection } from "./useDocumentSelection";
|
||||
import { LlmOverride, useFilters, useLlmOverride } from "@/lib/hooks";
|
||||
import { LlmDescriptor, useFilters, useLlmManager } from "@/lib/hooks";
|
||||
import { ChatState, FeedbackType, RegenerationState } from "./types";
|
||||
import { DocumentResults } from "./documentSidebar/DocumentResults";
|
||||
import { OnyxInitializingLoader } from "@/components/OnyxInitializingLoader";
|
||||
@@ -88,7 +90,11 @@ import {
|
||||
import { buildFilters } from "@/lib/search/utils";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { checkLLMSupportsImageInput, getFinalLLM } from "@/lib/llm/utils";
|
||||
import {
|
||||
checkLLMSupportsImageInput,
|
||||
getFinalLLM,
|
||||
structureValue,
|
||||
} from "@/lib/llm/utils";
|
||||
import { ChatInputBar } from "./input/ChatInputBar";
|
||||
import { useChatContext } from "@/components/context/ChatContext";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -193,16 +199,6 @@ export function ChatPage({
|
||||
return screenSize;
|
||||
}
|
||||
|
||||
const { height: screenHeight } = useScreenSize();
|
||||
|
||||
const getContainerHeight = () => {
|
||||
if (autoScrollEnabled) return undefined;
|
||||
|
||||
if (screenHeight < 600) return "20vh";
|
||||
if (screenHeight < 1200) return "30vh";
|
||||
return "40vh";
|
||||
};
|
||||
|
||||
// handle redirect if chat page is disabled
|
||||
// NOTE: this must be done here, in a client component since
|
||||
// settings are passed in via Context and therefore aren't
|
||||
@@ -221,6 +217,7 @@ export function ChatPage({
|
||||
setProSearchEnabled(!proSearchEnabled);
|
||||
};
|
||||
|
||||
const isInitialLoad = useRef(true);
|
||||
const [userSettingsToggled, setUserSettingsToggled] = useState(false);
|
||||
|
||||
const {
|
||||
@@ -355,7 +352,7 @@ export function ChatPage({
|
||||
]
|
||||
);
|
||||
|
||||
const llmOverrideManager = useLlmOverride(
|
||||
const llmManager = useLlmManager(
|
||||
llmProviders,
|
||||
selectedChatSession,
|
||||
liveAssistant
|
||||
@@ -519,8 +516,17 @@ export function ChatPage({
|
||||
scrollInitialized.current = false;
|
||||
|
||||
if (!hasPerformedInitialScroll) {
|
||||
if (isInitialLoad.current) {
|
||||
setHasPerformedInitialScroll(true);
|
||||
isInitialLoad.current = false;
|
||||
}
|
||||
clientScrollToBottom();
|
||||
|
||||
setTimeout(() => {
|
||||
setHasPerformedInitialScroll(true);
|
||||
}, 100);
|
||||
} else if (isChatSessionSwitch) {
|
||||
setHasPerformedInitialScroll(true);
|
||||
clientScrollToBottom(true);
|
||||
}
|
||||
|
||||
@@ -1129,6 +1135,56 @@ export function ChatPage({
|
||||
});
|
||||
};
|
||||
const [uncaughtError, setUncaughtError] = useState<string | null>(null);
|
||||
const [agenticGenerating, setAgenticGenerating] = useState(false);
|
||||
|
||||
const autoScrollEnabled =
|
||||
(user?.preferences?.auto_scroll && !agenticGenerating) ?? false;
|
||||
|
||||
useScrollonStream({
|
||||
chatState: currentSessionChatState,
|
||||
scrollableDivRef,
|
||||
scrollDist,
|
||||
endDivRef,
|
||||
debounceNumber,
|
||||
mobile: settings?.isMobile,
|
||||
enableAutoScroll: autoScrollEnabled,
|
||||
});
|
||||
|
||||
// Track whether a message has been sent during this page load, keyed by chat session id
|
||||
const [sessionHasSentLocalUserMessage, setSessionHasSentLocalUserMessage] =
|
||||
useState<Map<string | null, boolean>>(new Map());
|
||||
|
||||
// Update the local state for a session once the user sends a message
|
||||
const markSessionMessageSent = (sessionId: string | null) => {
|
||||
setSessionHasSentLocalUserMessage((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(sessionId, true);
|
||||
return newMap;
|
||||
});
|
||||
};
|
||||
const currentSessionHasSentLocalUserMessage = useMemo(
|
||||
() => (sessionId: string | null) => {
|
||||
return sessionHasSentLocalUserMessage.size === 0
|
||||
? undefined
|
||||
: sessionHasSentLocalUserMessage.get(sessionId) || false;
|
||||
},
|
||||
[sessionHasSentLocalUserMessage]
|
||||
);
|
||||
|
||||
const { height: screenHeight } = useScreenSize();
|
||||
|
||||
const getContainerHeight = useMemo(() => {
|
||||
return () => {
|
||||
if (!currentSessionHasSentLocalUserMessage(chatSessionIdRef.current)) {
|
||||
return undefined;
|
||||
}
|
||||
if (autoScrollEnabled) return undefined;
|
||||
|
||||
if (screenHeight < 600) return "40vh";
|
||||
if (screenHeight < 1200) return "50vh";
|
||||
return "60vh";
|
||||
};
|
||||
}, [autoScrollEnabled, screenHeight, currentSessionHasSentLocalUserMessage]);
|
||||
|
||||
const onSubmit = async ({
|
||||
messageIdToResend,
|
||||
@@ -1137,7 +1193,7 @@ export function ChatPage({
|
||||
forceSearch,
|
||||
isSeededChat,
|
||||
alternativeAssistantOverride = null,
|
||||
modelOverRide,
|
||||
modelOverride,
|
||||
regenerationRequest,
|
||||
overrideFileDescriptors,
|
||||
}: {
|
||||
@@ -1147,7 +1203,7 @@ export function ChatPage({
|
||||
forceSearch?: boolean;
|
||||
isSeededChat?: boolean;
|
||||
alternativeAssistantOverride?: Persona | null;
|
||||
modelOverRide?: LlmOverride;
|
||||
modelOverride?: LlmDescriptor;
|
||||
regenerationRequest?: RegenerationRequest | null;
|
||||
overrideFileDescriptors?: FileDescriptor[];
|
||||
} = {}) => {
|
||||
@@ -1155,6 +1211,9 @@ export function ChatPage({
|
||||
let frozenSessionId = currentSessionId();
|
||||
updateCanContinue(false, frozenSessionId);
|
||||
|
||||
// Mark that we've sent a message for this session in the current page load
|
||||
markSessionMessageSent(frozenSessionId);
|
||||
|
||||
if (currentChatState() != "input") {
|
||||
if (currentChatState() == "uploading") {
|
||||
setPopup({
|
||||
@@ -1190,6 +1249,22 @@ export function ChatPage({
|
||||
currChatSessionId = chatSessionIdRef.current as string;
|
||||
}
|
||||
frozenSessionId = currChatSessionId;
|
||||
// update the selected model for the chat session if one is specified so that
|
||||
// it persists across page reloads. Do not `await` here so that the message
|
||||
// request can continue and this will just happen in the background.
|
||||
// NOTE: only set the model override for the chat session once we send a
|
||||
// message with it. If the user switches models and then starts a new
|
||||
// chat session, it is unexpected for that model to be used when they
|
||||
// return to this session the next day.
|
||||
let finalLLM = modelOverride || llmManager.currentLlm;
|
||||
updateLlmOverrideForChatSession(
|
||||
currChatSessionId,
|
||||
structureValue(
|
||||
finalLLM.name || "",
|
||||
finalLLM.provider || "",
|
||||
finalLLM.modelName || ""
|
||||
)
|
||||
);
|
||||
|
||||
updateStatesWithNewSessionId(currChatSessionId);
|
||||
|
||||
@@ -1249,11 +1324,14 @@ export function ChatPage({
|
||||
: null) ||
|
||||
(messageMap.size === 1 ? Array.from(messageMap.values())[0] : null);
|
||||
|
||||
const currentAssistantId = alternativeAssistantOverride
|
||||
? alternativeAssistantOverride.id
|
||||
: alternativeAssistant
|
||||
? alternativeAssistant.id
|
||||
: liveAssistant.id;
|
||||
let currentAssistantId;
|
||||
if (alternativeAssistantOverride) {
|
||||
currentAssistantId = alternativeAssistantOverride.id;
|
||||
} else if (alternativeAssistant) {
|
||||
currentAssistantId = alternativeAssistant.id;
|
||||
} else {
|
||||
currentAssistantId = liveAssistant.id;
|
||||
}
|
||||
|
||||
resetInputBar();
|
||||
let messageUpdates: Message[] | null = null;
|
||||
@@ -1280,6 +1358,8 @@ export function ChatPage({
|
||||
let toolCall: ToolCallMetadata | null = null;
|
||||
let isImprovement: boolean | undefined = undefined;
|
||||
let isStreamingQuestions = true;
|
||||
let includeAgentic = false;
|
||||
let secondLevelMessageId: number | null = null;
|
||||
|
||||
let initialFetchDetails: null | {
|
||||
user_message_id: number;
|
||||
@@ -1323,20 +1403,18 @@ export function ChatPage({
|
||||
forceSearch,
|
||||
regenerate: regenerationRequest !== undefined,
|
||||
modelProvider:
|
||||
modelOverRide?.name ||
|
||||
llmOverrideManager.llmOverride.name ||
|
||||
undefined,
|
||||
modelOverride?.name || llmManager.currentLlm.name || undefined,
|
||||
modelVersion:
|
||||
modelOverRide?.modelName ||
|
||||
llmOverrideManager.llmOverride.modelName ||
|
||||
modelOverride?.modelName ||
|
||||
llmManager.currentLlm.modelName ||
|
||||
searchParams.get(SEARCH_PARAM_NAMES.MODEL_VERSION) ||
|
||||
undefined,
|
||||
temperature: llmOverrideManager.temperature || undefined,
|
||||
temperature: llmManager.temperature || undefined,
|
||||
systemPromptOverride:
|
||||
searchParams.get(SEARCH_PARAM_NAMES.SYSTEM_PROMPT) || undefined,
|
||||
useExistingUserMessage: isSeededChat,
|
||||
useLanggraph:
|
||||
!settings?.settings.pro_search_disabled &&
|
||||
settings?.settings.pro_search_enabled &&
|
||||
proSearchEnabled &&
|
||||
retrievalEnabled,
|
||||
});
|
||||
@@ -1417,6 +1495,17 @@ export function ChatPage({
|
||||
resetRegenerationState();
|
||||
} else {
|
||||
const { user_message_id, frozenMessageMap } = initialFetchDetails;
|
||||
if (Object.hasOwn(packet, "agentic_message_ids")) {
|
||||
const agenticMessageIds = (packet as AgenticMessageResponseIDInfo)
|
||||
.agentic_message_ids;
|
||||
const level1MessageId = agenticMessageIds.find(
|
||||
(item) => item.level === 1
|
||||
)?.message_id;
|
||||
if (level1MessageId) {
|
||||
secondLevelMessageId = level1MessageId;
|
||||
includeAgentic = true;
|
||||
}
|
||||
}
|
||||
|
||||
setChatState((prevState) => {
|
||||
if (prevState.get(chatSessionIdRef.current!) === "loading") {
|
||||
@@ -1568,7 +1657,10 @@ export function ChatPage({
|
||||
};
|
||||
}
|
||||
);
|
||||
} else if (Object.hasOwn(packet, "error")) {
|
||||
} else if (
|
||||
Object.hasOwn(packet, "error") &&
|
||||
(packet as any).error != null
|
||||
) {
|
||||
if (
|
||||
sub_questions.length > 0 &&
|
||||
sub_questions
|
||||
@@ -1580,8 +1672,8 @@ export function ChatPage({
|
||||
setAgenticGenerating(false);
|
||||
setAlternativeGeneratingAssistant(null);
|
||||
setSubmittedMessage("");
|
||||
return;
|
||||
// throw new Error((packet as StreamingError).error);
|
||||
|
||||
throw new Error((packet as StreamingError).error);
|
||||
} else {
|
||||
error = (packet as StreamingError).error;
|
||||
stackTrace = (packet as StreamingError).stack_trace;
|
||||
@@ -1664,6 +1756,19 @@ export function ChatPage({
|
||||
second_level_generating: second_level_generating,
|
||||
agentic_docs: agenticDocs,
|
||||
},
|
||||
...(includeAgentic
|
||||
? [
|
||||
{
|
||||
messageId: secondLevelMessageId!,
|
||||
message: second_level_answer,
|
||||
type: "assistant" as const,
|
||||
files: [],
|
||||
toolCall: null,
|
||||
parentMessageId:
|
||||
initialFetchDetails.assistant_message_id!,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1772,7 +1877,7 @@ export function ChatPage({
|
||||
const [_, llmModel] = getFinalLLM(
|
||||
llmProviders,
|
||||
liveAssistant,
|
||||
llmOverrideManager.llmOverride
|
||||
llmManager.currentLlm
|
||||
);
|
||||
const llmAcceptsImages = checkLLMSupportsImageInput(llmModel);
|
||||
|
||||
@@ -1827,7 +1932,6 @@ export function ChatPage({
|
||||
// Used to maintain a "time out" for history sidebar so our existing refs can have time to process change
|
||||
const [untoggled, setUntoggled] = useState(false);
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null);
|
||||
const [agenticGenerating, setAgenticGenerating] = useState(false);
|
||||
|
||||
const explicitlyUntoggle = () => {
|
||||
setShowHistorySidebar(false);
|
||||
@@ -1869,19 +1973,6 @@ export function ChatPage({
|
||||
isAnonymousUser: user?.is_anonymous_user,
|
||||
});
|
||||
|
||||
const autoScrollEnabled =
|
||||
(user?.preferences?.auto_scroll && !agenticGenerating) ?? false;
|
||||
|
||||
useScrollonStream({
|
||||
chatState: currentSessionChatState,
|
||||
scrollableDivRef,
|
||||
scrollDist,
|
||||
endDivRef,
|
||||
debounceNumber,
|
||||
mobile: settings?.isMobile,
|
||||
enableAutoScroll: autoScrollEnabled,
|
||||
});
|
||||
|
||||
// Virtualization + Scrolling related effects and functions
|
||||
const scrollInitialized = useRef(false);
|
||||
interface VisibleRange {
|
||||
@@ -2091,7 +2182,7 @@ export function ChatPage({
|
||||
}, [searchParams, router]);
|
||||
|
||||
useEffect(() => {
|
||||
llmOverrideManager.updateImageFilesPresent(imageFileInMessageHistory);
|
||||
llmManager.updateImageFilesPresent(imageFileInMessageHistory);
|
||||
}, [imageFileInMessageHistory]);
|
||||
|
||||
const pathname = usePathname();
|
||||
@@ -2145,9 +2236,9 @@ export function ChatPage({
|
||||
|
||||
function createRegenerator(regenerationRequest: RegenerationRequest) {
|
||||
// Returns new function that only needs `modelOverRide` to be specified when called
|
||||
return async function (modelOverRide: LlmOverride) {
|
||||
return async function (modelOverride: LlmDescriptor) {
|
||||
return await onSubmit({
|
||||
modelOverRide,
|
||||
modelOverride,
|
||||
messageIdToResend: regenerationRequest.parentMessage.messageId,
|
||||
regenerationRequest,
|
||||
forceSearch: regenerationRequest.forceSearch,
|
||||
@@ -2228,9 +2319,7 @@ export function ChatPage({
|
||||
{(settingsToggled || userSettingsToggled) && (
|
||||
<UserSettingsModal
|
||||
setPopup={setPopup}
|
||||
setLlmOverride={(newOverride) =>
|
||||
llmOverrideManager.updateLLMOverride(newOverride)
|
||||
}
|
||||
setCurrentLlm={(newLlm) => llmManager.updateCurrentLlm(newLlm)}
|
||||
defaultModel={user?.preferences.default_model!}
|
||||
llmProviders={llmProviders}
|
||||
onClose={() => {
|
||||
@@ -2294,7 +2383,7 @@ export function ChatPage({
|
||||
<ShareChatSessionModal
|
||||
assistantId={liveAssistant?.id}
|
||||
message={message}
|
||||
modelOverride={llmOverrideManager.llmOverride}
|
||||
modelOverride={llmManager.currentLlm}
|
||||
chatSessionId={sharedChatSession.id}
|
||||
existingSharedStatus={sharedChatSession.shared_status}
|
||||
onClose={() => setSharedChatSession(null)}
|
||||
@@ -2312,7 +2401,7 @@ export function ChatPage({
|
||||
<ShareChatSessionModal
|
||||
message={message}
|
||||
assistantId={liveAssistant?.id}
|
||||
modelOverride={llmOverrideManager.llmOverride}
|
||||
modelOverride={llmManager.currentLlm}
|
||||
chatSessionId={chatSessionIdRef.current}
|
||||
existingSharedStatus={chatSessionSharedStatus}
|
||||
onClose={() => setSharingModalVisible(false)}
|
||||
@@ -2542,6 +2631,7 @@ export function ChatPage({
|
||||
style={{ overflowAnchor: "none" }}
|
||||
key={currentSessionId()}
|
||||
className={
|
||||
(hasPerformedInitialScroll ? "" : " hidden ") +
|
||||
"desktop:-ml-4 w-full mx-auto " +
|
||||
"absolute mobile:top-0 desktop:top-0 left-0 " +
|
||||
(settings?.enterpriseSettings
|
||||
@@ -2692,6 +2782,11 @@ export function ChatPage({
|
||||
? messageHistory[i + 1]?.documents
|
||||
: undefined;
|
||||
|
||||
const nextMessage =
|
||||
messageHistory[i + 1]?.type === "assistant"
|
||||
? messageHistory[i + 1]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="text-text"
|
||||
@@ -2720,7 +2815,10 @@ export function ChatPage({
|
||||
selectedMessageForDocDisplay ==
|
||||
secondLevelMessage?.messageId)
|
||||
}
|
||||
isImprovement={message.isImprovement}
|
||||
isImprovement={
|
||||
message.isImprovement ||
|
||||
nextMessage?.isImprovement
|
||||
}
|
||||
secondLevelGenerating={
|
||||
(message.second_level_generating &&
|
||||
currentSessionChatState !==
|
||||
@@ -3020,7 +3118,7 @@ export function ChatPage({
|
||||
messageId: message.messageId,
|
||||
parentMessage: parentMessage!,
|
||||
forceSearch: true,
|
||||
})(llmOverrideManager.llmOverride);
|
||||
})(llmManager.currentLlm);
|
||||
} else {
|
||||
setPopup({
|
||||
type: "error",
|
||||
@@ -3165,7 +3263,7 @@ export function ChatPage({
|
||||
availableDocumentSets={documentSets}
|
||||
availableTags={tags}
|
||||
filterManager={filterManager}
|
||||
llmOverrideManager={llmOverrideManager}
|
||||
llmManager={llmManager}
|
||||
removeDocs={() => {
|
||||
clearSelectedDocuments();
|
||||
}}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useChatContext } from "@/components/context/ChatContext";
|
||||
import {
|
||||
getDisplayNameForModel,
|
||||
LlmOverride,
|
||||
useLlmOverride,
|
||||
LlmDescriptor,
|
||||
useLlmManager,
|
||||
} from "@/lib/hooks";
|
||||
import { StringOrNumberOption } from "@/components/Dropdown";
|
||||
|
||||
@@ -106,13 +106,13 @@ export default function RegenerateOption({
|
||||
onDropdownVisibleChange,
|
||||
}: {
|
||||
selectedAssistant: Persona;
|
||||
regenerate: (modelOverRide: LlmOverride) => Promise<void>;
|
||||
regenerate: (modelOverRide: LlmDescriptor) => Promise<void>;
|
||||
overriddenModel?: string;
|
||||
onHoverChange: (isHovered: boolean) => void;
|
||||
onDropdownVisibleChange: (isVisible: boolean) => void;
|
||||
}) {
|
||||
const { llmProviders } = useChatContext();
|
||||
const llmOverrideManager = useLlmOverride(llmProviders);
|
||||
const llmManager = useLlmManager(llmProviders);
|
||||
|
||||
const [_, llmName] = getFinalLLM(llmProviders, selectedAssistant, null);
|
||||
|
||||
@@ -148,7 +148,7 @@ export default function RegenerateOption({
|
||||
);
|
||||
|
||||
const currentModelName =
|
||||
llmOverrideManager?.llmOverride.modelName ||
|
||||
llmManager?.currentLlm.modelName ||
|
||||
(selectedAssistant
|
||||
? selectedAssistant.llm_model_version_override || llmName
|
||||
: llmName);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
import LLMPopover from "./LLMPopover";
|
||||
import { InputPrompt } from "@/app/chat/interfaces";
|
||||
|
||||
import { FilterManager, LlmOverrideManager } from "@/lib/hooks";
|
||||
import { FilterManager, LlmManager } from "@/lib/hooks";
|
||||
import { useChatContext } from "@/components/context/ChatContext";
|
||||
import { ChatFileType, FileDescriptor } from "../interfaces";
|
||||
import {
|
||||
@@ -180,7 +180,7 @@ interface ChatInputBarProps {
|
||||
setMessage: (message: string) => void;
|
||||
stopGenerating: () => void;
|
||||
onSubmit: () => void;
|
||||
llmOverrideManager: LlmOverrideManager;
|
||||
llmManager: LlmManager;
|
||||
chatState: ChatState;
|
||||
alternativeAssistant: Persona | null;
|
||||
// assistants
|
||||
@@ -225,7 +225,7 @@ export function ChatInputBar({
|
||||
availableSources,
|
||||
availableDocumentSets,
|
||||
availableTags,
|
||||
llmOverrideManager,
|
||||
llmManager,
|
||||
proSearchEnabled,
|
||||
setProSearchEnabled,
|
||||
}: ChatInputBarProps) {
|
||||
@@ -781,7 +781,7 @@ export function ChatInputBar({
|
||||
|
||||
<LLMPopover
|
||||
llmProviders={llmProviders}
|
||||
llmOverrideManager={llmOverrideManager}
|
||||
llmManager={llmManager}
|
||||
requiresImageGeneration={false}
|
||||
currentAssistant={selectedAssistant}
|
||||
/>
|
||||
@@ -805,13 +805,12 @@ export function ChatInputBar({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center my-auto">
|
||||
{retrievalEnabled &&
|
||||
!settings?.settings.pro_search_disabled && (
|
||||
<AgenticToggle
|
||||
proSearchEnabled={proSearchEnabled}
|
||||
setProSearchEnabled={setProSearchEnabled}
|
||||
/>
|
||||
)}
|
||||
{retrievalEnabled && settings?.settings.pro_search_enabled && (
|
||||
<AgenticToggle
|
||||
proSearchEnabled={proSearchEnabled}
|
||||
setProSearchEnabled={setProSearchEnabled}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
id="onyx-chat-input-send-button"
|
||||
className={`cursor-pointer ${
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
} from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -21,7 +16,7 @@ import {
|
||||
LLMProviderDescriptor,
|
||||
} from "@/app/admin/configuration/llm/interfaces";
|
||||
import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
import { LlmOverrideManager } from "@/lib/hooks";
|
||||
import { LlmManager } from "@/lib/hooks";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -33,25 +28,22 @@ import { FiAlertTriangle } from "react-icons/fi";
|
||||
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useUser } from "@/components/user/UserProvider";
|
||||
import { TruncatedText } from "@/components/ui/truncatedText";
|
||||
|
||||
interface LLMPopoverProps {
|
||||
llmProviders: LLMProviderDescriptor[];
|
||||
llmOverrideManager: LlmOverrideManager;
|
||||
llmManager: LlmManager;
|
||||
requiresImageGeneration?: boolean;
|
||||
currentAssistant?: Persona;
|
||||
}
|
||||
|
||||
export default function LLMPopover({
|
||||
llmProviders,
|
||||
llmOverrideManager,
|
||||
llmManager,
|
||||
requiresImageGeneration,
|
||||
currentAssistant,
|
||||
}: LLMPopoverProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { user } = useUser();
|
||||
const { llmOverride, updateLLMOverride } = llmOverrideManager;
|
||||
const currentLlm = llmOverride.modelName;
|
||||
|
||||
const llmOptionsByProvider: {
|
||||
[provider: string]: {
|
||||
@@ -99,19 +91,19 @@ export default function LLMPopover({
|
||||
: null;
|
||||
|
||||
const [localTemperature, setLocalTemperature] = useState(
|
||||
llmOverrideManager.temperature ?? 0.5
|
||||
llmManager.temperature ?? 0.5
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalTemperature(llmOverrideManager.temperature ?? 0.5);
|
||||
}, [llmOverrideManager.temperature]);
|
||||
setLocalTemperature(llmManager.temperature ?? 0.5);
|
||||
}, [llmManager.temperature]);
|
||||
|
||||
const handleTemperatureChange = (value: number[]) => {
|
||||
setLocalTemperature(value[0]);
|
||||
};
|
||||
|
||||
const handleTemperatureChangeComplete = (value: number[]) => {
|
||||
llmOverrideManager.updateTemperature(value[0]);
|
||||
llmManager.updateTemperature(value[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -126,15 +118,15 @@ export default function LLMPopover({
|
||||
toggle
|
||||
flexPriority="stiff"
|
||||
name={getDisplayNameForModel(
|
||||
llmOverrideManager?.llmOverride.modelName ||
|
||||
llmManager?.currentLlm.modelName ||
|
||||
defaultModelDisplayName ||
|
||||
"Models"
|
||||
)}
|
||||
Icon={getProviderIcon(
|
||||
llmOverrideManager?.llmOverride.provider ||
|
||||
llmManager?.currentLlm.provider ||
|
||||
defaultProvider?.provider ||
|
||||
"anthropic",
|
||||
llmOverrideManager?.llmOverride.modelName ||
|
||||
llmManager?.currentLlm.modelName ||
|
||||
defaultProvider?.default_model_name ||
|
||||
"claude-3-5-sonnet-20240620"
|
||||
)}
|
||||
@@ -153,12 +145,12 @@ export default function LLMPopover({
|
||||
<button
|
||||
key={index}
|
||||
className={`w-full flex items-center gap-x-2 px-3 py-2 text-sm text-left hover:bg-background-100 dark:hover:bg-neutral-800 transition-colors duration-150 ${
|
||||
currentLlm === name
|
||||
llmManager.currentLlm.modelName === name
|
||||
? "bg-background-100 dark:bg-neutral-900 text-text"
|
||||
: "text-text-darker"
|
||||
}`}
|
||||
onClick={() => {
|
||||
updateLLMOverride(destructureValue(value));
|
||||
llmManager.updateCurrentLlm(destructureValue(value));
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
@@ -166,7 +158,9 @@ export default function LLMPopover({
|
||||
size: 16,
|
||||
className: "flex-none my-auto text-black",
|
||||
})}
|
||||
<TruncatedText text={getDisplayNameForModel(name)} />
|
||||
<span className="line-clamp-1 ">
|
||||
{getDisplayNameForModel(name)}
|
||||
</span>
|
||||
{(() => {
|
||||
if (currentAssistant?.llm_model_version_override === name) {
|
||||
return (
|
||||
@@ -176,7 +170,7 @@ export default function LLMPopover({
|
||||
);
|
||||
}
|
||||
})()}
|
||||
{llmOverrideManager.imageFilesPresent &&
|
||||
{llmManager.imageFilesPresent &&
|
||||
!checkLLMSupportsImageInput(name) && (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
@@ -203,7 +197,7 @@ export default function LLMPopover({
|
||||
<div className="w-full px-3 py-2">
|
||||
<Slider
|
||||
value={[localTemperature]}
|
||||
max={llmOverrideManager.maxTemperature}
|
||||
max={llmManager.maxTemperature}
|
||||
min={0}
|
||||
step={0.01}
|
||||
onValueChange={handleTemperatureChange}
|
||||
|
||||
@@ -155,6 +155,15 @@ export interface MessageResponseIDInfo {
|
||||
reserved_assistant_message_id: number;
|
||||
}
|
||||
|
||||
export interface AgentMessageIDInfo {
|
||||
level: number;
|
||||
message_id: number;
|
||||
}
|
||||
|
||||
export interface AgenticMessageResponseIDInfo {
|
||||
agentic_message_ids: AgentMessageIDInfo[];
|
||||
}
|
||||
|
||||
export interface DocumentsResponse {
|
||||
top_documents: OnyxDocument[];
|
||||
rephrased_query: string | null;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
RetrievalType,
|
||||
StreamingError,
|
||||
ToolCallMetadata,
|
||||
AgenticMessageResponseIDInfo,
|
||||
} from "./interfaces";
|
||||
import { Persona } from "../admin/assistants/interfaces";
|
||||
import { ReadonlyURLSearchParams } from "next/navigation";
|
||||
@@ -64,7 +65,7 @@ export function getChatRetentionInfo(
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateModelOverrideForChatSession(
|
||||
export async function updateLlmOverrideForChatSession(
|
||||
chatSessionId: string,
|
||||
newAlternateModel: string
|
||||
) {
|
||||
@@ -154,7 +155,8 @@ export type PacketType =
|
||||
| AgentAnswerPiece
|
||||
| SubQuestionPiece
|
||||
| ExtendedToolResponse
|
||||
| RefinedAnswerImprovement;
|
||||
| RefinedAnswerImprovement
|
||||
| AgenticMessageResponseIDInfo;
|
||||
|
||||
export async function* sendMessage({
|
||||
regenerate,
|
||||
@@ -234,7 +236,7 @@ export async function* sendMessage({
|
||||
}
|
||||
: null,
|
||||
use_existing_user_message: useExistingUserMessage,
|
||||
use_agentic_search: useLanggraph,
|
||||
use_agentic_search: useLanggraph ?? false,
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/chat/send-message`, {
|
||||
|
||||
@@ -44,7 +44,7 @@ import { ValidSources } from "@/lib/types";
|
||||
import { useMouseTracking } from "./hooks";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import RegenerateOption from "../RegenerateOption";
|
||||
import { LlmOverride } from "@/lib/hooks";
|
||||
import { LlmDescriptor } from "@/lib/hooks";
|
||||
import { ContinueGenerating } from "./ContinueMessage";
|
||||
import { MemoizedAnchor, MemoizedParagraph } from "./MemoizedTextComponents";
|
||||
import { extractCodeText, preprocessLaTeX } from "./codeUtils";
|
||||
@@ -117,7 +117,7 @@ export const AgenticMessage = ({
|
||||
isComplete?: boolean;
|
||||
handleFeedback?: (feedbackType: FeedbackType) => void;
|
||||
overriddenModel?: string;
|
||||
regenerate?: (modelOverRide: LlmOverride) => Promise<void>;
|
||||
regenerate?: (modelOverRide: LlmDescriptor) => Promise<void>;
|
||||
setPresentingDocument?: (document: OnyxDocument) => void;
|
||||
toggleDocDisplay?: (agentic: boolean) => void;
|
||||
error?: string | null;
|
||||
|
||||
@@ -58,7 +58,7 @@ import { useMouseTracking } from "./hooks";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import GeneratingImageDisplay from "../tools/GeneratingImageDisplay";
|
||||
import RegenerateOption from "../RegenerateOption";
|
||||
import { LlmOverride } from "@/lib/hooks";
|
||||
import { LlmDescriptor } from "@/lib/hooks";
|
||||
import { ContinueGenerating } from "./ContinueMessage";
|
||||
import { MemoizedAnchor, MemoizedParagraph } from "./MemoizedTextComponents";
|
||||
import { extractCodeText, preprocessLaTeX } from "./codeUtils";
|
||||
@@ -213,7 +213,7 @@ export const AIMessage = ({
|
||||
handleForceSearch?: () => void;
|
||||
retrievalDisabled?: boolean;
|
||||
overriddenModel?: string;
|
||||
regenerate?: (modelOverRide: LlmOverride) => Promise<void>;
|
||||
regenerate?: (modelOverRide: LlmDescriptor) => Promise<void>;
|
||||
setPresentingDocument: (document: OnyxDocument) => void;
|
||||
removePadding?: boolean;
|
||||
}) => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CopyButton } from "@/components/CopyButton";
|
||||
import { SEARCH_PARAM_NAMES } from "../searchParams";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { structureValue } from "@/lib/llm/utils";
|
||||
import { LlmOverride } from "@/lib/hooks";
|
||||
import { LlmDescriptor } from "@/lib/hooks";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AdvancedOptionsToggle } from "@/components/AdvancedOptionsToggle";
|
||||
|
||||
@@ -38,7 +38,7 @@ async function generateShareLink(chatSessionId: string) {
|
||||
async function generateSeedLink(
|
||||
message?: string,
|
||||
assistantId?: number,
|
||||
modelOverride?: LlmOverride
|
||||
modelOverride?: LlmDescriptor
|
||||
) {
|
||||
const baseUrl = `${window.location.protocol}//${window.location.host}`;
|
||||
const model = modelOverride
|
||||
@@ -92,7 +92,7 @@ export function ShareChatSessionModal({
|
||||
onClose: () => void;
|
||||
message?: string;
|
||||
assistantId?: number;
|
||||
modelOverride?: LlmOverride;
|
||||
modelOverride?: LlmDescriptor;
|
||||
}) {
|
||||
const [shareLink, setShareLink] = useState<string>(
|
||||
existingSharedStatus === ChatSessionSharedStatus.Public
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { Modal } from "@/components/Modal";
|
||||
import { getDisplayNameForModel, LlmOverride } from "@/lib/hooks";
|
||||
import { getDisplayNameForModel, LlmDescriptor } from "@/lib/hooks";
|
||||
import { LLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
|
||||
|
||||
import { destructureValue, structureValue } from "@/lib/llm/utils";
|
||||
@@ -31,12 +31,12 @@ export function UserSettingsModal({
|
||||
setPopup,
|
||||
llmProviders,
|
||||
onClose,
|
||||
setLlmOverride,
|
||||
setCurrentLlm,
|
||||
defaultModel,
|
||||
}: {
|
||||
setPopup: (popupSpec: PopupSpec | null) => void;
|
||||
llmProviders: LLMProviderDescriptor[];
|
||||
setLlmOverride?: (newOverride: LlmOverride) => void;
|
||||
setCurrentLlm?: (newLlm: LlmDescriptor) => void;
|
||||
onClose: () => void;
|
||||
defaultModel: string | null;
|
||||
}) {
|
||||
@@ -127,18 +127,14 @@ export function UserSettingsModal({
|
||||
);
|
||||
});
|
||||
|
||||
const llmOptions = Object.entries(llmOptionsByProvider).flatMap(
|
||||
([provider, options]) => [...options]
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
const handleChangedefaultModel = async (defaultModel: string | null) => {
|
||||
try {
|
||||
const response = await setUserDefaultModel(defaultModel);
|
||||
|
||||
if (response.ok) {
|
||||
if (defaultModel && setLlmOverride) {
|
||||
setLlmOverride(destructureValue(defaultModel));
|
||||
if (defaultModel && setCurrentLlm) {
|
||||
setCurrentLlm(destructureValue(defaultModel));
|
||||
}
|
||||
setPopup({
|
||||
message: "Default model updated successfully",
|
||||
|
||||
@@ -4,7 +4,10 @@ import React, {
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
@@ -47,9 +50,9 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { CircleX, PinIcon } from "lucide-react";
|
||||
import { CirclePlus, CircleX, PinIcon } from "lucide-react";
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
import { TruncatedText } from "@/components/ui/truncatedText";
|
||||
import { turborepoTraceAccess } from "next/dist/build/turborepo-access-trace";
|
||||
|
||||
interface HistorySidebarProps {
|
||||
liveAssistant?: Persona | null;
|
||||
@@ -98,6 +101,24 @@ const SortableAssistant: React.FC<SortableAssistantProps> = ({
|
||||
...(isDragging ? { zIndex: 1000, position: "relative" as const } : {}),
|
||||
};
|
||||
|
||||
const nameRef = useRef<HTMLParagraphElement>(null);
|
||||
const hiddenNameRef = useRef<HTMLSpanElement>(null);
|
||||
const [isNameTruncated, setIsNameTruncated] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const checkTruncation = () => {
|
||||
if (nameRef.current && hiddenNameRef.current) {
|
||||
const visibleWidth = nameRef.current.offsetWidth;
|
||||
const fullTextWidth = hiddenNameRef.current.offsetWidth;
|
||||
setIsNameTruncated(fullTextWidth > visibleWidth);
|
||||
}
|
||||
};
|
||||
|
||||
checkTruncation();
|
||||
window.addEventListener("resize", checkTruncation);
|
||||
return () => window.removeEventListener("resize", checkTruncation);
|
||||
}, [assistant.name]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
@@ -125,11 +146,27 @@ const SortableAssistant: React.FC<SortableAssistantProps> = ({
|
||||
} relative flex items-center gap-x-2 py-1 px-2 rounded-md`}
|
||||
>
|
||||
<AssistantIcon assistant={assistant} size={16} className="flex-none" />
|
||||
<TruncatedText
|
||||
className="text-base mr-4 text-left w-fit line-clamp-1 text-ellipsis text-black dark:text-[#D4D4D4]"
|
||||
text={assistant.name}
|
||||
/>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p
|
||||
ref={nameRef}
|
||||
className="text-base text-left w-fit line-clamp-1 text-ellipsis text-black dark:text-[#D4D4D4]"
|
||||
>
|
||||
{assistant.name}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
{isNameTruncated && (
|
||||
<TooltipContent>{assistant.name}</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<span
|
||||
ref={hiddenNameRef}
|
||||
className="absolute left-[-9999px] whitespace-nowrap"
|
||||
>
|
||||
{assistant.name}
|
||||
</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -21,11 +21,9 @@ import { fetchAssistantData } from "@/lib/chat/fetchAssistantdata";
|
||||
import { AppProvider } from "@/components/context/AppProvider";
|
||||
import { PHProvider } from "./providers";
|
||||
import { getCurrentUserSS } from "@/lib/userSS";
|
||||
import CardSection from "@/components/admin/CardSection";
|
||||
import { Suspense } from "react";
|
||||
import PostHogPageView from "./PostHogPageView";
|
||||
import Script from "next/script";
|
||||
import { LogoType } from "@/components/logo/Logo";
|
||||
import { Hanken_Grotesk } from "next/font/google";
|
||||
import { WebVitals } from "./web-vitals";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
|
||||
@@ -8,58 +8,32 @@ interface HoverPopupProps {
|
||||
style?: "basic" | "dark";
|
||||
}
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
export const HoverPopup = ({
|
||||
mainContent,
|
||||
popupContent,
|
||||
classNameModifications,
|
||||
direction = "bottom",
|
||||
style = "basic",
|
||||
}: HoverPopupProps) => {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
let popupDirectionClass;
|
||||
let popupStyle = {};
|
||||
switch (direction) {
|
||||
case "left":
|
||||
popupDirectionClass = "top-0 left-0 transform";
|
||||
popupStyle = { transform: "translateX(calc(-100% - 5px))" };
|
||||
break;
|
||||
case "left-top":
|
||||
popupDirectionClass = "bottom-0 left-0";
|
||||
popupStyle = { transform: "translate(calc(-100% - 5px), 0)" };
|
||||
break;
|
||||
case "bottom":
|
||||
popupDirectionClass = "top-0 left-0 mt-6 pt-2";
|
||||
break;
|
||||
case "top":
|
||||
popupDirectionClass = "top-0 left-0 translate-y-[-100%] pb-2";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex"
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{hovered && (
|
||||
<div
|
||||
className={`absolute ${popupDirectionClass} z-30`}
|
||||
style={popupStyle}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{mainContent}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side={direction === "left-top" ? "left" : direction}
|
||||
className={classNameModifications}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
`px-3 py-2 rounded bg-background border border-border` +
|
||||
(classNameModifications || "")
|
||||
}
|
||||
>
|
||||
{popupContent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>{mainContent}</div>
|
||||
</div>
|
||||
{popupContent}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
FiPauseCircle,
|
||||
} from "react-icons/fi";
|
||||
import { HoverPopup } from "./HoverPopup";
|
||||
import { ConnectorCredentialPairStatus } from "@/app/admin/connector/[ccPairId]/types";
|
||||
|
||||
export function IndexAttemptStatus({
|
||||
status,
|
||||
@@ -70,6 +71,12 @@ export function IndexAttemptStatus({
|
||||
Canceled
|
||||
</Badge>
|
||||
);
|
||||
} else if (status === "invalid") {
|
||||
badge = (
|
||||
<Badge variant="invalid" icon={FiAlertTriangle}>
|
||||
Invalid
|
||||
</Badge>
|
||||
);
|
||||
} else {
|
||||
badge = (
|
||||
<Badge variant="outline" icon={FiMinus}>
|
||||
@@ -83,29 +90,33 @@ export function IndexAttemptStatus({
|
||||
|
||||
export function CCPairStatus({
|
||||
status,
|
||||
disabled,
|
||||
isDeleting,
|
||||
ccPairStatus,
|
||||
size = "md",
|
||||
}: {
|
||||
status: ValidStatuses;
|
||||
disabled: boolean;
|
||||
isDeleting: boolean;
|
||||
ccPairStatus: ConnectorCredentialPairStatus;
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
}) {
|
||||
let badge;
|
||||
|
||||
if (isDeleting) {
|
||||
if (ccPairStatus == ConnectorCredentialPairStatus.DELETING) {
|
||||
badge = (
|
||||
<Badge variant="destructive" icon={FiAlertTriangle}>
|
||||
Deleting
|
||||
</Badge>
|
||||
);
|
||||
} else if (disabled) {
|
||||
} else if (ccPairStatus == ConnectorCredentialPairStatus.PAUSED) {
|
||||
badge = (
|
||||
<Badge variant="paused" icon={FiPauseCircle}>
|
||||
Paused
|
||||
</Badge>
|
||||
);
|
||||
} else if (ccPairStatus == ConnectorCredentialPairStatus.INVALID) {
|
||||
badge = (
|
||||
<Badge variant="invalid" icon={FiAlertTriangle}>
|
||||
Invalid
|
||||
</Badge>
|
||||
);
|
||||
} else if (status === "failed") {
|
||||
badge = (
|
||||
<Badge variant="destructive" icon={FiAlertTriangle}>
|
||||
|
||||
@@ -79,14 +79,24 @@ export default function CredentialSection({
|
||||
selectedCredential: Credential<any>,
|
||||
connectorId: number
|
||||
) => {
|
||||
await swapCredential(selectedCredential.id, connectorId);
|
||||
mutate(buildSimilarCredentialInfoURL(sourceType));
|
||||
refresh();
|
||||
const response = await swapCredential(selectedCredential.id, connectorId);
|
||||
if (response.ok) {
|
||||
mutate(buildSimilarCredentialInfoURL(sourceType));
|
||||
refresh();
|
||||
|
||||
setPopup({
|
||||
message: "Swapped credential succesfully!",
|
||||
type: "success",
|
||||
});
|
||||
setPopup({
|
||||
message: "Swapped credential successfully!",
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setPopup({
|
||||
message: `Issue swapping credential: ${
|
||||
errorData.detail || errorData.message || "Unknown error"
|
||||
}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onUpdateCredential = async (
|
||||
|
||||
@@ -51,7 +51,7 @@ export async function fetchSettingsSS(): Promise<CombinedSettings | null> {
|
||||
notifications: [],
|
||||
needs_reindexing: false,
|
||||
anonymous_user_enabled: false,
|
||||
pro_search_disabled: false,
|
||||
pro_search_enabled: true,
|
||||
temperature_override_enabled: true,
|
||||
};
|
||||
} else {
|
||||
@@ -95,8 +95,8 @@ export async function fetchSettingsSS(): Promise<CombinedSettings | null> {
|
||||
}
|
||||
}
|
||||
|
||||
if (enterpriseSettings && settings.pro_search_disabled == null) {
|
||||
settings.pro_search_disabled = true;
|
||||
if (settings.pro_search_enabled == null) {
|
||||
settings.pro_search_enabled = true;
|
||||
}
|
||||
|
||||
const webVersion = getWebVersion();
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
@@ -8,6 +13,8 @@ const badgeVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
invalid:
|
||||
"border-orange-200 bg-orange-50 text-orange-600 dark:border-orange-700 dark:bg-orange-900 dark:text-orange-50",
|
||||
outline:
|
||||
"border-neutral-200 bg-neutral-50 text-neutral-600 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-50",
|
||||
purple:
|
||||
@@ -57,11 +64,13 @@ function Badge({
|
||||
icon: Icon,
|
||||
size = "sm",
|
||||
circle,
|
||||
tooltip,
|
||||
...props
|
||||
}: BadgeProps & {
|
||||
icon?: React.ElementType;
|
||||
size?: "sm" | "md" | "xs";
|
||||
circle?: boolean;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
const sizeClasses = {
|
||||
sm: "px-2.5 py-0.5 text-xs",
|
||||
@@ -69,7 +78,7 @@ function Badge({
|
||||
xs: "px-1.5 py-0.25 text-[.5rem]",
|
||||
};
|
||||
|
||||
return (
|
||||
const BadgeContent = (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-none inline-flex items-center whitespace-nowrap overflow-hidden",
|
||||
@@ -98,6 +107,21 @@ function Badge({
|
||||
<span className="truncate">{props.children}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{BadgeContent}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return BadgeContent;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -88,7 +88,6 @@ export interface ButtonProps
|
||||
tooltip?: string;
|
||||
reverse?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
@@ -124,7 +123,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipTrigger>
|
||||
<div>{button}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent showTick={true}>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useLayoutEffect,
|
||||
HTMLAttributes,
|
||||
} from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface TruncatedTextProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
text: string;
|
||||
tooltipClassName?: string;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
tooltipSideOffset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders passed in text on a single line. If text is truncated,
|
||||
* shows a tooltip on hover with the full text.
|
||||
*/
|
||||
export function TruncatedText({
|
||||
text,
|
||||
tooltipClassName,
|
||||
tooltipSide = "right",
|
||||
tooltipSideOffset = 5,
|
||||
className = "",
|
||||
...rest
|
||||
}: TruncatedTextProps) {
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
const visibleRef = useRef<HTMLSpanElement>(null);
|
||||
const hiddenRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
function checkTruncation() {
|
||||
if (visibleRef.current && hiddenRef.current) {
|
||||
const visibleWidth = visibleRef.current.offsetWidth;
|
||||
const fullTextWidth = hiddenRef.current.offsetWidth;
|
||||
setIsTruncated(fullTextWidth > visibleWidth);
|
||||
}
|
||||
}
|
||||
|
||||
checkTruncation();
|
||||
window.addEventListener("resize", checkTruncation);
|
||||
return () => window.removeEventListener("resize", checkTruncation);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
ref={visibleRef}
|
||||
// Ensure the text can actually truncate via line-clamp or overflow
|
||||
className={`line-clamp-1 break-all flex-grow ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{/* Hide offscreen to measure full text width */}
|
||||
<span
|
||||
ref={hiddenRef}
|
||||
className="absolute left-[-9999px] whitespace-nowrap pointer-events-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
{isTruncated && (
|
||||
<TooltipContent
|
||||
side={tooltipSide}
|
||||
sideOffset={tooltipSideOffset}
|
||||
className={tooltipClassName}
|
||||
>
|
||||
<p className="text-xs max-w-[200px] whitespace-normal break-words">
|
||||
{text}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -46,3 +46,23 @@ export async function setCCPairStatus(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const getCCPairStatusMessage = (
|
||||
isDisabled: boolean,
|
||||
isIndexing: boolean,
|
||||
ccPairStatus: ConnectorCredentialPairStatus
|
||||
) => {
|
||||
if (ccPairStatus === ConnectorCredentialPairStatus.INVALID) {
|
||||
return "Connector is in an invalid state. Please update the credentials or configuration before re-indexing.";
|
||||
}
|
||||
if (ccPairStatus === ConnectorCredentialPairStatus.DELETING) {
|
||||
return "Cannot index while connector is deleting";
|
||||
}
|
||||
if (isIndexing) {
|
||||
return "Indexing is already in progress";
|
||||
}
|
||||
if (isDisabled) {
|
||||
return "Connector must be re-enabled before indexing";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -152,7 +152,17 @@ export const connectorConfigs: Record<
|
||||
],
|
||||
},
|
||||
],
|
||||
advanced_values: [],
|
||||
advanced_values: [
|
||||
{
|
||||
type: "checkbox",
|
||||
query: "Scroll before scraping:",
|
||||
label: "Scroll before scraping",
|
||||
description:
|
||||
"Enable if the website requires scrolling for the desired content to load",
|
||||
name: "scroll_before_scraping",
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
overrideDefaultFreq: 60 * 60 * 24,
|
||||
},
|
||||
github: {
|
||||
|
||||
@@ -360,18 +360,18 @@ export const useUsers = ({ includeApiKeys }: UseUsersParams) => {
|
||||
};
|
||||
};
|
||||
|
||||
export interface LlmOverride {
|
||||
export interface LlmDescriptor {
|
||||
name: string;
|
||||
provider: string;
|
||||
modelName: string;
|
||||
}
|
||||
|
||||
export interface LlmOverrideManager {
|
||||
llmOverride: LlmOverride;
|
||||
updateLLMOverride: (newOverride: LlmOverride) => void;
|
||||
export interface LlmManager {
|
||||
currentLlm: LlmDescriptor;
|
||||
updateCurrentLlm: (newOverride: LlmDescriptor) => void;
|
||||
temperature: number;
|
||||
updateTemperature: (temperature: number) => void;
|
||||
updateModelOverrideForChatSession: (chatSession?: ChatSession) => void;
|
||||
updateModelOverrideBasedOnChatSession: (chatSession?: ChatSession) => void;
|
||||
imageFilesPresent: boolean;
|
||||
updateImageFilesPresent: (present: boolean) => void;
|
||||
liveAssistant: Persona | null;
|
||||
@@ -400,7 +400,7 @@ Thus, the input should be
|
||||
|
||||
Changes take place as
|
||||
- liveAssistant or currentChatSession changes (and the associated model override is set)
|
||||
- (uploadLLMOverride) User explicitly setting a model override (and we explicitly override and set the userSpecifiedOverride which we'll use in place of the user preferences unless overridden by an assistant)
|
||||
- (updateCurrentLlm) User explicitly setting a model override (and we explicitly override and set the userSpecifiedOverride which we'll use in place of the user preferences unless overridden by an assistant)
|
||||
|
||||
If we have a live assistant, we should use that model override
|
||||
|
||||
@@ -419,55 +419,78 @@ This approach ensures that user preferences are maintained for existing chats wh
|
||||
providing appropriate defaults for new conversations based on the available tools.
|
||||
*/
|
||||
|
||||
export function useLlmOverride(
|
||||
export function useLlmManager(
|
||||
llmProviders: LLMProviderDescriptor[],
|
||||
currentChatSession?: ChatSession,
|
||||
liveAssistant?: Persona
|
||||
): LlmOverrideManager {
|
||||
): LlmManager {
|
||||
const { user } = useUser();
|
||||
|
||||
const [userHasManuallyOverriddenLLM, setUserHasManuallyOverriddenLLM] =
|
||||
useState(false);
|
||||
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
|
||||
const [currentLlm, setCurrentLlm] = useState<LlmDescriptor>({
|
||||
name: "",
|
||||
provider: "",
|
||||
modelName: "",
|
||||
});
|
||||
|
||||
const llmOverrideUpdate = () => {
|
||||
if (liveAssistant?.llm_model_version_override) {
|
||||
setLlmOverride(
|
||||
getValidLlmOverride(liveAssistant.llm_model_version_override)
|
||||
);
|
||||
} else if (currentChatSession?.current_alternate_model) {
|
||||
setLlmOverride(
|
||||
getValidLlmOverride(currentChatSession.current_alternate_model)
|
||||
);
|
||||
} else if (user?.preferences?.default_model) {
|
||||
setLlmOverride(getValidLlmOverride(user.preferences.default_model));
|
||||
return;
|
||||
} else {
|
||||
const defaultProvider = llmProviders.find(
|
||||
(provider) => provider.is_default_provider
|
||||
);
|
||||
const llmUpdate = () => {
|
||||
/* Should be called when the live assistant or current chat session changes */
|
||||
|
||||
if (defaultProvider) {
|
||||
setLlmOverride({
|
||||
name: defaultProvider.name,
|
||||
provider: defaultProvider.provider,
|
||||
modelName: defaultProvider.default_model_name,
|
||||
});
|
||||
// separate function so we can `return` to break out
|
||||
const _llmUpdate = () => {
|
||||
// if the user has overridden in this session and just switched to a brand
|
||||
// new session, use their manually specified model
|
||||
if (userHasManuallyOverriddenLLM && !currentChatSession) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentChatSession?.current_alternate_model) {
|
||||
setCurrentLlm(
|
||||
getValidLlmDescriptor(currentChatSession.current_alternate_model)
|
||||
);
|
||||
} else if (liveAssistant?.llm_model_version_override) {
|
||||
setCurrentLlm(
|
||||
getValidLlmDescriptor(liveAssistant.llm_model_version_override)
|
||||
);
|
||||
} else if (userHasManuallyOverriddenLLM) {
|
||||
// if the user has an override and there's nothing special about the
|
||||
// current chat session, use the override
|
||||
return;
|
||||
} else if (user?.preferences?.default_model) {
|
||||
setCurrentLlm(getValidLlmDescriptor(user.preferences.default_model));
|
||||
} else {
|
||||
const defaultProvider = llmProviders.find(
|
||||
(provider) => provider.is_default_provider
|
||||
);
|
||||
|
||||
if (defaultProvider) {
|
||||
setCurrentLlm({
|
||||
name: defaultProvider.name,
|
||||
provider: defaultProvider.provider,
|
||||
modelName: defaultProvider.default_model_name,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_llmUpdate();
|
||||
setChatSession(currentChatSession || null);
|
||||
};
|
||||
|
||||
const getValidLlmOverride = (
|
||||
overrideModel: string | null | undefined
|
||||
): LlmOverride => {
|
||||
if (overrideModel) {
|
||||
const model = destructureValue(overrideModel);
|
||||
const getValidLlmDescriptor = (
|
||||
modelName: string | null | undefined
|
||||
): LlmDescriptor => {
|
||||
if (modelName) {
|
||||
const model = destructureValue(modelName);
|
||||
if (!(model.modelName && model.modelName.length > 0)) {
|
||||
const provider = llmProviders.find((p) =>
|
||||
p.model_names.includes(overrideModel)
|
||||
p.model_names.includes(modelName)
|
||||
);
|
||||
if (provider) {
|
||||
return {
|
||||
modelName: overrideModel,
|
||||
modelName: modelName,
|
||||
name: provider.name,
|
||||
provider: provider.provider,
|
||||
};
|
||||
@@ -491,38 +514,32 @@ export function useLlmOverride(
|
||||
setImageFilesPresent(present);
|
||||
};
|
||||
|
||||
const [llmOverride, setLlmOverride] = useState<LlmOverride>({
|
||||
name: "",
|
||||
provider: "",
|
||||
modelName: "",
|
||||
});
|
||||
|
||||
// Manually set the override
|
||||
const updateLLMOverride = (newOverride: LlmOverride) => {
|
||||
// Manually set the LLM
|
||||
const updateCurrentLlm = (newLlm: LlmDescriptor) => {
|
||||
const provider =
|
||||
newOverride.provider ||
|
||||
findProviderForModel(llmProviders, newOverride.modelName);
|
||||
newLlm.provider || findProviderForModel(llmProviders, newLlm.modelName);
|
||||
const structuredValue = structureValue(
|
||||
newOverride.name,
|
||||
newLlm.name,
|
||||
provider,
|
||||
newOverride.modelName
|
||||
newLlm.modelName
|
||||
);
|
||||
setLlmOverride(getValidLlmOverride(structuredValue));
|
||||
setCurrentLlm(getValidLlmDescriptor(structuredValue));
|
||||
setUserHasManuallyOverriddenLLM(true);
|
||||
};
|
||||
|
||||
const updateModelOverrideForChatSession = (chatSession?: ChatSession) => {
|
||||
const updateModelOverrideBasedOnChatSession = (chatSession?: ChatSession) => {
|
||||
if (chatSession && chatSession.current_alternate_model?.length > 0) {
|
||||
setLlmOverride(getValidLlmOverride(chatSession.current_alternate_model));
|
||||
setCurrentLlm(getValidLlmDescriptor(chatSession.current_alternate_model));
|
||||
}
|
||||
};
|
||||
|
||||
const [temperature, setTemperature] = useState<number>(() => {
|
||||
llmOverrideUpdate();
|
||||
llmUpdate();
|
||||
|
||||
if (currentChatSession?.current_temperature_override != null) {
|
||||
return Math.min(
|
||||
currentChatSession.current_temperature_override,
|
||||
isAnthropic(llmOverride.provider, llmOverride.modelName) ? 1.0 : 2.0
|
||||
isAnthropic(currentLlm.provider, currentLlm.modelName) ? 1.0 : 2.0
|
||||
);
|
||||
} else if (
|
||||
liveAssistant?.tools.some((tool) => tool.name === SEARCH_TOOL_ID)
|
||||
@@ -533,22 +550,23 @@ export function useLlmOverride(
|
||||
});
|
||||
|
||||
const maxTemperature = useMemo(() => {
|
||||
return isAnthropic(llmOverride.provider, llmOverride.modelName) ? 1.0 : 2.0;
|
||||
}, [llmOverride]);
|
||||
return isAnthropic(currentLlm.provider, currentLlm.modelName) ? 1.0 : 2.0;
|
||||
}, [currentLlm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAnthropic(llmOverride.provider, llmOverride.modelName)) {
|
||||
if (isAnthropic(currentLlm.provider, currentLlm.modelName)) {
|
||||
const newTemperature = Math.min(temperature, 1.0);
|
||||
setTemperature(newTemperature);
|
||||
if (chatSession?.id) {
|
||||
updateTemperatureOverrideForChatSession(chatSession.id, newTemperature);
|
||||
}
|
||||
}
|
||||
}, [llmOverride]);
|
||||
}, [currentLlm]);
|
||||
|
||||
useEffect(() => {
|
||||
llmUpdate();
|
||||
|
||||
if (!chatSession && currentChatSession) {
|
||||
setChatSession(currentChatSession || null);
|
||||
if (temperature) {
|
||||
updateTemperatureOverrideForChatSession(
|
||||
currentChatSession.id,
|
||||
@@ -570,7 +588,7 @@ export function useLlmOverride(
|
||||
}, [liveAssistant, currentChatSession]);
|
||||
|
||||
const updateTemperature = (temperature: number) => {
|
||||
if (isAnthropic(llmOverride.provider, llmOverride.modelName)) {
|
||||
if (isAnthropic(currentLlm.provider, currentLlm.modelName)) {
|
||||
setTemperature((prevTemp) => Math.min(temperature, 1.0));
|
||||
} else {
|
||||
setTemperature(temperature);
|
||||
@@ -581,9 +599,9 @@ export function useLlmOverride(
|
||||
};
|
||||
|
||||
return {
|
||||
updateModelOverrideForChatSession,
|
||||
llmOverride,
|
||||
updateLLMOverride,
|
||||
updateModelOverrideBasedOnChatSession,
|
||||
currentLlm,
|
||||
updateCurrentLlm,
|
||||
temperature,
|
||||
updateTemperature,
|
||||
imageFilesPresent,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
import { LLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
|
||||
import { LlmOverride } from "@/lib/hooks";
|
||||
import { LlmDescriptor } from "@/lib/hooks";
|
||||
|
||||
export function getFinalLLM(
|
||||
llmProviders: LLMProviderDescriptor[],
|
||||
persona: Persona | null,
|
||||
llmOverride: LlmOverride | null
|
||||
currentLlm: LlmDescriptor | null
|
||||
): [string, string] {
|
||||
const defaultProvider = llmProviders.find(
|
||||
(llmProvider) => llmProvider.is_default_provider
|
||||
@@ -26,9 +26,9 @@ export function getFinalLLM(
|
||||
model = persona.llm_model_version_override || model;
|
||||
}
|
||||
|
||||
if (llmOverride) {
|
||||
provider = llmOverride.provider || provider;
|
||||
model = llmOverride.modelName || model;
|
||||
if (currentLlm) {
|
||||
provider = currentLlm.provider || provider;
|
||||
model = currentLlm.modelName || model;
|
||||
}
|
||||
|
||||
return [provider, model];
|
||||
@@ -37,7 +37,7 @@ export function getFinalLLM(
|
||||
export function getLLMProviderOverrideForPersona(
|
||||
liveAssistant: Persona,
|
||||
llmProviders: LLMProviderDescriptor[]
|
||||
): LlmOverride | null {
|
||||
): LlmDescriptor | null {
|
||||
const overrideProvider = liveAssistant.llm_model_provider_override;
|
||||
const overrideModel = liveAssistant.llm_model_version_override;
|
||||
|
||||
@@ -135,7 +135,7 @@ export const structureValue = (
|
||||
return `${name}__${provider}__${modelName}`;
|
||||
};
|
||||
|
||||
export const destructureValue = (value: string): LlmOverride => {
|
||||
export const destructureValue = (value: string): LlmDescriptor => {
|
||||
const [displayName, provider, modelName] = value.split("__");
|
||||
return {
|
||||
name: displayName,
|
||||
|
||||
@@ -98,6 +98,7 @@ export type ValidInputTypes =
|
||||
| "event"
|
||||
| "slim_retrieval";
|
||||
export type ValidStatuses =
|
||||
| "invalid"
|
||||
| "success"
|
||||
| "completed_with_errors"
|
||||
| "canceled"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { LlmOverride } from "../hooks";
|
||||
|
||||
export async function setUserDefaultModel(
|
||||
model: string | null
|
||||
): Promise<Response> {
|
||||
|
||||
Reference in New Issue
Block a user