Compare commits

..

24 Commits

Author SHA1 Message Date
Dane Urban
bfd298f3be . 2026-03-18 16:12:10 -07:00
Dane Urban
a46bdaebd2 Changes 2026-03-18 16:05:57 -07:00
Dane Urban
2e9228b54a Round 1 2026-03-18 16:05:05 -07:00
Dane Urban
74150ed86b Adapter refactor 2026-03-18 15:59:58 -07:00
Dane Urban
129e9872c4 Support streaming via document adapter 2026-03-17 10:48:25 -07:00
Dane Urban
4918fc9652 Max chunks 2026-03-16 17:10:07 -07:00
Dane Urban
39a88416c4 Remove restriction comment 2026-03-16 15:49:33 -07:00
Dane Urban
2a1ac754b4 mypy fixes 2026-03-16 15:39:13 -07:00
Dane Urban
c65c5d57e9 . 2026-03-16 15:32:31 -07:00
Dane Urban
6247defd4d Vespa change 2026-03-16 15:31:52 -07:00
Dane Urban
17063d2eef Open-search iterable refactor 2026-03-16 15:13:14 -07:00
Wenxi
eb311c7550 fix: use uuid as ph unique id from BE (#9371) 2026-03-16 18:06:34 +00:00
Jamison Lahman
13284d9def chore(voice): support non-default FE ports for IS_DEV (#9356) 2026-03-16 11:03:56 -07:00
Bo-Onyx
aaa99fcb60 chore(hook): Add feature control (#9320) 2026-03-16 17:48:53 +00:00
dependabot[bot]
5f628da4e8 chore(deps): bump authlib from 1.6.7 to 1.6.9 (#9370)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-16 17:21:05 +00:00
Jamison Lahman
e40f80cfe1 chore(posthog): allow no-op client in DEV_MODE (#9357) 2026-03-16 16:55:00 +00:00
Nikolas Garza
ca6ba2cca9 fix(admin): users page UI/UX polish (#9366) 2026-03-16 15:27:03 +00:00
Nikolas Garza
98ef5006ff feat(ci): add Slack @-mention support to slack-notify action (#9359) 2026-03-16 15:26:32 +00:00
Nikolas Garza
dfd168cde9 fix(fe): bump flatted to patch CVE-2026-32141 (#9350) 2026-03-14 05:46:04 +00:00
Raunak Bhagat
6c7ae243d0 feat: refresh admin sidebar with new sections, search, and disabled EE tabs (#9344) 2026-03-14 04:09:16 +00:00
Raunak Bhagat
c4a2ff2593 feat: add progress-bars opal icon (#9349) 2026-03-14 02:18:41 +00:00
Danelegend
4b74a6dc76 fix(litellm): filter embedding models (#9347) 2026-03-14 01:40:06 +00:00
dependabot[bot]
eea5f5b380 chore(deps): bump pyjwt from 2.11.0 to 2.12.0 (#9341)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-13 21:57:49 +00:00
Raunak Bhagat
ae428ba684 feat: add curate and user variant opal icons (#9343) 2026-03-13 21:51:02 +00:00
119 changed files with 2083 additions and 1116 deletions

View File

@@ -10,6 +10,9 @@ inputs:
failed-jobs:
description: "Deprecated alias for details"
required: false
mention:
description: "GitHub username to resolve to a Slack @-mention. Replaces {mention} in details."
required: false
title:
description: "Title for the notification"
required: false
@@ -26,6 +29,7 @@ runs:
SLACK_WEBHOOK_URL: ${{ inputs.webhook-url }}
DETAILS: ${{ inputs.details }}
FAILED_JOBS: ${{ inputs.failed-jobs }}
MENTION_USER: ${{ inputs.mention }}
TITLE: ${{ inputs.title }}
REF_NAME: ${{ inputs.ref-name }}
REPO: ${{ github.repository }}
@@ -52,6 +56,27 @@ runs:
DETAILS="$FAILED_JOBS"
fi
# Resolve {mention} placeholder if a GitHub username was provided.
# Looks up the username in user-mappings.json (co-located with this action)
# and replaces {mention} with <@SLACK_ID> for a Slack @-mention.
# Falls back to the plain GitHub username if not found in the mapping.
if [ -n "$MENTION_USER" ]; then
MAPPINGS_FILE="${GITHUB_ACTION_PATH}/user-mappings.json"
slack_id="$(jq -r --arg gh "$MENTION_USER" 'to_entries[] | select(.value | ascii_downcase == ($gh | ascii_downcase)) | .key' "$MAPPINGS_FILE" 2>/dev/null | head -1)"
if [ -n "$slack_id" ]; then
mention_text="<@${slack_id}>"
else
mention_text="${MENTION_USER}"
fi
DETAILS="${DETAILS//\{mention\}/$mention_text}"
TITLE="${TITLE//\{mention\}/}"
else
DETAILS="${DETAILS//\{mention\}/}"
TITLE="${TITLE//\{mention\}/}"
fi
normalize_multiline() {
printf '%s' "$1" | awk 'BEGIN { ORS=""; first=1 } { if (!first) printf "\\n"; printf "%s", $0; first=0 }'
}

View File

@@ -0,0 +1,18 @@
{
"U05SAGZPEA1": "yuhongsun96",
"U05SAH6UGUD": "Weves",
"U07PWEQB7A5": "evan-onyx",
"U07V1SM68KF": "joachim-danswer",
"U08JZ9N3QNN": "raunakab",
"U08L24NCLJE": "Subash-Mohan",
"U090B9M07B2": "wenxi-onyx",
"U094RASDP0Q": "duo-onyx",
"U096L8ZQ85B": "justin-tahara",
"U09AHV8UBQX": "jessicasingh7",
"U09KAL5T3C2": "nmgarza5",
"U09KPGVQ70R": "acaprau",
"U09QR8KTSJH": "rohoswagger",
"U09RB4NTXA4": "jmelahman",
"U0A6K9VCY6A": "Danelegend",
"U0AGC4KH71A": "Bo-Onyx"
}

View File

@@ -207,7 +207,7 @@ jobs:
CHERRY_PICK_PR_URL: ${{ needs.cherry-pick-to-latest-release.outputs.cherry_pick_pr_url }}
run: |
source_pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${SOURCE_PR_NUMBER}"
details="*Cherry-pick PR opened successfully.*\\n• source PR: ${source_pr_url}"
details="*Cherry-pick PR opened successfully.*\\n• author: {mention}\\n• source PR: ${source_pr_url}"
if [ -n "${CHERRY_PICK_PR_URL}" ]; then
details="${details}\\n• cherry-pick PR: ${CHERRY_PICK_PR_URL}"
fi
@@ -221,6 +221,7 @@ jobs:
uses: ./.github/actions/slack-notify
with:
webhook-url: ${{ secrets.CHERRY_PICK_PRS_WEBHOOK }}
mention: ${{ needs.resolve-cherry-pick-request.outputs.merged_by }}
details: ${{ steps.success-summary.outputs.details }}
title: "✅ Automated Cherry-Pick PR Opened"
ref-name: ${{ github.event.pull_request.base.ref }}
@@ -275,20 +276,21 @@ jobs:
else
failed_job_label="cherry-pick-to-latest-release"
fi
failed_jobs="• ${failed_job_label}\\n• source PR: ${source_pr_url}\\n• reason: ${reason_text}"
details="• author: {mention}\\n• ${failed_job_label}\\n• source PR: ${source_pr_url}\\n• reason: ${reason_text}"
if [ -n "${MERGE_COMMIT_SHA}" ]; then
failed_jobs="${failed_jobs}\\n• merge SHA: ${MERGE_COMMIT_SHA}"
details="${details}\\n• merge SHA: ${MERGE_COMMIT_SHA}"
fi
if [ -n "${details_excerpt}" ]; then
failed_jobs="${failed_jobs}\\n• excerpt: ${details_excerpt}"
details="${details}\\n• excerpt: ${details_excerpt}"
fi
echo "jobs=${failed_jobs}" >> "$GITHUB_OUTPUT"
echo "details=${details}" >> "$GITHUB_OUTPUT"
- name: Notify #cherry-pick-prs about cherry-pick failure
uses: ./.github/actions/slack-notify
with:
webhook-url: ${{ secrets.CHERRY_PICK_PRS_WEBHOOK }}
details: ${{ steps.failure-summary.outputs.jobs }}
mention: ${{ needs.resolve-cherry-pick-request.outputs.merged_by }}
details: ${{ steps.failure-summary.outputs.details }}
title: "🚨 Automated Cherry-Pick Failed"
ref-name: ${{ github.event.pull_request.base.ref }}

View File

@@ -118,9 +118,7 @@ JWT_PUBLIC_KEY_URL: str | None = os.getenv("JWT_PUBLIC_KEY_URL", None)
SUPER_USERS = json.loads(os.environ.get("SUPER_USERS", "[]"))
SUPER_CLOUD_API_KEY = os.environ.get("SUPER_CLOUD_API_KEY", "api_key")
# The posthog client does not accept empty API keys or hosts however it fails silently
# when the capture is called. These defaults prevent Posthog issues from breaking the Onyx app
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY") or "FooBar"
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY")
POSTHOG_HOST = os.environ.get("POSTHOG_HOST") or "https://us.i.posthog.com"
POSTHOG_DEBUG_LOGS_ENABLED = (
os.environ.get("POSTHOG_DEBUG_LOGS_ENABLED", "").lower() == "true"

View File

@@ -34,6 +34,9 @@ class PostHogFeatureFlagProvider(FeatureFlagProvider):
Returns:
True if the feature is enabled for the user, False otherwise.
"""
if not posthog:
return False
try:
posthog.set(
distinct_id=user_id,

View File

@@ -29,7 +29,6 @@ from onyx.configs.app_configs import OPENAI_DEFAULT_API_KEY
from onyx.configs.app_configs import OPENROUTER_DEFAULT_API_KEY
from onyx.configs.app_configs import VERTEXAI_DEFAULT_CREDENTIALS
from onyx.configs.app_configs import VERTEXAI_DEFAULT_LOCATION
from onyx.configs.constants import MilestoneRecordType
from onyx.db.engine.sql_engine import get_session_with_shared_schema
from onyx.db.engine.sql_engine import get_session_with_tenant
from onyx.db.image_generation import create_default_image_gen_config_from_api_key
@@ -59,7 +58,6 @@ from onyx.server.manage.llm.models import LLMProviderUpsertRequest
from onyx.server.manage.llm.models import ModelConfigurationUpsertRequest
from onyx.setup import setup_onyx
from onyx.utils.logger import setup_logger
from onyx.utils.telemetry import mt_cloud_telemetry
from shared_configs.configs import MULTI_TENANT
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA
from shared_configs.configs import TENANT_ID_PREFIX
@@ -71,7 +69,9 @@ logger = setup_logger()
async def get_or_provision_tenant(
email: str, referral_source: str | None = None, request: Request | None = None
email: str,
referral_source: str | None = None,
request: Request | None = None,
) -> str:
"""
Get existing tenant ID for an email or create a new tenant if none exists.
@@ -693,12 +693,6 @@ async def assign_tenant_to_user(
try:
add_users_to_tenant([email], tenant_id)
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=email,
event=MilestoneRecordType.TENANT_CREATED,
)
except Exception:
logger.exception(f"Failed to assign tenant {tenant_id} to user {email}")
raise Exception("Failed to assign tenant to user")

View File

@@ -9,6 +9,7 @@ from ee.onyx.configs.app_configs import POSTHOG_API_KEY
from ee.onyx.configs.app_configs import POSTHOG_DEBUG_LOGS_ENABLED
from ee.onyx.configs.app_configs import POSTHOG_HOST
from onyx.utils.logger import setup_logger
from shared_configs.configs import MULTI_TENANT
logger = setup_logger()
@@ -18,12 +19,19 @@ def posthog_on_error(error: Any, items: Any) -> None:
logger.error(f"PostHog error: {error}, items: {items}")
posthog = Posthog(
project_api_key=POSTHOG_API_KEY,
host=POSTHOG_HOST,
debug=POSTHOG_DEBUG_LOGS_ENABLED,
on_error=posthog_on_error,
)
posthog: Posthog | None = None
if POSTHOG_API_KEY:
posthog = Posthog(
project_api_key=POSTHOG_API_KEY,
host=POSTHOG_HOST,
debug=POSTHOG_DEBUG_LOGS_ENABLED,
on_error=posthog_on_error,
)
elif MULTI_TENANT:
logger.warning(
"POSTHOG_API_KEY is not set but MULTI_TENANT is enabled — "
"PostHog telemetry and feature flags will be disabled"
)
# For cross referencing between cloud and www Onyx sites
# NOTE: These clients are separate because they are separate posthog projects.
@@ -60,7 +68,7 @@ def capture_and_sync_with_alternate_posthog(
logger.error(f"Error capturing marketing posthog event: {e}")
try:
if cloud_user_id := props.get("onyx_cloud_user_id"):
if posthog and (cloud_user_id := props.get("onyx_cloud_user_id")):
cloud_props = props.copy()
cloud_props.pop("onyx_cloud_user_id", None)

View File

@@ -8,6 +8,9 @@ def event_telemetry(
distinct_id: str, event: str, properties: dict | None = None
) -> None:
"""Capture and send an event to PostHog, flushing immediately."""
if not posthog:
return
logger.info(f"Capturing PostHog event: {distinct_id} {event} {properties}")
try:
posthog.capture(distinct_id, event, properties)

View File

@@ -812,10 +812,17 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=user.email,
distinct_id=str(user.id),
event=MilestoneRecordType.USER_SIGNED_UP,
)
if user_count == 1:
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=str(user.id),
event=MilestoneRecordType.TENANT_CREATED,
)
finally:
CURRENT_TENANT_ID_CONTEXTVAR.reset(token)

View File

@@ -490,13 +490,13 @@ def handle_stream_message_objects(
# Milestone tracking, most devs using the API don't need to understand this
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=user.email if not user.is_anonymous else tenant_id,
distinct_id=str(user.id) if not user.is_anonymous else tenant_id,
event=MilestoneRecordType.MULTIPLE_ASSISTANTS,
)
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=user.email if not user.is_anonymous else tenant_id,
distinct_id=str(user.id) if not user.is_anonymous else tenant_id,
event=MilestoneRecordType.USER_MESSAGE_SENT,
properties={
"origin": new_msg_req.origin.value,

View File

@@ -776,6 +776,9 @@ MINI_CHUNK_SIZE = 150
# This is the number of regular chunks per large chunk
LARGE_CHUNK_RATIO = 4
# The number of chunks in an indexing batch
CHUNKS_PER_BATCH = 1000
# Include the document level metadata in each chunk. If the metadata is too long, then it is thrown out
# We don't want the metadata to overwhelm the actual contents of the chunk
SKIP_METADATA_IN_CHUNK = os.environ.get("SKIP_METADATA_IN_CHUNK", "").lower() == "true"
@@ -1046,6 +1049,8 @@ POD_NAMESPACE = os.environ.get("POD_NAMESPACE")
DEV_MODE = os.environ.get("DEV_MODE", "").lower() == "true"
HOOK_ENABLED = os.environ.get("HOOK_ENABLED", "").lower() == "true"
INTEGRATION_TESTS_MODE = os.environ.get("INTEGRATION_TESTS_MODE", "").lower() == "true"
#####

View File

@@ -5,6 +5,7 @@ accidentally reaches the vector DB layer will fail loudly instead of timing
out against a nonexistent Vespa/OpenSearch instance.
"""
from collections.abc import Iterable
from typing import Any
from onyx.context.search.models import IndexFilters
@@ -66,7 +67,7 @@ class DisabledDocumentIndex(DocumentIndex):
# ------------------------------------------------------------------
def index(
self,
chunks: list[DocMetadataAwareIndexChunk], # noqa: ARG002
chunks: Iterable[DocMetadataAwareIndexChunk], # noqa: ARG002
index_batch_params: IndexBatchParams, # noqa: ARG002
) -> set[DocumentInsertionRecord]:
raise RuntimeError(VECTOR_DB_DISABLED_ERROR)

View File

@@ -1,4 +1,5 @@
import abc
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import datetime
from typing import Any
@@ -206,7 +207,7 @@ class Indexable(abc.ABC):
@abc.abstractmethod
def index(
self,
chunks: list[DocMetadataAwareIndexChunk],
chunks: Iterable[DocMetadataAwareIndexChunk],
index_batch_params: IndexBatchParams,
) -> set[DocumentInsertionRecord]:
"""
@@ -226,8 +227,8 @@ class Indexable(abc.ABC):
it is done automatically outside of this code.
Parameters:
- chunks: Document chunks with all of the information needed for indexing to the document
index.
- chunks: Document chunks with all of the information needed for
indexing to the document index.
- tenant_id: The tenant id of the user whose chunks are being indexed
- large_chunks_enabled: Whether large chunks are enabled

View File

@@ -1,4 +1,5 @@
import abc
from collections.abc import Iterable
from typing import Self
from pydantic import BaseModel
@@ -209,7 +210,7 @@ class Indexable(abc.ABC):
@abc.abstractmethod
def index(
self,
chunks: list[DocMetadataAwareIndexChunk],
chunks: Iterable[DocMetadataAwareIndexChunk],
indexing_metadata: IndexingMetadata,
) -> list[DocumentInsertionRecord]:
"""Indexes a list of document chunks into the document index.

View File

@@ -1,11 +1,12 @@
import json
from collections import defaultdict
from collections.abc import Iterable
from typing import Any
import httpx
from opensearchpy import NotFoundError
from onyx.access.models import DocumentAccess
from onyx.configs.app_configs import CHUNKS_PER_BATCH
from onyx.configs.app_configs import VERIFY_CREATE_OPENSEARCH_INDEX_ON_INIT_MT
from onyx.configs.chat_configs import NUM_RETURNED_HITS
from onyx.configs.chat_configs import TITLE_CONTENT_RATIO
@@ -346,7 +347,7 @@ class OpenSearchOldDocumentIndex(OldDocumentIndex):
def index(
self,
chunks: list[DocMetadataAwareIndexChunk],
chunks: Iterable[DocMetadataAwareIndexChunk],
index_batch_params: IndexBatchParams,
) -> set[OldDocumentInsertionRecord]:
"""
@@ -642,8 +643,8 @@ class OpenSearchDocumentIndex(DocumentIndex):
def index(
self,
chunks: list[DocMetadataAwareIndexChunk],
indexing_metadata: IndexingMetadata, # noqa: ARG002
chunks: Iterable[DocMetadataAwareIndexChunk],
indexing_metadata: IndexingMetadata,
) -> list[DocumentInsertionRecord]:
"""Indexes a list of document chunks into the document index.
@@ -668,29 +669,32 @@ class OpenSearchDocumentIndex(DocumentIndex):
document is newly indexed or had already existed and was just
updated.
"""
# Group chunks by document ID.
doc_id_to_chunks: dict[str, list[DocMetadataAwareIndexChunk]] = defaultdict(
list
total_chunks = sum(
cc.new_chunk_cnt
for cc in indexing_metadata.doc_id_to_chunk_cnt_diff.values()
)
for chunk in chunks:
doc_id_to_chunks[chunk.source_document.id].append(chunk)
logger.debug(
f"[OpenSearchDocumentIndex] Indexing {len(chunks)} chunks from {len(doc_id_to_chunks)} "
f"[OpenSearchDocumentIndex] Indexing {total_chunks} chunks from {len(indexing_metadata.doc_id_to_chunk_cnt_diff)} "
f"documents for index {self._index_name}."
)
document_indexing_results: list[DocumentInsertionRecord] = []
# Try to index per-document.
for _, chunks in doc_id_to_chunks.items():
deleted_doc_ids: set[str] = set()
# Buffer chunks per document as they arrive from the iterable.
# When the document ID changes flush the buffered chunks.
current_doc_id: str | None = None
current_chunks: list[DocMetadataAwareIndexChunk] = []
def _flush_chunks(doc_chunks: list[DocMetadataAwareIndexChunk]) -> None:
# Create a batch of OpenSearch-formatted chunks for bulk insertion.
# Do this before deleting existing chunks to reduce the amount of
# time the document index has no content for a given document, and
# to reduce the chance of entering a state where we delete chunks,
# then some error happens, and never successfully index new chunks.
# Since we are doing this in batches, an error occurring midway
# can result in a state where chunks are deleted and not all the
# new chunks have been indexed.
chunk_batch: list[DocumentChunk] = [
_convert_onyx_chunk_to_opensearch_document(chunk) for chunk in chunks
_convert_onyx_chunk_to_opensearch_document(chunk)
for chunk in doc_chunks
]
onyx_document: Document = chunks[0].source_document
onyx_document: Document = doc_chunks[0].source_document
# First delete the doc's chunks from the index. This is so that
# there are no dangling chunks in the index, in the event that the
# new document's content contains fewer chunks than the previous
@@ -699,22 +703,39 @@ class OpenSearchDocumentIndex(DocumentIndex):
# if the chunk count has actually decreased. This assumes that
# overlapping chunks are perfectly overwritten. If we can't
# guarantee that then we need the code as-is.
num_chunks_deleted = self.delete(
onyx_document.id, onyx_document.chunk_count
)
# If we see that chunks were deleted we assume the doc already
# existed.
document_insertion_record = DocumentInsertionRecord(
document_id=onyx_document.id,
already_existed=num_chunks_deleted > 0,
)
if onyx_document.id not in deleted_doc_ids:
num_chunks_deleted = self.delete(
onyx_document.id, onyx_document.chunk_count
)
deleted_doc_ids.add(onyx_document.id)
document_indexing_results.append(
DocumentInsertionRecord(
document_id=onyx_document.id,
already_existed=num_chunks_deleted > 0,
)
)
# Now index. This will raise if a chunk of the same ID exists, which
# we do not expect because we should have deleted all chunks.
self._client.bulk_index_documents(
documents=chunk_batch,
tenant_state=self._tenant_state,
)
document_indexing_results.append(document_insertion_record)
for chunk in chunks:
doc_id = chunk.source_document.id
if doc_id != current_doc_id:
if current_chunks:
_flush_chunks(current_chunks)
current_doc_id = doc_id
current_chunks = [chunk]
elif len(current_chunks) >= CHUNKS_PER_BATCH:
_flush_chunks(current_chunks)
current_chunks = [chunk]
else:
current_chunks.append(chunk)
if current_chunks:
_flush_chunks(current_chunks)
return document_indexing_results

View File

@@ -6,6 +6,7 @@ import re
import time
import urllib
import zipfile
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import datetime
from datetime import timedelta
@@ -461,7 +462,7 @@ class VespaIndex(DocumentIndex):
def index(
self,
chunks: list[DocMetadataAwareIndexChunk],
chunks: Iterable[DocMetadataAwareIndexChunk],
index_batch_params: IndexBatchParams,
) -> set[OldDocumentInsertionRecord]:
"""

View File

@@ -1,6 +1,8 @@
import concurrent.futures
import logging
import random
from collections.abc import Generator
from collections.abc import Iterable
from typing import Any
from uuid import UUID
@@ -8,6 +10,7 @@ import httpx
from pydantic import BaseModel
from retry import retry
from onyx.configs.app_configs import CHUNKS_PER_BATCH
from onyx.configs.app_configs import RECENCY_BIAS_MULTIPLIER
from onyx.configs.app_configs import RERANK_COUNT
from onyx.configs.chat_configs import DOC_TIME_DECAY
@@ -318,7 +321,7 @@ class VespaDocumentIndex(DocumentIndex):
def index(
self,
chunks: list[DocMetadataAwareIndexChunk],
chunks: Iterable[DocMetadataAwareIndexChunk],
indexing_metadata: IndexingMetadata,
) -> list[DocumentInsertionRecord]:
doc_id_to_chunk_cnt_diff = indexing_metadata.doc_id_to_chunk_cnt_diff
@@ -338,22 +341,31 @@ class VespaDocumentIndex(DocumentIndex):
# Vespa has restrictions on valid characters, yet document IDs come from
# external w.r.t. this class. We need to sanitize them.
cleaned_chunks: list[DocMetadataAwareIndexChunk] = [
clean_chunk_id_copy(chunk) for chunk in chunks
]
assert len(cleaned_chunks) == len(
chunks
), "Bug: Cleaned chunks and input chunks have different lengths."
#
# Instead of materializing all cleaned chunks upfront, we stream them
# through a generator that cleans IDs and builds the original-ID mapping
# incrementally as chunks flow into Vespa.
def _clean_and_track(
chunks_iter: Iterable[DocMetadataAwareIndexChunk],
id_map: dict[str, str],
seen_ids: set[str],
) -> Generator[DocMetadataAwareIndexChunk, None, None]:
"""Cleans chunk IDs and builds the original-ID mapping
incrementally as chunks flow through, avoiding a separate
materialization pass."""
for chunk in chunks_iter:
original_id = chunk.source_document.id
cleaned = clean_chunk_id_copy(chunk)
cleaned_id = cleaned.source_document.id
# Needed so the final DocumentInsertionRecord returned can have
# the original document ID. cleaned_chunks might not contain IDs
# exactly as callers supplied them.
id_map[cleaned_id] = original_id
seen_ids.add(cleaned_id)
yield cleaned
# Needed so the final DocumentInsertionRecord returned can have the
# original document ID. cleaned_chunks might not contain IDs exactly as
# callers supplied them.
new_document_id_to_original_document_id: dict[str, str] = dict()
for i, cleaned_chunk in enumerate(cleaned_chunks):
old_chunk = chunks[i]
new_document_id_to_original_document_id[
cleaned_chunk.source_document.id
] = old_chunk.source_document.id
new_document_id_to_original_document_id: dict[str, str] = {}
all_cleaned_doc_ids: set[str] = set()
existing_docs: set[str] = set()
@@ -409,8 +421,16 @@ class VespaDocumentIndex(DocumentIndex):
executor=executor,
)
# Insert new Vespa documents.
for chunk_batch in batch_generator(cleaned_chunks, BATCH_SIZE):
# Insert new Vespa documents, streaming through the cleaning
# pipeline so chunks are never fully materialized.
cleaned_chunks = _clean_and_track(
chunks,
new_document_id_to_original_document_id,
all_cleaned_doc_ids,
)
for chunk_batch in batch_generator(
cleaned_chunks, min(BATCH_SIZE, CHUNKS_PER_BATCH)
):
batch_index_vespa_chunks(
chunks=chunk_batch,
index_name=self._index_name,
@@ -419,10 +439,6 @@ class VespaDocumentIndex(DocumentIndex):
executor=executor,
)
all_cleaned_doc_ids: set[str] = {
chunk.source_document.id for chunk in cleaned_chunks
}
return [
DocumentInsertionRecord(
document_id=new_document_id_to_original_document_id[cleaned_doc_id],

View File

@@ -35,6 +35,8 @@ class OnyxErrorCode(Enum):
INSUFFICIENT_PERMISSIONS = ("INSUFFICIENT_PERMISSIONS", 403)
ADMIN_ONLY = ("ADMIN_ONLY", 403)
EE_REQUIRED = ("EE_REQUIRED", 403)
SINGLE_TENANT_ONLY = ("SINGLE_TENANT_ONLY", 403)
ENV_VAR_GATED = ("ENV_VAR_GATED", 403)
# ------------------------------------------------------------------
# Validation / Bad Request (400)

View File

View File

@@ -0,0 +1,26 @@
from onyx.configs.app_configs import HOOK_ENABLED
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from shared_configs.configs import MULTI_TENANT
def require_hook_enabled() -> None:
"""FastAPI dependency that gates all hook management endpoints.
Hooks are only available in single-tenant / self-hosted deployments with
HOOK_ENABLED=true explicitly set. Two layers of protection:
1. MULTI_TENANT check — rejects even if HOOK_ENABLED is accidentally set true
2. HOOK_ENABLED flag — explicit opt-in by the operator
Use as: Depends(require_hook_enabled)
"""
if MULTI_TENANT:
raise OnyxError(
OnyxErrorCode.SINGLE_TENANT_ONLY,
"Hooks are not available in multi-tenant deployments",
)
if not HOOK_ENABLED:
raise OnyxError(
OnyxErrorCode.ENV_VAR_GATED,
"Hooks are not enabled. Set HOOK_ENABLED=true to enable.",
)

View File

@@ -1,4 +1,7 @@
from __future__ import annotations
import contextlib
from collections import defaultdict
from collections.abc import Generator
from sqlalchemy.engine.util import TransactionalContext
@@ -19,7 +22,7 @@ from onyx.db.document import update_docs_updated_at__no_commit
from onyx.db.document_set import fetch_document_sets_for_documents
from onyx.indexing.indexing_pipeline import DocumentBatchPrepareContext
from onyx.indexing.indexing_pipeline import index_doc_batch_prepare
from onyx.indexing.models import BuildMetadataAwareChunksResult
from onyx.indexing.models import DocAwareChunk
from onyx.indexing.models import DocMetadataAwareIndexChunk
from onyx.indexing.models import IndexChunk
from onyx.indexing.models import UpdatableChunkData
@@ -85,14 +88,18 @@ class DocumentIndexingBatchAdapter:
) as transaction:
yield transaction
def build_metadata_aware_chunks(
def prepare_enrichment(
self,
chunks_with_embeddings: list[IndexChunk],
chunk_content_scores: list[float],
tenant_id: str,
context: DocumentBatchPrepareContext,
) -> BuildMetadataAwareChunksResult:
"""Enrich chunks with access, document sets, boosts, token counts, and hierarchy."""
tenant_id: str,
chunks: list[DocAwareChunk],
) -> DocumentChunkEnricher:
"""Do all DB lookups once and return a per-chunk enricher."""
updatable_ids = [doc.id for doc in context.updatable_docs]
doc_id_to_new_chunk_cnt: dict[str, int] = defaultdict(int)
for chunk in chunks:
doc_id_to_new_chunk_cnt[chunk.source_document.id] += 1
no_access = DocumentAccess.build(
user_emails=[],
@@ -102,67 +109,30 @@ class DocumentIndexingBatchAdapter:
is_public=False,
)
updatable_ids = [doc.id for doc in context.updatable_docs]
doc_id_to_access_info = get_access_for_documents(
document_ids=updatable_ids, db_session=self.db_session
)
doc_id_to_document_set = {
document_id: document_sets
for document_id, document_sets in fetch_document_sets_for_documents(
return DocumentChunkEnricher(
doc_id_to_access_info=get_access_for_documents(
document_ids=updatable_ids, db_session=self.db_session
)
}
doc_id_to_previous_chunk_cnt: dict[str, int] = {
document_id: chunk_count
for document_id, chunk_count in fetch_chunk_counts_for_documents(
document_ids=updatable_ids,
db_session=self.db_session,
)
}
doc_id_to_new_chunk_cnt: dict[str, int] = {
doc_id: 0 for doc_id in updatable_ids
}
for chunk in chunks_with_embeddings:
if chunk.source_document.id in doc_id_to_new_chunk_cnt:
doc_id_to_new_chunk_cnt[chunk.source_document.id] += 1
# Get ancestor hierarchy node IDs for each document
doc_id_to_ancestor_ids = self._get_ancestor_ids_for_documents(
context.updatable_docs, tenant_id
)
access_aware_chunks = [
DocMetadataAwareIndexChunk.from_index_chunk(
index_chunk=chunk,
access=doc_id_to_access_info.get(chunk.source_document.id, no_access),
document_sets=set(
doc_id_to_document_set.get(chunk.source_document.id, [])
),
user_project=[],
personas=[],
boost=(
context.id_to_boost_map[chunk.source_document.id]
if chunk.source_document.id in context.id_to_boost_map
else DEFAULT_BOOST
),
tenant_id=tenant_id,
aggregated_chunk_boost_factor=chunk_content_scores[chunk_num],
ancestor_hierarchy_node_ids=doc_id_to_ancestor_ids[
chunk.source_document.id
],
)
for chunk_num, chunk in enumerate(chunks_with_embeddings)
]
return BuildMetadataAwareChunksResult(
chunks=access_aware_chunks,
doc_id_to_previous_chunk_cnt=doc_id_to_previous_chunk_cnt,
doc_id_to_new_chunk_cnt=doc_id_to_new_chunk_cnt,
user_file_id_to_raw_text={},
user_file_id_to_token_count={},
),
doc_id_to_document_set={
document_id: document_sets
for document_id, document_sets in fetch_document_sets_for_documents(
document_ids=updatable_ids, db_session=self.db_session
)
},
doc_id_to_ancestor_ids=self._get_ancestor_ids_for_documents(
context.updatable_docs, tenant_id
),
id_to_boost_map=context.id_to_boost_map,
doc_id_to_previous_chunk_cnt={
document_id: chunk_count
for document_id, chunk_count in fetch_chunk_counts_for_documents(
document_ids=updatable_ids,
db_session=self.db_session,
)
},
doc_id_to_new_chunk_cnt=dict(doc_id_to_new_chunk_cnt),
no_access=no_access,
tenant_id=tenant_id,
)
def _get_ancestor_ids_for_documents(
@@ -203,7 +173,7 @@ class DocumentIndexingBatchAdapter:
context: DocumentBatchPrepareContext,
updatable_chunk_data: list[UpdatableChunkData],
filtered_documents: list[Document],
result: BuildMetadataAwareChunksResult,
enrichment: DocumentChunkEnricher,
) -> None:
"""Finalize DB updates, store plaintext, and mark docs as indexed."""
updatable_ids = [doc.id for doc in context.updatable_docs]
@@ -227,7 +197,7 @@ class DocumentIndexingBatchAdapter:
update_docs_chunk_count__no_commit(
document_ids=updatable_ids,
doc_id_to_chunk_count=result.doc_id_to_new_chunk_cnt,
doc_id_to_chunk_count=enrichment.doc_id_to_new_chunk_cnt,
db_session=self.db_session,
)
@@ -249,3 +219,52 @@ class DocumentIndexingBatchAdapter:
)
self.db_session.commit()
class DocumentChunkEnricher:
"""Pre-computed metadata for per-chunk enrichment of connector documents."""
def __init__(
self,
doc_id_to_access_info: dict[str, DocumentAccess],
doc_id_to_document_set: dict[str, list[str]],
doc_id_to_ancestor_ids: dict[str, list[int]],
id_to_boost_map: dict[str, int],
doc_id_to_previous_chunk_cnt: dict[str, int],
doc_id_to_new_chunk_cnt: dict[str, int],
no_access: DocumentAccess,
tenant_id: str,
) -> None:
self._doc_id_to_access_info = doc_id_to_access_info
self._doc_id_to_document_set = doc_id_to_document_set
self._doc_id_to_ancestor_ids = doc_id_to_ancestor_ids
self._id_to_boost_map = id_to_boost_map
self._no_access = no_access
self._tenant_id = tenant_id
self.doc_id_to_previous_chunk_cnt = doc_id_to_previous_chunk_cnt
self.doc_id_to_new_chunk_cnt = doc_id_to_new_chunk_cnt
def enrich_chunk(
self, chunk: IndexChunk, score: float
) -> DocMetadataAwareIndexChunk:
return DocMetadataAwareIndexChunk.from_index_chunk(
index_chunk=chunk,
access=self._doc_id_to_access_info.get(
chunk.source_document.id, self._no_access
),
document_sets=set(
self._doc_id_to_document_set.get(chunk.source_document.id, [])
),
user_project=[],
personas=[],
boost=(
self._id_to_boost_map[chunk.source_document.id]
if chunk.source_document.id in self._id_to_boost_map
else DEFAULT_BOOST
),
tenant_id=self._tenant_id,
aggregated_chunk_boost_factor=score,
ancestor_hierarchy_node_ids=self._doc_id_to_ancestor_ids[
chunk.source_document.id
],
)

View File

@@ -1,6 +1,9 @@
from __future__ import annotations
import contextlib
import datetime
import time
from collections import defaultdict
from collections.abc import Generator
from uuid import UUID
@@ -24,7 +27,7 @@ from onyx.db.user_file import fetch_persona_ids_for_user_files
from onyx.db.user_file import fetch_user_project_ids_for_user_files
from onyx.file_store.utils import store_user_file_plaintext
from onyx.indexing.indexing_pipeline import DocumentBatchPrepareContext
from onyx.indexing.models import BuildMetadataAwareChunksResult
from onyx.indexing.models import DocAwareChunk
from onyx.indexing.models import DocMetadataAwareIndexChunk
from onyx.indexing.models import IndexChunk
from onyx.indexing.models import UpdatableChunkData
@@ -101,13 +104,20 @@ class UserFileIndexingAdapter:
f"Failed to acquire locks after {_NUM_LOCK_ATTEMPTS} attempts for user files: {[doc.id for doc in documents]}"
)
def build_metadata_aware_chunks(
def prepare_enrichment(
self,
chunks_with_embeddings: list[IndexChunk],
chunk_content_scores: list[float],
tenant_id: str,
context: DocumentBatchPrepareContext,
) -> BuildMetadataAwareChunksResult:
tenant_id: str,
chunks: list[DocAwareChunk],
) -> UserFileChunkEnricher:
"""Do all DB lookups and pre-compute file metadata from chunks."""
updatable_ids = [doc.id for doc in context.updatable_docs]
doc_id_to_new_chunk_cnt: dict[str, int] = defaultdict(int)
content_by_file: dict[str, list[str]] = defaultdict(list)
for chunk in chunks:
doc_id_to_new_chunk_cnt[chunk.source_document.id] += 1
content_by_file[chunk.source_document.id].append(chunk.content)
no_access = DocumentAccess.build(
user_emails=[],
@@ -117,7 +127,6 @@ class UserFileIndexingAdapter:
is_public=False,
)
updatable_ids = [doc.id for doc in context.updatable_docs]
user_file_id_to_project_ids = fetch_user_project_ids_for_user_files(
user_file_ids=updatable_ids,
db_session=self.db_session,
@@ -138,17 +147,6 @@ class UserFileIndexingAdapter:
)
}
user_file_id_to_new_chunk_cnt: dict[str, int] = {
user_file_id: len(
[
chunk
for chunk in chunks_with_embeddings
if chunk.source_document.id == user_file_id
]
)
for user_file_id in updatable_ids
}
# Initialize tokenizer used for token count calculation
try:
llm = get_default_llm()
@@ -163,15 +161,9 @@ class UserFileIndexingAdapter:
user_file_id_to_raw_text: dict[str, str] = {}
user_file_id_to_token_count: dict[str, int | None] = {}
for user_file_id in updatable_ids:
user_file_chunks = [
chunk
for chunk in chunks_with_embeddings
if chunk.source_document.id == user_file_id
]
if user_file_chunks:
combined_content = " ".join(
[chunk.content for chunk in user_file_chunks]
)
contents = content_by_file.get(user_file_id)
if contents:
combined_content = " ".join(contents)
user_file_id_to_raw_text[str(user_file_id)] = combined_content
token_count = (
len(llm_tokenizer.encode(combined_content)) if llm_tokenizer else 0
@@ -181,28 +173,16 @@ class UserFileIndexingAdapter:
user_file_id_to_raw_text[str(user_file_id)] = ""
user_file_id_to_token_count[str(user_file_id)] = None
access_aware_chunks = [
DocMetadataAwareIndexChunk.from_index_chunk(
index_chunk=chunk,
access=user_file_id_to_access.get(chunk.source_document.id, no_access),
document_sets=set(),
user_project=user_file_id_to_project_ids.get(
chunk.source_document.id, []
),
personas=user_file_id_to_persona_ids.get(chunk.source_document.id, []),
boost=DEFAULT_BOOST,
tenant_id=tenant_id,
aggregated_chunk_boost_factor=chunk_content_scores[chunk_num],
)
for chunk_num, chunk in enumerate(chunks_with_embeddings)
]
return BuildMetadataAwareChunksResult(
chunks=access_aware_chunks,
return UserFileChunkEnricher(
user_file_id_to_access=user_file_id_to_access,
user_file_id_to_project_ids=user_file_id_to_project_ids,
user_file_id_to_persona_ids=user_file_id_to_persona_ids,
doc_id_to_previous_chunk_cnt=user_file_id_to_previous_chunk_cnt,
doc_id_to_new_chunk_cnt=user_file_id_to_new_chunk_cnt,
doc_id_to_new_chunk_cnt=dict(doc_id_to_new_chunk_cnt),
user_file_id_to_raw_text=user_file_id_to_raw_text,
user_file_id_to_token_count=user_file_id_to_token_count,
no_access=no_access,
tenant_id=tenant_id,
)
def _notify_assistant_owners_if_files_ready(
@@ -246,7 +226,7 @@ class UserFileIndexingAdapter:
context: DocumentBatchPrepareContext,
updatable_chunk_data: list[UpdatableChunkData], # noqa: ARG002
filtered_documents: list[Document], # noqa: ARG002
result: BuildMetadataAwareChunksResult,
enrichment: UserFileChunkEnricher,
) -> None:
user_file_ids = [doc.id for doc in context.updatable_docs]
@@ -263,8 +243,10 @@ class UserFileIndexingAdapter:
user_file.last_project_sync_at = datetime.datetime.now(
datetime.timezone.utc
)
user_file.chunk_count = result.doc_id_to_new_chunk_cnt[str(user_file.id)]
user_file.token_count = result.user_file_id_to_token_count[
user_file.chunk_count = enrichment.doc_id_to_new_chunk_cnt[
str(user_file.id)
]
user_file.token_count = enrichment.user_file_id_to_token_count[
str(user_file.id)
]
@@ -276,8 +258,54 @@ class UserFileIndexingAdapter:
# Store the plaintext in the file store for faster retrieval
# NOTE: this creates its own session to avoid committing the overall
# transaction.
for user_file_id, raw_text in result.user_file_id_to_raw_text.items():
for user_file_id, raw_text in enrichment.user_file_id_to_raw_text.items():
store_user_file_plaintext(
user_file_id=UUID(user_file_id),
plaintext_content=raw_text,
)
class UserFileChunkEnricher:
"""Pre-computed metadata for per-chunk enrichment of user-uploaded files."""
def __init__(
self,
user_file_id_to_access: dict[str, DocumentAccess],
user_file_id_to_project_ids: dict[str, list[int]],
user_file_id_to_persona_ids: dict[str, list[int]],
doc_id_to_previous_chunk_cnt: dict[str, int],
doc_id_to_new_chunk_cnt: dict[str, int],
user_file_id_to_raw_text: dict[str, str],
user_file_id_to_token_count: dict[str, int | None],
no_access: DocumentAccess,
tenant_id: str,
) -> None:
self._user_file_id_to_access = user_file_id_to_access
self._user_file_id_to_project_ids = user_file_id_to_project_ids
self._user_file_id_to_persona_ids = user_file_id_to_persona_ids
self._no_access = no_access
self._tenant_id = tenant_id
self.doc_id_to_previous_chunk_cnt = doc_id_to_previous_chunk_cnt
self.doc_id_to_new_chunk_cnt = doc_id_to_new_chunk_cnt
self.user_file_id_to_raw_text = user_file_id_to_raw_text
self.user_file_id_to_token_count = user_file_id_to_token_count
def enrich_chunk(
self, chunk: IndexChunk, score: float
) -> DocMetadataAwareIndexChunk:
return DocMetadataAwareIndexChunk.from_index_chunk(
index_chunk=chunk,
access=self._user_file_id_to_access.get(
chunk.source_document.id, self._no_access
),
document_sets=set(),
user_project=self._user_file_id_to_project_ids.get(
chunk.source_document.id, []
),
personas=self._user_file_id_to_persona_ids.get(
chunk.source_document.id, []
),
boost=DEFAULT_BOOST,
tenant_id=self._tenant_id,
aggregated_chunk_boost_factor=score,
)

View File

@@ -1,11 +1,18 @@
import pickle
import tempfile
from collections import defaultdict
from collections.abc import Callable
from collections.abc import Generator
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Protocol
from pydantic import BaseModel
from pydantic import ConfigDict
from sqlalchemy.orm import Session
from onyx.configs.app_configs import CHUNKS_PER_BATCH
from onyx.configs.app_configs import DEFAULT_CONTEXTUAL_RAG_LLM_NAME
from onyx.configs.app_configs import DEFAULT_CONTEXTUAL_RAG_LLM_PROVIDER
from onyx.configs.app_configs import ENABLE_CONTEXTUAL_RAG
@@ -47,6 +54,8 @@ from onyx.indexing.chunker import Chunker
from onyx.indexing.embedder import embed_chunks_with_failure_handling
from onyx.indexing.embedder import IndexingEmbedder
from onyx.indexing.models import DocAwareChunk
from onyx.indexing.models import DocMetadataAwareIndexChunk
from onyx.indexing.models import IndexChunk
from onyx.indexing.models import IndexingBatchAdapter
from onyx.indexing.models import UpdatableChunkData
from onyx.indexing.vector_db_insertion import write_chunks_to_vector_db_with_backoff
@@ -63,6 +72,7 @@ from onyx.natural_language_processing.utils import tokenizer_trim_middle
from onyx.prompts.contextual_retrieval import CONTEXTUAL_RAG_PROMPT1
from onyx.prompts.contextual_retrieval import CONTEXTUAL_RAG_PROMPT2
from onyx.prompts.contextual_retrieval import DOCUMENT_SUMMARY_PROMPT
from onyx.utils.batching import batch_generator
from onyx.utils.logger import setup_logger
from onyx.utils.postgres_sanitization import sanitize_documents_for_postgres
from onyx.utils.threadpool_concurrency import run_functions_tuples_in_parallel
@@ -91,6 +101,21 @@ class IndexingPipelineResult(BaseModel):
failures: list[ConnectorFailure]
@classmethod
def empty(cls, total_docs: int) -> "IndexingPipelineResult":
return cls(
new_docs=0,
total_docs=total_docs,
total_chunks=0,
failures=[],
)
class ChunkEmbeddingResult(BaseModel):
embedding_path: Path
successful_chunk_ids: list[tuple[int, str]] # (chunk_id, document_id)
connector_failures: list[ConnectorFailure]
class IndexingPipelineProtocol(Protocol):
def __call__(
@@ -139,6 +164,100 @@ def _upsert_documents_in_db(
)
def embed_chunks_in_batches(
chunks: list[DocAwareChunk],
embedder: IndexingEmbedder,
tenant_id: str,
request_id: str | None,
) -> ChunkEmbeddingResult:
"""Embeds chunks in batches of CHUNKS_PER_BATCH, spilling each batch to disk.
For each batch:
1. Embed the chunks via embed_chunks_with_failure_handling
2. Pickle the resulting IndexChunks to a temp file
3. Clear the batch from memory
Returns:
- Path to the temp directory containing one pickle file per batch
- Accumulated embedding failures across all batches
"""
tmpdir = Path(tempfile.mkdtemp(prefix="onyx_embeddings_"))
successful_chunk_ids: list[tuple[int, str]] = []
all_embedding_failures: list[ConnectorFailure] = []
for batch_idx, chunk_batch in enumerate(batch_generator(chunks, CHUNKS_PER_BATCH)):
logger.debug(f"Embedding batch {batch_idx}: {len(chunk_batch)} chunks")
chunks_with_embeddings, embedding_failures = embed_chunks_with_failure_handling(
chunks=chunk_batch,
embedder=embedder,
tenant_id=tenant_id,
request_id=request_id,
)
all_embedding_failures.extend(embedding_failures)
# Track which chunks succeeded by excluding failed doc IDs
failed_doc_ids = {
f.failed_document.document_id
for f in embedding_failures
if f.failed_document
}
successful_chunk_ids.extend(
(c.chunk_id, c.source_document.id)
for c in chunk_batch
if c.source_document.id not in failed_doc_ids
)
# Spill embeddings to disk
batch_file = tmpdir / f"batch_{batch_idx}.pkl"
with open(batch_file, "wb") as f:
pickle.dump(chunks_with_embeddings, f)
# Free memory
del chunks_with_embeddings
return ChunkEmbeddingResult(
embedding_path=tmpdir,
successful_chunk_ids=successful_chunk_ids,
connector_failures=all_embedding_failures,
)
class EmbedStream:
def __init__(self, tmpdir: Path) -> None:
self._tmpdir = tmpdir
def stream(self) -> Iterator[IndexChunk]:
for batch_file in sorted(self._tmpdir.glob("batch_*.pkl")):
with open(batch_file, "rb") as f:
batch: list[IndexChunk] = pickle.load(f)
yield from batch
@contextmanager
def use_embed_stream(
tmpdir: Path,
) -> Generator[EmbedStream, None, None]:
"""Context manager that provides a factory for creating chunk iterators.
Each call to stream() returns a fresh generator over the embedded chunks
on disk, so the data can be iterated multiple times (e.g. once per
document_index). Files are cleaned up when the context manager exits.
Usage:
with use_embed_stream(embedding_path) as embed_stream:
for document_index in document_indices:
for chunk in embed_stream.stream():
...
"""
try:
yield EmbedStream(tmpdir)
finally:
for batch_file in tmpdir.glob("batch_*.pkl"):
batch_file.unlink(missing_ok=True)
tmpdir.rmdir()
def get_doc_ids_to_update(
documents: list[Document], db_docs: list[DBDocument]
) -> list[Document]:
@@ -631,6 +750,29 @@ def add_contextual_summaries(
return chunks
def _verify_indexing_completeness(
insertion_records: list[DocumentInsertionRecord],
write_failures: list[ConnectorFailure],
embedding_failed_doc_ids: set[str],
updatable_ids: list[str],
document_index_name: str,
) -> None:
"""Verify that every updatable document was either indexed or reported as failed."""
all_returned_doc_ids = (
{r.document_id for r in insertion_records}
| {f.failed_document.document_id for f in write_failures if f.failed_document}
| embedding_failed_doc_ids
)
if all_returned_doc_ids != set(updatable_ids):
raise RuntimeError(
f"Some documents were not successfully indexed. "
f"Updatable IDs: {updatable_ids}, "
f"Returned IDs: {all_returned_doc_ids}. "
f"This should never happen. "
f"This occured for document index {document_index_name}"
)
@log_function_time(debug_only=True)
def index_doc_batch(
*,
@@ -666,12 +808,7 @@ def index_doc_batch(
filtered_documents = filter_fnc(document_batch)
context = adapter.prepare(filtered_documents, ignore_time_skip)
if not context:
return IndexingPipelineResult(
new_docs=0,
total_docs=len(filtered_documents),
total_chunks=0,
failures=[],
)
return IndexingPipelineResult.empty(len(filtered_documents))
# Convert documents to IndexingDocument objects with processed section
# logger.debug("Processing image sections")
@@ -710,117 +847,96 @@ def index_doc_batch(
)
logger.debug("Starting embedding")
chunks_with_embeddings, embedding_failures = (
embed_chunks_with_failure_handling(
chunks=chunks,
embedder=embedder,
tenant_id=tenant_id,
request_id=request_id,
)
if chunks
else ([], [])
embedding_result = embed_chunks_in_batches(
chunks=chunks,
embedder=embedder,
tenant_id=tenant_id,
request_id=request_id,
)
chunk_content_scores = [1.0] * len(chunks_with_embeddings)
updatable_ids = [doc.id for doc in context.updatable_docs]
updatable_chunk_data = [
UpdatableChunkData(
chunk_id=chunk.chunk_id,
document_id=chunk.source_document.id,
boost_score=score,
chunk_id=chunk_id,
document_id=document_id,
boost_score=1.0,
)
for chunk, score in zip(chunks_with_embeddings, chunk_content_scores)
for chunk_id, document_id in embedding_result.successful_chunk_ids
]
# Acquires a lock on the documents so that no other process can modify them
# NOTE: don't need to acquire till here, since this is when the actual race condition
# with Vespa can occur.
with adapter.lock_context(context.updatable_docs):
# we're concerned about race conditions where multiple simultaneous indexings might result
# in one set of metadata overwriting another one in vespa.
# we still write data here for the immediate and most likely correct sync, but
# to resolve this, an update of the last modified field at the end of this loop
# always triggers a final metadata sync via the celery queue
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=chunks_with_embeddings,
chunk_content_scores=chunk_content_scores,
tenant_id=tenant_id,
with (
adapter.lock_context(context.updatable_docs),
use_embed_stream(embedding_result.embedding_path) as embed_stream,
):
enricher = adapter.prepare_enrichment(
context=context,
tenant_id=tenant_id,
chunks=chunks,
)
short_descriptor_list = [chunk.to_short_descriptor() for chunk in result.chunks]
short_descriptor_log = str(short_descriptor_list)[:1024]
logger.debug(f"Indexing the following chunks: {short_descriptor_log}")
index_batch_params = IndexBatchParams(
doc_id_to_previous_chunk_cnt=enricher.doc_id_to_previous_chunk_cnt,
doc_id_to_new_chunk_cnt=enricher.doc_id_to_new_chunk_cnt,
tenant_id=tenant_id,
large_chunks_enabled=chunker.enable_large_chunks,
)
embedding_failed_doc_ids = {
f.failed_document.document_id
for f in embedding_result.connector_failures
if f.failed_document
}
primary_doc_idx_insertion_records: list[DocumentInsertionRecord] | None = None
primary_doc_idx_vector_db_write_failures: list[ConnectorFailure] | None = None
for document_index in document_indices:
# A document will not be spread across different batches, so all the
# documents with chunks in this set, are fully represented by the chunks
# in this set
(
insertion_records,
vector_db_write_failures,
) = write_chunks_to_vector_db_with_backoff(
def _enriched_stream() -> Iterator[DocMetadataAwareIndexChunk]:
for chunk in embed_stream.stream():
yield enricher.enrich_chunk(chunk, 1.0)
insertion_records, write_failures = write_chunks_to_vector_db_with_backoff(
document_index=document_index,
chunks=result.chunks,
index_batch_params=IndexBatchParams(
doc_id_to_previous_chunk_cnt=result.doc_id_to_previous_chunk_cnt,
doc_id_to_new_chunk_cnt=result.doc_id_to_new_chunk_cnt,
tenant_id=tenant_id,
large_chunks_enabled=chunker.enable_large_chunks,
),
chunks=_enriched_stream(),
index_batch_params=index_batch_params,
)
all_returned_doc_ids: set[str] = (
{record.document_id for record in insertion_records}
.union(
{
record.failed_document.document_id
for record in vector_db_write_failures
if record.failed_document
}
)
.union(
{
record.failed_document.document_id
for record in embedding_failures
if record.failed_document
}
)
_verify_indexing_completeness(
insertion_records=insertion_records,
write_failures=write_failures,
embedding_failed_doc_ids=embedding_failed_doc_ids,
updatable_ids=updatable_ids,
document_index_name=document_index.__class__.__name__,
)
if all_returned_doc_ids != set(updatable_ids):
raise RuntimeError(
f"Some documents were not successfully indexed. "
f"Updatable IDs: {updatable_ids}, "
f"Returned IDs: {all_returned_doc_ids}. "
"This should never happen."
f"This occured for document index {document_index.__class__.__name__}"
)
# We treat the first document index we got as the primary one used
# for reporting the state of indexing.
if primary_doc_idx_insertion_records is None:
primary_doc_idx_insertion_records = insertion_records
if primary_doc_idx_vector_db_write_failures is None:
primary_doc_idx_vector_db_write_failures = vector_db_write_failures
primary_doc_idx_vector_db_write_failures = write_failures
adapter.post_index(
context=context,
updatable_chunk_data=updatable_chunk_data,
filtered_documents=filtered_documents,
result=result,
enrichment=enricher,
)
assert primary_doc_idx_insertion_records is not None
assert primary_doc_idx_vector_db_write_failures is not None
return IndexingPipelineResult(
new_docs=len(
[r for r in primary_doc_idx_insertion_records if not r.already_existed]
new_docs=sum(
1 for r in primary_doc_idx_insertion_records if not r.already_existed
),
total_docs=len(filtered_documents),
total_chunks=len(chunks_with_embeddings),
failures=primary_doc_idx_vector_db_write_failures + embedding_failures,
total_chunks=len(embedding_result.successful_chunk_ids),
failures=primary_doc_idx_vector_db_write_failures
+ embedding_result.connector_failures,
)

View File

@@ -235,12 +235,16 @@ class UpdatableChunkData(BaseModel):
boost_score: float
class BuildMetadataAwareChunksResult(BaseModel):
chunks: list[DocMetadataAwareIndexChunk]
class ChunkEnrichmentContext(Protocol):
"""Returned by prepare_enrichment. Holds pre-computed metadata lookups
and provides per-chunk enrichment."""
doc_id_to_previous_chunk_cnt: dict[str, int]
doc_id_to_new_chunk_cnt: dict[str, int]
user_file_id_to_raw_text: dict[str, str]
user_file_id_to_token_count: dict[str, int | None]
def enrich_chunk(
self, chunk: IndexChunk, score: float
) -> DocMetadataAwareIndexChunk: ...
class IndexingBatchAdapter(Protocol):
@@ -254,18 +258,17 @@ class IndexingBatchAdapter(Protocol):
) -> Generator[TransactionalContext, None, None]:
"""Provide a transaction/row-lock context for critical updates."""
def build_metadata_aware_chunks(
def prepare_enrichment(
self,
chunks_with_embeddings: list[IndexChunk],
chunk_content_scores: list[float],
tenant_id: str,
context: "DocumentBatchPrepareContext",
) -> BuildMetadataAwareChunksResult: ...
tenant_id: str,
chunks: list[DocAwareChunk],
) -> ChunkEnrichmentContext: ...
def post_index(
self,
context: "DocumentBatchPrepareContext",
updatable_chunk_data: list[UpdatableChunkData],
filtered_documents: list[Document],
result: BuildMetadataAwareChunksResult,
enrichment: ChunkEnrichmentContext,
) -> None: ...

View File

@@ -1,5 +1,6 @@
import time
from collections import defaultdict
from collections.abc import Iterable
from http import HTTPStatus
import httpx
@@ -28,7 +29,7 @@ def _log_insufficient_storage_error(e: Exception) -> None:
def write_chunks_to_vector_db_with_backoff(
document_index: DocumentIndex,
chunks: list[DocMetadataAwareIndexChunk],
chunks: Iterable[DocMetadataAwareIndexChunk],
index_batch_params: IndexBatchParams,
) -> tuple[list[DocumentInsertionRecord], list[ConnectorFailure]]:
"""Tries to insert all chunks in one large batch. If that batch fails for any reason,

View File

@@ -1319,7 +1319,7 @@ def get_connector_indexing_status(
# Track admin page visit for analytics
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=user.email,
distinct_id=str(user.id),
event=MilestoneRecordType.VISITED_ADMIN_PAGE,
)
@@ -1533,7 +1533,7 @@ def create_connector_from_model(
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=user.email,
distinct_id=str(user.id),
event=MilestoneRecordType.CREATED_CONNECTOR,
)
@@ -1611,7 +1611,7 @@ def create_connector_with_mock_credential(
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=user.email,
distinct_id=str(user.id),
event=MilestoneRecordType.CREATED_CONNECTOR,
)
return response
@@ -1915,9 +1915,7 @@ def submit_connector_request(
if not connector_name:
raise HTTPException(status_code=400, detail="Connector name cannot be empty")
# Get user identifier for telemetry
user_email = user.email
distinct_id = user_email or tenant_id
# Track connector request via PostHog telemetry (Cloud only)
from shared_configs.configs import MULTI_TENANT
@@ -1925,11 +1923,11 @@ def submit_connector_request(
if MULTI_TENANT:
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=distinct_id,
distinct_id=str(user.id),
event=MilestoneRecordType.REQUESTED_CONNECTOR,
properties={
"connector_name": connector_name,
"user_email": user_email,
"user_email": user.email,
},
)

View File

@@ -314,7 +314,7 @@ def create_persona(
)
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=user.email,
distinct_id=str(user.id),
event=MilestoneRecordType.CREATED_ASSISTANT,
)

View File

@@ -81,6 +81,7 @@ from onyx.server.manage.llm.models import VisionProviderResponse
from onyx.server.manage.llm.utils import generate_bedrock_display_name
from onyx.server.manage.llm.utils import generate_ollama_display_name
from onyx.server.manage.llm.utils import infer_vision_support
from onyx.server.manage.llm.utils import is_embedding_model
from onyx.server.manage.llm.utils import is_reasoning_model
from onyx.server.manage.llm.utils import is_valid_bedrock_model
from onyx.server.manage.llm.utils import ModelMetadata
@@ -1374,6 +1375,10 @@ def get_litellm_available_models(
try:
model_details = LitellmModelDetails.model_validate(model)
# Skip embedding models
if is_embedding_model(model_details.id):
continue
results.append(
LitellmFinalModelResponse(
provider_name=model_details.owned_by,

View File

@@ -366,3 +366,18 @@ def extract_vendor_from_model_name(model_name: str, provider: str) -> str | None
return None
return None
def is_embedding_model(model_name: str) -> bool:
"""Checks for if a model is an embedding model"""
from litellm import get_model_info
try:
# get_model_info raises on unknown models
# default to False
model_info = get_model_info(model_name)
except Exception:
return False
is_embedding_mode = model_info.get("mode") == "embedding"
return is_embedding_mode

View File

@@ -118,12 +118,6 @@ async def handle_streaming_transcription(
if result is None: # End of stream
logger.info("Streaming transcription: transcript stream ended")
break
if result.error:
logger.warning(
f"Streaming transcription: provider error: {result.error}"
)
await websocket.send_json({"type": "error", "message": result.error})
continue
# Send if text changed OR if VAD detected end of speech (for auto-send trigger)
if result.text and (result.text != last_transcript or result.is_vad_end):
last_transcript = result.text

View File

@@ -561,7 +561,7 @@ def handle_send_chat_message(
tenant_id = get_current_tenant_id()
mt_cloud_telemetry(
tenant_id=tenant_id,
distinct_id=tenant_id if user.is_anonymous else user.email,
distinct_id=tenant_id if user.is_anonymous else str(user.id),
event=MilestoneRecordType.RAN_QUERY,
)

View File

@@ -15,9 +15,6 @@ class TranscriptResult(BaseModel):
is_vad_end: bool = False
"""True if VAD detected end of speech (silence). Use for auto-send."""
error: str | None = None
"""Provider error message to forward to the client, if any."""
class StreamingTranscriberProtocol(Protocol):
"""Protocol for streaming transcription sessions."""

View File

@@ -56,17 +56,6 @@ def _http_to_ws_url(http_url: str) -> str:
return http_url
_USER_FACING_ERROR_MESSAGES: dict[str, str] = {
"input_audio_buffer_commit_empty": (
"No audio was recorded. Please check your microphone and try again."
),
"invalid_api_key": "Voice service authentication failed. Please contact support.",
"rate_limit_exceeded": "Voice service is temporarily busy. Please try again shortly.",
}
_DEFAULT_USER_ERROR = "A voice transcription error occurred. Please try again."
class OpenAIStreamingTranscriber(StreamingTranscriberProtocol):
"""Streaming transcription using OpenAI Realtime API."""
@@ -153,17 +142,6 @@ class OpenAIStreamingTranscriber(StreamingTranscriberProtocol):
if msg_type == OpenAIRealtimeMessageType.ERROR:
error = data.get("error", {})
self._logger.error(f"OpenAI error: {error}")
error_code = error.get("code", "")
user_message = _USER_FACING_ERROR_MESSAGES.get(
error_code, _DEFAULT_USER_ERROR
)
await self._transcript_queue.put(
TranscriptResult(
text="",
is_vad_end=False,
error=user_message,
)
)
continue
# Handle VAD events

View File

@@ -65,7 +65,7 @@ attrs==25.4.0
# jsonschema
# referencing
# zeep
authlib==1.6.7
authlib==1.6.9
# via fastmcp
azure-cognitiveservices-speech==1.38.0
# via onyx
@@ -737,7 +737,7 @@ pygithub==2.5.0
# via onyx
pygments==2.19.2
# via rich
pyjwt==2.11.0
pyjwt==2.12.0
# via
# fastapi-users
# mcp

View File

@@ -353,7 +353,7 @@ pygments==2.19.2
# via
# ipython
# ipython-pygments-lexers
pyjwt==2.11.0
pyjwt==2.12.0
# via mcp
pyparsing==3.2.5
# via matplotlib

View File

@@ -218,7 +218,7 @@ pydantic-core==2.33.2
# via pydantic
pydantic-settings==2.12.0
# via mcp
pyjwt==2.11.0
pyjwt==2.12.0
# via mcp
python-dateutil==2.8.2
# via

View File

@@ -308,7 +308,7 @@ pydantic-core==2.33.2
# via pydantic
pydantic-settings==2.12.0
# via mcp
pyjwt==2.11.0
pyjwt==2.12.0
# via mcp
python-dateutil==2.8.2
# via

View File

@@ -1,7 +1,7 @@
"""
External dependency unit tests for UserFileIndexingAdapter metadata writing.
Validates that build_metadata_aware_chunks produces DocMetadataAwareIndexChunk
Validates that prepare_enrichment produces DocMetadataAwareIndexChunk
objects with both `user_project` and `personas` fields populated correctly
based on actual DB associations.
@@ -127,7 +127,7 @@ def _make_index_chunk(user_file: UserFile) -> IndexChunk:
class TestAdapterWritesBothMetadataFields:
"""build_metadata_aware_chunks must populate user_project AND personas."""
"""prepare_enrichment must populate user_project AND personas."""
@patch(
"onyx.indexing.adapters.user_file_indexing_adapter.get_default_llm",
@@ -153,15 +153,13 @@ class TestAdapterWritesBothMetadataFields:
doc = chunk.source_document
context = DocumentBatchPrepareContext(updatable_docs=[doc], id_to_boost_map={})
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=[chunk],
chunk_content_scores=[1.0],
tenant_id=TEST_TENANT_ID,
enricher = adapter.prepare_enrichment(
context=context,
tenant_id=TEST_TENANT_ID,
chunks=[chunk],
)
assert len(result.chunks) == 1
aware_chunk = result.chunks[0]
aware_chunk = enricher.enrich_chunk(chunk, 1.0)
assert persona.id in aware_chunk.personas
assert aware_chunk.user_project == []
@@ -190,15 +188,13 @@ class TestAdapterWritesBothMetadataFields:
updatable_docs=[chunk.source_document], id_to_boost_map={}
)
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=[chunk],
chunk_content_scores=[1.0],
tenant_id=TEST_TENANT_ID,
enricher = adapter.prepare_enrichment(
context=context,
tenant_id=TEST_TENANT_ID,
chunks=[chunk],
)
assert len(result.chunks) == 1
aware_chunk = result.chunks[0]
aware_chunk = enricher.enrich_chunk(chunk, 1.0)
assert project.id in aware_chunk.user_project
assert aware_chunk.personas == []
@@ -229,14 +225,13 @@ class TestAdapterWritesBothMetadataFields:
updatable_docs=[chunk.source_document], id_to_boost_map={}
)
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=[chunk],
chunk_content_scores=[1.0],
tenant_id=TEST_TENANT_ID,
enricher = adapter.prepare_enrichment(
context=context,
tenant_id=TEST_TENANT_ID,
chunks=[chunk],
)
aware_chunk = result.chunks[0]
aware_chunk = enricher.enrich_chunk(chunk, 1.0)
assert persona.id in aware_chunk.personas
assert project.id in aware_chunk.user_project
@@ -261,14 +256,13 @@ class TestAdapterWritesBothMetadataFields:
updatable_docs=[chunk.source_document], id_to_boost_map={}
)
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=[chunk],
chunk_content_scores=[1.0],
tenant_id=TEST_TENANT_ID,
enricher = adapter.prepare_enrichment(
context=context,
tenant_id=TEST_TENANT_ID,
chunks=[chunk],
)
aware_chunk = result.chunks[0]
aware_chunk = enricher.enrich_chunk(chunk, 1.0)
assert aware_chunk.personas == []
assert aware_chunk.user_project == []
@@ -300,12 +294,11 @@ class TestAdapterWritesBothMetadataFields:
updatable_docs=[chunk.source_document], id_to_boost_map={}
)
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=[chunk],
chunk_content_scores=[1.0],
tenant_id=TEST_TENANT_ID,
enricher = adapter.prepare_enrichment(
context=context,
tenant_id=TEST_TENANT_ID,
chunks=[chunk],
)
aware_chunk = result.chunks[0]
aware_chunk = enricher.enrich_chunk(chunk, 1.0)
assert set(aware_chunk.personas) == {persona_a.id, persona_b.id}

View File

@@ -0,0 +1,208 @@
from unittest.mock import MagicMock
from unittest.mock import patch
from onyx.access.models import DocumentAccess
from onyx.configs.constants import DocumentSource
from onyx.connectors.models import Document
from onyx.connectors.models import TextSection
from onyx.document_index.interfaces_new import IndexingMetadata
from onyx.document_index.interfaces_new import TenantState
from onyx.document_index.opensearch.opensearch_document_index import (
OpenSearchDocumentIndex,
)
from onyx.indexing.models import DocMetadataAwareIndexChunk
def _make_chunk(
doc_id: str,
chunk_id: int,
) -> DocMetadataAwareIndexChunk:
"""Creates a minimal DocMetadataAwareIndexChunk for testing."""
doc = Document(
id=doc_id,
sections=[TextSection(text="test", link="http://test.com")],
source=DocumentSource.FILE,
semantic_identifier="test_doc",
metadata={},
)
access = DocumentAccess.build(
user_emails=[],
user_groups=[],
external_user_emails=[],
external_user_group_ids=[],
is_public=True,
)
return DocMetadataAwareIndexChunk(
chunk_id=chunk_id,
blurb="test",
content="test content",
source_links={0: "http://test.com"},
image_file_id=None,
section_continuation=False,
source_document=doc,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
mini_chunk_texts=None,
large_chunk_id=None,
doc_summary="",
chunk_context="",
contextual_rag_reserved_tokens=0,
embeddings={"full_embedding": [0.1] * 10, "mini_chunk_embeddings": []},
title_embedding=[0.1] * 10,
tenant_id="test_tenant",
access=access,
document_sets=set(),
user_project=[],
personas=[],
boost=0,
aggregated_chunk_boost_factor=1.0,
ancestor_hierarchy_node_ids=[],
)
def _make_index() -> OpenSearchDocumentIndex:
"""Creates an OpenSearchDocumentIndex with a mocked client."""
mock_client = MagicMock()
mock_client.bulk_index_documents = MagicMock()
tenant_state = TenantState(tenant_id="test_tenant", multitenant=False)
index = OpenSearchDocumentIndex.__new__(OpenSearchDocumentIndex)
index._index_name = "test_index"
index._client = mock_client
index._tenant_state = tenant_state
return index
def _make_metadata(doc_id: str, chunk_count: int) -> IndexingMetadata:
return IndexingMetadata(
doc_id_to_chunk_cnt_diff={
doc_id: IndexingMetadata.ChunkCounts(
old_chunk_cnt=0,
new_chunk_cnt=chunk_count,
),
},
)
@patch("onyx.document_index.opensearch.opensearch_document_index.CHUNKS_PER_BATCH", 100)
def test_single_doc_under_batch_limit_flushes_once() -> None:
"""A document with fewer chunks than CHUNKS_PER_BATCH should flush once."""
index = _make_index()
doc_id = "doc_1"
num_chunks = 50
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
assert index._client.bulk_index_documents.call_count == 1
batch_arg = index._client.bulk_index_documents.call_args_list[0]
assert len(batch_arg.kwargs["documents"]) == num_chunks
@patch("onyx.document_index.opensearch.opensearch_document_index.CHUNKS_PER_BATCH", 100)
def test_single_doc_over_batch_limit_flushes_multiple_times() -> None:
"""A document with more chunks than CHUNKS_PER_BATCH should flush multiple times."""
index = _make_index()
doc_id = "doc_1"
num_chunks = 250
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
# 250 chunks / 100 per batch = 3 flushes (100 + 100 + 50)
assert index._client.bulk_index_documents.call_count == 3
batch_sizes = [
len(call.kwargs["documents"])
for call in index._client.bulk_index_documents.call_args_list
]
assert batch_sizes == [100, 100, 50]
@patch("onyx.document_index.opensearch.opensearch_document_index.CHUNKS_PER_BATCH", 100)
def test_single_doc_exactly_at_batch_limit() -> None:
"""A document with exactly CHUNKS_PER_BATCH chunks should flush once
(the flush happens on the next chunk, not at the boundary)."""
index = _make_index()
doc_id = "doc_1"
num_chunks = 100
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
# 100 chunks hit the >= check on chunk 101 which doesn't exist,
# so final flush handles all 100
# Actually: the elif fires when len(current_chunks) >= 100, which happens
# when current_chunks has 100 items and the 101st chunk arrives.
# With exactly 100 chunks, the 100th chunk makes len == 99, then appended -> 100.
# No 101st chunk arrives, so the final flush handles all 100.
assert index._client.bulk_index_documents.call_count == 1
@patch("onyx.document_index.opensearch.opensearch_document_index.CHUNKS_PER_BATCH", 100)
def test_single_doc_one_over_batch_limit() -> None:
"""101 chunks for one doc: first 100 flushed when the 101st arrives, then
the 101st is flushed at the end."""
index = _make_index()
doc_id = "doc_1"
num_chunks = 101
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
assert index._client.bulk_index_documents.call_count == 2
batch_sizes = [
len(call.kwargs["documents"])
for call in index._client.bulk_index_documents.call_args_list
]
assert batch_sizes == [100, 1]
@patch("onyx.document_index.opensearch.opensearch_document_index.CHUNKS_PER_BATCH", 100)
def test_multiple_docs_each_under_limit_flush_per_doc() -> None:
"""Multiple documents each under the batch limit should flush once per document."""
index = _make_index()
chunks = []
for doc_idx in range(3):
doc_id = f"doc_{doc_idx}"
for chunk_idx in range(50):
chunks.append(_make_chunk(doc_id, chunk_idx))
metadata = IndexingMetadata(
doc_id_to_chunk_cnt_diff={
f"doc_{i}": IndexingMetadata.ChunkCounts(old_chunk_cnt=0, new_chunk_cnt=50)
for i in range(3)
},
)
with patch.object(index, "delete", return_value=0):
index.index(chunks, metadata)
# 3 documents = 3 flushes (one per doc boundary + final)
assert index._client.bulk_index_documents.call_count == 3
@patch("onyx.document_index.opensearch.opensearch_document_index.CHUNKS_PER_BATCH", 100)
def test_delete_called_once_per_document() -> None:
"""Even with multiple flushes for a single document, delete should only be
called once per document."""
index = _make_index()
doc_id = "doc_1"
num_chunks = 250
chunks = [_make_chunk(doc_id, i) for i in range(num_chunks)]
metadata = _make_metadata(doc_id, num_chunks)
with patch.object(index, "delete", return_value=0) as mock_delete:
index.index(chunks, metadata)
mock_delete.assert_called_once_with(doc_id, None)

View File

@@ -0,0 +1,40 @@
"""Unit tests for the hooks feature gate."""
from unittest.mock import patch
import pytest
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.hooks.api_dependencies import require_hook_enabled
class TestRequireHookEnabled:
def test_raises_when_multi_tenant(self) -> None:
with (
patch("onyx.hooks.api_dependencies.MULTI_TENANT", True),
patch("onyx.hooks.api_dependencies.HOOK_ENABLED", True),
):
with pytest.raises(OnyxError) as exc_info:
require_hook_enabled()
assert exc_info.value.error_code is OnyxErrorCode.SINGLE_TENANT_ONLY
assert exc_info.value.status_code == 403
assert "multi-tenant" in exc_info.value.detail
def test_raises_when_flag_disabled(self) -> None:
with (
patch("onyx.hooks.api_dependencies.MULTI_TENANT", False),
patch("onyx.hooks.api_dependencies.HOOK_ENABLED", False),
):
with pytest.raises(OnyxError) as exc_info:
require_hook_enabled()
assert exc_info.value.error_code is OnyxErrorCode.ENV_VAR_GATED
assert exc_info.value.status_code == 403
assert "HOOK_ENABLED" in exc_info.value.detail
def test_passes_when_enabled_single_tenant(self) -> None:
with (
patch("onyx.hooks.api_dependencies.MULTI_TENANT", False),
patch("onyx.hooks.api_dependencies.HOOK_ENABLED", True),
):
require_hook_enabled() # must not raise

View File

@@ -116,7 +116,7 @@ def _run_adapter_build(
project_ids_map: dict[str, list[int]],
persona_ids_map: dict[str, list[int]],
) -> list[DocMetadataAwareIndexChunk]:
"""Helper that runs UserFileIndexingAdapter.build_metadata_aware_chunks
"""Helper that runs UserFileIndexingAdapter.prepare_enrichment + enrich_chunk
with all external dependencies mocked."""
from onyx.indexing.adapters.user_file_indexing_adapter import (
UserFileIndexingAdapter,
@@ -155,18 +155,17 @@ def _run_adapter_build(
side_effect=Exception("no LLM in tests"),
),
):
result = adapter.build_metadata_aware_chunks(
chunks_with_embeddings=[chunk],
chunk_content_scores=[1.0],
tenant_id="test_tenant",
enricher = adapter.prepare_enrichment(
context=context,
tenant_id="test_tenant",
chunks=[chunk],
)
return result.chunks
return [enricher.enrich_chunk(chunk, 1.0)]
def test_build_metadata_aware_chunks_includes_persona_ids() -> None:
"""UserFileIndexingAdapter.build_metadata_aware_chunks writes persona IDs
def test_prepare_enrichment_includes_persona_ids() -> None:
"""UserFileIndexingAdapter.prepare_enrichment writes persona IDs
fetched from the DB into each chunk's metadata."""
file_id = str(uuid4())
persona_ids = [5, 12]
@@ -183,7 +182,7 @@ def test_build_metadata_aware_chunks_includes_persona_ids() -> None:
assert chunks[0].user_project == project_ids
def test_build_metadata_aware_chunks_missing_file_defaults_to_empty() -> None:
def test_prepare_enrichment_missing_file_defaults_to_empty() -> None:
"""When a file has no persona or project associations in the DB, the
adapter should default to empty lists (not KeyError or None)."""
file_id = str(uuid4())

View File

@@ -3,6 +3,7 @@
from onyx.server.manage.llm.utils import generate_bedrock_display_name
from onyx.server.manage.llm.utils import generate_ollama_display_name
from onyx.server.manage.llm.utils import infer_vision_support
from onyx.server.manage.llm.utils import is_embedding_model
from onyx.server.manage.llm.utils import is_reasoning_model
from onyx.server.manage.llm.utils import is_valid_bedrock_model
from onyx.server.manage.llm.utils import strip_openrouter_vendor_prefix
@@ -209,3 +210,35 @@ class TestIsReasoningModel:
is_reasoning_model("anthropic/claude-3-5-sonnet", "Claude 3.5 Sonnet")
is False
)
class TestIsEmbeddingModel:
"""Tests for embedding model detection."""
def test_openai_embedding_ada(self) -> None:
assert is_embedding_model("text-embedding-ada-002") is True
def test_openai_embedding_3_small(self) -> None:
assert is_embedding_model("text-embedding-3-small") is True
def test_openai_embedding_3_large(self) -> None:
assert is_embedding_model("text-embedding-3-large") is True
def test_cohere_embed_model(self) -> None:
assert is_embedding_model("embed-english-v3.0") is True
def test_bedrock_titan_embed(self) -> None:
assert is_embedding_model("amazon.titan-embed-text-v1") is True
def test_gpt4o_not_embedding(self) -> None:
assert is_embedding_model("gpt-4o") is False
def test_gpt4_not_embedding(self) -> None:
assert is_embedding_model("gpt-4") is False
def test_dall_e_not_embedding(self) -> None:
assert is_embedding_model("dall-e-3") is False
def test_unknown_custom_model_not_embedding(self) -> None:
"""Custom/local models not in litellm's model DB should default to False."""
assert is_embedding_model("my-custom-local-model-v1") is False

View File

@@ -17,7 +17,7 @@ def test_mt_cloud_telemetry_noop_when_not_multi_tenant(monkeypatch: Any) -> None
telemetry_utils.mt_cloud_telemetry(
tenant_id="tenant-1",
distinct_id="user@example.com",
distinct_id="12345678-1234-1234-1234-123456789abc",
event=MilestoneRecordType.USER_MESSAGE_SENT,
properties={"origin": "web"},
)
@@ -40,7 +40,7 @@ def test_mt_cloud_telemetry_calls_event_telemetry_when_multi_tenant(
telemetry_utils.mt_cloud_telemetry(
tenant_id="tenant-1",
distinct_id="user@example.com",
distinct_id="12345678-1234-1234-1234-123456789abc",
event=MilestoneRecordType.USER_MESSAGE_SENT,
properties={"origin": "web"},
)
@@ -51,7 +51,7 @@ def test_mt_cloud_telemetry_calls_event_telemetry_when_multi_tenant(
fallback=telemetry_utils.noop_fallback,
)
event_telemetry.assert_called_once_with(
"user@example.com",
"12345678-1234-1234-1234-123456789abc",
MilestoneRecordType.USER_MESSAGE_SENT,
{"origin": "web", "tenant_id": "tenant-1"},
)

12
uv.lock generated
View File

@@ -453,14 +453,14 @@ wheels = [
[[package]]
name = "authlib"
version = "1.6.7"
version = "1.6.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/dc/ed1681bf1339dd6ea1ce56136bad4baabc6f7ad466e375810702b0237047/authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b", size = 164950, upload-time = "2026-02-06T14:04:14.171Z" }
sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/00/3ed12264094ec91f534fae429945efbaa9f8c666f3aa7061cc3b2a26a0cd/authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0", size = 244115, upload-time = "2026-02-06T14:04:12.141Z" },
{ url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" },
]
[[package]]
@@ -5643,11 +5643,11 @@ wheels = [
[[package]]
name = "pyjwt"
version = "2.11.0"
version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a8/10/e8192be5f38f3e8e7e046716de4cae33d56fd5ae08927a823bb916be36c1/pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02", size = 102511, upload-time = "2026-03-12T17:15:30.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
{ url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", size = 29700, upload-time = "2026-03-12T17:15:29.257Z" },
]
[package.optional-dependencies]

View File

@@ -7,6 +7,7 @@ import {
type InteractiveStatefulInteraction,
} from "@opal/core";
import type { SizeVariant, WidthVariant } from "@opal/shared";
import type { InteractiveContainerRoundingVariant } from "@opal/core";
import type { TooltipSide } from "@opal/components";
import type { IconFunctionComponent, IconProps } from "@opal/types";
import { SvgChevronDownSmall } from "@opal/icons";
@@ -80,6 +81,9 @@ type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> & {
/** Which side the tooltip appears on. */
tooltipSide?: TooltipSide;
/** Override the default rounding derived from `size`. */
roundingVariant?: InteractiveContainerRoundingVariant;
};
// ---------------------------------------------------------------------------
@@ -95,6 +99,7 @@ function OpenButton({
justifyContent,
tooltip,
tooltipSide = "top",
roundingVariant: roundingVariantOverride,
interaction,
variant = "select-heavy",
...statefulProps
@@ -132,7 +137,8 @@ function OpenButton({
heightVariant={size}
widthVariant={width}
roundingVariant={
isLarge ? "default" : size === "2xs" ? "mini" : "compact"
roundingVariantOverride ??
(isLarge ? "default" : size === "2xs" ? "mini" : "compact")
}
>
<div

View File

@@ -0,0 +1,20 @@
import type { IconProps } from "@opal/types";
const SvgCurate = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M8 9L8 14.5M8 9C7.35971 8.35971 6.9055 8 6 8H2.5L2.5 13.5H6C6.9055 13.5 7.35971 13.8597 8 14.5M8 9C8.64029 8.35971 9.09449 8 10 8H13.5L13.5 13.5H10C9.09449 13.5 8.64029 13.8597 8 14.5M10.25 3.75C10.25 4.99264 9.24264 6 8 6C6.75736 6 5.75 4.99264 5.75 3.75C5.75 2.50736 6.75736 1.5 8 1.5C9.24264 1.5 10.25 2.50736 10.25 3.75Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgCurate;

View File

@@ -54,6 +54,7 @@ export { default as SvgColumn } from "@opal/icons/column";
export { default as SvgCopy } from "@opal/icons/copy";
export { default as SvgCornerRightUpDot } from "@opal/icons/corner-right-up-dot";
export { default as SvgCpu } from "@opal/icons/cpu";
export { default as SvgCurate } from "@opal/icons/curate";
export { default as SvgCreditCard } from "@opal/icons/credit-card";
export { default as SvgDashboard } from "@opal/icons/dashboard";
export { default as SvgDevKit } from "@opal/icons/dev-kit";
@@ -135,6 +136,7 @@ export { default as SvgPlayCircle } from "@opal/icons/play-circle";
export { default as SvgPlug } from "@opal/icons/plug";
export { default as SvgPlus } from "@opal/icons/plus";
export { default as SvgPlusCircle } from "@opal/icons/plus-circle";
export { default as SvgProgressBars } from "@opal/icons/progress-bars";
export { default as SvgProgressCircle } from "@opal/icons/progress-circle";
export { default as SvgQuestionMarkSmall } from "@opal/icons/question-mark-small";
export { default as SvgQuoteEnd } from "@opal/icons/quote-end";
@@ -176,9 +178,16 @@ export { default as SvgTwoLineSmall } from "@opal/icons/two-line-small";
export { default as SvgUnplug } from "@opal/icons/unplug";
export { default as SvgUploadCloud } from "@opal/icons/upload-cloud";
export { default as SvgUser } from "@opal/icons/user";
export { default as SvgUserCheck } from "@opal/icons/user-check";
export { default as SvgUserEdit } from "@opal/icons/user-edit";
export { default as SvgUserKey } from "@opal/icons/user-key";
export { default as SvgUserManage } from "@opal/icons/user-manage";
export { default as SvgUserMinus } from "@opal/icons/user-minus";
export { default as SvgUserPlus } from "@opal/icons/user-plus";
export { default as SvgUserShield } from "@opal/icons/user-shield";
export { default as SvgUserSpeaker } from "@opal/icons/user-speaker";
export { default as SvgUserSync } from "@opal/icons/user-sync";
export { default as SvgUserX } from "@opal/icons/user-x";
export { default as SvgUsers } from "@opal/icons/users";
export { default as SvgVolume } from "@opal/icons/volume";
export { default as SvgVolumeOff } from "@opal/icons/volume-off";

View File

@@ -0,0 +1,20 @@
import type { IconProps } from "@opal/types";
const SvgProgressBars = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M5.5 2.00003L13.25 2C13.9403 2 14.5 2.55964 14.5 3.25C14.5 3.94036 13.9403 4.5 13.25 4.5L5.5 4.50003M5.5 2.00003L2.74998 2C2.05963 2 1.49998 2.55964 1.49998 3.25C1.49998 3.94036 2.05963 4.5 2.74998 4.5L5.5 4.50003M5.5 2.00003V4.50003M10.5 11.5H13.25C13.9403 11.5 14.5 12.0596 14.5 12.75C14.5 13.4404 13.9403 14 13.25 14H10.5M10.5 11.5H2.74998C2.05963 11.5 1.49998 12.0596 1.49998 12.75C1.49998 13.4404 2.05963 14 2.74999 14H10.5M10.5 11.5V14M8 6.75H13.25C13.9403 6.75 14.5 7.30964 14.5 8C14.5 8.69036 13.9403 9.25 13.25 9.25H8M8 6.75H2.74998C2.05963 6.75 1.49998 7.30964 1.49998 8C1.49998 8.69036 2.05963 9.25 2.74998 9.25H8M8 6.75V9.25"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgProgressBars;

View File

@@ -0,0 +1,20 @@
import type { IconProps } from "@opal/types";
const SvgUserCheck = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M11 14C11 13.6667 11 13.3333 11 13C11 11.3431 9.65684 10 7.99998 10H4.00002C2.34316 10 1 11.3431 1 13C1 13.3333 1 13.6667 1 14M10.75 7.49999L12.25 9L15 6.24999M8.75 4.75C8.75 6.26878 7.51878 7.5 6 7.5C4.48122 7.5 3.25 6.26878 3.25 4.75C3.25 3.23122 4.48122 2 6 2C7.51878 2 8.75 3.23122 8.75 4.75Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgUserCheck;

View File

@@ -0,0 +1,20 @@
import type { IconProps } from "@opal/types";
const SvgUserEdit = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M1 14C1 13.6667 1 13.3333 1 13C1 11.3431 2.34316 10 4.00002 10H7M8.75 4.75C8.75 6.26878 7.51878 7.5 6 7.5C4.48122 7.5 3.25 6.26878 3.25 4.75C3.25 3.23122 4.48122 2 6 2C7.51878 2 8.75 3.23122 8.75 4.75ZM12.09 8.41421C12.3552 8.149 12.7149 8 13.09 8C13.2757 8 13.4596 8.03658 13.6312 8.10765C13.8028 8.17872 13.9587 8.28289 14.09 8.41421C14.2213 8.54554 14.3255 8.70144 14.3966 8.87302C14.4676 9.0446 14.5042 9.2285 14.5042 9.41421C14.5042 9.59993 14.4676 9.78383 14.3966 9.95541C14.3255 10.127 14.2213 10.2829 14.09 10.4142L10.6667 13.8333L8 14.5L8.66667 11.8333L12.09 8.41421Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgUserEdit;

View File

@@ -0,0 +1,20 @@
import type { IconProps } from "@opal/types";
const SvgUserKey = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M1 14C1 13.6667 1 13.3333 1 13C1 11.3431 2.34316 10 4.00002 10H8.5M12.625 10C13.6605 10 14.5 9.16053 14.5 8.125C14.5 7.08947 13.6605 6.25 12.625 6.25C11.5895 6.25 10.75 7.08947 10.75 8.125C10.75 9.16053 11.5895 10 12.625 10ZM12.625 10V12.25M12.625 14.5V13.5M12.625 13.5H13.875V12.25H12.625M12.625 13.5V12.25M8.75 4.75C8.75 6.26878 7.51878 7.5 6 7.5C4.48122 7.5 3.25 6.26878 3.25 4.75C3.25 3.23122 4.48122 2 6 2C7.51878 2 8.75 3.23122 8.75 4.75Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgUserKey;

View File

@@ -0,0 +1,20 @@
import type { IconProps } from "@opal/types";
const SvgUserMinus = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M11 14C11 13.6667 11 13.3333 11 13C11 11.3431 9.65684 10 7.99998 10H4.00002C2.34316 10 1 11.3431 1 13C1 13.3333 1 13.6667 1 14M10.75 7.49999L14.75 7.50007M8.75 4.75C8.75 6.26878 7.51878 7.5 6 7.5C4.48122 7.5 3.25 6.26878 3.25 4.75C3.25 3.23122 4.48122 2 6 2C7.51878 2 8.75 3.23122 8.75 4.75Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgUserMinus;

View File

@@ -0,0 +1,20 @@
import type { IconProps } from "@opal/types";
const SvgUserShield = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M1 14C1 13.6667 1 13.3333 1 13C1 11.3431 2.34316 10 4.00002 10H7M8.75 4.75C8.75 6.26878 7.51878 7.5 6 7.5C4.48122 7.5 3.25 6.26878 3.25 4.75C3.25 3.23122 4.48122 2 6 2C7.51878 2 8.75 3.23122 8.75 4.75ZM12 14.5C12 14.5 14.5 13.25 14.5 11.375V9L12 8L9.5 9V11.375C9.5 13.25 12 14.5 12 14.5Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgUserShield;

View File

@@ -0,0 +1,20 @@
import type { IconProps } from "@opal/types";
const SvgUserSpeaker = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M1 14C1 13.6667 1 13.3333 1 13C1 11.3431 2.34316 10 4.00002 10H7.99998C9.65684 10 11 11.3431 11 13C11 13.3333 11 13.6667 11 14H14.5V10L12.7071 8.20711M12 7.5L12.7071 8.20711M12.7071 8.20711C13.0976 7.81658 13.0976 7.18342 12.7071 6.79289C12.3166 6.40237 11.6834 6.40237 11.2929 6.79289C10.9024 7.18342 10.9024 7.81658 11.2929 8.20711C11.6834 8.59763 12.3166 8.59763 12.7071 8.20711ZM8.75 4.75C8.75 6.26878 7.51878 7.5 6 7.5C4.48122 7.5 3.25 6.26878 3.25 4.75C3.25 3.23122 4.48122 2 6 2C7.51878 2 8.75 3.23122 8.75 4.75Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgUserSpeaker;

View File

@@ -0,0 +1,20 @@
import type { IconProps } from "@opal/types";
const SvgUserX = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M11 14C11 13.6667 11 13.3333 11 13C11 11.3431 9.65684 10 7.99998 10H4.00002C2.34316 10 1 11.3431 1 13C1 13.3333 1 13.6667 1 14M11.5 8.5L13.25 6.75M13.25 6.75L15 5M13.25 6.75L15 8.5M13.25 6.75L11.5 5M8.75 4.75C8.75 6.26878 7.51878 7.5 6 7.5C4.48122 7.5 3.25 6.26878 3.25 4.75C3.25 3.23122 4.48122 2 6 2C7.51878 2 8.75 3.23122 8.75 4.75Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgUserX;

4
web/package-lock.json generated
View File

@@ -10309,7 +10309,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"dev": true,
"license": "ISC"
},

View File

@@ -2,9 +2,9 @@
import MCPPageContent from "@/sections/actions/MCPPageContent";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.MCP_ACTIONS]!;
const route = ADMIN_ROUTES.MCP_ACTIONS;
export default function Main() {
return (

View File

@@ -2,9 +2,9 @@
import * as SettingsLayouts from "@/layouts/settings-layouts";
import OpenApiPageContent from "@/sections/actions/OpenApiPageContent";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.OPENAPI_ACTIONS]!;
const route = ADMIN_ROUTES.OPENAPI_ACTIONS;
export default function Main() {
return (

View File

@@ -32,7 +32,10 @@ import { SettingsContext } from "@/providers/SettingsProvider";
import SourceTile from "@/components/SourceTile";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import Text from "@/refresh-components/texts/Text";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
const route = ADMIN_ROUTES.ADD_CONNECTOR;
function SourceTileTooltipWrapper({
sourceMetadata,
preSelect,
@@ -124,7 +127,6 @@ function SourceTileTooltipWrapper({
}
export default function Page() {
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.ADD_CONNECTOR]!;
const sources = useMemo(() => listSourceMetadata(), []);
const [rawSearchTerm, setSearchTerm] = useState("");

View File

@@ -11,10 +11,11 @@ import { useAdminPersonas } from "@/hooks/useAdminPersonas";
import { Persona } from "./interfaces";
import { ThreeDotsLoader } from "@/components/Loading";
import { ErrorCallout } from "@/components/ErrorCallout";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { useState, useEffect } from "react";
import Pagination from "@/refresh-components/Pagination";
const route = ADMIN_ROUTES.AGENTS;
const PAGE_SIZE = 20;
function MainContent({
@@ -120,7 +121,6 @@ function MainContent({
}
export default function Page() {
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.AGENTS]!;
const [currentPage, setCurrentPage] = useState(1);
const { personas, totalItems, isLoading, error, refresh } = useAdminPersonas({
pageNum: currentPage - 1, // Backend uses 0-indexed pages

View File

@@ -32,9 +32,9 @@ import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import Text from "@/refresh-components/texts/Text";
import { SvgEdit, SvgKey, SvgRefreshCw } from "@opal/icons";
import { useCloudSubscription } from "@/hooks/useCloudSubscription";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.API_KEYS]!;
const route = ADMIN_ROUTES.API_KEYS;
function Main() {
const {

View File

@@ -6,10 +6,12 @@ import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import { SlackBotTable } from "./SlackBotTable";
import { useSlackBots } from "./[bot-id]/hooks";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import CreateButton from "@/refresh-components/buttons/CreateButton";
import { DOCS_ADMINS_PATH } from "@/lib/constants";
const route = ADMIN_ROUTES.SLACK_BOTS;
function Main() {
const {
data: slackBots,
@@ -75,8 +77,6 @@ function Main() {
}
export default function Page() {
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.SLACK_BOTS]!;
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />

View File

@@ -10,9 +10,9 @@ import * as SettingsLayouts from "@/layouts/settings-layouts";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import { SvgLock } from "@opal/icons";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DOCUMENT_PROCESSING]!;
const route = ADMIN_ROUTES.DOCUMENT_PROCESSING;
function Main() {
const {

View File

@@ -2,9 +2,9 @@
import * as SettingsLayouts from "@/layouts/settings-layouts";
import ImageGenerationContent from "./ImageGenerationContent";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.IMAGE_GENERATION]!;
const route = ADMIN_ROUTES.IMAGE_GENERATION;
export default function Page() {
return (

View File

@@ -19,9 +19,9 @@ import { SettingsContext } from "@/providers/SettingsProvider";
import CardSection from "@/components/admin/CardSection";
import { ErrorCallout } from "@/components/ErrorCallout";
import { useToastFromQuery } from "@/hooks/useToast";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.SEARCH_SETTINGS]!;
const route = ADMIN_ROUTES.INDEX_SETTINGS;
export interface EmbeddingDetails {
api_key: string;
@@ -131,7 +131,7 @@ function Main() {
<div className="mt-4">
<Button variant="action" href="/admin/embeddings">
Update Search Settings
Update Index Settings
</Button>
</div>
</>

View File

@@ -23,10 +23,10 @@ import {
SvgOnyxLogo,
SvgX,
} from "@opal/icons";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { WebProviderSetupModal } from "@/app/admin/configuration/web-search/WebProviderSetupModal";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.WEB_SEARCH]!;
const route = ADMIN_ROUTES.WEB_SEARCH;
import {
SEARCH_PROVIDERS_URL,
SEARCH_PROVIDER_DETAILS,

View File

@@ -16,9 +16,9 @@ import { Card } from "@/components/ui/card";
import Text from "@/components/ui/text";
import { Spinner } from "@/components/Spinner";
import { SvgDownloadCloud } from "@opal/icons";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DEBUG]!;
const route = ADMIN_ROUTES.DEBUG;
function Main() {
const [categories, setCategories] = useState<string[]>([]);

View File

@@ -19,7 +19,9 @@ import {
import { createGuildConfig } from "@/app/admin/discord-bot/lib";
import { DiscordGuildsTable } from "@/app/admin/discord-bot/DiscordGuildsTable";
import { BotConfigCard } from "@/app/admin/discord-bot/BotConfigCard";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
const route = ADMIN_ROUTES.DISCORD_BOTS;
function DiscordBotContent() {
const { data: guilds, isLoading, error, refreshGuilds } = useDiscordGuilds();
@@ -118,8 +120,6 @@ function DiscordBotContent() {
}
export default function Page() {
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DISCORD_BOTS]!;
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header

View File

@@ -3,9 +3,9 @@
import { useState } from "react";
import useSWR from "swr";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.INDEX_MIGRATION]!;
const route = ADMIN_ROUTES.INDEX_MIGRATION;
import Card from "@/refresh-components/cards/Card";
import { Content, ContentAction } from "@opal/layouts";

View File

@@ -1,11 +1,13 @@
"use client";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { Explorer } from "./Explorer";
import { Connector } from "@/lib/connectors/connectors";
import { DocumentSetSummary } from "@/lib/types";
const route = ADMIN_ROUTES.DOCUMENT_EXPLORER;
interface DocumentExplorerPageProps {
initialSearchValue: string | undefined;
connectors: Connector<any>[];
@@ -17,8 +19,6 @@ export default function DocumentExplorerPage({
connectors,
documentSets,
}: DocumentExplorerPageProps) {
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DOCUMENT_EXPLORER]!;
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />

View File

@@ -6,7 +6,9 @@ import { DocumentFeedbackTable } from "./DocumentFeedbackTable";
import { numPages, numToDisplay } from "./constants";
import Title from "@/components/ui/title";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
const route = ADMIN_ROUTES.DOCUMENT_FEEDBACK;
function Main() {
const {
@@ -61,8 +63,6 @@ function Main() {
}
export default function Page() {
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DOCUMENT_FEEDBACK]!;
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />

View File

@@ -6,12 +6,14 @@ import { refreshDocumentSets, useDocumentSets } from "../hooks";
import { useConnectorStatus, useUserGroups } from "@/lib/hooks";
import { ThreeDotsLoader } from "@/components/Loading";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import CardSection from "@/components/admin/CardSection";
import { DocumentSetCreationForm } from "../DocumentSetCreationForm";
import { useRouter } from "next/navigation";
import { useVectorDbEnabled } from "@/providers/SettingsProvider";
const route = ADMIN_ROUTES.DOCUMENT_SETS;
function Main({ documentSetId }: { documentSetId: number }) {
const router = useRouter();
const vectorDbEnabled = useVectorDbEnabled();
@@ -93,7 +95,6 @@ export default function Page(props: {
}) {
const params = use(props.params);
const documentSetId = parseInt(params.documentSetId);
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DOCUMENT_SETS]!;
return (
<SettingsLayouts.Root>

View File

@@ -1,7 +1,7 @@
"use client";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { DocumentSetCreationForm } from "../DocumentSetCreationForm";
import { useConnectorStatus, useUserGroups } from "@/lib/hooks";
import { ThreeDotsLoader } from "@/components/Loading";
@@ -11,6 +11,8 @@ import { refreshDocumentSets } from "../hooks";
import CardSection from "@/components/admin/CardSection";
import { useVectorDbEnabled } from "@/providers/SettingsProvider";
const route = ADMIN_ROUTES.DOCUMENT_SETS;
function Main() {
const router = useRouter();
const vectorDbEnabled = useVectorDbEnabled();
@@ -58,8 +60,6 @@ function Main() {
}
export default function Page() {
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DOCUMENT_SETS]!;
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header

View File

@@ -20,7 +20,7 @@ import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
import { deleteDocumentSet } from "./lib";
import { toast } from "@/hooks/useToast";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import {
FiAlertTriangle,
FiCheckCircle,
@@ -43,6 +43,7 @@ import CreateButton from "@/refresh-components/buttons/CreateButton";
import { SourceIcon } from "@/components/SourceIcon";
import Link from "next/link";
const route = ADMIN_ROUTES.DOCUMENT_SETS;
const numToDisplay = 50;
// Component to display federated connectors with consistent styling
@@ -422,8 +423,6 @@ function Main() {
}
export default function Page() {
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.DOCUMENT_SETS]!;
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />

View File

@@ -4,7 +4,7 @@ import { CCPairIndexingStatusTable } from "./CCPairIndexingStatusTable";
import { SearchAndFilterControls } from "./SearchAndFilterControls";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import Link from "next/link";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import Text from "@/components/ui/text";
import { useConnectorIndexingStatusWithPagination } from "@/lib/hooks";
import { useToastFromQuery } from "@/hooks/useToast";
@@ -18,6 +18,8 @@ import { TOGGLED_CONNECTORS_COOKIE_NAME } from "@/lib/constants";
import { ConnectorStaggeredSkeleton } from "./ConnectorRowSkeleton";
import { IndexingStatusRequest } from "@/lib/types";
const route = ADMIN_ROUTES.INDEXING_STATUS;
function Main() {
const vectorDbEnabled = useVectorDbEnabled();
@@ -204,8 +206,6 @@ function Main() {
}
export default function Status() {
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.INDEXING_STATUS]!;
useToastFromQuery({
"connector-created": {
message: "Connector created successfully",

View File

@@ -31,9 +31,9 @@ import KGEntityTypes from "@/app/admin/kg/KGEntityTypes";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import { SvgSettings } from "@opal/icons";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.KNOWLEDGE_GRAPH]!;
const route = ADMIN_ROUTES.KNOWLEDGE_GRAPH;
function createDomainField(
name: string,

View File

@@ -18,9 +18,9 @@ import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidE
import CreateButton from "@/refresh-components/buttons/CreateButton";
import { SvgGlobe, SvgUser, SvgUsers } from "@opal/icons";
import { Section } from "@/layouts/general-layouts";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.TOKEN_RATE_LIMITS]!;
const route = ADMIN_ROUTES.TOKEN_RATE_LIMITS;
const BASE_URL = "/api/admin/token-rate-limits";
const GLOBAL_TOKEN_FETCH_URL = `${BASE_URL}/global`;
const USER_TOKEN_FETCH_URL = `${BASE_URL}/users`;

View File

@@ -6,11 +6,11 @@ import { useSpecificUserGroup } from "./hook";
import { ThreeDotsLoader } from "@/components/Loading";
import { useConnectorStatus } from "@/lib/hooks";
import useUsers from "@/hooks/useUsers";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { useVectorDbEnabled } from "@/providers/SettingsProvider";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.GROUPS]!;
const route = ADMIN_ROUTES.GROUPS;
function Main({ groupId }: { groupId: string }) {
const vectorDbEnabled = useVectorDbEnabled();

View File

@@ -8,11 +8,11 @@ import { useConnectorStatus, useUserGroups } from "@/lib/hooks";
import useUsers from "@/hooks/useUsers";
import { useUser } from "@/providers/UserProvider";
import CreateButton from "@/refresh-components/buttons/CreateButton";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { useVectorDbEnabled } from "@/providers/SettingsProvider";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.GROUPS]!;
const route = ADMIN_ROUTES.GROUPS;
function Main() {
const [showForm, setShowForm] = useState(false);

View File

@@ -1,11 +1,11 @@
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { CUSTOM_ANALYTICS_ENABLED } from "@/lib/constants";
import { Callout } from "@/components/ui/callout";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import Text from "@/components/ui/text";
import { CustomAnalyticsUpdateForm } from "./CustomAnalyticsUpdateForm";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.CUSTOM_ANALYTICS]!;
const route = ADMIN_ROUTES.CUSTOM_ANALYTICS;
function Main() {
if (!CUSTOM_ANALYTICS_ENABLED) {

View File

@@ -2,9 +2,9 @@
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { QueryHistoryTable } from "@/app/ee/admin/performance/query-history/QueryHistoryTable";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.QUERY_HISTORY]!;
const route = ADMIN_ROUTES.QUERY_HISTORY;
export default function QueryHistoryPage() {
return (

View File

@@ -9,10 +9,10 @@ import { useTimeRange } from "@/app/ee/admin/performance/lib";
import UsageReports from "@/app/ee/admin/performance/usage/UsageReports";
import Separator from "@/refresh-components/Separator";
import { useAdminPersonas } from "@/hooks/useAdminPersonas";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import * as SettingsLayouts from "@/layouts/settings-layouts";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.USAGE]!;
const route = ADMIN_ROUTES.USAGE;
export default function AnalyticsPage() {
const [timeRange, setTimeRange] = useTimeRange();

View File

@@ -2,10 +2,10 @@ import { StandardAnswerCreationForm } from "@/app/ee/admin/standard-answer/Stand
import { fetchSS } from "@/lib/utilsSS";
import { ErrorCallout } from "@/components/ErrorCallout";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { StandardAnswer, StandardAnswerCategory } from "@/lib/types";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.STANDARD_ANSWERS]!;
const route = ADMIN_ROUTES.STANDARD_ANSWERS;
async function Main({ id }: { id: string }) {
const tasks = [

View File

@@ -2,10 +2,10 @@ import { StandardAnswerCreationForm } from "@/app/ee/admin/standard-answer/Stand
import { fetchSS } from "@/lib/utilsSS";
import { ErrorCallout } from "@/components/ErrorCallout";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { StandardAnswerCategory } from "@/lib/types";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.STANDARD_ANSWERS]!;
const route = ADMIN_ROUTES.STANDARD_ANSWERS;
async function Page() {
const standardAnswerCategoriesResponse = await fetchSS(

View File

@@ -30,10 +30,10 @@ import { TableHeader } from "@/components/ui/table";
import CreateButton from "@/refresh-components/buttons/CreateButton";
import { SvgEdit, SvgTrash } from "@opal/icons";
import { Button } from "@opal/components";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
const NUM_RESULTS_PER_PAGE = 10;
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.STANDARD_ANSWERS]!;
const route = ADMIN_ROUTES.STANDARD_ANSWERS;
type Displayable = JSX.Element | string;

View File

@@ -1,7 +1,7 @@
"use client";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import {
@@ -16,7 +16,7 @@ import * as Yup from "yup";
import { EnterpriseSettings } from "@/interfaces/settings";
import { useRouter } from "next/navigation";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.THEME]!;
const route = ADMIN_ROUTES.THEME;
const CHAR_LIMITS = {
application_name: 50,

View File

@@ -6,11 +6,10 @@ import { useSettingsContext } from "@/providers/SettingsProvider";
import { ApplicationStatus } from "@/interfaces/settings";
import { Button } from "@opal/components";
import { cn } from "@/lib/utils";
import { ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
export interface ClientLayoutProps {
children: React.ReactNode;
enableEnterprise: boolean;
enableCloud: boolean;
}
@@ -19,40 +18,36 @@ export interface ClientLayoutProps {
// the `py-10 px-4 md:px-12` padding below can be removed entirely and
// this prefix list can be deleted.
const SETTINGS_LAYOUT_PREFIXES = [
ADMIN_PATHS.CHAT_PREFERENCES,
ADMIN_PATHS.IMAGE_GENERATION,
ADMIN_PATHS.WEB_SEARCH,
ADMIN_PATHS.MCP_ACTIONS,
ADMIN_PATHS.OPENAPI_ACTIONS,
ADMIN_PATHS.BILLING,
ADMIN_PATHS.INDEX_MIGRATION,
ADMIN_PATHS.DISCORD_BOTS,
ADMIN_PATHS.THEME,
ADMIN_PATHS.LLM_MODELS,
ADMIN_PATHS.AGENTS,
ADMIN_PATHS.USERS,
ADMIN_PATHS.TOKEN_RATE_LIMITS,
ADMIN_PATHS.SEARCH_SETTINGS,
ADMIN_PATHS.DOCUMENT_PROCESSING,
ADMIN_PATHS.CODE_INTERPRETER,
ADMIN_PATHS.API_KEYS,
ADMIN_PATHS.ADD_CONNECTOR,
ADMIN_PATHS.INDEXING_STATUS,
ADMIN_PATHS.DOCUMENTS,
ADMIN_PATHS.DEBUG,
ADMIN_PATHS.KNOWLEDGE_GRAPH,
ADMIN_PATHS.SLACK_BOTS,
ADMIN_PATHS.STANDARD_ANSWERS,
ADMIN_PATHS.GROUPS,
ADMIN_PATHS.PERFORMANCE,
ADMIN_PATHS.SCIM,
ADMIN_ROUTES.CHAT_PREFERENCES.path,
ADMIN_ROUTES.IMAGE_GENERATION.path,
ADMIN_ROUTES.WEB_SEARCH.path,
ADMIN_ROUTES.MCP_ACTIONS.path,
ADMIN_ROUTES.OPENAPI_ACTIONS.path,
ADMIN_ROUTES.BILLING.path,
ADMIN_ROUTES.INDEX_MIGRATION.path,
ADMIN_ROUTES.DISCORD_BOTS.path,
ADMIN_ROUTES.THEME.path,
ADMIN_ROUTES.LLM_MODELS.path,
ADMIN_ROUTES.AGENTS.path,
ADMIN_ROUTES.USERS.path,
ADMIN_ROUTES.TOKEN_RATE_LIMITS.path,
ADMIN_ROUTES.INDEX_SETTINGS.path,
ADMIN_ROUTES.DOCUMENT_PROCESSING.path,
ADMIN_ROUTES.CODE_INTERPRETER.path,
ADMIN_ROUTES.API_KEYS.path,
ADMIN_ROUTES.ADD_CONNECTOR.path,
ADMIN_ROUTES.INDEXING_STATUS.path,
ADMIN_ROUTES.DOCUMENTS.path,
ADMIN_ROUTES.DEBUG.path,
ADMIN_ROUTES.KNOWLEDGE_GRAPH.path,
ADMIN_ROUTES.SLACK_BOTS.path,
ADMIN_ROUTES.STANDARD_ANSWERS.path,
ADMIN_ROUTES.GROUPS.path,
ADMIN_ROUTES.PERFORMANCE.path,
ADMIN_ROUTES.SCIM.path,
];
export function ClientLayout({
children,
enableEnterprise,
enableCloud,
}: ClientLayoutProps) {
export function ClientLayout({ children, enableCloud }: ClientLayoutProps) {
const pathname = usePathname();
const settings = useSettingsContext();
@@ -86,10 +81,7 @@ export function ClientLayout({
<div className="flex-1 min-w-0 min-h-0 overflow-y-auto">{children}</div>
) : (
<>
<AdminSidebar
enableCloudSS={enableCloud}
enableEnterpriseSS={enableEnterprise}
/>
<AdminSidebar enableCloudSS={enableCloud} />
<div
data-main-container
className={cn(

View File

@@ -2,10 +2,7 @@ import { redirect } from "next/navigation";
import type { Route } from "next";
import { requireAdminAuth } from "@/lib/auth/requireAuth";
import { ClientLayout } from "./ClientLayout";
import {
NEXT_PUBLIC_CLOUD_ENABLED,
SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED,
} from "@/lib/constants";
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
import { AnnouncementBanner } from "../header/AnnouncementBanner";
export interface LayoutProps {
@@ -22,10 +19,7 @@ export default async function Layout({ children }: LayoutProps) {
}
return (
<ClientLayout
enableEnterprise={SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED}
enableCloud={NEXT_PUBLIC_CLOUD_ENABLED}
>
<ClientLayout enableCloud={NEXT_PUBLIC_CLOUD_ENABLED}>
<AnnouncementBanner />
{children}
</ClientLayout>

View File

@@ -65,6 +65,7 @@ import useAppFocus from "@/hooks/useAppFocus";
import { useQueryController } from "@/providers/QueryControllerProvider";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import useBrowserInfo from "@/hooks/useBrowserInfo";
import { APP_SLOGAN } from "@/lib/constants";
/**
* App Header Component
@@ -461,7 +462,7 @@ function Footer() {
settings?.enterpriseSettings?.custom_lower_disclaimer_content ||
`[Onyx ${
settings?.webVersion || "dev"
}](https://www.onyx.app/) - Open Source AI Platform`;
}](https://www.onyx.app/) - ${APP_SLOGAN}`;
return (
<footer

View File

@@ -9,7 +9,7 @@ import React from "react";
export type FlexDirection = "row" | "column";
export type JustifyContent = "start" | "center" | "end" | "between";
export type AlignItems = "start" | "center" | "end" | "stretch";
export type Length = "auto" | "fit" | "full";
export type Length = "auto" | "fit" | "full" | number;
const flexDirectionClassMap: Record<FlexDirection, string> = {
row: "flex-row",
@@ -90,11 +90,12 @@ export const heightClassmap: Record<Length, string> = {
* @remarks
* - The component defaults to column layout when no direction is specified
* - Full width and height by default
* - Prevents style overrides (className and style props are not available)
* - Accepts className for additional styling; style prop is not available
* - Import using namespace import for consistent usage: `import * as GeneralLayouts from "@/layouts/general-layouts"`
*/
export interface SectionProps
extends WithoutStyles<React.HtmlHTMLAttributes<HTMLDivElement>> {
className?: string;
flexDirection?: FlexDirection;
justifyContent?: JustifyContent;
alignItems?: AlignItems;
@@ -116,6 +117,7 @@ export interface SectionProps
* wrap a `Section` without affecting layout.
*/
function Section({
className,
flexDirection = "column",
justifyContent = "center",
alignItems = "center",
@@ -137,13 +139,20 @@ function Section({
flexDirectionClassMap[flexDirection],
justifyClassMap[justifyContent],
alignClassMap[alignItems],
widthClassmap[width],
heightClassmap[height],
typeof width === "string" && widthClassmap[width],
typeof height === "string" && heightClassmap[height],
typeof height === "number" && "overflow-hidden",
wrap && "flex-wrap",
dbg && "dbg-red"
dbg && "dbg-red",
className
)}
style={{ gap: `${gap}rem`, padding: `${padding}rem` }}
style={{
gap: `${gap}rem`,
padding: `${padding}rem`,
...(typeof width === "number" && { width: `${width}rem` }),
...(typeof height === "number" && { height: `${height}rem` }),
}}
{...rest}
/>
);

View File

@@ -3,6 +3,7 @@ import {
SvgActions,
SvgActivity,
SvgArrowExchange,
SvgAudio,
SvgBarChart,
SvgBookOpen,
SvgBubbleText,
@@ -10,243 +11,254 @@ import {
SvgCpu,
SvgDiscordMono,
SvgDownload,
SvgEmpty,
SvgFileText,
SvgFolder,
SvgFiles,
SvgGlobe,
SvgHistory,
SvgImage,
SvgKey,
SvgMcp,
SvgNetworkGraph,
SvgOnyxOctagon,
SvgPaintBrush,
SvgSearch,
SvgServer,
SvgShield,
SvgProgressBars,
SvgSearchMenu,
SvgSlack,
SvgTerminal,
SvgThumbsUp,
SvgUploadCloud,
SvgUser,
SvgUserKey,
SvgUserSync,
SvgUsers,
SvgWallet,
SvgZoomIn,
} from "@opal/icons";
/**
* Canonical path constants for every admin route.
*/
export const ADMIN_PATHS = {
INDEXING_STATUS: "/admin/indexing/status",
ADD_CONNECTOR: "/admin/add-connector",
DOCUMENT_SETS: "/admin/documents/sets",
DOCUMENT_EXPLORER: "/admin/documents/explorer",
DOCUMENT_FEEDBACK: "/admin/documents/feedback",
AGENTS: "/admin/agents",
SLACK_BOTS: "/admin/bots",
DISCORD_BOTS: "/admin/discord-bot",
MCP_ACTIONS: "/admin/actions/mcp",
OPENAPI_ACTIONS: "/admin/actions/open-api",
STANDARD_ANSWERS: "/admin/standard-answer",
GROUPS: "/admin/groups",
CHAT_PREFERENCES: "/admin/configuration/chat-preferences",
LLM_MODELS: "/admin/configuration/llm",
WEB_SEARCH: "/admin/configuration/web-search",
IMAGE_GENERATION: "/admin/configuration/image-generation",
CODE_INTERPRETER: "/admin/configuration/code-interpreter",
SEARCH_SETTINGS: "/admin/configuration/search",
DOCUMENT_PROCESSING: "/admin/configuration/document-processing",
KNOWLEDGE_GRAPH: "/admin/kg",
USERS: "/admin/users",
API_KEYS: "/admin/api-key",
TOKEN_RATE_LIMITS: "/admin/token-rate-limits",
USAGE: "/admin/performance/usage",
QUERY_HISTORY: "/admin/performance/query-history",
CUSTOM_ANALYTICS: "/admin/performance/custom-analytics",
THEME: "/admin/theme",
BILLING: "/admin/billing",
INDEX_MIGRATION: "/admin/document-index-migration",
SCIM: "/admin/scim",
DEBUG: "/admin/debug",
// Prefix-only entries (used in SETTINGS_LAYOUT_PREFIXES but have no
// single page header of their own)
DOCUMENTS: "/admin/documents",
PERFORMANCE: "/admin/performance",
} as const;
interface AdminRouteConfig {
export interface AdminRouteEntry {
path: string;
icon: IconFunctionComponent;
title: string;
sidebarLabel: string;
}
/**
* Single source of truth for icon, page-header title, and sidebar label
* for every admin route. Keyed by path from `ADMIN_PATHS`.
* Single source of truth for every admin route: path, icon, page-header
* title, and sidebar label.
*/
export const ADMIN_ROUTE_CONFIG: Record<string, AdminRouteConfig> = {
[ADMIN_PATHS.INDEXING_STATUS]: {
export const ADMIN_ROUTES = {
INDEXING_STATUS: {
path: "/admin/indexing/status",
icon: SvgBookOpen,
title: "Existing Connectors",
sidebarLabel: "Existing Connectors",
},
[ADMIN_PATHS.ADD_CONNECTOR]: {
ADD_CONNECTOR: {
path: "/admin/add-connector",
icon: SvgUploadCloud,
title: "Add Connector",
sidebarLabel: "Add Connector",
},
[ADMIN_PATHS.DOCUMENT_SETS]: {
icon: SvgFolder,
DOCUMENT_SETS: {
path: "/admin/documents/sets",
icon: SvgFiles,
title: "Document Sets",
sidebarLabel: "Document Sets",
},
[ADMIN_PATHS.DOCUMENT_EXPLORER]: {
DOCUMENT_EXPLORER: {
path: "/admin/documents/explorer",
icon: SvgZoomIn,
title: "Document Explorer",
sidebarLabel: "Explorer",
},
[ADMIN_PATHS.DOCUMENT_FEEDBACK]: {
DOCUMENT_FEEDBACK: {
path: "/admin/documents/feedback",
icon: SvgThumbsUp,
title: "Document Feedback",
sidebarLabel: "Feedback",
},
[ADMIN_PATHS.AGENTS]: {
AGENTS: {
path: "/admin/agents",
icon: SvgOnyxOctagon,
title: "Agents",
sidebarLabel: "Agents",
},
[ADMIN_PATHS.SLACK_BOTS]: {
SLACK_BOTS: {
path: "/admin/bots",
icon: SvgSlack,
title: "Slack Bots",
sidebarLabel: "Slack Bots",
title: "Slack Integration",
sidebarLabel: "Slack Integration",
},
[ADMIN_PATHS.DISCORD_BOTS]: {
DISCORD_BOTS: {
path: "/admin/discord-bot",
icon: SvgDiscordMono,
title: "Discord Bots",
sidebarLabel: "Discord Bots",
title: "Discord Integration",
sidebarLabel: "Discord Integration",
},
[ADMIN_PATHS.MCP_ACTIONS]: {
MCP_ACTIONS: {
path: "/admin/actions/mcp",
icon: SvgMcp,
title: "MCP Actions",
sidebarLabel: "MCP Actions",
},
[ADMIN_PATHS.OPENAPI_ACTIONS]: {
OPENAPI_ACTIONS: {
path: "/admin/actions/open-api",
icon: SvgActions,
title: "OpenAPI Actions",
sidebarLabel: "OpenAPI Actions",
},
[ADMIN_PATHS.STANDARD_ANSWERS]: {
STANDARD_ANSWERS: {
path: "/admin/standard-answer",
icon: SvgClipboard,
title: "Standard Answers",
sidebarLabel: "Standard Answers",
},
[ADMIN_PATHS.GROUPS]: {
GROUPS: {
path: "/admin/groups",
icon: SvgUsers,
title: "Manage User Groups",
sidebarLabel: "Groups",
},
[ADMIN_PATHS.CHAT_PREFERENCES]: {
CHAT_PREFERENCES: {
path: "/admin/configuration/chat-preferences",
icon: SvgBubbleText,
title: "Chat Preferences",
sidebarLabel: "Chat Preferences",
},
[ADMIN_PATHS.LLM_MODELS]: {
LLM_MODELS: {
path: "/admin/configuration/llm",
icon: SvgCpu,
title: "Language Models",
sidebarLabel: "Language Models",
},
[ADMIN_PATHS.WEB_SEARCH]: {
WEB_SEARCH: {
path: "/admin/configuration/web-search",
icon: SvgGlobe,
title: "Web Search",
sidebarLabel: "Web Search",
},
[ADMIN_PATHS.IMAGE_GENERATION]: {
IMAGE_GENERATION: {
path: "/admin/configuration/image-generation",
icon: SvgImage,
title: "Image Generation",
sidebarLabel: "Image Generation",
},
[ADMIN_PATHS.CODE_INTERPRETER]: {
VOICE: {
path: "/admin/configuration/voice",
icon: SvgAudio,
title: "Voice",
sidebarLabel: "Voice",
},
CODE_INTERPRETER: {
path: "/admin/configuration/code-interpreter",
icon: SvgTerminal,
title: "Code Interpreter",
sidebarLabel: "Code Interpreter",
},
[ADMIN_PATHS.SEARCH_SETTINGS]: {
icon: SvgSearch,
title: "Search Settings",
sidebarLabel: "Search Settings",
INDEX_SETTINGS: {
path: "/admin/configuration/search",
icon: SvgSearchMenu,
title: "Index Settings",
sidebarLabel: "Index Settings",
},
[ADMIN_PATHS.DOCUMENT_PROCESSING]: {
DOCUMENT_PROCESSING: {
path: "/admin/configuration/document-processing",
icon: SvgFileText,
title: "Document Processing",
sidebarLabel: "Document Processing",
},
[ADMIN_PATHS.KNOWLEDGE_GRAPH]: {
KNOWLEDGE_GRAPH: {
path: "/admin/kg",
icon: SvgNetworkGraph,
title: "Knowledge Graph",
sidebarLabel: "Knowledge Graph",
},
[ADMIN_PATHS.USERS]: {
USERS: {
path: "/admin/users",
icon: SvgUser,
title: "Users & Requests",
sidebarLabel: "Users",
},
[ADMIN_PATHS.API_KEYS]: {
icon: SvgKey,
title: "API Keys",
sidebarLabel: "API Keys",
API_KEYS: {
path: "/admin/api-key",
icon: SvgUserKey,
title: "Service Accounts",
sidebarLabel: "Service Accounts",
},
[ADMIN_PATHS.TOKEN_RATE_LIMITS]: {
icon: SvgShield,
title: "Token Rate Limits",
sidebarLabel: "Token Rate Limits",
TOKEN_RATE_LIMITS: {
path: "/admin/token-rate-limits",
icon: SvgProgressBars,
title: "Spending Limits",
sidebarLabel: "Spending Limits",
},
[ADMIN_PATHS.USAGE]: {
USAGE: {
path: "/admin/performance/usage",
icon: SvgActivity,
title: "Usage Statistics",
sidebarLabel: "Usage Statistics",
},
[ADMIN_PATHS.QUERY_HISTORY]: {
icon: SvgServer,
QUERY_HISTORY: {
path: "/admin/performance/query-history",
icon: SvgHistory,
title: "Query History",
sidebarLabel: "Query History",
},
[ADMIN_PATHS.CUSTOM_ANALYTICS]: {
CUSTOM_ANALYTICS: {
path: "/admin/performance/custom-analytics",
icon: SvgBarChart,
title: "Custom Analytics",
sidebarLabel: "Custom Analytics",
},
[ADMIN_PATHS.THEME]: {
THEME: {
path: "/admin/theme",
icon: SvgPaintBrush,
title: "Appearance & Theming",
sidebarLabel: "Appearance & Theming",
},
[ADMIN_PATHS.BILLING]: {
BILLING: {
path: "/admin/billing",
icon: SvgWallet,
title: "Plans & Billing",
sidebarLabel: "Plans & Billing",
},
[ADMIN_PATHS.INDEX_MIGRATION]: {
INDEX_MIGRATION: {
path: "/admin/document-index-migration",
icon: SvgArrowExchange,
title: "Document Index Migration",
sidebarLabel: "Document Index Migration",
},
[ADMIN_PATHS.SCIM]: {
SCIM: {
path: "/admin/scim",
icon: SvgUserSync,
title: "SCIM",
sidebarLabel: "SCIM",
},
[ADMIN_PATHS.DEBUG]: {
DEBUG: {
path: "/admin/debug",
icon: SvgDownload,
title: "Debug Logs",
sidebarLabel: "Debug Logs",
},
};
// Prefix-only entries used for layout matching — not rendered as sidebar
// items or page headers.
DOCUMENTS: {
path: "/admin/documents",
icon: SvgEmpty,
title: "",
sidebarLabel: "",
},
PERFORMANCE: {
path: "/admin/performance",
icon: SvgEmpty,
title: "",
sidebarLabel: "",
},
} as const satisfies Record<string, AdminRouteEntry>;
/**
* Helper that converts a route config entry into the `{ name, icon, link }`
* shape expected by the sidebar. Extra fields (e.g. `error`) can be spread in.
* Helper that converts a route entry into the `{ name, icon, link }`
* shape expected by the sidebar.
*/
export function sidebarItem(path: string) {
const config = ADMIN_ROUTE_CONFIG[path]!;
return { name: config.sidebarLabel, icon: config.icon, link: path };
export function sidebarItem(route: AdminRouteEntry) {
return { name: route.sidebarLabel, icon: route.icon, link: route.path };
}

View File

@@ -110,7 +110,6 @@ export const CREDENTIAL_JSON = "credential_json";
export const MODAL_ROOT_ID = "modal-root";
export const ANONYMOUS_USER_NAME = "Anonymous";
export const UNNAMED_CHAT = "New Chat";
export const DEFAULT_AGENT_ID = 0;
@@ -133,3 +132,5 @@ export const LOGO_UNFOLDED_SIZE_PX = 88;
export const DEFAULT_CONTEXT_TOKENS = 120_000;
export const MAX_CHUNKS_FED_TO_CHAT = 25;
export const APP_SLOGAN = "Open Source AI Platform";

View File

@@ -1,4 +1,4 @@
import { User } from "./types";
import { User } from "@/lib/types";
export const checkUserIsNoAuthUser = (userId: string) => {
return userId === "__no_auth_user__";
@@ -113,3 +113,18 @@ export async function refreshToken(
throw error;
}
}
export function getUserDisplayName(user: User | null): string {
// Prioritize custom personal name if set
if (!!user?.personalization?.name) return user.personalization.name;
// Then, prioritize personal email
if (!!user?.email) {
const atIndex = user.email.indexOf("@");
if (atIndex > 0) {
return user.email.substring(0, atIndex);
}
}
// If nothing works, then fall back to anonymous user name
return "Anonymous";
}

View File

@@ -1,3 +1,4 @@
import { cn } from "@/lib/utils";
import Text from "@/refresh-components/texts/Text";
import { SvgX } from "@opal/icons";
import { Button } from "@opal/components";
@@ -10,6 +11,8 @@ export interface ChipProps {
rightIcon?: React.FunctionComponent<IconProps>;
onRemove?: () => void;
smallLabel?: boolean;
/** When true, applies warning-coloured styling to the right icon. */
error?: boolean;
}
/**
@@ -29,16 +32,27 @@ export default function Chip({
rightIcon: RightIcon,
onRemove,
smallLabel = true,
error = false,
}: ChipProps) {
return (
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded-08 bg-background-tint-02">
<div
className={cn(
"flex items-center gap-1 px-1.5 py-0.5 rounded-08",
"bg-background-tint-02"
)}
>
{Icon && <Icon size={12} className="text-text-03" />}
{children && (
<Text figureSmallLabel={smallLabel} text03>
{children}
</Text>
)}
{RightIcon && <RightIcon size={14} className="text-text-03" />}
{RightIcon && (
<RightIcon
size={14}
className={cn(error ? "text-status-warning-05" : "text-text-03")}
/>
)}
{onRemove && (
<Button
onClick={(e) => {

View File

@@ -107,9 +107,10 @@ const PopoverClose = PopoverPrimitive.Close;
* </Popover.Content>
* ```
*/
type PopoverWidths = "fit" | "md" | "lg" | "xl" | "trigger";
type PopoverWidths = "fit" | "sm" | "md" | "lg" | "xl" | "trigger";
const widthClasses: Record<PopoverWidths, string> = {
fit: "w-fit",
sm: "w-[10rem]",
md: "w-[12rem]",
lg: "w-[15rem]",
xl: "w-[18rem]",
@@ -120,25 +121,29 @@ interface PopoverContentProps
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
> {
width?: PopoverWidths;
/** Portal container. Set to a DOM element to render inside it (e.g. inside a modal). */
container?: HTMLElement | null;
ref?: React.Ref<React.ComponentRef<typeof PopoverPrimitive.Content>>;
}
function PopoverContent({
width = "fit",
container,
align = "center",
sideOffset = 4,
ref,
...props
}: PopoverContentProps) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Portal container={container}>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
collisionPadding={8}
className={cn(
"bg-background-neutral-00 p-1 z-popover rounded-12 overflow-hidden border shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"bg-background-neutral-00 p-1 z-popover rounded-12 border shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"max-h-[var(--radix-popover-content-available-height)]",
"overflow-hidden",
widthClasses[width]
)}
{...props}

View File

@@ -7,6 +7,8 @@ import { cn } from "@/lib/utils";
export interface SeparatorProps
extends React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> {
noPadding?: boolean;
/** Custom horizontal padding in rem. Overrides the default padding. */
paddingXRem?: number;
}
/**
@@ -34,6 +36,7 @@ const Separator = React.forwardRef(
(
{
noPadding,
paddingXRem,
className,
orientation = "horizontal",
@@ -46,9 +49,17 @@ const Separator = React.forwardRef(
return (
<div
style={{
...(paddingXRem != null
? {
paddingLeft: `${paddingXRem}rem`,
paddingRight: `${paddingXRem}rem`,
}
: {}),
}}
className={cn(
isHorizontal ? "w-full" : "h-full",
!noPadding && (isHorizontal ? "py-4" : "px-4"),
paddingXRem == null && !noPadding && (isHorizontal ? "py-4" : "px-4"),
className
)}
>

View File

@@ -105,7 +105,7 @@ export default function ShadowDiv({
}, [containerRef, checkScroll]);
return (
<div className="relative">
<div className="relative min-h-0">
<div
ref={containerRef}
className={cn("overflow-y-auto", className)}
@@ -118,7 +118,7 @@ export default function ShadowDiv({
{!bottomOnly && (
<div
className={cn(
"absolute top-0 left-0 right-0 pointer-events-none",
"absolute top-0 left-0 right-0 pointer-events-none transition-opacity duration-150",
showTopShadow ? "opacity-100" : "opacity-0"
)}
style={{
@@ -132,7 +132,7 @@ export default function ShadowDiv({
{!topOnly && (
<div
className={cn(
"absolute bottom-0 left-0 right-0 pointer-events-none",
"absolute bottom-0 left-0 right-0 pointer-events-none transition-opacity duration-150",
showBottomShadow ? "opacity-100" : "opacity-0"
)}
style={{

View File

@@ -97,16 +97,8 @@ function InputChipField({
<Chip
key={chip.id}
onRemove={disabled ? undefined : () => onRemoveChip(chip.id)}
rightIcon={
chip.error
? (props) => (
<SvgAlertTriangle
{...props}
className="text-status-warning-text"
/>
)
: undefined
}
rightIcon={chip.error ? SvgAlertTriangle : undefined}
error={chip.error}
smallLabel={layout === "stacked"}
>
{chip.label}
@@ -124,7 +116,7 @@ function InputChipField({
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={chips.length === 0 ? placeholder : undefined}
placeholder={placeholder}
className={cn(
"flex-1 min-w-[80px] h-[1.5rem] bg-transparent p-0.5 focus:outline-none",
innerClasses[variant],

View File

@@ -24,7 +24,7 @@ import {
SvgFold,
SvgExternalLink,
} from "@opal/icons";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { Content } from "@opal/layouts";
import {
useSettingsContext,
@@ -58,7 +58,7 @@ import useFilter from "@/hooks/useFilter";
import { MCPServer } from "@/lib/tools/interfaces";
import type { IconProps } from "@opal/types";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.CHAT_PREFERENCES]!;
const route = ADMIN_ROUTES.CHAT_PREFERENCES;
interface DefaultAgentConfiguration {
tool_ids: number[];

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