mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-24 00:52:47 +00:00
Compare commits
3 Commits
bo/hook_ui
...
chore/filt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3e38a7ef7 | ||
|
|
a4c9926eb1 | ||
|
|
8c63831fff |
6
.github/workflows/deployment.yml
vendored
6
.github/workflows/deployment.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "0.9.9"
|
||||
enable-cache: false
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "0.9.9"
|
||||
# NOTE: This isn't caching much and zizmor suggests this could be poisoned, so disable.
|
||||
@@ -307,7 +307,7 @@ jobs:
|
||||
xdg-utils
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # ratchet:actions/setup-node@v6.3.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # ratchet:actions/setup-node@v6.2.0
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: false
|
||||
version: "0.9.9"
|
||||
|
||||
2
.github/workflows/pr-desktop-build.yml
vendored
2
.github/workflows/pr-desktop-build.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm" # zizmor: ignore[cache-poisoning]
|
||||
|
||||
2
.github/workflows/pr-jest-tests.yml
vendored
2
.github/workflows/pr-jest-tests.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # ratchet:actions/setup-node@v4
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm" # zizmor: ignore[cache-poisoning] test-only workflow; no deploy artifacts
|
||||
|
||||
6
.github/workflows/pr-playwright-tests.yml
vendored
6
.github/workflows/pr-playwright-tests.yml
vendored
@@ -272,7 +272,7 @@ jobs:
|
||||
|
||||
- name: Setup node
|
||||
# zizmor: ignore[cache-poisoning] ephemeral runners; no release artifacts
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # ratchet:actions/setup-node@v4
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm" # zizmor: ignore[cache-poisoning]
|
||||
@@ -471,7 +471,7 @@ jobs:
|
||||
|
||||
- name: Install the latest version of uv
|
||||
if: always()
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: false
|
||||
version: "0.9.9"
|
||||
@@ -614,7 +614,7 @@ jobs:
|
||||
|
||||
- name: Setup node
|
||||
# zizmor: ignore[cache-poisoning] ephemeral runners; no release artifacts
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # ratchet:actions/setup-node@v4
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm" # zizmor: ignore[cache-poisoning]
|
||||
|
||||
2
.github/workflows/pr-python-model-tests.yml
vendored
2
.github/workflows/pr-python-model-tests.yml
vendored
@@ -73,7 +73,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
|
||||
|
||||
- name: Build and load
|
||||
uses: docker/bake-action@82490499d2e5613fcead7e128237ef0b0ea210f7 # ratchet:docker/bake-action@v7.0.0
|
||||
uses: docker/bake-action@5be5f02ff8819ecd3092ea6b2e6261c31774f2b4 # ratchet:docker/bake-action@v6
|
||||
env:
|
||||
TAG: model-server-${{ github.run_id }}
|
||||
with:
|
||||
|
||||
2
.github/workflows/pr-quality-checks.yml
vendored
2
.github/workflows/pr-quality-checks.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 # ratchet:hashicorp/setup-terraform@v4.0.0
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # ratchet:actions/setup-node@v6
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # ratchet:actions/setup-node@v6
|
||||
with: # zizmor: ignore[cache-poisoning]
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
|
||||
2
.github/workflows/preview.yml
vendored
2
.github/workflows/preview.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # ratchet:actions/setup-node@v4
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
|
||||
2
.github/workflows/release-cli.yml
vendored
2
.github/workflows/release-cli.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
|
||||
- uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: false
|
||||
version: "0.9.9"
|
||||
|
||||
2
.github/workflows/release-devtools.yml
vendored
2
.github/workflows/release-devtools.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
|
||||
- uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: false
|
||||
version: "0.9.9"
|
||||
|
||||
2
.github/workflows/storybook-deploy.yml
vendored
2
.github/workflows/storybook-deploy.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # ratchet:actions/setup-node@v4
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
|
||||
2
.github/workflows/zizmor.yml
vendored
2
.github/workflows/zizmor.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: false
|
||||
version: "0.9.9"
|
||||
|
||||
@@ -157,11 +157,7 @@ def fetch_logo_helper(db_session: Session) -> Response: # noqa: ARG001
|
||||
detail="No logo file found",
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
content=onyx_file.data,
|
||||
media_type=onyx_file.mime_type,
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
return Response(content=onyx_file.data, media_type=onyx_file.mime_type)
|
||||
|
||||
|
||||
def fetch_logotype_helper(db_session: Session) -> Response: # noqa: ARG001
|
||||
|
||||
@@ -177,8 +177,8 @@ class ExtractedContextFiles(BaseModel):
|
||||
class SearchParams(BaseModel):
|
||||
"""Resolved search filter IDs and search-tool usage for a chat turn."""
|
||||
|
||||
search_project_id: int | None
|
||||
search_persona_id: int | None
|
||||
project_id_filter: int | None
|
||||
persona_id_filter: int | None
|
||||
search_usage: SearchToolUsage
|
||||
|
||||
|
||||
|
||||
@@ -399,13 +399,13 @@ def determine_search_params(
|
||||
"""
|
||||
is_custom_persona = persona_id != DEFAULT_PERSONA_ID
|
||||
|
||||
search_project_id: int | None = None
|
||||
search_persona_id: int | None = None
|
||||
project_id_filter: int | None = None
|
||||
persona_id_filter: int | None = None
|
||||
if extracted_context_files.use_as_search_filter:
|
||||
if is_custom_persona:
|
||||
search_persona_id = persona_id
|
||||
persona_id_filter = persona_id
|
||||
else:
|
||||
search_project_id = project_id
|
||||
project_id_filter = project_id
|
||||
|
||||
search_usage = SearchToolUsage.AUTO
|
||||
if not is_custom_persona and project_id:
|
||||
@@ -418,8 +418,8 @@ def determine_search_params(
|
||||
search_usage = SearchToolUsage.DISABLED
|
||||
|
||||
return SearchParams(
|
||||
search_project_id=search_project_id,
|
||||
search_persona_id=search_persona_id,
|
||||
project_id_filter=project_id_filter,
|
||||
persona_id_filter=persona_id_filter,
|
||||
search_usage=search_usage,
|
||||
)
|
||||
|
||||
@@ -711,8 +711,8 @@ def handle_stream_message_objects(
|
||||
llm=llm,
|
||||
search_tool_config=SearchToolConfig(
|
||||
user_selected_filters=new_msg_req.internal_search_filters,
|
||||
project_id=search_params.search_project_id,
|
||||
persona_id=search_params.search_persona_id,
|
||||
project_id_filter=search_params.project_id_filter,
|
||||
persona_id_filter=search_params.persona_id_filter,
|
||||
bypass_acl=bypass_acl,
|
||||
slack_context=slack_context,
|
||||
enable_slack_search=_should_enable_slack_search(
|
||||
|
||||
@@ -88,9 +88,8 @@ WEB_CONNECTOR_MAX_SCROLL_ATTEMPTS = 20
|
||||
IFRAME_TEXT_LENGTH_THRESHOLD = 700
|
||||
# Message indicating JavaScript is disabled, which often appears when scraping fails
|
||||
JAVASCRIPT_DISABLED_MESSAGE = "You have JavaScript disabled in your browser"
|
||||
# Grace period after page navigation to allow bot-detection challenges
|
||||
# and SPA content rendering to complete
|
||||
PAGE_RENDER_TIMEOUT_MS = 5000
|
||||
# Grace period after page navigation to allow bot-detection challenges to complete
|
||||
BOT_DETECTION_GRACE_PERIOD_MS = 5000
|
||||
|
||||
# Define common headers that mimic a real browser
|
||||
DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
|
||||
@@ -548,15 +547,7 @@ class WebConnector(LoadConnector):
|
||||
)
|
||||
# Give the page a moment to start rendering after navigation commits.
|
||||
# Allows CloudFlare and other bot-detection challenges to complete.
|
||||
page.wait_for_timeout(PAGE_RENDER_TIMEOUT_MS)
|
||||
|
||||
# Wait for network activity to settle so SPAs that fetch content
|
||||
# asynchronously after the initial JS bundle have time to render.
|
||||
try:
|
||||
# A bit of extra time to account for long-polling, websockets, etc.
|
||||
page.wait_for_load_state("networkidle", timeout=PAGE_RENDER_TIMEOUT_MS)
|
||||
except TimeoutError:
|
||||
pass
|
||||
page.wait_for_timeout(BOT_DETECTION_GRACE_PERIOD_MS)
|
||||
|
||||
last_modified = (
|
||||
page_response.header_value("Last-Modified") if page_response else None
|
||||
@@ -585,7 +576,7 @@ class WebConnector(LoadConnector):
|
||||
# (e.g., CloudFlare protection keeps making requests)
|
||||
try:
|
||||
page.wait_for_load_state(
|
||||
"networkidle", timeout=PAGE_RENDER_TIMEOUT_MS
|
||||
"networkidle", timeout=BOT_DETECTION_GRACE_PERIOD_MS
|
||||
)
|
||||
except TimeoutError:
|
||||
# If networkidle times out, just give it a moment for content to render
|
||||
|
||||
@@ -2,7 +2,6 @@ from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import Field
|
||||
@@ -70,9 +69,13 @@ class BaseFilters(BaseModel):
|
||||
|
||||
|
||||
class UserFileFilters(BaseModel):
|
||||
user_file_ids: list[UUID] | None = None
|
||||
project_id: int | None = None
|
||||
persona_id: int | None = None
|
||||
# Scopes search to user files tagged with a given project/persona in Vespa.
|
||||
# These are NOT simply the IDs of the current project or persona — they are
|
||||
# only set when the persona's/project's user files overflowed the LLM
|
||||
# context window and must be searched via vector DB instead of being loaded
|
||||
# directly into the prompt.
|
||||
project_id_filter: int | None = None
|
||||
persona_id_filter: int | None = None
|
||||
|
||||
|
||||
class AssistantKnowledgeFilters(BaseModel):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -39,9 +38,8 @@ logger = setup_logger()
|
||||
def _build_index_filters(
|
||||
user_provided_filters: BaseFilters | None,
|
||||
user: User, # Used for ACLs, anonymous users only see public docs
|
||||
project_id: int | None,
|
||||
persona_id: int | None,
|
||||
user_file_ids: list[UUID] | None,
|
||||
project_id_filter: int | None,
|
||||
persona_id_filter: int | None,
|
||||
persona_document_sets: list[str] | None,
|
||||
persona_time_cutoff: datetime | None,
|
||||
db_session: Session | None = None,
|
||||
@@ -97,16 +95,6 @@ def _build_index_filters(
|
||||
if not source_filter and detected_source_filter:
|
||||
source_filter = detected_source_filter
|
||||
|
||||
# CRITICAL FIX: If user_file_ids are present, we must ensure "user_file"
|
||||
# source type is included in the filter, otherwise user files will be excluded!
|
||||
if user_file_ids and source_filter:
|
||||
from onyx.configs.constants import DocumentSource
|
||||
|
||||
# Add user_file to the source filter if not already present
|
||||
if DocumentSource.USER_FILE not in source_filter:
|
||||
source_filter = list(source_filter) + [DocumentSource.USER_FILE]
|
||||
logger.debug("Added USER_FILE to source_filter for user knowledge search")
|
||||
|
||||
if bypass_acl:
|
||||
user_acl_filters = None
|
||||
elif acl_filters is not None:
|
||||
@@ -117,9 +105,8 @@ def _build_index_filters(
|
||||
user_acl_filters = build_access_filters_for_user(user, db_session)
|
||||
|
||||
final_filters = IndexFilters(
|
||||
user_file_ids=user_file_ids,
|
||||
project_id=project_id,
|
||||
persona_id=persona_id,
|
||||
project_id_filter=project_id_filter,
|
||||
persona_id_filter=persona_id_filter,
|
||||
source_type=source_filter,
|
||||
document_set=document_set_filter,
|
||||
time_cutoff=time_filter,
|
||||
@@ -265,19 +252,16 @@ def search_pipeline(
|
||||
db_session: Session | None = None,
|
||||
auto_detect_filters: bool = False,
|
||||
llm: LLM | None = None,
|
||||
# If a project ID is provided, it will be exclusively scoped to that project
|
||||
project_id: int | None = None,
|
||||
# If a persona_id is provided, search scopes to files attached to this persona
|
||||
persona_id: int | None = None,
|
||||
# Vespa metadata filters for overflowing user files. NOT the raw IDs
|
||||
# of the current project/persona — only set when user files couldn't fit
|
||||
# in the LLM context and need to be searched via vector DB.
|
||||
project_id_filter: int | None = None,
|
||||
persona_id_filter: int | None = None,
|
||||
# Pre-fetched data — when provided, avoids DB queries (no session needed)
|
||||
acl_filters: list[str] | None = None,
|
||||
embedding_model: EmbeddingModel | None = None,
|
||||
prefetched_federated_retrieval_infos: list[FederatedRetrievalInfo] | None = None,
|
||||
) -> list[InferenceChunk]:
|
||||
user_uploaded_persona_files: list[UUID] | None = (
|
||||
[user_file.id for user_file in persona.user_files] if persona else None
|
||||
)
|
||||
|
||||
persona_document_sets: list[str] | None = (
|
||||
[persona_document_set.name for persona_document_set in persona.document_sets]
|
||||
if persona
|
||||
@@ -302,9 +286,8 @@ def search_pipeline(
|
||||
filters = _build_index_filters(
|
||||
user_provided_filters=chunk_search_request.user_selected_filters,
|
||||
user=user,
|
||||
project_id=project_id,
|
||||
persona_id=persona_id,
|
||||
user_file_ids=user_uploaded_persona_files,
|
||||
project_id_filter=project_id_filter,
|
||||
persona_id_filter=persona_id_filter,
|
||||
persona_document_sets=persona_document_sets,
|
||||
persona_time_cutoff=persona_time_cutoff,
|
||||
db_session=db_session,
|
||||
|
||||
@@ -110,7 +110,6 @@ def search_chunks(
|
||||
user_id=user_id,
|
||||
source_types=list(source_filters) if source_filters else None,
|
||||
document_set_names=query_request.filters.document_set,
|
||||
user_file_ids=query_request.filters.user_file_ids,
|
||||
)
|
||||
|
||||
federated_sources = set(
|
||||
|
||||
@@ -583,67 +583,6 @@ def get_latest_index_attempt_for_cc_pair_id(
|
||||
return db_session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_latest_successful_index_attempt_for_cc_pair_id(
|
||||
db_session: Session,
|
||||
connector_credential_pair_id: int,
|
||||
secondary_index: bool = False,
|
||||
) -> IndexAttempt | None:
|
||||
"""Returns the most recent successful index attempt for the given cc pair,
|
||||
filtered to the current (or future) search settings.
|
||||
Uses MAX(id) semantics to match get_latest_index_attempts_by_status."""
|
||||
status = IndexModelStatus.FUTURE if secondary_index else IndexModelStatus.PRESENT
|
||||
stmt = (
|
||||
select(IndexAttempt)
|
||||
.where(
|
||||
IndexAttempt.connector_credential_pair_id == connector_credential_pair_id,
|
||||
IndexAttempt.status.in_(
|
||||
[IndexingStatus.SUCCESS, IndexingStatus.COMPLETED_WITH_ERRORS]
|
||||
),
|
||||
)
|
||||
.join(SearchSettings)
|
||||
.where(SearchSettings.status == status)
|
||||
.order_by(desc(IndexAttempt.id))
|
||||
.limit(1)
|
||||
)
|
||||
return db_session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_latest_successful_index_attempts_parallel(
|
||||
secondary_index: bool = False,
|
||||
) -> Sequence[IndexAttempt]:
|
||||
"""Batch version: returns the latest successful index attempt per cc pair.
|
||||
Covers both SUCCESS and COMPLETED_WITH_ERRORS (matching is_successful())."""
|
||||
model_status = (
|
||||
IndexModelStatus.FUTURE if secondary_index else IndexModelStatus.PRESENT
|
||||
)
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
latest_ids = (
|
||||
select(
|
||||
IndexAttempt.connector_credential_pair_id,
|
||||
func.max(IndexAttempt.id).label("max_id"),
|
||||
)
|
||||
.join(SearchSettings, IndexAttempt.search_settings_id == SearchSettings.id)
|
||||
.where(
|
||||
SearchSettings.status == model_status,
|
||||
IndexAttempt.status.in_(
|
||||
[IndexingStatus.SUCCESS, IndexingStatus.COMPLETED_WITH_ERRORS]
|
||||
),
|
||||
)
|
||||
.group_by(IndexAttempt.connector_credential_pair_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
stmt = select(IndexAttempt).join(
|
||||
latest_ids,
|
||||
(
|
||||
IndexAttempt.connector_credential_pair_id
|
||||
== latest_ids.c.connector_credential_pair_id
|
||||
)
|
||||
& (IndexAttempt.id == latest_ids.c.max_id),
|
||||
)
|
||||
return db_session.execute(stmt).scalars().all()
|
||||
|
||||
|
||||
def count_index_attempts_for_cc_pair(
|
||||
db_session: Session,
|
||||
cc_pair_id: int,
|
||||
|
||||
@@ -10,8 +10,8 @@ How `IndexFilters` fields combine into the final query filter. Applies to both V
|
||||
| **Tenant** | `tenant_id` | AND (multi-tenant only) |
|
||||
| **ACL** | `access_control_list` | OR within, AND with rest |
|
||||
| **Narrowing** | `source_type`, `tags`, `time_cutoff` | Each OR within, AND with rest |
|
||||
| **Knowledge scope** | `document_set`, `user_file_ids`, `attached_document_ids`, `hierarchy_node_ids` | OR within group, AND with rest |
|
||||
| **Additive scope** | `project_id`, `persona_id` | OR'd into knowledge scope **only when** a knowledge scope filter already exists |
|
||||
| **Knowledge scope** | `document_set`, `attached_document_ids`, `hierarchy_node_ids`, `persona_id_filter` | OR within group, AND with rest |
|
||||
| **Additive scope** | `project_id_filter` | OR'd into knowledge scope **only when** a knowledge scope filter already exists |
|
||||
|
||||
## How filters combine
|
||||
|
||||
@@ -31,12 +31,22 @@ AND time >= cutoff -- if set
|
||||
|
||||
The knowledge scope filter controls **what knowledge an assistant can access**.
|
||||
|
||||
### Primary vs additive triggers
|
||||
|
||||
- **`persona_id_filter`** is a **primary** trigger. A persona with user files IS explicit
|
||||
knowledge, so `persona_id_filter` alone can start a knowledge scope. Note: this is
|
||||
NOT the raw ID of the persona being used — it is only set when the persona's
|
||||
user files overflowed the LLM context window.
|
||||
- **`project_id_filter`** is **additive**. It widens an existing scope to include project
|
||||
files but never restricts on its own — a chat inside a project should still search
|
||||
team knowledge when no other knowledge is attached.
|
||||
|
||||
### No explicit knowledge attached
|
||||
|
||||
When `document_set`, `user_file_ids`, `attached_document_ids`, and `hierarchy_node_ids` are all empty/None:
|
||||
When `document_set`, `attached_document_ids`, `hierarchy_node_ids`, and `persona_id_filter` are all empty/None:
|
||||
|
||||
- **No knowledge scope filter is applied.** The assistant can see everything (subject to ACL).
|
||||
- `project_id` and `persona_id` are ignored — they never restrict on their own.
|
||||
- `project_id_filter` is ignored — it never restricts on its own.
|
||||
|
||||
### One explicit knowledge type
|
||||
|
||||
@@ -44,39 +54,40 @@ When `document_set`, `user_file_ids`, `attached_document_ids`, and `hierarchy_no
|
||||
-- Only document sets
|
||||
AND (document_sets contains "Engineering" OR document_sets contains "Legal")
|
||||
|
||||
-- Only user files
|
||||
AND (document_id = "uuid-1" OR document_id = "uuid-2")
|
||||
-- Only persona user files (overflowed context)
|
||||
AND (personas contains 42)
|
||||
```
|
||||
|
||||
### Multiple explicit knowledge types (OR'd)
|
||||
|
||||
```
|
||||
-- Document sets + user files
|
||||
AND (
|
||||
document_sets contains "Engineering"
|
||||
OR document_id = "uuid-1"
|
||||
)
|
||||
```
|
||||
|
||||
### Explicit knowledge + overflowing user files
|
||||
|
||||
When an explicit knowledge restriction is in effect **and** `project_id` or `persona_id` is set (user files overflowed the LLM context window), the additive scopes widen the filter:
|
||||
|
||||
```
|
||||
-- Document sets + persona user files overflowed
|
||||
-- Document sets + persona user files
|
||||
AND (
|
||||
document_sets contains "Engineering"
|
||||
OR personas contains 42
|
||||
)
|
||||
```
|
||||
|
||||
-- User files + project files overflowed
|
||||
### Explicit knowledge + overflowing project files
|
||||
|
||||
When an explicit knowledge restriction is in effect **and** `project_id_filter` is set (project files overflowed the LLM context window), `project_id_filter` widens the filter:
|
||||
|
||||
```
|
||||
-- Document sets + project files overflowed
|
||||
AND (
|
||||
document_id = "uuid-1"
|
||||
document_sets contains "Engineering"
|
||||
OR user_project contains 7
|
||||
)
|
||||
|
||||
-- Persona user files + project files (won't happen in practice;
|
||||
-- custom personas ignore project files per the precedence rule)
|
||||
AND (
|
||||
personas contains 42
|
||||
OR user_project contains 7
|
||||
)
|
||||
```
|
||||
|
||||
### Only project_id or persona_id (no explicit knowledge)
|
||||
### Only project_id_filter (no explicit knowledge)
|
||||
|
||||
No knowledge scope filter. The assistant searches everything.
|
||||
|
||||
@@ -91,11 +102,10 @@ AND (acl contains ...)
|
||||
| Filter field | Vespa field | Vespa type | Purpose |
|
||||
|---|---|---|---|
|
||||
| `document_set` | `document_sets` | `weightedset<string>` | Connector doc sets attached to assistant |
|
||||
| `user_file_ids` | `document_id` | `string` | User files uploaded to assistant |
|
||||
| `attached_document_ids` | `document_id` | `string` | Documents explicitly attached (OpenSearch only) |
|
||||
| `hierarchy_node_ids` | `ancestor_hierarchy_node_ids` | `array<int>` | Folder/space nodes (OpenSearch only) |
|
||||
| `project_id` | `user_project` | `array<int>` | Project tag for overflowing user files |
|
||||
| `persona_id` | `personas` | `array<int>` | Persona tag for overflowing user files |
|
||||
| `persona_id_filter` | `personas` | `array<int>` | Persona tag for overflowing user files (**primary** trigger) |
|
||||
| `project_id_filter` | `user_project` | `array<int>` | Project tag for overflowing project files (**additive** only) |
|
||||
| `access_control_list` | `access_control_list` | `weightedset<string>` | ACL entries for the requesting user |
|
||||
| `source_type` | `source_type` | `string` | Connector source type (e.g. `web`, `jira`) |
|
||||
| `tags` | `metadata_list` | `array<string>` | Document metadata tags |
|
||||
|
||||
@@ -3,7 +3,6 @@ from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from datetime import timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from onyx.configs.app_configs import DEFAULT_OPENSEARCH_QUERY_TIMEOUT_S
|
||||
from onyx.configs.app_configs import OPENSEARCH_EXPLAIN_ENABLED
|
||||
@@ -219,9 +218,8 @@ class DocumentQuery:
|
||||
source_types=index_filters.source_type or [],
|
||||
tags=index_filters.tags or [],
|
||||
document_sets=index_filters.document_set or [],
|
||||
user_file_ids=index_filters.user_file_ids or [],
|
||||
project_id=index_filters.project_id,
|
||||
persona_id=index_filters.persona_id,
|
||||
project_id_filter=index_filters.project_id_filter,
|
||||
persona_id_filter=index_filters.persona_id_filter,
|
||||
time_cutoff=index_filters.time_cutoff,
|
||||
min_chunk_index=min_chunk_index,
|
||||
max_chunk_index=max_chunk_index,
|
||||
@@ -286,9 +284,8 @@ class DocumentQuery:
|
||||
source_types=[],
|
||||
tags=[],
|
||||
document_sets=[],
|
||||
user_file_ids=[],
|
||||
project_id=None,
|
||||
persona_id=None,
|
||||
project_id_filter=None,
|
||||
persona_id_filter=None,
|
||||
time_cutoff=None,
|
||||
min_chunk_index=None,
|
||||
max_chunk_index=None,
|
||||
@@ -356,9 +353,8 @@ class DocumentQuery:
|
||||
source_types=index_filters.source_type or [],
|
||||
tags=index_filters.tags or [],
|
||||
document_sets=index_filters.document_set or [],
|
||||
user_file_ids=index_filters.user_file_ids or [],
|
||||
project_id=index_filters.project_id,
|
||||
persona_id=index_filters.persona_id,
|
||||
project_id_filter=index_filters.project_id_filter,
|
||||
persona_id_filter=index_filters.persona_id_filter,
|
||||
time_cutoff=index_filters.time_cutoff,
|
||||
min_chunk_index=None,
|
||||
max_chunk_index=None,
|
||||
@@ -449,9 +445,8 @@ class DocumentQuery:
|
||||
source_types=index_filters.source_type or [],
|
||||
tags=index_filters.tags or [],
|
||||
document_sets=index_filters.document_set or [],
|
||||
user_file_ids=index_filters.user_file_ids or [],
|
||||
project_id=index_filters.project_id,
|
||||
persona_id=index_filters.persona_id,
|
||||
project_id_filter=index_filters.project_id_filter,
|
||||
persona_id_filter=index_filters.persona_id_filter,
|
||||
time_cutoff=index_filters.time_cutoff,
|
||||
min_chunk_index=None,
|
||||
max_chunk_index=None,
|
||||
@@ -529,9 +524,8 @@ class DocumentQuery:
|
||||
source_types=index_filters.source_type or [],
|
||||
tags=index_filters.tags or [],
|
||||
document_sets=index_filters.document_set or [],
|
||||
user_file_ids=index_filters.user_file_ids or [],
|
||||
project_id=index_filters.project_id,
|
||||
persona_id=index_filters.persona_id,
|
||||
project_id_filter=index_filters.project_id_filter,
|
||||
persona_id_filter=index_filters.persona_id_filter,
|
||||
time_cutoff=index_filters.time_cutoff,
|
||||
min_chunk_index=None,
|
||||
max_chunk_index=None,
|
||||
@@ -591,9 +585,8 @@ class DocumentQuery:
|
||||
source_types=index_filters.source_type or [],
|
||||
tags=index_filters.tags or [],
|
||||
document_sets=index_filters.document_set or [],
|
||||
user_file_ids=index_filters.user_file_ids or [],
|
||||
project_id=index_filters.project_id,
|
||||
persona_id=index_filters.persona_id,
|
||||
project_id_filter=index_filters.project_id_filter,
|
||||
persona_id_filter=index_filters.persona_id_filter,
|
||||
time_cutoff=index_filters.time_cutoff,
|
||||
min_chunk_index=None,
|
||||
max_chunk_index=None,
|
||||
@@ -824,9 +817,8 @@ class DocumentQuery:
|
||||
source_types: list[DocumentSource],
|
||||
tags: list[Tag],
|
||||
document_sets: list[str],
|
||||
user_file_ids: list[UUID],
|
||||
project_id: int | None,
|
||||
persona_id: int | None,
|
||||
project_id_filter: int | None,
|
||||
persona_id_filter: int | None,
|
||||
time_cutoff: datetime | None,
|
||||
min_chunk_index: int | None,
|
||||
max_chunk_index: int | None,
|
||||
@@ -857,12 +849,12 @@ class DocumentQuery:
|
||||
list corresponding to a tag will be retrieved.
|
||||
document_sets: If supplied, only documents with at least one
|
||||
document set ID from this list will be retrieved.
|
||||
user_file_ids: If supplied, only document IDs in this list will be
|
||||
retrieved.
|
||||
project_id: If not None, only documents with this project ID in user
|
||||
projects will be retrieved.
|
||||
persona_id: If not None, only documents whose personas array
|
||||
contains this persona ID will be retrieved.
|
||||
project_id_filter: If not None, only documents with this project ID
|
||||
in user projects will be retrieved. Additive — only applied
|
||||
when a knowledge scope already exists.
|
||||
persona_id_filter: If not None, only documents whose personas array
|
||||
contains this persona ID will be retrieved. Primary — creates
|
||||
a knowledge scope on its own.
|
||||
time_cutoff: Time cutoff for the documents to retrieve. If not None,
|
||||
Documents which were last updated before this date will not be
|
||||
returned. For documents which do not have a value for their last
|
||||
@@ -879,10 +871,6 @@ class DocumentQuery:
|
||||
NOTE: See DocumentChunk.max_chunk_size.
|
||||
document_id: The document ID to retrieve. If None, no filter will be
|
||||
applied for this. Defaults to None.
|
||||
WARNING: This filters on the same property as user_file_ids.
|
||||
Although it would never make sense to supply both, note that if
|
||||
user_file_ids is supplied and does not contain document_id, no
|
||||
matches will be retrieved.
|
||||
attached_document_ids: Document IDs explicitly attached to the
|
||||
assistant. If provided along with hierarchy_node_ids, documents
|
||||
matching EITHER criteria will be retrieved (OR logic).
|
||||
@@ -943,15 +931,6 @@ class DocumentQuery:
|
||||
)
|
||||
return document_set_filter
|
||||
|
||||
def _get_user_file_id_filter(user_file_ids: list[UUID]) -> dict[str, Any]:
|
||||
# Logical OR operator on its elements.
|
||||
user_file_id_filter: dict[str, Any] = {"bool": {"should": []}}
|
||||
for user_file_id in user_file_ids:
|
||||
user_file_id_filter["bool"]["should"].append(
|
||||
{"term": {DOCUMENT_ID_FIELD_NAME: {"value": str(user_file_id)}}}
|
||||
)
|
||||
return user_file_id_filter
|
||||
|
||||
def _get_user_project_filter(project_id: int) -> dict[str, Any]:
|
||||
# Logical OR operator on its elements.
|
||||
user_project_filter: dict[str, Any] = {"bool": {"should": []}}
|
||||
@@ -1052,14 +1031,17 @@ class DocumentQuery:
|
||||
# assistant can see. When none are set the assistant searches
|
||||
# everything.
|
||||
#
|
||||
# project_id / persona_id are additive: they make overflowing user files
|
||||
# findable but must NOT trigger the restriction on their own (an agent
|
||||
# with no explicit knowledge should search everything).
|
||||
# persona_id_filter is a primary trigger — a persona with user files IS
|
||||
# explicit knowledge, so it can start a knowledge scope on its own.
|
||||
#
|
||||
# project_id_filter is additive — it widens the scope to also cover
|
||||
# overflowing project files but never restricts on its own (a chat
|
||||
# inside a project should still search team knowledge).
|
||||
has_knowledge_scope = (
|
||||
attached_document_ids
|
||||
or hierarchy_node_ids
|
||||
or user_file_ids
|
||||
or document_sets
|
||||
or persona_id_filter is not None
|
||||
)
|
||||
|
||||
if has_knowledge_scope:
|
||||
@@ -1074,23 +1056,17 @@ class DocumentQuery:
|
||||
knowledge_filter["bool"]["should"].append(
|
||||
_get_hierarchy_node_filter(hierarchy_node_ids)
|
||||
)
|
||||
if user_file_ids:
|
||||
knowledge_filter["bool"]["should"].append(
|
||||
_get_user_file_id_filter(user_file_ids)
|
||||
)
|
||||
if document_sets:
|
||||
knowledge_filter["bool"]["should"].append(
|
||||
_get_document_set_filter(document_sets)
|
||||
)
|
||||
# Additive: widen scope to also cover overflowing user files, but
|
||||
# only when an explicit restriction is already in effect.
|
||||
if project_id is not None:
|
||||
if persona_id_filter is not None:
|
||||
knowledge_filter["bool"]["should"].append(
|
||||
_get_user_project_filter(project_id)
|
||||
_get_persona_filter(persona_id_filter)
|
||||
)
|
||||
if persona_id is not None:
|
||||
if project_id_filter is not None:
|
||||
knowledge_filter["bool"]["should"].append(
|
||||
_get_persona_filter(persona_id)
|
||||
_get_user_project_filter(project_id_filter)
|
||||
)
|
||||
filter_clauses.append(knowledge_filter)
|
||||
|
||||
@@ -1108,8 +1084,6 @@ class DocumentQuery:
|
||||
)
|
||||
|
||||
if document_id is not None:
|
||||
# WARNING: If user_file_ids has elements and if none of them are
|
||||
# document_id, no matches will be retrieved.
|
||||
filter_clauses.append(
|
||||
{"term": {DOCUMENT_ID_FIELD_NAME: {"value": document_id}}}
|
||||
)
|
||||
|
||||
@@ -199,31 +199,29 @@ def build_vespa_filters(
|
||||
]
|
||||
_append(filter_parts, _build_or_filters(METADATA_LIST, tag_attributes))
|
||||
|
||||
# Knowledge scope: explicit knowledge attachments (document_sets,
|
||||
# user_file_ids) restrict what an assistant can see. When none are
|
||||
# set, the assistant can see everything.
|
||||
# Knowledge scope: explicit knowledge attachments restrict what an
|
||||
# assistant can see. When none are set, the assistant can see
|
||||
# everything.
|
||||
#
|
||||
# project_id / persona_id are additive: they make overflowing user
|
||||
# files findable in Vespa but must NOT trigger the restriction on
|
||||
# their own (an agent with no explicit knowledge should search
|
||||
# everything).
|
||||
# persona_id_filter is a primary trigger — a persona with user files IS
|
||||
# explicit knowledge, so it can start a knowledge scope on its own.
|
||||
#
|
||||
# project_id_filter is additive — it widens the scope to also cover
|
||||
# overflowing project files but never restricts on its own (a chat
|
||||
# inside a project should still search team knowledge).
|
||||
knowledge_scope_parts: list[str] = []
|
||||
|
||||
_append(
|
||||
knowledge_scope_parts, _build_or_filters(DOCUMENT_SETS, filters.document_set)
|
||||
)
|
||||
_append(knowledge_scope_parts, _build_persona_filter(filters.persona_id_filter))
|
||||
|
||||
user_file_ids_str = (
|
||||
[str(uuid) for uuid in filters.user_file_ids] if filters.user_file_ids else None
|
||||
)
|
||||
_append(knowledge_scope_parts, _build_or_filters(DOCUMENT_ID, user_file_ids_str))
|
||||
|
||||
# Only include project/persona scopes when an explicit knowledge
|
||||
# restriction is already in effect — they widen the scope to also
|
||||
# cover overflowing user files but never restrict on their own.
|
||||
# project_id_filter only widens an existing scope.
|
||||
if knowledge_scope_parts:
|
||||
_append(knowledge_scope_parts, _build_user_project_filter(filters.project_id))
|
||||
_append(knowledge_scope_parts, _build_persona_filter(filters.persona_id))
|
||||
_append(
|
||||
knowledge_scope_parts,
|
||||
_build_user_project_filter(filters.project_id_filter),
|
||||
)
|
||||
|
||||
if len(knowledge_scope_parts) > 1:
|
||||
filter_parts.append("(" + " or ".join(knowledge_scope_parts) + ")")
|
||||
|
||||
@@ -38,17 +38,7 @@ def get_federated_retrieval_functions(
|
||||
source_types: list[DocumentSource] | None,
|
||||
document_set_names: list[str] | None,
|
||||
slack_context: SlackContext | None = None,
|
||||
user_file_ids: list[UUID] | None = None,
|
||||
) -> list[FederatedRetrievalInfo]:
|
||||
# When User Knowledge (user files) is the only knowledge source enabled,
|
||||
# skip federated connectors entirely. User Knowledge mode means the agent
|
||||
# should ONLY use uploaded files, not team connectors like Slack.
|
||||
if user_file_ids and not document_set_names:
|
||||
logger.debug(
|
||||
"Skipping all federated connectors: User Knowledge mode enabled "
|
||||
f"with {len(user_file_ids)} user files and no document sets"
|
||||
)
|
||||
return []
|
||||
|
||||
# Check for Slack bot context first (regardless of user_id)
|
||||
if slack_context:
|
||||
|
||||
@@ -26,8 +26,6 @@ class DocumentIngestionSpec(HookPointSpec):
|
||||
default_timeout_seconds = 30.0
|
||||
fail_hard_description = "The document will not be indexed."
|
||||
default_fail_strategy = HookFailStrategy.HARD
|
||||
# TODO(Bo-Onyx): update later
|
||||
docs_url = "https://docs.google.com/document/d/1pGhB8Wcnhhj8rS4baEJL6CX05yFhuIDNk1gbBRiWu94/edit?tab=t.ue263ual5vdi"
|
||||
|
||||
payload_model = DocumentIngestionPayload
|
||||
response_model = DocumentIngestionResponse
|
||||
|
||||
@@ -65,8 +65,6 @@ class QueryProcessingSpec(HookPointSpec):
|
||||
"The query will be blocked and the user will see an error message."
|
||||
)
|
||||
default_fail_strategy = HookFailStrategy.HARD
|
||||
# TODO(Bo-Onyx): update later
|
||||
docs_url = "https://docs.google.com/document/d/1pGhB8Wcnhhj8rS4baEJL6CX05yFhuIDNk1gbBRiWu94/edit?tab=t.g2r1a1699u87"
|
||||
|
||||
payload_model = QueryProcessingPayload
|
||||
response_model = QueryProcessingResponse
|
||||
|
||||
@@ -43,9 +43,6 @@ from onyx.db.index_attempt import count_index_attempt_errors_for_cc_pair
|
||||
from onyx.db.index_attempt import count_index_attempts_for_cc_pair
|
||||
from onyx.db.index_attempt import get_index_attempt_errors_for_cc_pair
|
||||
from onyx.db.index_attempt import get_latest_index_attempt_for_cc_pair_id
|
||||
from onyx.db.index_attempt import (
|
||||
get_latest_successful_index_attempt_for_cc_pair_id,
|
||||
)
|
||||
from onyx.db.index_attempt import get_paginated_index_attempts_for_cc_pair_id
|
||||
from onyx.db.indexing_coordination import IndexingCoordination
|
||||
from onyx.db.models import IndexAttempt
|
||||
@@ -193,11 +190,6 @@ def get_cc_pair_full_info(
|
||||
only_finished=False,
|
||||
)
|
||||
|
||||
latest_successful_attempt = get_latest_successful_index_attempt_for_cc_pair_id(
|
||||
db_session=db_session,
|
||||
connector_credential_pair_id=cc_pair_id,
|
||||
)
|
||||
|
||||
# Get latest permission sync attempt for status
|
||||
latest_permission_sync_attempt = None
|
||||
if cc_pair.access_type == AccessType.SYNC:
|
||||
@@ -215,11 +207,6 @@ def get_cc_pair_full_info(
|
||||
cc_pair_id=cc_pair_id,
|
||||
),
|
||||
last_index_attempt=latest_attempt,
|
||||
last_successful_index_time=(
|
||||
latest_successful_attempt.time_started
|
||||
if latest_successful_attempt
|
||||
else None
|
||||
),
|
||||
latest_deletion_attempt=get_deletion_attempt_snapshot(
|
||||
connector_id=cc_pair.connector_id,
|
||||
credential_id=cc_pair.credential_id,
|
||||
|
||||
@@ -3,7 +3,6 @@ import math
|
||||
import mimetypes
|
||||
import os
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
from typing import cast
|
||||
@@ -110,9 +109,6 @@ from onyx.db.federated import fetch_all_federated_connectors_parallel
|
||||
from onyx.db.index_attempt import get_index_attempts_for_cc_pair
|
||||
from onyx.db.index_attempt import get_latest_index_attempts_by_status
|
||||
from onyx.db.index_attempt import get_latest_index_attempts_parallel
|
||||
from onyx.db.index_attempt import (
|
||||
get_latest_successful_index_attempts_parallel,
|
||||
)
|
||||
from onyx.db.models import ConnectorCredentialPair
|
||||
from onyx.db.models import FederatedConnector
|
||||
from onyx.db.models import IndexAttempt
|
||||
@@ -1162,26 +1158,21 @@ def get_connector_indexing_status(
|
||||
),
|
||||
(),
|
||||
),
|
||||
# Get most recent successful index attempts
|
||||
(
|
||||
lambda: get_latest_successful_index_attempts_parallel(
|
||||
request.secondary_index,
|
||||
),
|
||||
(),
|
||||
),
|
||||
]
|
||||
|
||||
if user and user.role == UserRole.ADMIN:
|
||||
# For Admin users, we already got all the cc pair in editable_cc_pairs
|
||||
# its not needed to get them again
|
||||
(
|
||||
editable_cc_pairs,
|
||||
federated_connectors,
|
||||
latest_index_attempts,
|
||||
latest_finished_index_attempts,
|
||||
latest_successful_index_attempts,
|
||||
) = run_functions_tuples_in_parallel(parallel_functions)
|
||||
non_editable_cc_pairs = []
|
||||
else:
|
||||
parallel_functions.append(
|
||||
# Get non-editable connector/credential pairs
|
||||
(
|
||||
lambda: get_connector_credential_pairs_for_user_parallel(
|
||||
user, False, None, True, True, False, True, request.source
|
||||
@@ -1195,7 +1186,6 @@ def get_connector_indexing_status(
|
||||
federated_connectors,
|
||||
latest_index_attempts,
|
||||
latest_finished_index_attempts,
|
||||
latest_successful_index_attempts,
|
||||
non_editable_cc_pairs,
|
||||
) = run_functions_tuples_in_parallel(parallel_functions)
|
||||
|
||||
@@ -1207,9 +1197,6 @@ def get_connector_indexing_status(
|
||||
latest_finished_index_attempts = cast(
|
||||
list[IndexAttempt], latest_finished_index_attempts
|
||||
)
|
||||
latest_successful_index_attempts = cast(
|
||||
list[IndexAttempt], latest_successful_index_attempts
|
||||
)
|
||||
|
||||
document_count_info = get_document_counts_for_all_cc_pairs(db_session)
|
||||
|
||||
@@ -1219,48 +1206,42 @@ def get_connector_indexing_status(
|
||||
for connector_id, credential_id, cnt in document_count_info
|
||||
}
|
||||
|
||||
def _attempt_lookup(
|
||||
attempts: list[IndexAttempt],
|
||||
) -> dict[int, IndexAttempt]:
|
||||
return {attempt.connector_credential_pair_id: attempt for attempt in attempts}
|
||||
cc_pair_to_latest_index_attempt: dict[tuple[int, int], IndexAttempt] = {
|
||||
(
|
||||
attempt.connector_credential_pair.connector_id,
|
||||
attempt.connector_credential_pair.credential_id,
|
||||
): attempt
|
||||
for attempt in latest_index_attempts
|
||||
}
|
||||
|
||||
cc_pair_to_latest_index_attempt = _attempt_lookup(latest_index_attempts)
|
||||
cc_pair_to_latest_finished_index_attempt = _attempt_lookup(
|
||||
latest_finished_index_attempts
|
||||
)
|
||||
cc_pair_to_latest_successful_index_attempt = _attempt_lookup(
|
||||
latest_successful_index_attempts
|
||||
)
|
||||
cc_pair_to_latest_finished_index_attempt: dict[tuple[int, int], IndexAttempt] = {
|
||||
(
|
||||
attempt.connector_credential_pair.connector_id,
|
||||
attempt.connector_credential_pair.credential_id,
|
||||
): attempt
|
||||
for attempt in latest_finished_index_attempts
|
||||
}
|
||||
|
||||
def build_connector_indexing_status(
|
||||
cc_pair: ConnectorCredentialPair,
|
||||
is_editable: bool,
|
||||
) -> ConnectorIndexingStatusLite | None:
|
||||
# TODO remove this to enable ingestion API
|
||||
if cc_pair.name == "DefaultCCPair":
|
||||
return None
|
||||
|
||||
latest_attempt = cc_pair_to_latest_index_attempt.get(cc_pair.id)
|
||||
latest_finished_attempt = cc_pair_to_latest_finished_index_attempt.get(
|
||||
cc_pair.id
|
||||
latest_attempt = cc_pair_to_latest_index_attempt.get(
|
||||
(cc_pair.connector_id, cc_pair.credential_id)
|
||||
)
|
||||
latest_successful_attempt = cc_pair_to_latest_successful_index_attempt.get(
|
||||
cc_pair.id
|
||||
latest_finished_attempt = cc_pair_to_latest_finished_index_attempt.get(
|
||||
(cc_pair.connector_id, cc_pair.credential_id)
|
||||
)
|
||||
doc_count = cc_pair_to_document_cnt.get(
|
||||
(cc_pair.connector_id, cc_pair.credential_id), 0
|
||||
)
|
||||
|
||||
return _get_connector_indexing_status_lite(
|
||||
cc_pair,
|
||||
latest_attempt,
|
||||
latest_finished_attempt,
|
||||
(
|
||||
latest_successful_attempt.time_started
|
||||
if latest_successful_attempt
|
||||
else None
|
||||
),
|
||||
is_editable,
|
||||
doc_count,
|
||||
cc_pair, latest_attempt, latest_finished_attempt, is_editable, doc_count
|
||||
)
|
||||
|
||||
# Process editable cc_pairs
|
||||
@@ -1421,7 +1402,6 @@ def _get_connector_indexing_status_lite(
|
||||
cc_pair: ConnectorCredentialPair,
|
||||
latest_index_attempt: IndexAttempt | None,
|
||||
latest_finished_index_attempt: IndexAttempt | None,
|
||||
last_successful_index_time: datetime | None,
|
||||
is_editable: bool,
|
||||
document_cnt: int,
|
||||
) -> ConnectorIndexingStatusLite | None:
|
||||
@@ -1455,7 +1435,7 @@ def _get_connector_indexing_status_lite(
|
||||
else None
|
||||
),
|
||||
last_status=latest_index_attempt.status if latest_index_attempt else None,
|
||||
last_success=last_successful_index_time,
|
||||
last_success=cc_pair.last_successful_index_time,
|
||||
docs_indexed=document_cnt,
|
||||
latest_index_attempt_docs_indexed=(
|
||||
latest_index_attempt.total_docs_indexed if latest_index_attempt else None
|
||||
|
||||
@@ -330,7 +330,6 @@ class CCPairFullInfo(BaseModel):
|
||||
num_docs_indexed: int, # not ideal, but this must be computed separately
|
||||
is_editable_for_current_user: bool,
|
||||
indexing: bool,
|
||||
last_successful_index_time: datetime | None = None,
|
||||
last_permission_sync_attempt_status: PermissionSyncStatus | None = None,
|
||||
permission_syncing: bool = False,
|
||||
last_permission_sync_attempt_finished: datetime | None = None,
|
||||
@@ -383,7 +382,9 @@ class CCPairFullInfo(BaseModel):
|
||||
creator_email=(
|
||||
cc_pair_model.creator.email if cc_pair_model.creator else None
|
||||
),
|
||||
last_indexed=last_successful_index_time,
|
||||
last_indexed=(
|
||||
last_index_attempt.time_started if last_index_attempt else None
|
||||
),
|
||||
last_pruned=cc_pair_model.last_pruned,
|
||||
last_full_permission_sync=cls._get_last_full_permission_sync(cc_pair_model),
|
||||
overall_indexing_speed=overall_indexing_speed,
|
||||
|
||||
@@ -5,7 +5,6 @@ from fastapi import Depends
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx import __version__ as onyx_version
|
||||
from onyx.auth.users import current_admin_user
|
||||
from onyx.auth.users import current_user
|
||||
from onyx.auth.users import is_user_admin
|
||||
@@ -17,7 +16,6 @@ from onyx.db.models import User
|
||||
from onyx.db.notification import dismiss_all_notifications
|
||||
from onyx.db.notification import get_notifications
|
||||
from onyx.db.notification import update_notification_last_shown
|
||||
from onyx.hooks.utils import HOOKS_AVAILABLE
|
||||
from onyx.key_value_store.factory import get_kv_store
|
||||
from onyx.key_value_store.interface import KvKeyNotFoundError
|
||||
from onyx.server.features.build.utils import is_onyx_craft_enabled
|
||||
@@ -81,8 +79,6 @@ def fetch_settings(
|
||||
needs_reindexing=needs_reindexing,
|
||||
onyx_craft_enabled=onyx_craft_enabled_for_user,
|
||||
vector_db_enabled=not DISABLE_VECTOR_DB,
|
||||
hooks_enabled=HOOKS_AVAILABLE,
|
||||
version=onyx_version,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -104,7 +104,3 @@ class UserSettings(Settings):
|
||||
# False when DISABLE_VECTOR_DB is set — connectors, RAG search, and
|
||||
# document sets are unavailable.
|
||||
vector_db_enabled: bool = True
|
||||
# True when hooks are available: single-tenant deployment with HOOK_ENABLED=true.
|
||||
hooks_enabled: bool = False
|
||||
# Application version, read from the ONYX_VERSION env var at startup.
|
||||
version: str | None = None
|
||||
|
||||
@@ -53,8 +53,12 @@ logger = setup_logger()
|
||||
|
||||
class SearchToolConfig(BaseModel):
|
||||
user_selected_filters: BaseFilters | None = None
|
||||
project_id: int | None = None
|
||||
persona_id: int | None = None
|
||||
# Vespa metadata filters for overflowing user files. These are NOT the
|
||||
# IDs of the current project/persona — they are only set when the
|
||||
# project's/persona's user files didn't fit in the LLM context window and
|
||||
# must be found via vector DB search instead.
|
||||
project_id_filter: int | None = None
|
||||
persona_id_filter: int | None = None
|
||||
bypass_acl: bool = False
|
||||
additional_context: str | None = None
|
||||
slack_context: SlackContext | None = None
|
||||
@@ -180,8 +184,8 @@ def construct_tools(
|
||||
llm=llm,
|
||||
document_index=document_index,
|
||||
user_selected_filters=search_tool_config.user_selected_filters,
|
||||
project_id=search_tool_config.project_id,
|
||||
persona_id=search_tool_config.persona_id,
|
||||
project_id_filter=search_tool_config.project_id_filter,
|
||||
persona_id_filter=search_tool_config.persona_id_filter,
|
||||
bypass_acl=search_tool_config.bypass_acl,
|
||||
slack_context=search_tool_config.slack_context,
|
||||
enable_slack_search=search_tool_config.enable_slack_search,
|
||||
@@ -396,7 +400,6 @@ def construct_tools(
|
||||
tool_definition=saved_tool.mcp_input_schema or {},
|
||||
connection_config=connection_config,
|
||||
user_email=user_email,
|
||||
user_id=str(user.id),
|
||||
user_oauth_token=mcp_user_oauth_token,
|
||||
additional_headers=additional_mcp_headers,
|
||||
)
|
||||
@@ -429,8 +432,8 @@ def construct_tools(
|
||||
llm=llm,
|
||||
document_index=document_index,
|
||||
user_selected_filters=search_tool_config.user_selected_filters,
|
||||
project_id=search_tool_config.project_id,
|
||||
persona_id=search_tool_config.persona_id,
|
||||
project_id_filter=search_tool_config.project_id_filter,
|
||||
persona_id_filter=search_tool_config.persona_id_filter,
|
||||
bypass_acl=search_tool_config.bypass_acl,
|
||||
slack_context=search_tool_config.slack_context,
|
||||
enable_slack_search=search_tool_config.enable_slack_search,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from mcp.client.auth import OAuthClientProvider
|
||||
|
||||
from onyx.chat.emitter import Emitter
|
||||
from onyx.db.enums import MCPAuthenticationType
|
||||
from onyx.db.enums import MCPTransport
|
||||
@@ -49,7 +47,6 @@ class MCPTool(Tool[None]):
|
||||
tool_definition: dict[str, Any],
|
||||
connection_config: MCPConnectionConfig | None = None,
|
||||
user_email: str = "",
|
||||
user_id: str = "",
|
||||
user_oauth_token: str | None = None,
|
||||
additional_headers: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
@@ -59,7 +56,6 @@ class MCPTool(Tool[None]):
|
||||
self.mcp_server = mcp_server
|
||||
self.connection_config = connection_config
|
||||
self.user_email = user_email
|
||||
self._user_id = user_id
|
||||
self._user_oauth_token = user_oauth_token
|
||||
self._additional_headers = additional_headers or {}
|
||||
|
||||
@@ -202,42 +198,12 @@ class MCPTool(Tool[None]):
|
||||
llm_facing_response=llm_facing_response,
|
||||
)
|
||||
|
||||
# For OAuth servers, construct OAuthClientProvider so the MCP SDK
|
||||
# can refresh expired tokens automatically
|
||||
auth: OAuthClientProvider | None = None
|
||||
if (
|
||||
self.mcp_server.auth_type == MCPAuthenticationType.OAUTH
|
||||
and self.connection_config is not None
|
||||
and self._user_id
|
||||
):
|
||||
if self.mcp_server.transport == MCPTransport.SSE:
|
||||
logger.warning(
|
||||
f"MCP tool '{self._name}': OAuth token refresh is not supported "
|
||||
f"for SSE transport — auth provider will be ignored. "
|
||||
f"Re-authentication may be required after token expiry."
|
||||
)
|
||||
else:
|
||||
from onyx.server.features.mcp.api import UNUSED_RETURN_PATH
|
||||
from onyx.server.features.mcp.api import make_oauth_provider
|
||||
|
||||
# user_id is the requesting user's UUID; safe here because
|
||||
# UNUSED_RETURN_PATH ensures redirect_handler raises immediately
|
||||
# and user_id is never consulted for Redis state lookups.
|
||||
auth = make_oauth_provider(
|
||||
self.mcp_server,
|
||||
self._user_id,
|
||||
UNUSED_RETURN_PATH,
|
||||
self.connection_config.id,
|
||||
None,
|
||||
)
|
||||
|
||||
tool_result = call_mcp_tool(
|
||||
self.mcp_server.server_url,
|
||||
self._name,
|
||||
llm_kwargs,
|
||||
connection_headers=headers,
|
||||
transport=self.mcp_server.transport or MCPTransport.STREAMABLE_HTTP,
|
||||
auth=auth,
|
||||
)
|
||||
|
||||
logger.info(f"MCP tool '{self._name}' executed successfully")
|
||||
@@ -282,7 +248,6 @@ class MCPTool(Tool[None]):
|
||||
"invalid token",
|
||||
"invalid api key",
|
||||
"invalid credentials",
|
||||
"please reconnect to the server",
|
||||
]
|
||||
|
||||
is_auth_error = any(
|
||||
|
||||
@@ -764,8 +764,7 @@ class OpenURLTool(Tool[OpenURLToolOverrideKwargs]):
|
||||
tags=None,
|
||||
access_control_list=access_control_list,
|
||||
tenant_id=get_current_tenant_id() if MULTI_TENANT else None,
|
||||
user_file_ids=None,
|
||||
project_id=None,
|
||||
project_id_filter=None,
|
||||
)
|
||||
|
||||
def _merge_indexed_and_crawled_results(
|
||||
|
||||
@@ -244,10 +244,11 @@ class SearchTool(Tool[SearchToolOverrideKwargs]):
|
||||
document_index: DocumentIndex,
|
||||
# Respecting user selections
|
||||
user_selected_filters: BaseFilters | None,
|
||||
# If the chat is part of a project
|
||||
project_id: int | None,
|
||||
# If set, search scopes to files attached to this persona
|
||||
persona_id: int | None = None,
|
||||
# Vespa metadata filters for overflowing user files. NOT the raw IDs
|
||||
# of the current project/persona — only set when user files couldn't
|
||||
# fit in the LLM context and need to be searched via vector DB.
|
||||
project_id_filter: int | None,
|
||||
persona_id_filter: int | None = None,
|
||||
bypass_acl: bool = False,
|
||||
# Slack context for federated Slack search (tokens fetched internally)
|
||||
slack_context: SlackContext | None = None,
|
||||
@@ -261,8 +262,8 @@ class SearchTool(Tool[SearchToolOverrideKwargs]):
|
||||
self.llm = llm
|
||||
self.document_index = document_index
|
||||
self.user_selected_filters = user_selected_filters
|
||||
self.project_id = project_id
|
||||
self.persona_id = persona_id
|
||||
self.project_id_filter = project_id_filter
|
||||
self.persona_id_filter = persona_id_filter
|
||||
self.bypass_acl = bypass_acl
|
||||
self.slack_context = slack_context
|
||||
self.enable_slack_search = enable_slack_search
|
||||
@@ -451,13 +452,15 @@ class SearchTool(Tool[SearchToolOverrideKwargs]):
|
||||
hybrid_alpha=hybrid_alpha,
|
||||
# For projects, the search scope is the project and has no other limits
|
||||
user_selected_filters=(
|
||||
self.user_selected_filters if self.project_id is None else None
|
||||
self.user_selected_filters
|
||||
if self.project_id_filter is None
|
||||
else None
|
||||
),
|
||||
bypass_acl=self.bypass_acl,
|
||||
limit=num_hits,
|
||||
),
|
||||
project_id=self.project_id,
|
||||
persona_id=self.persona_id,
|
||||
project_id_filter=self.project_id_filter,
|
||||
persona_id_filter=self.persona_id_filter,
|
||||
document_index=self.document_index,
|
||||
user=self.user,
|
||||
persona=self.persona,
|
||||
@@ -574,7 +577,7 @@ class SearchTool(Tool[SearchToolOverrideKwargs]):
|
||||
)
|
||||
|
||||
# Federated retrieval functions (non-Slack; Slack is separate)
|
||||
if self.project_id is not None:
|
||||
if self.project_id_filter is not None:
|
||||
# Project mode ignores user filters → no federated sources
|
||||
prefetch_source_types = None
|
||||
else:
|
||||
@@ -587,16 +590,12 @@ class SearchTool(Tool[SearchToolOverrideKwargs]):
|
||||
persona_document_sets = (
|
||||
[ds.name for ds in self.persona.document_sets] if self.persona else None
|
||||
)
|
||||
user_file_ids = (
|
||||
[uf.id for uf in self.persona.user_files] if self.persona else None
|
||||
)
|
||||
federated_retrieval_infos = (
|
||||
get_federated_retrieval_functions(
|
||||
db_session=db_session,
|
||||
user_id=self.user.id if self.user else None,
|
||||
source_types=prefetch_source_types,
|
||||
document_set_names=persona_document_sets,
|
||||
user_file_ids=user_file_ids,
|
||||
)
|
||||
or []
|
||||
)
|
||||
|
||||
@@ -1,34 +1,30 @@
|
||||
"""Tests for OpenSearch assistant knowledge filter construction.
|
||||
|
||||
These tests verify that when an assistant (persona) has user files attached,
|
||||
the search filter includes those user file IDs in the assistant knowledge filter
|
||||
with OR logic (not AND), ensuring user files are discoverable alongside other
|
||||
knowledge types like attached documents and hierarchy nodes.
|
||||
|
||||
This prevents a regression where user_file_ids were added as a separate AND
|
||||
filter, making it impossible to find user files when the assistant also had
|
||||
attached documents or hierarchy nodes (since no document could match both).
|
||||
These tests verify that when an assistant (persona) has knowledge attached,
|
||||
the search filter includes the appropriate scope filters with OR logic (not AND),
|
||||
ensuring documents are discoverable across knowledge types like attached documents,
|
||||
hierarchy nodes, document sets, and persona/project user files.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.document_index.interfaces_new import TenantState
|
||||
from onyx.document_index.opensearch.schema import DOCUMENT_ID_FIELD_NAME
|
||||
from onyx.document_index.opensearch.schema import PERSONAS_FIELD_NAME
|
||||
from onyx.document_index.opensearch.search import DocumentQuery
|
||||
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA
|
||||
|
||||
USER_FILE_ID = UUID("6ad84e45-4450-406c-9d36-fcb5e74aca6b")
|
||||
ATTACHED_DOCUMENT_ID = "https://docs.google.com/document/d/test-doc-id"
|
||||
HIERARCHY_NODE_ID = 42
|
||||
PERSONA_ID = 7
|
||||
|
||||
|
||||
def _get_search_filters(
|
||||
source_types: list[DocumentSource],
|
||||
user_file_ids: list[UUID],
|
||||
attached_document_ids: list[str] | None,
|
||||
hierarchy_node_ids: list[int] | None,
|
||||
persona_id_filter: int | None = None,
|
||||
document_sets: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
return DocumentQuery._get_search_filters(
|
||||
tenant_state=TenantState(tenant_id=POSTGRES_DEFAULT_SCHEMA, multitenant=False),
|
||||
@@ -36,15 +32,14 @@ def _get_search_filters(
|
||||
access_control_list=["user_email:test@example.com"],
|
||||
source_types=source_types,
|
||||
tags=[],
|
||||
document_sets=[],
|
||||
project_id=None,
|
||||
persona_id=None,
|
||||
document_sets=document_sets or [],
|
||||
project_id_filter=None,
|
||||
persona_id_filter=persona_id_filter,
|
||||
time_cutoff=None,
|
||||
min_chunk_index=None,
|
||||
max_chunk_index=None,
|
||||
max_chunk_size=None,
|
||||
document_id=None,
|
||||
user_file_ids=user_file_ids,
|
||||
attached_document_ids=attached_document_ids,
|
||||
hierarchy_node_ids=hierarchy_node_ids,
|
||||
)
|
||||
@@ -53,137 +48,97 @@ def _get_search_filters(
|
||||
class TestAssistantKnowledgeFilter:
|
||||
"""Tests for assistant knowledge filter construction in OpenSearch queries."""
|
||||
|
||||
def test_user_file_ids_included_in_assistant_knowledge_filter(self) -> None:
|
||||
"""
|
||||
Tests that user_file_ids are included in the assistant knowledge filter
|
||||
with OR logic when the assistant has both user files and attached documents.
|
||||
|
||||
This prevents the regression where user files were ANDed with other
|
||||
knowledge types, making them unfindable.
|
||||
"""
|
||||
|
||||
# Under test: Call the filter construction method directly
|
||||
def test_persona_id_filter_added_when_knowledge_scope_exists(self) -> None:
|
||||
"""persona_id_filter should be OR'd into the knowledge scope filter
|
||||
when explicit knowledge attachments (attached_document_ids,
|
||||
hierarchy_node_ids, document_sets) are present."""
|
||||
filter_clauses = _get_search_filters(
|
||||
source_types=[DocumentSource.FILE, DocumentSource.USER_FILE],
|
||||
user_file_ids=[USER_FILE_ID],
|
||||
source_types=[DocumentSource.FILE],
|
||||
attached_document_ids=[ATTACHED_DOCUMENT_ID],
|
||||
hierarchy_node_ids=[HIERARCHY_NODE_ID],
|
||||
persona_id_filter=PERSONA_ID,
|
||||
)
|
||||
|
||||
knowledge_filter = None
|
||||
for clause in filter_clauses:
|
||||
if "bool" in clause and "should" in clause["bool"]:
|
||||
if clause["bool"].get("minimum_should_match") == 1:
|
||||
knowledge_filter = clause
|
||||
break
|
||||
|
||||
assert knowledge_filter is not None, (
|
||||
"Expected to find an assistant knowledge filter with "
|
||||
"'minimum_should_match: 1'"
|
||||
)
|
||||
|
||||
should_clauses = knowledge_filter["bool"]["should"]
|
||||
persona_found = any(
|
||||
clause.get("term", {}).get(PERSONAS_FIELD_NAME, {}).get("value")
|
||||
== PERSONA_ID
|
||||
for clause in should_clauses
|
||||
)
|
||||
assert persona_found, (
|
||||
f"Expected persona_id={PERSONA_ID} filter on {PERSONAS_FIELD_NAME} "
|
||||
f"in should clauses. Got: {should_clauses}"
|
||||
)
|
||||
|
||||
def test_persona_id_filter_alone_creates_knowledge_scope(self) -> None:
|
||||
"""persona_id_filter IS a primary knowledge scope trigger — a persona
|
||||
with user files is explicit knowledge, so it should restrict
|
||||
search on its own."""
|
||||
filter_clauses = _get_search_filters(
|
||||
source_types=[],
|
||||
attached_document_ids=None,
|
||||
hierarchy_node_ids=None,
|
||||
persona_id_filter=PERSONA_ID,
|
||||
)
|
||||
|
||||
# Postcondition: Find the assistant knowledge filter (bool with should clauses)
|
||||
knowledge_filter = None
|
||||
for clause in filter_clauses:
|
||||
if "bool" in clause and "should" in clause["bool"]:
|
||||
# Check if this is the knowledge filter (has minimum_should_match=1)
|
||||
if clause["bool"].get("minimum_should_match") == 1:
|
||||
knowledge_filter = clause
|
||||
break
|
||||
|
||||
assert (
|
||||
knowledge_filter is not None
|
||||
), "Expected to find an assistant knowledge filter with 'minimum_should_match: 1'"
|
||||
|
||||
# The knowledge filter should have 3 should clauses (user files, attached docs, hierarchy nodes)
|
||||
should_clauses = knowledge_filter["bool"]["should"]
|
||||
assert (
|
||||
len(should_clauses) == 3
|
||||
), f"Expected 3 should clauses (user_file, attached_doc, hierarchy_node), got {len(should_clauses)}"
|
||||
|
||||
# Verify user_file_id is in one of the should clauses
|
||||
user_file_filter_found = False
|
||||
for should_clause in should_clauses:
|
||||
# The user file filter uses a nested bool with should for each file ID
|
||||
if "bool" in should_clause and "should" in should_clause["bool"]:
|
||||
for term_clause in should_clause["bool"]["should"]:
|
||||
if "term" in term_clause:
|
||||
term_value = term_clause["term"].get(DOCUMENT_ID_FIELD_NAME, {})
|
||||
if term_value.get("value") == str(USER_FILE_ID):
|
||||
user_file_filter_found = True
|
||||
break
|
||||
|
||||
assert user_file_filter_found, (
|
||||
f"Expected user_file_id {USER_FILE_ID} to be in the assistant knowledge "
|
||||
f"filter's should clauses. Filter structure: {knowledge_filter}"
|
||||
), "Expected persona_id_filter alone to create a knowledge scope filter"
|
||||
persona_found = any(
|
||||
clause.get("term", {}).get(PERSONAS_FIELD_NAME, {}).get("value")
|
||||
== PERSONA_ID
|
||||
for clause in knowledge_filter["bool"]["should"]
|
||||
)
|
||||
assert persona_found, (
|
||||
f"Expected persona_id={PERSONA_ID} filter in knowledge scope. "
|
||||
f"Got: {knowledge_filter}"
|
||||
)
|
||||
|
||||
def test_user_file_ids_only_creates_knowledge_filter(self) -> None:
|
||||
"""
|
||||
Tests that when only user_file_ids are provided (no attached_documents or
|
||||
hierarchy_nodes), the assistant knowledge filter is still created with the
|
||||
user file IDs.
|
||||
"""
|
||||
# Precondition
|
||||
|
||||
def test_knowledge_filter_with_document_sets_and_persona_filter(self) -> None:
|
||||
"""document_sets and persona_id_filter should be OR'd together in
|
||||
the knowledge scope filter."""
|
||||
filter_clauses = _get_search_filters(
|
||||
source_types=[DocumentSource.USER_FILE],
|
||||
user_file_ids=[USER_FILE_ID],
|
||||
source_types=[],
|
||||
attached_document_ids=None,
|
||||
hierarchy_node_ids=None,
|
||||
persona_id_filter=PERSONA_ID,
|
||||
document_sets=["engineering"],
|
||||
)
|
||||
|
||||
# Postcondition: Find filter that contains our user file ID
|
||||
user_file_filter_found = False
|
||||
knowledge_filter = None
|
||||
for clause in filter_clauses:
|
||||
clause_str = str(clause)
|
||||
if str(USER_FILE_ID) in clause_str:
|
||||
user_file_filter_found = True
|
||||
break
|
||||
if "bool" in clause and "should" in clause["bool"]:
|
||||
if clause["bool"].get("minimum_should_match") == 1:
|
||||
knowledge_filter = clause
|
||||
break
|
||||
|
||||
assert (
|
||||
user_file_filter_found
|
||||
), f"Expected user_file_id {USER_FILE_ID} to be in the filter clauses. Got: {filter_clauses}"
|
||||
knowledge_filter is not None
|
||||
), "Expected knowledge filter when document_sets is provided"
|
||||
|
||||
def test_no_separate_user_file_filter_when_assistant_has_knowledge(self) -> None:
|
||||
"""
|
||||
Tests that user_file_ids are NOT added as a separate AND filter when the
|
||||
assistant has other knowledge attached (attached_documents or hierarchy_nodes).
|
||||
"""
|
||||
|
||||
filter_clauses = _get_search_filters(
|
||||
source_types=[DocumentSource.FILE, DocumentSource.USER_FILE],
|
||||
user_file_ids=[USER_FILE_ID],
|
||||
attached_document_ids=[ATTACHED_DOCUMENT_ID],
|
||||
hierarchy_node_ids=None,
|
||||
)
|
||||
|
||||
# Postcondition: Count how many times user_file_id appears in filter clauses
|
||||
# It should appear exactly once (in the knowledge filter), not twice
|
||||
user_file_id_str = str(USER_FILE_ID)
|
||||
occurrences = 0
|
||||
for clause in filter_clauses:
|
||||
if user_file_id_str in str(clause):
|
||||
occurrences += 1
|
||||
|
||||
assert occurrences == 1, (
|
||||
f"Expected user_file_id to appear exactly once in filter clauses "
|
||||
f"(inside the assistant knowledge filter), but found {occurrences} "
|
||||
f"occurrences. This suggests user_file_ids is being added as both a "
|
||||
f"separate AND filter and inside the knowledge filter. "
|
||||
f"Filter clauses: {filter_clauses}"
|
||||
)
|
||||
|
||||
def test_multiple_user_files_all_included_in_filter(self) -> None:
|
||||
"""
|
||||
Tests that when multiple user files are attached to an assistant,
|
||||
all of them are included in the filter.
|
||||
"""
|
||||
# Precondition
|
||||
user_file_ids = [
|
||||
UUID("6ad84e45-4450-406c-9d36-fcb5e74aca6b"),
|
||||
UUID("7be95f56-5561-517d-ae47-acd6f85bdb7c"),
|
||||
UUID("8cf06a67-6672-628e-bf58-ade7a96cec8d"),
|
||||
]
|
||||
|
||||
filter_clauses = _get_search_filters(
|
||||
source_types=[DocumentSource.USER_FILE],
|
||||
user_file_ids=user_file_ids,
|
||||
attached_document_ids=[ATTACHED_DOCUMENT_ID],
|
||||
hierarchy_node_ids=None,
|
||||
)
|
||||
|
||||
# Postcondition: All user file IDs should be in the filter
|
||||
filter_str = str(filter_clauses)
|
||||
for user_file_id in user_file_ids:
|
||||
assert (
|
||||
str(user_file_id) in filter_str
|
||||
), f"Expected user_file_id {user_file_id} to be in the filter clauses"
|
||||
filter_str = str(knowledge_filter)
|
||||
assert (
|
||||
"engineering" in filter_str
|
||||
), "Expected document_set 'engineering' in knowledge filter"
|
||||
assert (
|
||||
str(PERSONA_ID) in filter_str
|
||||
), f"Expected persona_id_filter {PERSONA_ID} in knowledge filter"
|
||||
|
||||
@@ -368,10 +368,9 @@ class TestMCPPassThroughOAuth:
|
||||
def mock_call_mcp_tool(
|
||||
server_url: str, # noqa: ARG001
|
||||
tool_name: str, # noqa: ARG001
|
||||
arguments: dict[str, Any], # noqa: ARG001
|
||||
kwargs: dict[str, Any], # noqa: ARG001
|
||||
connection_headers: dict[str, str],
|
||||
transport: MCPTransport, # noqa: ARG001
|
||||
auth: Any = None, # noqa: ARG001
|
||||
) -> dict[str, Any]:
|
||||
captured_headers.update(connection_headers)
|
||||
return mocked_response
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
"""
|
||||
Integration tests for the "Last Indexed" time displayed on both the
|
||||
per-connector detail page and the all-connectors listing page.
|
||||
|
||||
Expected behavior: "Last Indexed" = time_started of the most recent
|
||||
successful index attempt for the cc pair, regardless of pagination.
|
||||
|
||||
Edge cases:
|
||||
1. First page of index attempts is entirely errors — last_indexed should
|
||||
still reflect the older successful attempt beyond page 1.
|
||||
2. Credential swap — successful attempts, then failures after a
|
||||
"credential change"; last_indexed should reflect the most recent
|
||||
successful attempt.
|
||||
3. Mix of statuses — only the most recent successful attempt matters.
|
||||
4. COMPLETED_WITH_ERRORS counts as a success for last_indexed purposes.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from datetime import timezone
|
||||
|
||||
from onyx.db.models import IndexingStatus
|
||||
from onyx.server.documents.models import CCPairFullInfo
|
||||
from onyx.server.documents.models import ConnectorIndexingStatusLite
|
||||
from tests.integration.common_utils.managers.cc_pair import CCPairManager
|
||||
from tests.integration.common_utils.managers.connector import ConnectorManager
|
||||
from tests.integration.common_utils.managers.credential import CredentialManager
|
||||
from tests.integration.common_utils.managers.index_attempt import IndexAttemptManager
|
||||
from tests.integration.common_utils.managers.user import UserManager
|
||||
from tests.integration.common_utils.test_models import DATestCCPair
|
||||
from tests.integration.common_utils.test_models import DATestUser
|
||||
|
||||
|
||||
def _wait_for_real_success(
|
||||
cc_pair: DATestCCPair,
|
||||
admin: DATestUser,
|
||||
) -> None:
|
||||
"""Wait for the initial index attempt to complete successfully."""
|
||||
CCPairManager.wait_for_indexing_completion(
|
||||
cc_pair,
|
||||
after=datetime(2000, 1, 1, tzinfo=timezone.utc),
|
||||
user_performing_action=admin,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
|
||||
def _get_detail(cc_pair_id: int, admin: DATestUser) -> CCPairFullInfo:
|
||||
result = CCPairManager.get_single(cc_pair_id, admin)
|
||||
assert result is not None
|
||||
return result
|
||||
|
||||
|
||||
def _get_listing(cc_pair_id: int, admin: DATestUser) -> ConnectorIndexingStatusLite:
|
||||
result = CCPairManager.get_indexing_status_by_id(cc_pair_id, admin)
|
||||
assert result is not None
|
||||
return result
|
||||
|
||||
|
||||
def test_last_indexed_first_page_all_errors(reset: None) -> None: # noqa: ARG001
|
||||
"""When the first page of index attempts is entirely errors but an
|
||||
older successful attempt exists, both the detail page and the listing
|
||||
page should still show the time of that successful attempt.
|
||||
|
||||
The detail page UI uses page size 8. We insert 10 failed attempts
|
||||
more recent than the initial success to push the success off page 1.
|
||||
"""
|
||||
admin = UserManager.create(name="admin_first_page_errors")
|
||||
cc_pair = CCPairManager.create_from_scratch(user_performing_action=admin)
|
||||
_wait_for_real_success(cc_pair, admin)
|
||||
|
||||
# Baseline: last_success should be set from the initial successful run
|
||||
listing_before = _get_listing(cc_pair.id, admin)
|
||||
assert listing_before.last_success is not None
|
||||
|
||||
# 10 recent failures push the success off page 1
|
||||
IndexAttemptManager.create_test_index_attempts(
|
||||
num_attempts=10,
|
||||
cc_pair_id=cc_pair.id,
|
||||
status=IndexingStatus.FAILED,
|
||||
error_msg="simulated failure",
|
||||
base_time=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
|
||||
detail = _get_detail(cc_pair.id, admin)
|
||||
listing = _get_listing(cc_pair.id, admin)
|
||||
|
||||
assert (
|
||||
detail.last_indexed is not None
|
||||
), "Detail page last_indexed is None even though a successful attempt exists"
|
||||
assert (
|
||||
listing.last_success is not None
|
||||
), "Listing page last_success is None even though a successful attempt exists"
|
||||
|
||||
# Both surfaces must agree
|
||||
assert detail.last_indexed == listing.last_success, (
|
||||
f"Detail last_indexed={detail.last_indexed} != "
|
||||
f"listing last_success={listing.last_success}"
|
||||
)
|
||||
|
||||
|
||||
def test_last_indexed_credential_swap_scenario(reset: None) -> None: # noqa: ARG001
|
||||
"""Perform an actual credential swap: create connector + cred1 (cc_pair_1),
|
||||
wait for success, then associate a new cred2 with the same connector
|
||||
(cc_pair_2), wait for that to succeed, and inject failures on cc_pair_2.
|
||||
|
||||
cc_pair_2's last_indexed must reflect cc_pair_2's own success, not
|
||||
cc_pair_1's older one. Both the detail page and listing page must agree.
|
||||
"""
|
||||
admin = UserManager.create(name="admin_cred_swap")
|
||||
|
||||
connector = ConnectorManager.create(user_performing_action=admin)
|
||||
cred1 = CredentialManager.create(user_performing_action=admin)
|
||||
cc_pair_1 = CCPairManager.create(
|
||||
connector_id=connector.id,
|
||||
credential_id=cred1.id,
|
||||
user_performing_action=admin,
|
||||
)
|
||||
_wait_for_real_success(cc_pair_1, admin)
|
||||
|
||||
cred2 = CredentialManager.create(user_performing_action=admin, name="swapped-cred")
|
||||
cc_pair_2 = CCPairManager.create(
|
||||
connector_id=connector.id,
|
||||
credential_id=cred2.id,
|
||||
user_performing_action=admin,
|
||||
)
|
||||
_wait_for_real_success(cc_pair_2, admin)
|
||||
|
||||
listing_after_swap = _get_listing(cc_pair_2.id, admin)
|
||||
assert listing_after_swap.last_success is not None
|
||||
|
||||
IndexAttemptManager.create_test_index_attempts(
|
||||
num_attempts=10,
|
||||
cc_pair_id=cc_pair_2.id,
|
||||
status=IndexingStatus.FAILED,
|
||||
error_msg="credential expired",
|
||||
base_time=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
|
||||
detail = _get_detail(cc_pair_2.id, admin)
|
||||
listing = _get_listing(cc_pair_2.id, admin)
|
||||
|
||||
assert detail.last_indexed is not None
|
||||
assert listing.last_success is not None
|
||||
|
||||
assert detail.last_indexed == listing.last_success, (
|
||||
f"Detail last_indexed={detail.last_indexed} != "
|
||||
f"listing last_success={listing.last_success}"
|
||||
)
|
||||
|
||||
|
||||
def test_last_indexed_mixed_statuses(reset: None) -> None: # noqa: ARG001
|
||||
"""Mix of in_progress, failed, and successful attempts. Only the most
|
||||
recent successful attempt's time matters."""
|
||||
admin = UserManager.create(name="admin_mixed")
|
||||
cc_pair = CCPairManager.create_from_scratch(user_performing_action=admin)
|
||||
_wait_for_real_success(cc_pair, admin)
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
|
||||
# Success 5 hours ago
|
||||
IndexAttemptManager.create_test_index_attempts(
|
||||
num_attempts=1,
|
||||
cc_pair_id=cc_pair.id,
|
||||
status=IndexingStatus.SUCCESS,
|
||||
base_time=now - timedelta(hours=5),
|
||||
)
|
||||
|
||||
# Failures 3 hours ago
|
||||
IndexAttemptManager.create_test_index_attempts(
|
||||
num_attempts=3,
|
||||
cc_pair_id=cc_pair.id,
|
||||
status=IndexingStatus.FAILED,
|
||||
error_msg="transient failure",
|
||||
base_time=now - timedelta(hours=3),
|
||||
)
|
||||
|
||||
# In-progress 1 hour ago
|
||||
IndexAttemptManager.create_test_index_attempts(
|
||||
num_attempts=1,
|
||||
cc_pair_id=cc_pair.id,
|
||||
status=IndexingStatus.IN_PROGRESS,
|
||||
base_time=now - timedelta(hours=1),
|
||||
)
|
||||
|
||||
detail = _get_detail(cc_pair.id, admin)
|
||||
listing = _get_listing(cc_pair.id, admin)
|
||||
|
||||
assert detail.last_indexed is not None
|
||||
assert listing.last_success is not None
|
||||
|
||||
assert detail.last_indexed == listing.last_success, (
|
||||
f"Detail last_indexed={detail.last_indexed} != "
|
||||
f"listing last_success={listing.last_success}"
|
||||
)
|
||||
|
||||
|
||||
def test_last_indexed_completed_with_errors(reset: None) -> None: # noqa: ARG001
|
||||
"""COMPLETED_WITH_ERRORS is treated as a successful attempt (matching
|
||||
IndexingStatus.is_successful()). When it is the most recent "success"
|
||||
and later attempts all failed, both surfaces should reflect its time."""
|
||||
admin = UserManager.create(name="admin_completed_errors")
|
||||
cc_pair = CCPairManager.create_from_scratch(user_performing_action=admin)
|
||||
_wait_for_real_success(cc_pair, admin)
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
|
||||
# COMPLETED_WITH_ERRORS 2 hours ago
|
||||
IndexAttemptManager.create_test_index_attempts(
|
||||
num_attempts=1,
|
||||
cc_pair_id=cc_pair.id,
|
||||
status=IndexingStatus.COMPLETED_WITH_ERRORS,
|
||||
base_time=now - timedelta(hours=2),
|
||||
)
|
||||
|
||||
# 10 failures after — push everything else off page 1
|
||||
IndexAttemptManager.create_test_index_attempts(
|
||||
num_attempts=10,
|
||||
cc_pair_id=cc_pair.id,
|
||||
status=IndexingStatus.FAILED,
|
||||
error_msg="post-partial failure",
|
||||
base_time=now,
|
||||
)
|
||||
|
||||
detail = _get_detail(cc_pair.id, admin)
|
||||
listing = _get_listing(cc_pair.id, admin)
|
||||
|
||||
assert (
|
||||
detail.last_indexed is not None
|
||||
), "COMPLETED_WITH_ERRORS should count as a success for last_indexed"
|
||||
assert (
|
||||
listing.last_success is not None
|
||||
), "COMPLETED_WITH_ERRORS should count as a success for last_success"
|
||||
|
||||
assert detail.last_indexed == listing.last_success, (
|
||||
f"Detail last_indexed={detail.last_indexed} != "
|
||||
f"listing last_success={listing.last_success}"
|
||||
)
|
||||
@@ -1,7 +1,6 @@
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from datetime import timezone
|
||||
from uuid import UUID
|
||||
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.configs.constants import INDEX_SEPARATOR
|
||||
@@ -11,10 +10,10 @@ from onyx.document_index.vespa.shared_utils.vespa_request_builders import (
|
||||
build_vespa_filters,
|
||||
)
|
||||
from onyx.document_index.vespa_constants import DOC_UPDATED_AT
|
||||
from onyx.document_index.vespa_constants import DOCUMENT_ID
|
||||
from onyx.document_index.vespa_constants import DOCUMENT_SETS
|
||||
from onyx.document_index.vespa_constants import HIDDEN
|
||||
from onyx.document_index.vespa_constants import METADATA_LIST
|
||||
from onyx.document_index.vespa_constants import PERSONAS
|
||||
from onyx.document_index.vespa_constants import SOURCE_TYPE
|
||||
from onyx.document_index.vespa_constants import TENANT_ID
|
||||
from onyx.document_index.vespa_constants import USER_PROJECT
|
||||
@@ -151,56 +150,30 @@ class TestBuildVespaFilters:
|
||||
result = build_vespa_filters(filters)
|
||||
assert f"!({HIDDEN}=true) and " == result
|
||||
|
||||
def test_user_file_ids_filter(self) -> None:
|
||||
"""Test user file IDs filtering."""
|
||||
id1 = UUID("00000000-0000-0000-0000-000000000123")
|
||||
id2 = UUID("00000000-0000-0000-0000-000000000456")
|
||||
|
||||
# Single user file ID (UUID)
|
||||
filters = IndexFilters(access_control_list=[], user_file_ids=[id1])
|
||||
result = build_vespa_filters(filters)
|
||||
assert (
|
||||
f'!({HIDDEN}=true) and ({DOCUMENT_ID} contains "{str(id1)}") and ' == result
|
||||
)
|
||||
|
||||
# Multiple user file IDs (UUIDs)
|
||||
filters = IndexFilters(access_control_list=[], user_file_ids=[id1, id2])
|
||||
result = build_vespa_filters(filters)
|
||||
assert (
|
||||
f'!({HIDDEN}=true) and ({DOCUMENT_ID} contains "{str(id1)}" or {DOCUMENT_ID} contains "{str(id2)}") and '
|
||||
== result
|
||||
)
|
||||
|
||||
# Empty user file IDs
|
||||
filters = IndexFilters(access_control_list=[], user_file_ids=[])
|
||||
result = build_vespa_filters(filters)
|
||||
assert f"!({HIDDEN}=true) and " == result
|
||||
|
||||
def test_user_project_filter(self) -> None:
|
||||
"""Test user project filtering.
|
||||
|
||||
project_id alone does NOT trigger a knowledge scope restriction
|
||||
project_id_filter alone does NOT trigger a knowledge scope restriction
|
||||
(an agent with no explicit knowledge should search everything).
|
||||
It only participates when explicit knowledge filters are present.
|
||||
"""
|
||||
# project_id alone → no restriction
|
||||
filters = IndexFilters(access_control_list=[], project_id=789)
|
||||
# project_id_filter alone → no restriction
|
||||
filters = IndexFilters(access_control_list=[], project_id_filter=789)
|
||||
result = build_vespa_filters(filters)
|
||||
assert f"!({HIDDEN}=true) and " == result
|
||||
|
||||
# project_id with user_file_ids → both OR'd
|
||||
id1 = UUID("00000000-0000-0000-0000-000000000123")
|
||||
# project_id_filter with document_set → both OR'd
|
||||
filters = IndexFilters(
|
||||
access_control_list=[], project_id=789, user_file_ids=[id1]
|
||||
access_control_list=[], project_id_filter=789, document_set=["set1"]
|
||||
)
|
||||
result = build_vespa_filters(filters)
|
||||
assert (
|
||||
f'!({HIDDEN}=true) and (({DOCUMENT_ID} contains "{str(id1)}") or ({USER_PROJECT} contains "789")) and '
|
||||
f'!({HIDDEN}=true) and (({DOCUMENT_SETS} contains "set1") or ({USER_PROJECT} contains "789")) and '
|
||||
== result
|
||||
)
|
||||
|
||||
# No project id
|
||||
filters = IndexFilters(access_control_list=[], project_id=None)
|
||||
# No project id filter
|
||||
filters = IndexFilters(access_control_list=[], project_id_filter=None)
|
||||
result = build_vespa_filters(filters)
|
||||
assert f"!({HIDDEN}=true) and " == result
|
||||
|
||||
@@ -233,17 +206,16 @@ class TestBuildVespaFilters:
|
||||
def test_combined_filters(self) -> None:
|
||||
"""Test combining multiple filter types.
|
||||
|
||||
Knowledge-scope filters (document_set, user_file_ids, project_id,
|
||||
persona_id) are OR'd together, while all other filters are AND'd.
|
||||
Knowledge-scope filters (document_set, project_id_filter, persona_id_filter)
|
||||
are OR'd together, while all other filters are AND'd.
|
||||
"""
|
||||
id1 = UUID("00000000-0000-0000-0000-000000000123")
|
||||
filters = IndexFilters(
|
||||
access_control_list=["user1", "group1"],
|
||||
source_type=[DocumentSource.WEB],
|
||||
tags=[Tag(tag_key="color", tag_value="red")],
|
||||
document_set=["set1"],
|
||||
user_file_ids=[id1],
|
||||
project_id=789,
|
||||
project_id_filter=789,
|
||||
persona_id_filter=42,
|
||||
time_cutoff=datetime(2023, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
@@ -254,9 +226,10 @@ class TestBuildVespaFilters:
|
||||
expected += f'({SOURCE_TYPE} contains "web") and '
|
||||
expected += f'({METADATA_LIST} contains "color{INDEX_SEPARATOR}red") and '
|
||||
# Knowledge scope filters are OR'd together
|
||||
# (persona_id_filter is primary, project_id_filter is additive — order reflects this)
|
||||
expected += (
|
||||
f'(({DOCUMENT_SETS} contains "set1")'
|
||||
f' or ({DOCUMENT_ID} contains "{str(id1)}")'
|
||||
f' or ({PERSONAS} contains "42")'
|
||||
f' or ({USER_PROJECT} contains "789")'
|
||||
f") and "
|
||||
)
|
||||
@@ -276,18 +249,37 @@ class TestBuildVespaFilters:
|
||||
result = build_vespa_filters(filters)
|
||||
assert f'!({HIDDEN}=true) and ({DOCUMENT_SETS} contains "set1") and ' == result
|
||||
|
||||
def test_knowledge_scope_document_set_and_user_files_ored(self) -> None:
|
||||
"""Document set filter and user file IDs must be OR'd so that
|
||||
connector documents (in the set) and user files (with specific
|
||||
IDs) can both be found."""
|
||||
id1 = UUID("00000000-0000-0000-0000-000000000123")
|
||||
def test_persona_id_filter_is_primary_knowledge_scope(self) -> None:
|
||||
"""persona_id_filter alone should trigger a knowledge scope restriction
|
||||
(a persona with user files IS explicit knowledge)."""
|
||||
filters = IndexFilters(access_control_list=[], persona_id_filter=42)
|
||||
result = build_vespa_filters(filters)
|
||||
assert f'!({HIDDEN}=true) and ({PERSONAS} contains "42") and ' == result
|
||||
|
||||
def test_persona_id_filter_with_project_id_filter(self) -> None:
|
||||
"""When persona_id_filter triggers the scope, project_id_filter should be
|
||||
OR'd in additively."""
|
||||
filters = IndexFilters(
|
||||
access_control_list=[], persona_id_filter=42, project_id_filter=789
|
||||
)
|
||||
result = build_vespa_filters(filters)
|
||||
expected = (
|
||||
f"!({HIDDEN}=true) and "
|
||||
f'(({PERSONAS} contains "42") or ({USER_PROJECT} contains "789")) and '
|
||||
)
|
||||
assert expected == result
|
||||
|
||||
def test_knowledge_scope_document_set_and_persona_filter_ored(self) -> None:
|
||||
"""Document set filter and persona_id_filter must be OR'd so that
|
||||
connector documents (in the set) and persona user files can
|
||||
both be found."""
|
||||
filters = IndexFilters(
|
||||
access_control_list=[],
|
||||
document_set=["engineering"],
|
||||
user_file_ids=[id1],
|
||||
persona_id_filter=42,
|
||||
)
|
||||
result = build_vespa_filters(filters)
|
||||
expected = f'!({HIDDEN}=true) and (({DOCUMENT_SETS} contains "engineering") or ({DOCUMENT_ID} contains "{str(id1)}")) and '
|
||||
expected = f'!({HIDDEN}=true) and (({DOCUMENT_SETS} contains "engineering") or ({PERSONAS} contains "42")) and '
|
||||
assert expected == result
|
||||
|
||||
def test_acl_large_list_uses_weighted_set(self) -> None:
|
||||
|
||||
@@ -144,7 +144,6 @@ module.exports = {
|
||||
"**/src/app/**/services/*.test.ts",
|
||||
"**/src/app/**/utils/*.test.ts",
|
||||
"**/src/app/**/hooks/*.test.ts", // Pure packet processor tests
|
||||
"**/src/hooks/**/*.test.ts",
|
||||
"**/src/refresh-components/**/*.test.ts",
|
||||
"**/src/refresh-pages/**/*.test.ts",
|
||||
"**/src/sections/**/*.test.ts",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@opal/utils";
|
||||
import { useTableSize } from "@opal/components/table/TableSizeContext";
|
||||
|
||||
interface ActionsContainerProps {
|
||||
@@ -24,7 +25,14 @@ export default function ActionsContainer({
|
||||
data-size={size}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex h-full items-center justify-end">{children}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full items-center",
|
||||
type === "cell" ? "justify-end" : "justify-center"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ function DragOverlayRowInner<TData>({
|
||||
imageSrc={qualifierColumn.getImageSrc?.(row.original)}
|
||||
imageAlt={qualifierColumn.getImageAlt?.(row.original)}
|
||||
background={qualifierColumn.background}
|
||||
iconSize={qualifierColumn.iconSize}
|
||||
selectable={isSelectable}
|
||||
selected={isSelectable && row.getIsSelected()}
|
||||
/>
|
||||
|
||||
@@ -47,7 +47,7 @@ function Table({
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("border-separate border-spacing-0", !width && "min-w-full")}
|
||||
style={{ width }}
|
||||
style={{ tableLayout: "fixed", width }}
|
||||
data-size={size}
|
||||
data-variant={variant}
|
||||
data-selection={selectionBehavior}
|
||||
|
||||
@@ -92,7 +92,9 @@ export default function TableHead({
|
||||
data-size={resolvedSize}
|
||||
data-bottom-border={bottomBorder || undefined}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className={cn("flex items-center gap-1", alignmentFlexClass[alignment])}
|
||||
>
|
||||
<div className="table-head-label">
|
||||
<Text
|
||||
mainUiAction={!isSmall}
|
||||
|
||||
@@ -26,13 +26,11 @@ interface TableQualifierProps {
|
||||
imageAlt?: string;
|
||||
/** Show a tinted background container behind the content. */
|
||||
background?: boolean;
|
||||
/** Icon size preset. `"lg"` = 28/24, `"md"` = 20/16. @default "md" */
|
||||
iconSize?: "lg" | "md";
|
||||
}
|
||||
|
||||
const iconSizesMap = {
|
||||
lg: { lg: 28, md: 24 },
|
||||
md: { lg: 20, md: 16 },
|
||||
const iconSizes = {
|
||||
lg: 28,
|
||||
md: 24,
|
||||
} as const;
|
||||
|
||||
function getOverlayStyles(selected: boolean, disabled: boolean) {
|
||||
@@ -55,10 +53,9 @@ function TableQualifier({
|
||||
imageSrc,
|
||||
imageAlt = "",
|
||||
background = false,
|
||||
iconSize: iconSizePreset = "md",
|
||||
}: TableQualifierProps) {
|
||||
const resolvedSize = useTableSize();
|
||||
const iconSize = iconSizesMap[iconSizePreset][resolvedSize];
|
||||
const iconSize = iconSizes[resolvedSize];
|
||||
const overlayStyles = getOverlayStyles(selected, disabled);
|
||||
|
||||
function renderContent() {
|
||||
|
||||
@@ -33,8 +33,6 @@ interface QualifierConfig<TData> {
|
||||
getImageAlt?: (row: TData) => string;
|
||||
/** Show a tinted background container behind the content. @default false */
|
||||
background?: boolean;
|
||||
/** Icon size preset. `"lg"` = 28/24, `"md"` = 20/16. @default "md" */
|
||||
iconSize?: "lg" | "md";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -162,7 +160,6 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
|
||||
getImageSrc: config?.getImageSrc,
|
||||
getImageAlt: config?.getImageAlt,
|
||||
background: config?.background,
|
||||
iconSize: config?.iconSize,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -544,7 +544,6 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
imageSrc={qDef.getImageSrc?.(row.original)}
|
||||
imageAlt={qDef.getImageAlt?.(row.original)}
|
||||
background={qDef.background}
|
||||
iconSize={qDef.iconSize}
|
||||
selectable={showQualifierCheckbox}
|
||||
selected={
|
||||
showQualifierCheckbox && row.getIsSelected()
|
||||
|
||||
@@ -59,8 +59,6 @@ export interface OnyxQualifierColumn<TData> extends OnyxColumnBase<TData> {
|
||||
getImageAlt?: (row: TData) => string;
|
||||
/** Show a tinted background container behind the content. @default false */
|
||||
background?: boolean;
|
||||
/** Icon size preset. Use `"lg"` for avatars, `"md"` for regular icons. @default "md" */
|
||||
iconSize?: "lg" | "md";
|
||||
}
|
||||
|
||||
/** Data column — accessor-based column with sorting/resizing. */
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgFileBroadcast = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M6.1875 2.25003H2.625C1.808 2.25003 1.125 2.93303 1.125 3.75003L1.125 14.25C1.125 15.067 1.808 15.75 2.625 15.75L9.37125 15.75C10.1883 15.75 10.8713 15.067 10.8713 14.25L10.8713 6.94128M6.1875 2.25003L10.8713 6.94128M6.1875 2.25003V6.94128H10.8713M10.3069 2.25L13.216 5.15914C13.6379 5.5811 13.875 6.15339 13.875 6.75013V13.875C13.875 14.5212 13.737 15.2081 13.4392 15.7538M16.4391 15.7538C16.737 15.2081 16.875 14.5213 16.875 13.8751L16.875 7.02481C16.875 5.53418 16.2833 4.10451 15.23 3.04982L14.4301 2.25003"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgFileBroadcast;
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgHookNodes = ({ 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="M10.0002 4C10.0002 3.99708 10.0002 3.99415 10.0001 3.99123C9.99542 2.8907 9.10181 2 8.00016 2C6.89559 2 6.00016 2.89543 6.00016 4C6.00016 4.73701 6.39882 5.38092 6.99226 5.72784L4.67276 9.70412M11.6589 13.7278C11.9549 13.9009 12.2993 14 12.6668 14C13.7714 14 14.6668 13.1046 14.6668 12C14.6668 10.8954 13.7714 10 12.6668 10C12.2993 10 11.9549 10.0991 11.6589 10.2722L9.33943 6.29588M2.33316 10.2678C1.73555 10.6136 1.3335 11.2599 1.3335 12C1.3335 13.1046 2.22893 14 3.3335 14C4.43807 14 5.3335 13.1046 5.3335 12H10.0002"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgHookNodes;
|
||||
@@ -68,9 +68,8 @@ export { default as SvgExpand } from "@opal/icons/expand";
|
||||
export { default as SvgExternalLink } from "@opal/icons/external-link";
|
||||
export { default as SvgEye } from "@opal/icons/eye";
|
||||
export { default as SvgEyeClosed } from "@opal/icons/eye-closed";
|
||||
export { default as SvgFileBraces } from "@opal/icons/file-braces";
|
||||
export { default as SvgFileBroadcast } from "@opal/icons/file-broadcast";
|
||||
export { default as SvgFiles } from "@opal/icons/files";
|
||||
export { default as SvgFileBraces } from "@opal/icons/file-braces";
|
||||
export { default as SvgFileChartPie } from "@opal/icons/file-chart-pie";
|
||||
export { default as SvgFileSmall } from "@opal/icons/file-small";
|
||||
export { default as SvgFileText } from "@opal/icons/file-text";
|
||||
@@ -90,7 +89,6 @@ export { default as SvgHashSmall } from "@opal/icons/hash-small";
|
||||
export { default as SvgHash } from "@opal/icons/hash";
|
||||
export { default as SvgHeadsetMic } from "@opal/icons/headset-mic";
|
||||
export { default as SvgHistory } from "@opal/icons/history";
|
||||
export { default as SvgHookNodes } from "@opal/icons/hook-nodes";
|
||||
export { default as SvgHourglass } from "@opal/icons/hourglass";
|
||||
export { default as SvgImage } from "@opal/icons/image";
|
||||
export { default as SvgImageSmall } from "@opal/icons/image-small";
|
||||
@@ -161,7 +159,6 @@ export { default as SvgSort } from "@opal/icons/sort";
|
||||
export { default as SvgSortOrder } from "@opal/icons/sort-order";
|
||||
export { default as SvgSparkle } from "@opal/icons/sparkle";
|
||||
export { default as SvgStar } from "@opal/icons/star";
|
||||
export { default as SvgStarOff } from "@opal/icons/star-off";
|
||||
export { default as SvgStep1 } from "@opal/icons/step1";
|
||||
export { default as SvgStep2 } from "@opal/icons/step2";
|
||||
export { default as SvgStep3 } from "@opal/icons/step3";
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgStarOff = ({ 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 1L5.56196 5.56196M15 15L5.56196 5.56196M5.56196 5.56196L1.33333 6.18004L4.66666 9.42671L3.88 14.0134L8 11.8467L12.12 14.0134L11.7267 11.72M12.1405 8.64051L14.6667 6.18004L10.06 5.50671L8 1.33337L6.95349 3.45349"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default SvgStarOff;
|
||||
@@ -626,7 +626,10 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
<div className="w-[200px]">
|
||||
<div className="text-sm font-medium mb-1">Last Indexed</div>
|
||||
<div className="text-sm text-text-default">
|
||||
{timeAgo(ccPair?.last_indexed) ?? "-"}
|
||||
{timeAgo(
|
||||
indexAttempts?.find((attempt) => attempt.status === "success")
|
||||
?.time_started
|
||||
) ?? "-"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { useState } from "react";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import InputSearch from "@/refresh-components/inputs/InputSearch";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
SvgBubbleText,
|
||||
SvgExternalLink,
|
||||
SvgFileBroadcast,
|
||||
SvgHookNodes,
|
||||
} from "@opal/icons";
|
||||
import { HookPointMeta } from "@/app/admin/hooks/interfaces";
|
||||
import { IconFunctionComponent } from "@opal/types";
|
||||
|
||||
const HOOK_POINT_ICONS: Record<string, IconFunctionComponent> = {
|
||||
document_ingestion: SvgFileBroadcast,
|
||||
query_processing: SvgBubbleText,
|
||||
};
|
||||
|
||||
function getHookPointIcon(hookPoint: string): IconFunctionComponent {
|
||||
return HOOK_POINT_ICONS[hookPoint] ?? SvgHookNodes;
|
||||
}
|
||||
|
||||
export default function HooksPageContent() {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const {
|
||||
data: specs,
|
||||
isLoading,
|
||||
error,
|
||||
} = useSWR<HookPointMeta[]>("/api/admin/hooks/specs", errorHandlingFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <ThreeDotsLoader />;
|
||||
}
|
||||
|
||||
if (error || !specs) {
|
||||
return (
|
||||
<ErrorCallout
|
||||
errorTitle="Failed to load hook specs"
|
||||
errorMsg={error?.info?.detail ?? error?.toString()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const filtered = specs.filter(
|
||||
(spec) =>
|
||||
spec.display_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
spec.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="pb-6">
|
||||
<InputSearch
|
||||
placeholder="Search hooks..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{filtered.length === 0 ? (
|
||||
<Text text03 secondaryBody>
|
||||
{search
|
||||
? "No hooks match your search."
|
||||
: "No hook points are available."}
|
||||
</Text>
|
||||
) : (
|
||||
filtered.map((spec) => (
|
||||
<Card
|
||||
key={spec.hook_point}
|
||||
variant="secondary"
|
||||
padding={0.5}
|
||||
gap={0}
|
||||
>
|
||||
<ContentAction
|
||||
icon={getHookPointIcon(spec.hook_point)}
|
||||
title={spec.display_name}
|
||||
description={spec.description}
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
rightChildren={
|
||||
<Button prominence="tertiary" rightIcon={SvgArrowExchange}>
|
||||
Connect
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{spec.docs_url && (
|
||||
<div className="pl-7 pt-1">
|
||||
<a
|
||||
href={spec.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 w-fit text-text-03"
|
||||
>
|
||||
<Text as="span" secondaryBody text03 className="underline">
|
||||
Documentation
|
||||
</Text>
|
||||
<SvgExternalLink size={16} className="text-text-02" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
export type HookPoint = string;
|
||||
export type HookFailStrategy = "hard" | "soft";
|
||||
|
||||
export interface HookPointMeta {
|
||||
hook_point: HookPoint;
|
||||
display_name: string;
|
||||
description: string;
|
||||
docs_url: string | null;
|
||||
input_schema: Record<string, unknown>;
|
||||
output_schema: Record<string, unknown>;
|
||||
default_timeout_seconds: number;
|
||||
default_fail_strategy: HookFailStrategy;
|
||||
fail_hard_description: string;
|
||||
}
|
||||
|
||||
export interface HookResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
hook_point: HookPoint;
|
||||
endpoint_url: string | null;
|
||||
fail_strategy: HookFailStrategy;
|
||||
timeout_seconds: number;
|
||||
is_active: boolean;
|
||||
is_reachable: boolean | null;
|
||||
creator_email: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface HookCreateRequest {
|
||||
name: string;
|
||||
hook_point: HookPoint;
|
||||
endpoint_url: string;
|
||||
api_key?: string;
|
||||
fail_strategy?: HookFailStrategy;
|
||||
timeout_seconds?: number;
|
||||
}
|
||||
|
||||
export interface HookUpdateRequest {
|
||||
name?: string;
|
||||
endpoint_url?: string;
|
||||
api_key?: string | null;
|
||||
fail_strategy?: HookFailStrategy;
|
||||
timeout_seconds?: number;
|
||||
}
|
||||
|
||||
export interface HookExecutionRecord {
|
||||
error_message: string | null;
|
||||
status_code: number | null;
|
||||
duration_ms: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type HookValidateStatus =
|
||||
| "passed"
|
||||
| "auth_failed"
|
||||
| "timeout"
|
||||
| "cannot_connect";
|
||||
|
||||
export interface HookValidateResponse {
|
||||
status: HookValidateStatus;
|
||||
error_message: string | null;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { ADMIN_ROUTES } from "@/lib/admin-routes";
|
||||
import HooksPageContent from "@/app/admin/hooks/HooksPageContent";
|
||||
|
||||
const route = ADMIN_ROUTES.HOOKS;
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={route.icon}
|
||||
title={route.title}
|
||||
description="Extend Onyx pipelines by registering external API endpoints as callbacks at predefined hook points."
|
||||
separator
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<HooksPageContent />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AuthTypeMetadata } from "@/hooks/useAuthTypeMetadata";
|
||||
import { AuthTypeMetadata } from "@/lib/userSS";
|
||||
import LoginText from "@/app/auth/login/LoginText";
|
||||
import SignInButton from "@/app/auth/login/SignInButton";
|
||||
import EmailPasswordForm from "./EmailPasswordForm";
|
||||
|
||||
@@ -26,7 +26,6 @@ import { SvgEdit } from "@opal/icons";
|
||||
interface AppearanceThemeSettingsProps {
|
||||
selectedLogo: File | null;
|
||||
setSelectedLogo: (file: File | null) => void;
|
||||
logoVersion: number;
|
||||
charLimits: {
|
||||
application_name: number;
|
||||
custom_greeting_message: number;
|
||||
@@ -46,7 +45,7 @@ export const AppearanceThemeSettings = forwardRef<
|
||||
AppearanceThemeSettingsRef,
|
||||
AppearanceThemeSettingsProps
|
||||
>(function AppearanceThemeSettings(
|
||||
{ selectedLogo, setSelectedLogo, logoVersion, charLimits },
|
||||
{ selectedLogo, setSelectedLogo, charLimits },
|
||||
ref
|
||||
) {
|
||||
const { values, errors, setFieldValue } = useFormikContext<any>();
|
||||
@@ -175,15 +174,15 @@ export const AppearanceThemeSettings = forwardRef<
|
||||
};
|
||||
}, [logoObjectUrl]);
|
||||
|
||||
const logoSrc = useMemo(() => {
|
||||
const getLogoSrc = () => {
|
||||
if (logoObjectUrl) {
|
||||
return logoObjectUrl;
|
||||
}
|
||||
if (values.use_custom_logo) {
|
||||
return `/api/enterprise-settings/logo?v=${logoVersion}`;
|
||||
return `/api/enterprise-settings/logo?u=${Date.now()}`;
|
||||
}
|
||||
return undefined;
|
||||
}, [logoObjectUrl, values.use_custom_logo, logoVersion]);
|
||||
};
|
||||
|
||||
// Determine which tabs should be enabled
|
||||
const hasLogo = Boolean(selectedLogo || values.use_custom_logo);
|
||||
@@ -303,7 +302,7 @@ export const AppearanceThemeSettings = forwardRef<
|
||||
<FormField.Label>Application Logo</FormField.Label>
|
||||
<FormField.Control>
|
||||
<InputImage
|
||||
src={logoSrc}
|
||||
src={getLogoSrc()}
|
||||
onEdit={handleLogoEdit}
|
||||
onDrop={(file) => {
|
||||
setSelectedLogo(file);
|
||||
@@ -342,7 +341,7 @@ export const AppearanceThemeSettings = forwardRef<
|
||||
greeting_message={
|
||||
values.custom_greeting_message || "Welcome to Acme Chat"
|
||||
}
|
||||
logoSrc={logoSrc}
|
||||
logoSrc={getLogoSrc()}
|
||||
highlightTarget={highlightTarget}
|
||||
/>
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { toast } from "@/hooks/useToast";
|
||||
import { Formik, Form } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { EnterpriseSettings } from "@/interfaces/settings";
|
||||
import { mutate } from "swr";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const route = ADMIN_ROUTES.THEME;
|
||||
|
||||
@@ -29,9 +29,9 @@ const CHAR_LIMITS = {
|
||||
};
|
||||
|
||||
export default function ThemePage() {
|
||||
const router = useRouter();
|
||||
const settings = useContext(SettingsContext);
|
||||
const [selectedLogo, setSelectedLogo] = useState<File | null>(null);
|
||||
const [logoVersion, setLogoVersion] = useState(0);
|
||||
const appearanceSettingsRef = useRef<AppearanceThemeSettingsRef>(null);
|
||||
|
||||
if (!settings) {
|
||||
@@ -54,7 +54,7 @@ export default function ThemePage() {
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
await mutate("/api/enterprise-settings");
|
||||
router.refresh();
|
||||
return true;
|
||||
} else {
|
||||
const errorMsg = (await response.json()).detail;
|
||||
@@ -150,8 +150,6 @@ export default function ThemePage() {
|
||||
validationSchema={validationSchema}
|
||||
validateOnChange={false}
|
||||
onSubmit={async (values, formikHelpers) => {
|
||||
let logoUploaded = false;
|
||||
|
||||
// Handle logo upload if a new logo was selected
|
||||
if (selectedLogo) {
|
||||
const formData = new FormData();
|
||||
@@ -168,7 +166,6 @@ export default function ThemePage() {
|
||||
}
|
||||
// Only clear the selected logo after a successful upload
|
||||
setSelectedLogo(null);
|
||||
logoUploaded = true;
|
||||
values.use_custom_logo = true;
|
||||
}
|
||||
|
||||
@@ -196,9 +193,6 @@ export default function ThemePage() {
|
||||
// dirty comparisons reflect the newly-saved values.
|
||||
if (success) {
|
||||
formikHelpers.resetForm({ values });
|
||||
if (logoUploaded) {
|
||||
setLogoVersion((v) => v + 1);
|
||||
}
|
||||
toast.success("Appearance settings saved successfully!");
|
||||
}
|
||||
|
||||
@@ -251,7 +245,6 @@ export default function ThemePage() {
|
||||
ref={appearanceSettingsRef}
|
||||
selectedLogo={selectedLogo}
|
||||
setSelectedLogo={setSelectedLogo}
|
||||
logoVersion={logoVersion}
|
||||
charLimits={CHAR_LIMITS}
|
||||
/>
|
||||
</SettingsLayouts.Body>
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
import "./globals.css";
|
||||
|
||||
import { GTM_ENABLED, MODAL_ROOT_ID } from "@/lib/constants";
|
||||
import {
|
||||
fetchEnterpriseSettingsSS,
|
||||
fetchSettingsSS,
|
||||
} from "@/components/settings/lib";
|
||||
import {
|
||||
CUSTOM_ANALYTICS_ENABLED,
|
||||
GTM_ENABLED,
|
||||
SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED,
|
||||
NEXT_PUBLIC_CLOUD_ENABLED,
|
||||
MODAL_ROOT_ID,
|
||||
} from "@/lib/constants";
|
||||
import { Metadata } from "next";
|
||||
|
||||
import { Inter } from "next/font/google";
|
||||
import { EnterpriseSettings, ApplicationStatus } from "@/interfaces/settings";
|
||||
import AppProvider from "@/providers/AppProvider";
|
||||
import DynamicMetadata from "@/providers/DynamicMetadata";
|
||||
import { PHProvider } from "./providers";
|
||||
import { getAuthTypeMetadataSS, getCurrentUserSS } from "@/lib/userSS";
|
||||
import { Suspense } from "react";
|
||||
import PostHogPageView from "./PostHogPageView";
|
||||
import Script from "next/script";
|
||||
import { Hanken_Grotesk } from "next/font/google";
|
||||
import { WebVitals } from "./web-vitals";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import CloudError from "@/components/errorPages/CloudErrorPage";
|
||||
import Error from "@/components/errorPages/ErrorPage";
|
||||
import GatedContentWrapper from "@/components/GatedContentWrapper";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { fetchAppSidebarMetadata } from "@/lib/appSidebarSS";
|
||||
import StatsOverlayLoader from "@/components/dev/StatsOverlayLoader";
|
||||
import AppHealthBanner from "@/sections/AppHealthBanner";
|
||||
import CustomAnalyticsScript from "@/providers/CustomAnalyticsScript";
|
||||
import ProductGatingWrapper from "@/providers/ProductGatingWrapper";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
@@ -31,23 +44,45 @@ const hankenGrotesk = Hanken_Grotesk({
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Onyx",
|
||||
description: "Question answering for your documents",
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
let logoLocation = "/onyx.ico";
|
||||
let enterpriseSettings: EnterpriseSettings | null = null;
|
||||
if (SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED) {
|
||||
enterpriseSettings = await (await fetchEnterpriseSettingsSS()).json();
|
||||
logoLocation =
|
||||
enterpriseSettings && enterpriseSettings.use_custom_logo
|
||||
? "/api/enterprise-settings/logo"
|
||||
: "/onyx.ico";
|
||||
}
|
||||
|
||||
return {
|
||||
title: enterpriseSettings?.application_name || "Onyx",
|
||||
description: "Question answering for your documents",
|
||||
icons: {
|
||||
icon: logoLocation,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// force-dynamic prevents Next.js from statically prerendering pages at build
|
||||
// time — many child routes use cookies() which requires dynamic rendering.
|
||||
// This is safe because the layout itself has no server-side data fetching;
|
||||
// all data is fetched client-side via SWR in the provider tree.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
const [combinedSettings, user, authTypeMetadata] = await Promise.all([
|
||||
fetchSettingsSS(),
|
||||
getCurrentUserSS(),
|
||||
getAuthTypeMetadataSS(),
|
||||
]);
|
||||
|
||||
const { folded } = await fetchAppSidebarMetadata(user);
|
||||
|
||||
const productGating =
|
||||
combinedSettings?.settings.application_status ?? ApplicationStatus.ACTIVE;
|
||||
|
||||
const getPageContent = async (content: React.ReactNode) => (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${inter.variable} ${hankenGrotesk.variable}`}
|
||||
@@ -58,6 +93,15 @@ export default function RootLayout({
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0, interactive-widget=resizes-content"
|
||||
/>
|
||||
{CUSTOM_ANALYTICS_ENABLED &&
|
||||
combinedSettings?.customAnalyticsScript && (
|
||||
<script
|
||||
type="text/javascript"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: combinedSettings.customAnalyticsScript,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{GTM_ENABLED && (
|
||||
<Script
|
||||
@@ -87,20 +131,7 @@ export default function RootLayout({
|
||||
<TooltipProvider>
|
||||
<PHProvider>
|
||||
<AppHealthBanner />
|
||||
<AppProvider>
|
||||
<DynamicMetadata />
|
||||
<CustomAnalyticsScript />
|
||||
<Suspense fallback={null}>
|
||||
<PostHogPageView />
|
||||
</Suspense>
|
||||
<div id={MODAL_ROOT_ID} className="h-screen w-screen">
|
||||
<ProductGatingWrapper>{children}</ProductGatingWrapper>
|
||||
</div>
|
||||
{process.env.NEXT_PUBLIC_POSTHOG_KEY && <WebVitals />}
|
||||
{process.env.NEXT_PUBLIC_ENABLE_STATS === "true" && (
|
||||
<StatsOverlayLoader />
|
||||
)}
|
||||
</AppProvider>
|
||||
{content}
|
||||
</PHProvider>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
@@ -108,4 +139,45 @@ export default function RootLayout({
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
if (!combinedSettings) {
|
||||
return getPageContent(
|
||||
NEXT_PUBLIC_CLOUD_ENABLED ? <CloudError /> : <Error />
|
||||
);
|
||||
}
|
||||
|
||||
// When gated, wrap children in GatedContentWrapper which checks the path
|
||||
// client-side and shows AccessRestrictedPage for non-billing paths.
|
||||
//
|
||||
// Trade-off: Server components still render and attempt API calls before the
|
||||
// client-side check runs. This is safe because the backend license enforcement
|
||||
// middleware returns 402 for all non-allowlisted API calls, preventing data
|
||||
// leakage. The user sees a brief loading state before being redirected.
|
||||
const content =
|
||||
productGating === ApplicationStatus.GATED_ACCESS ||
|
||||
productGating === ApplicationStatus.SEAT_LIMIT_EXCEEDED ? (
|
||||
<GatedContentWrapper>{children}</GatedContentWrapper>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
|
||||
return getPageContent(
|
||||
<AppProvider
|
||||
authTypeMetadata={authTypeMetadata}
|
||||
user={user}
|
||||
settings={combinedSettings}
|
||||
folded={folded}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<PostHogPageView />
|
||||
</Suspense>
|
||||
<div id={MODAL_ROOT_ID} className="h-screen w-screen">
|
||||
{content}
|
||||
</div>
|
||||
{process.env.NEXT_PUBLIC_POSTHOG_KEY && <WebVitals />}
|
||||
{process.env.NEXT_PUBLIC_ENABLE_STATS === "true" && (
|
||||
<StatsOverlayLoader />
|
||||
)}
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
import NewTeamModal from "@/components/modals/NewTeamModal";
|
||||
import NewTenantModal from "@/sections/modals/NewTenantModal";
|
||||
import { NewTenantInfo } from "@/lib/types";
|
||||
import { User, NewTenantInfo } from "@/lib/types";
|
||||
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
|
||||
type ModalContextType = {
|
||||
showNewTeamModal: boolean;
|
||||
@@ -28,27 +27,23 @@ export const useModalContext = () => {
|
||||
|
||||
export const ModalProvider: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
const { user } = useUser();
|
||||
user: User | null;
|
||||
}> = ({ children, user }) => {
|
||||
const [showNewTeamModal, setShowNewTeamModal] = useState(false);
|
||||
const [newTenantInfo, setNewTenantInfo] = useState<NewTenantInfo | null>(
|
||||
null
|
||||
user?.tenant_info?.new_tenant || null
|
||||
);
|
||||
const [invitationInfo, setInvitationInfo] = useState<NewTenantInfo | null>(
|
||||
null
|
||||
user?.tenant_info?.invitation || null
|
||||
);
|
||||
|
||||
// Sync modal states with user info — clear when backend no longer has the data
|
||||
// Initialize modal states based on user info
|
||||
useEffect(() => {
|
||||
if (user?.tenant_info?.new_tenant) {
|
||||
setNewTenantInfo(user.tenant_info.new_tenant);
|
||||
} else {
|
||||
setNewTenantInfo(null);
|
||||
}
|
||||
if (user?.tenant_info?.invitation) {
|
||||
setInvitationInfo(user.tenant_info.invitation);
|
||||
} else {
|
||||
setInvitationInfo(null);
|
||||
}
|
||||
}, [user?.tenant_info]);
|
||||
|
||||
|
||||
@@ -118,16 +118,17 @@ export async function fetchSettingsSS(): Promise<CombinedSettings | null> {
|
||||
settings.deep_research_enabled = true;
|
||||
}
|
||||
|
||||
const webVersion = getWebVersion();
|
||||
|
||||
const combinedSettings: CombinedSettings = {
|
||||
settings,
|
||||
enterpriseSettings,
|
||||
customAnalyticsScript,
|
||||
webVersion: settings.version ?? getWebVersion(),
|
||||
webVersion,
|
||||
webDomain: HOST_URL,
|
||||
// Server-side default; the real value is computed client-side in
|
||||
// SettingsProvider where connector data is available via useCCPairs.
|
||||
isSearchModeAvailable: settings.search_ui_enabled !== false,
|
||||
settingsLoading: false,
|
||||
};
|
||||
|
||||
return combinedSettings;
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { AuthType, NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
|
||||
export interface AuthTypeMetadata {
|
||||
authType: AuthType;
|
||||
autoRedirect: boolean;
|
||||
requiresVerification: boolean;
|
||||
anonymousUserEnabled: boolean | null;
|
||||
passwordMinLength: number;
|
||||
hasUsers: boolean;
|
||||
oauthEnabled: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_AUTH_TYPE_METADATA: AuthTypeMetadata = {
|
||||
authType: NEXT_PUBLIC_CLOUD_ENABLED ? AuthType.CLOUD : AuthType.BASIC,
|
||||
autoRedirect: false,
|
||||
requiresVerification: false,
|
||||
anonymousUserEnabled: null,
|
||||
passwordMinLength: 0,
|
||||
hasUsers: false,
|
||||
oauthEnabled: false,
|
||||
};
|
||||
|
||||
export function useAuthTypeMetadata(): {
|
||||
authTypeMetadata: AuthTypeMetadata;
|
||||
isLoading: boolean;
|
||||
error: Error | undefined;
|
||||
} {
|
||||
const { data, error, isLoading } = useSWR<AuthTypeMetadata>(
|
||||
"/api/auth/type",
|
||||
errorHandlingFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 30_000,
|
||||
}
|
||||
);
|
||||
|
||||
if (NEXT_PUBLIC_CLOUD_ENABLED && data) {
|
||||
return {
|
||||
authTypeMetadata: { ...data, authType: AuthType.CLOUD },
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authTypeMetadata: data ?? DEFAULT_AUTH_TYPE_METADATA,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { UserGroup } from "@/lib/types";
|
||||
import { useContext } from "react";
|
||||
@@ -37,35 +37,21 @@ import { SettingsContext } from "@/providers/SettingsProvider";
|
||||
*/
|
||||
export default function useGroups() {
|
||||
const combinedSettings = useContext(SettingsContext);
|
||||
const settingsLoading = combinedSettings?.settingsLoading ?? false;
|
||||
const isPaidEnterpriseFeaturesEnabled =
|
||||
!settingsLoading &&
|
||||
combinedSettings &&
|
||||
combinedSettings.enterpriseSettings !== null;
|
||||
combinedSettings && combinedSettings.enterpriseSettings !== null;
|
||||
|
||||
const GROUPS_URL = "/api/manage/admin/user-group";
|
||||
const { data, error, isLoading } = useSWR<UserGroup[]>(
|
||||
isPaidEnterpriseFeaturesEnabled ? GROUPS_URL : null,
|
||||
const { data, error, mutate, isLoading } = useSWR<UserGroup[]>(
|
||||
isPaidEnterpriseFeaturesEnabled ? "/api/manage/admin/user-group" : null,
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const refreshGroups = () => mutate(GROUPS_URL);
|
||||
|
||||
if (settingsLoading) {
|
||||
return {
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: undefined,
|
||||
refreshGroups,
|
||||
};
|
||||
}
|
||||
|
||||
// If enterprise features are not enabled, return empty array
|
||||
if (!isPaidEnterpriseFeaturesEnabled) {
|
||||
return {
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
refreshGroups,
|
||||
refreshGroups: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,6 +59,6 @@ export default function useGroups() {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refreshGroups,
|
||||
refreshGroups: mutate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import useSWR from "swr";
|
||||
import {
|
||||
useSettings,
|
||||
useEnterpriseSettings,
|
||||
useCustomAnalyticsScript,
|
||||
} from "@/hooks/useSettings";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { ApplicationStatus, QueryHistoryType } from "@/interfaces/settings";
|
||||
|
||||
jest.mock("swr", () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/fetcher", () => ({
|
||||
errorHandlingFetcher: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/lib/constants", () => ({
|
||||
EE_ENABLED: false,
|
||||
}));
|
||||
|
||||
const mockUseSWR = useSWR as jest.MockedFunction<typeof useSWR>;
|
||||
|
||||
describe("useSettings", () => {
|
||||
beforeEach(() => {
|
||||
mockUseSWR.mockReset();
|
||||
});
|
||||
|
||||
test("returns DEFAULT_SETTINGS when SWR data is undefined", () => {
|
||||
mockUseSWR.mockReturnValue({
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
isLoading: true,
|
||||
mutate: jest.fn(),
|
||||
isValidating: false,
|
||||
} as any);
|
||||
|
||||
const result = useSettings();
|
||||
|
||||
expect(result.settings).toEqual({
|
||||
auto_scroll: true,
|
||||
application_status: ApplicationStatus.ACTIVE,
|
||||
gpu_enabled: false,
|
||||
maximum_chat_retention_days: null,
|
||||
notifications: [],
|
||||
needs_reindexing: false,
|
||||
anonymous_user_enabled: false,
|
||||
invite_only_enabled: false,
|
||||
deep_research_enabled: true,
|
||||
temperature_override_enabled: true,
|
||||
query_history_type: QueryHistoryType.NORMAL,
|
||||
});
|
||||
expect(result.isLoading).toBe(true);
|
||||
});
|
||||
|
||||
test("returns fetched settings when SWR has data", () => {
|
||||
const mockSettings = {
|
||||
auto_scroll: false,
|
||||
application_status: ApplicationStatus.ACTIVE,
|
||||
gpu_enabled: true,
|
||||
maximum_chat_retention_days: 30,
|
||||
notifications: [],
|
||||
needs_reindexing: false,
|
||||
anonymous_user_enabled: false,
|
||||
invite_only_enabled: false,
|
||||
deep_research_enabled: true,
|
||||
temperature_override_enabled: true,
|
||||
query_history_type: QueryHistoryType.NORMAL,
|
||||
};
|
||||
|
||||
mockUseSWR.mockReturnValue({
|
||||
data: mockSettings,
|
||||
error: undefined,
|
||||
isLoading: false,
|
||||
mutate: jest.fn(),
|
||||
isValidating: false,
|
||||
} as any);
|
||||
|
||||
const result = useSettings();
|
||||
|
||||
expect(result.settings).toBe(mockSettings);
|
||||
expect(result.isLoading).toBe(false);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test("fetches from /api/settings with correct SWR config", () => {
|
||||
mockUseSWR.mockReturnValue({
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
isLoading: true,
|
||||
mutate: jest.fn(),
|
||||
isValidating: false,
|
||||
} as any);
|
||||
|
||||
useSettings();
|
||||
|
||||
expect(mockUseSWR).toHaveBeenCalledWith(
|
||||
"/api/settings",
|
||||
errorHandlingFetcher,
|
||||
expect.objectContaining({
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 30_000,
|
||||
errorRetryInterval: 5_000,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useEnterpriseSettings", () => {
|
||||
beforeEach(() => {
|
||||
mockUseSWR.mockReset();
|
||||
});
|
||||
|
||||
test("passes null key when EE is disabled at both build and runtime", () => {
|
||||
mockUseSWR.mockReturnValue({
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
isLoading: false,
|
||||
mutate: jest.fn(),
|
||||
isValidating: false,
|
||||
} as any);
|
||||
|
||||
const result = useEnterpriseSettings(false);
|
||||
|
||||
expect(mockUseSWR).toHaveBeenCalledWith(
|
||||
null,
|
||||
errorHandlingFetcher,
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(result.enterpriseSettings).toBeNull();
|
||||
expect(result.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
test("fetches from /api/enterprise-settings when runtime EE is enabled", () => {
|
||||
mockUseSWR.mockReturnValue({
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
isLoading: true,
|
||||
mutate: jest.fn(),
|
||||
isValidating: false,
|
||||
} as any);
|
||||
|
||||
useEnterpriseSettings(true);
|
||||
|
||||
expect(mockUseSWR).toHaveBeenCalledWith(
|
||||
"/api/enterprise-settings",
|
||||
errorHandlingFetcher,
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test("uses referential equality for compare to ensure logo cache-busters update", () => {
|
||||
mockUseSWR.mockReturnValue({
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
isLoading: true,
|
||||
mutate: jest.fn(),
|
||||
isValidating: false,
|
||||
} as any);
|
||||
|
||||
useEnterpriseSettings(true);
|
||||
|
||||
const swrConfig = mockUseSWR.mock.calls[0]![2] as any;
|
||||
expect(swrConfig.compare).toBeDefined();
|
||||
|
||||
// Same reference should be equal
|
||||
const obj = { use_custom_logo: true };
|
||||
expect(swrConfig.compare(obj, obj)).toBe(true);
|
||||
|
||||
// Different references with same values should NOT be equal
|
||||
// (this is the key behavior — SWR's default deep compare would return true)
|
||||
const a = { use_custom_logo: true };
|
||||
const b = { use_custom_logo: true };
|
||||
expect(swrConfig.compare(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns enterprise settings when SWR has data", () => {
|
||||
const mockEnterprise = {
|
||||
application_name: "Acme Corp",
|
||||
use_custom_logo: true,
|
||||
};
|
||||
|
||||
mockUseSWR.mockReturnValue({
|
||||
data: mockEnterprise,
|
||||
error: undefined,
|
||||
isLoading: false,
|
||||
mutate: jest.fn(),
|
||||
isValidating: false,
|
||||
} as any);
|
||||
|
||||
const result = useEnterpriseSettings(true);
|
||||
|
||||
expect(result.enterpriseSettings).toBe(mockEnterprise);
|
||||
expect(result.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCustomAnalyticsScript", () => {
|
||||
beforeEach(() => {
|
||||
mockUseSWR.mockReset();
|
||||
});
|
||||
|
||||
test("returns null when EE is disabled", () => {
|
||||
mockUseSWR.mockReturnValue({
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
isLoading: false,
|
||||
mutate: jest.fn(),
|
||||
isValidating: false,
|
||||
} as any);
|
||||
|
||||
const result = useCustomAnalyticsScript(false);
|
||||
|
||||
expect(mockUseSWR).toHaveBeenCalledWith(
|
||||
null,
|
||||
errorHandlingFetcher,
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns script content when available", () => {
|
||||
const script = "console.log('analytics');";
|
||||
mockUseSWR.mockReturnValue({
|
||||
data: script,
|
||||
error: undefined,
|
||||
isLoading: false,
|
||||
mutate: jest.fn(),
|
||||
isValidating: false,
|
||||
} as any);
|
||||
|
||||
const result = useCustomAnalyticsScript(true);
|
||||
|
||||
expect(result).toBe(script);
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import {
|
||||
Settings,
|
||||
EnterpriseSettings,
|
||||
ApplicationStatus,
|
||||
QueryHistoryType,
|
||||
} from "@/interfaces/settings";
|
||||
import { EE_ENABLED } from "@/lib/constants";
|
||||
|
||||
// Longer retry delay for critical settings fetches — avoids rapid error→success
|
||||
// flicker in the SettingsProvider error boundary when there's a transient blip.
|
||||
const SETTINGS_ERROR_RETRY_INTERVAL = 5_000;
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
auto_scroll: true,
|
||||
application_status: ApplicationStatus.ACTIVE,
|
||||
gpu_enabled: false,
|
||||
maximum_chat_retention_days: null,
|
||||
notifications: [],
|
||||
needs_reindexing: false,
|
||||
anonymous_user_enabled: false,
|
||||
invite_only_enabled: false,
|
||||
deep_research_enabled: true,
|
||||
temperature_override_enabled: true,
|
||||
query_history_type: QueryHistoryType.NORMAL,
|
||||
} satisfies Settings;
|
||||
|
||||
export function useSettings(): {
|
||||
settings: Settings;
|
||||
isLoading: boolean;
|
||||
error: Error | undefined;
|
||||
} {
|
||||
const { data, error, isLoading } = useSWR<Settings>(
|
||||
"/api/settings",
|
||||
errorHandlingFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 30_000,
|
||||
errorRetryInterval: SETTINGS_ERROR_RETRY_INTERVAL,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
settings: data ?? DEFAULT_SETTINGS,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useEnterpriseSettings(eeEnabledRuntime: boolean): {
|
||||
enterpriseSettings: EnterpriseSettings | null;
|
||||
isLoading: boolean;
|
||||
error: Error | undefined;
|
||||
} {
|
||||
// Gate on the build-time flag OR the runtime ee_features_enabled from
|
||||
// /api/settings. The build-time flag (NEXT_PUBLIC_ENABLE_PAID_EE_FEATURES)
|
||||
// may be unset even when the server enables EE via LICENSE_ENFORCEMENT_ENABLED,
|
||||
// so the runtime check is needed as a fallback.
|
||||
const shouldFetch = EE_ENABLED || eeEnabledRuntime;
|
||||
|
||||
const { data, error, isLoading } = useSWR<EnterpriseSettings>(
|
||||
shouldFetch ? "/api/enterprise-settings" : null,
|
||||
errorHandlingFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 30_000,
|
||||
errorRetryInterval: SETTINGS_ERROR_RETRY_INTERVAL,
|
||||
// Referential equality instead of SWR's default deep comparison.
|
||||
// The logo image can change without the settings JSON changing
|
||||
// (same use_custom_logo: true), so we need every mutate() call
|
||||
// to propagate a new reference so cache-busters recalculate.
|
||||
compare: (a, b) => a === b,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
enterpriseSettings: data ?? null,
|
||||
isLoading: shouldFetch ? isLoading : false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCustomAnalyticsScript(
|
||||
eeEnabledRuntime: boolean
|
||||
): string | null {
|
||||
const shouldFetch = EE_ENABLED || eeEnabledRuntime;
|
||||
|
||||
const { data } = useSWR<string>(
|
||||
shouldFetch ? "/api/enterprise-settings/custom-analytics-script" : null,
|
||||
errorHandlingFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 60_000,
|
||||
}
|
||||
);
|
||||
|
||||
return data ?? null;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
import useSWR from "swr";
|
||||
import { useContext } from "react";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { SettingsContext } from "@/providers/SettingsProvider";
|
||||
@@ -15,35 +15,20 @@ export interface MinimalUserGroupSnapshot {
|
||||
|
||||
export default function useShareableGroups() {
|
||||
const combinedSettings = useContext(SettingsContext);
|
||||
const settingsLoading = combinedSettings?.settingsLoading ?? false;
|
||||
const isPaidEnterpriseFeaturesEnabled =
|
||||
!settingsLoading &&
|
||||
combinedSettings &&
|
||||
combinedSettings.enterpriseSettings !== null;
|
||||
combinedSettings && combinedSettings.enterpriseSettings !== null;
|
||||
|
||||
const SHAREABLE_GROUPS_URL = "/api/manage/user-groups/minimal";
|
||||
const { data, error, isLoading } = useSWR<MinimalUserGroupSnapshot[]>(
|
||||
isPaidEnterpriseFeaturesEnabled ? SHAREABLE_GROUPS_URL : null,
|
||||
const { data, error, mutate, isLoading } = useSWR<MinimalUserGroupSnapshot[]>(
|
||||
isPaidEnterpriseFeaturesEnabled ? "/api/manage/user-groups/minimal" : null,
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const refreshShareableGroups = () => mutate(SHAREABLE_GROUPS_URL);
|
||||
|
||||
if (settingsLoading) {
|
||||
return {
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: undefined,
|
||||
refreshShareableGroups,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isPaidEnterpriseFeaturesEnabled) {
|
||||
return {
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
refreshShareableGroups,
|
||||
refreshShareableGroups: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +36,6 @@ export default function useShareableGroups() {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refreshShareableGroups,
|
||||
refreshShareableGroups: mutate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { User } from "@/lib/types";
|
||||
import { NO_AUTH_USER_ID } from "@/lib/extension/constants";
|
||||
import { AuthTypeMetadata } from "@/hooks/useAuthTypeMetadata";
|
||||
import { AuthTypeMetadata } from "@/lib/userSS";
|
||||
import { AuthType } from "@/lib/constants";
|
||||
|
||||
// Refresh token every 10 minutes (600000ms)
|
||||
|
||||
@@ -62,12 +62,6 @@ export interface Settings {
|
||||
// When false, connectors, RAG search, document sets, and related features
|
||||
// are unavailable.
|
||||
vector_db_enabled?: boolean;
|
||||
|
||||
// True when hooks are available: single-tenant deployment with HOOK_ENABLED=true.
|
||||
hooks_enabled?: boolean;
|
||||
|
||||
// Application version from the ONYX_VERSION env var on the server.
|
||||
version?: string | null;
|
||||
}
|
||||
|
||||
export enum NotificationType {
|
||||
@@ -142,5 +136,4 @@ export interface CombinedSettings {
|
||||
* exist) so consumers get a single, accurate boolean.
|
||||
*/
|
||||
isSearchModeAvailable: boolean;
|
||||
settingsLoading: boolean;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
SvgActions,
|
||||
SvgActivity,
|
||||
SvgArrowExchange,
|
||||
SvgHookNodes,
|
||||
SvgAudio,
|
||||
SvgBarChart,
|
||||
SvgBookOpen,
|
||||
@@ -228,12 +227,6 @@ export const ADMIN_ROUTES = {
|
||||
title: "Document Index Migration",
|
||||
sidebarLabel: "Document Index Migration",
|
||||
},
|
||||
HOOKS: {
|
||||
path: "/admin/hooks",
|
||||
icon: SvgHookNodes,
|
||||
title: "Hook Extensions",
|
||||
sidebarLabel: "Hook Extensions",
|
||||
},
|
||||
SCIM: {
|
||||
path: "/admin/scim",
|
||||
icon: SvgUserSync,
|
||||
|
||||
@@ -901,40 +901,28 @@ export const useUserGroups = (): {
|
||||
refreshUserGroups: () => void;
|
||||
} => {
|
||||
const combinedSettings = useContext(SettingsContext);
|
||||
const isLoading = combinedSettings?.settingsLoading ?? false;
|
||||
const isPaidEnterpriseFeaturesEnabled =
|
||||
!isLoading &&
|
||||
combinedSettings &&
|
||||
combinedSettings.enterpriseSettings !== null;
|
||||
combinedSettings && combinedSettings.enterpriseSettings !== null;
|
||||
|
||||
const swrResponse = useSWR<UserGroup[]>(
|
||||
isPaidEnterpriseFeaturesEnabled ? USER_GROUP_URL : null,
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const refreshUserGroups = () => mutate(USER_GROUP_URL);
|
||||
|
||||
if (isLoading) {
|
||||
return {
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: "",
|
||||
refreshUserGroups,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isPaidEnterpriseFeaturesEnabled) {
|
||||
return {
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: "",
|
||||
refreshUserGroups,
|
||||
...{
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: "",
|
||||
},
|
||||
refreshUserGroups: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...swrResponse,
|
||||
refreshUserGroups,
|
||||
refreshUserGroups: () => mutate(USER_GROUP_URL),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -6,9 +6,15 @@
|
||||
* at the root layout level (`app/layout.tsx`) and provides global state
|
||||
* and functionality to the entire application.
|
||||
*
|
||||
* All data is fetched client-side by individual providers via SWR hooks,
|
||||
* eliminating server-side data fetching from the root layout and preventing
|
||||
* RSC prefetch amplification.
|
||||
* ## Why a Wrapper?
|
||||
*
|
||||
* Instead of nesting dozens of providers in the layout file (which becomes
|
||||
* unwieldy and hard to maintain), we compose them here in a logical order.
|
||||
* This pattern:
|
||||
* - Keeps the layout file clean
|
||||
* - Makes provider dependencies explicit
|
||||
* - Allows easy addition/removal of providers
|
||||
* - Ensures consistent provider ordering across the app
|
||||
*
|
||||
* ## Provider Hierarchy (outermost to innermost)
|
||||
*
|
||||
@@ -19,13 +25,45 @@
|
||||
* 5. **ModalProvider** - Global modal state management
|
||||
* 6. **AppSidebarProvider** - Sidebar open/closed state
|
||||
* 7. **QueryControllerProvider** - Search/Chat mode + query lifecycle
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* This component is used once in `app/layout.tsx`:
|
||||
*
|
||||
* ```tsx
|
||||
* <AppProvider user={user} settings={settings} authTypeMetadata={authType}>
|
||||
* {children}
|
||||
* </AppProvider>
|
||||
* ```
|
||||
*
|
||||
* Individual providers can then be accessed via their respective hooks:
|
||||
* - `useSettingsContext()` - from SettingsProvider
|
||||
* - `useUser()` - from UserProvider
|
||||
* - `useAppBackground()` - from AppBackgroundProvider
|
||||
* - `useQueryController()` - from QueryControllerProvider (includes appMode)
|
||||
* - etc.
|
||||
*
|
||||
* @TODO(@raunakab): The providers wrapped by this component are currently
|
||||
* scattered across multiple directories:
|
||||
* - `@/providers/UserProvider`
|
||||
* - `@/components/chat/ProviderContext`
|
||||
* - `@/providers/SettingsProvider`
|
||||
* - `@/components/context/ModalContext`
|
||||
* - `@/providers/AppSidebarProvider`
|
||||
*
|
||||
* These should eventually be consolidated into the `/web/src/providers`
|
||||
* directory for consistency and discoverability. This would make it clear
|
||||
* where all global state providers live.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { CombinedSettings } from "@/interfaces/settings";
|
||||
import { UserProvider } from "@/providers/UserProvider";
|
||||
import { ProviderContextProvider } from "@/components/chat/ProviderContext";
|
||||
import { SettingsProvider } from "@/providers/SettingsProvider";
|
||||
import { User } from "@/lib/types";
|
||||
import { ModalProvider } from "@/components/context/ModalContext";
|
||||
import { AuthTypeMetadata } from "@/lib/userSS";
|
||||
import { AppSidebarProvider } from "@/providers/AppSidebarProvider";
|
||||
import { AppBackgroundProvider } from "@/providers/AppBackgroundProvider";
|
||||
import { QueryControllerProvider } from "@/providers/QueryControllerProvider";
|
||||
@@ -33,16 +71,30 @@ import ToastProvider from "@/providers/ToastProvider";
|
||||
|
||||
interface AppProviderProps {
|
||||
children: React.ReactNode;
|
||||
user: User | null;
|
||||
settings: CombinedSettings;
|
||||
authTypeMetadata: AuthTypeMetadata;
|
||||
folded?: boolean;
|
||||
}
|
||||
|
||||
export default function AppProvider({ children }: AppProviderProps) {
|
||||
export default function AppProvider({
|
||||
children,
|
||||
user,
|
||||
settings,
|
||||
authTypeMetadata,
|
||||
folded,
|
||||
}: AppProviderProps) {
|
||||
return (
|
||||
<SettingsProvider>
|
||||
<UserProvider>
|
||||
<SettingsProvider settings={settings}>
|
||||
<UserProvider
|
||||
settings={settings}
|
||||
user={user}
|
||||
authTypeMetadata={authTypeMetadata}
|
||||
>
|
||||
<AppBackgroundProvider>
|
||||
<ProviderContextProvider>
|
||||
<ModalProvider>
|
||||
<AppSidebarProvider>
|
||||
<ModalProvider user={user}>
|
||||
<AppSidebarProvider folded={!!folded}>
|
||||
<QueryControllerProvider>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</QueryControllerProvider>
|
||||
|
||||
@@ -21,20 +21,15 @@ function setFoldedCookie(folded: boolean) {
|
||||
}
|
||||
|
||||
export interface AppSidebarProviderProps {
|
||||
folded: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AppSidebarProvider({ children }: AppSidebarProviderProps) {
|
||||
const [folded, setFoldedInternal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored =
|
||||
Cookies.get(SIDEBAR_TOGGLED_COOKIE_NAME) ??
|
||||
localStorage.getItem(SIDEBAR_TOGGLED_COOKIE_NAME);
|
||||
if (stored === "true") {
|
||||
setFoldedInternal(true);
|
||||
}
|
||||
}, []);
|
||||
export function AppSidebarProvider({
|
||||
folded: initiallyFolded,
|
||||
children,
|
||||
}: AppSidebarProviderProps) {
|
||||
const [folded, setFoldedInternal] = useState(initiallyFolded);
|
||||
|
||||
const setFolded: Dispatch<SetStateAction<boolean>> = (value) => {
|
||||
setFoldedInternal((prev) => {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
|
||||
export default function CustomAnalyticsScript() {
|
||||
const { customAnalyticsScript } = useSettingsContext();
|
||||
const injectedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!customAnalyticsScript || injectedRef.current) return;
|
||||
injectedRef.current = true;
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.type = "text/javascript";
|
||||
script.textContent = customAnalyticsScript;
|
||||
document.head.appendChild(script);
|
||||
}, [customAnalyticsScript]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
|
||||
export default function DynamicMetadata() {
|
||||
const { enterpriseSettings } = useSettingsContext();
|
||||
|
||||
useEffect(() => {
|
||||
const title = enterpriseSettings?.application_name || "Onyx";
|
||||
if (document.title !== title) {
|
||||
document.title = title;
|
||||
}
|
||||
}, [enterpriseSettings]);
|
||||
|
||||
// Cache-buster so the favicon re-fetches after an admin uploads a new logo.
|
||||
const cacheBuster = useMemo(
|
||||
() => Date.now(),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[enterpriseSettings]
|
||||
);
|
||||
|
||||
const favicon = enterpriseSettings?.use_custom_logo
|
||||
? `/api/enterprise-settings/logo?v=${cacheBuster}`
|
||||
: "/onyx.ico";
|
||||
|
||||
return <link rel="icon" href={favicon} />;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ApplicationStatus } from "@/interfaces/settings";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import GatedContentWrapper from "@/components/GatedContentWrapper";
|
||||
|
||||
export default function ProductGatingWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { settings, settingsLoading } = useSettingsContext();
|
||||
const status = settings.application_status;
|
||||
|
||||
if (settingsLoading) return null;
|
||||
|
||||
if (
|
||||
status === ApplicationStatus.GATED_ACCESS ||
|
||||
status === ApplicationStatus.SEAT_LIMIT_EXCEEDED
|
||||
) {
|
||||
return <GatedContentWrapper>{children}</GatedContentWrapper>;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -10,46 +10,16 @@ import {
|
||||
JSX,
|
||||
} from "react";
|
||||
import useCCPairs from "@/hooks/useCCPairs";
|
||||
import {
|
||||
useSettings,
|
||||
useEnterpriseSettings,
|
||||
useCustomAnalyticsScript,
|
||||
} from "@/hooks/useSettings";
|
||||
import { HOST_URL, NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
import CloudError from "@/components/errorPages/CloudErrorPage";
|
||||
import ErrorPage from "@/components/errorPages/ErrorPage";
|
||||
import { FetchError } from "@/lib/fetcher";
|
||||
|
||||
export function SettingsProvider({
|
||||
children,
|
||||
settings,
|
||||
}: {
|
||||
children: React.ReactNode | JSX.Element;
|
||||
settings: CombinedSettings;
|
||||
}) {
|
||||
const {
|
||||
settings,
|
||||
isLoading: coreSettingsLoading,
|
||||
error: settingsError,
|
||||
} = useSettings();
|
||||
|
||||
// Once core settings load, check if the backend reports EE as enabled.
|
||||
// This handles deployments where NEXT_PUBLIC_ENABLE_PAID_EE_FEATURES is
|
||||
// unset but LICENSE_ENFORCEMENT_ENABLED defaults to true on the server.
|
||||
const eeEnabledRuntime =
|
||||
!coreSettingsLoading &&
|
||||
!settingsError &&
|
||||
settings.ee_features_enabled !== false;
|
||||
|
||||
const {
|
||||
enterpriseSettings,
|
||||
isLoading: enterpriseSettingsLoading,
|
||||
error: enterpriseSettingsError,
|
||||
} = useEnterpriseSettings(eeEnabledRuntime);
|
||||
const customAnalyticsScript = useCustomAnalyticsScript(eeEnabledRuntime);
|
||||
|
||||
const [isMobile, setIsMobile] = useState<boolean | undefined>();
|
||||
const settingsLoading = coreSettingsLoading || enterpriseSettingsLoading;
|
||||
const vectorDbEnabled =
|
||||
!coreSettingsLoading && settings.vector_db_enabled !== false;
|
||||
const vectorDbEnabled = settings.settings.vector_db_enabled !== false;
|
||||
const { ccPairs } = useCCPairs(vectorDbEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -72,46 +42,14 @@ export function SettingsProvider({
|
||||
* consumers don't need to independently verify availability.
|
||||
*/
|
||||
const isSearchModeAvailable = useMemo(
|
||||
() => settings.search_ui_enabled !== false && ccPairs.length > 0,
|
||||
[settings.search_ui_enabled, ccPairs.length]
|
||||
() => settings.settings.search_ui_enabled !== false && ccPairs.length > 0,
|
||||
[settings.settings.search_ui_enabled, ccPairs.length]
|
||||
);
|
||||
|
||||
const combinedSettings: CombinedSettings = useMemo(
|
||||
() => ({
|
||||
settings,
|
||||
enterpriseSettings,
|
||||
customAnalyticsScript,
|
||||
webVersion: settings.version ?? null,
|
||||
webDomain: HOST_URL,
|
||||
isMobile,
|
||||
isSearchModeAvailable,
|
||||
settingsLoading,
|
||||
}),
|
||||
[
|
||||
settings,
|
||||
enterpriseSettings,
|
||||
customAnalyticsScript,
|
||||
isMobile,
|
||||
isSearchModeAvailable,
|
||||
settingsLoading,
|
||||
]
|
||||
);
|
||||
|
||||
// Auth errors (401/403) are expected for unauthenticated users (e.g. login
|
||||
// page). Fall through with default settings so the app can render normally.
|
||||
const isAuthError = (err: Error | undefined) =>
|
||||
err instanceof FetchError && (err.status === 401 || err.status === 403);
|
||||
|
||||
const hasFatalError =
|
||||
(settingsError && !isAuthError(settingsError)) ||
|
||||
(enterpriseSettingsError && !isAuthError(enterpriseSettingsError));
|
||||
|
||||
if (hasFatalError) {
|
||||
return NEXT_PUBLIC_CLOUD_ENABLED ? <CloudError /> : <ErrorPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={combinedSettings}>
|
||||
<SettingsContext.Provider
|
||||
value={{ ...settings, isMobile, isSearchModeAvailable }}
|
||||
>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
@@ -15,14 +13,12 @@ import {
|
||||
UserRole,
|
||||
ThemePreference,
|
||||
} from "@/lib/types";
|
||||
import { getCurrentUser } from "@/lib/user";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { CombinedSettings } from "@/interfaces/settings";
|
||||
import { SettingsContext } from "@/providers/SettingsProvider";
|
||||
import { useTokenRefresh } from "@/hooks/useTokenRefresh";
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import {
|
||||
useAuthTypeMetadata,
|
||||
AuthTypeMetadata,
|
||||
} from "@/hooks/useAuthTypeMetadata";
|
||||
import { AuthTypeMetadata } from "@/lib/userSS";
|
||||
import { updateUserPersonalization as persistPersonalization } from "@/lib/userSettings";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
@@ -59,64 +55,79 @@ interface UserContextType {
|
||||
|
||||
const UserContext = createContext<UserContextType | undefined>(undefined);
|
||||
|
||||
export function UserProvider({ children }: { children: React.ReactNode }) {
|
||||
const { user: fetchedUser, mutateUser } = useCurrentUser();
|
||||
const { authTypeMetadata } = useAuthTypeMetadata();
|
||||
export function UserProvider({
|
||||
authTypeMetadata,
|
||||
children,
|
||||
user,
|
||||
settings,
|
||||
}: {
|
||||
authTypeMetadata: AuthTypeMetadata;
|
||||
children: React.ReactNode;
|
||||
user: User | null;
|
||||
settings: CombinedSettings;
|
||||
}) {
|
||||
const updatedSettings = useContext(SettingsContext);
|
||||
const posthog = usePostHog();
|
||||
|
||||
// For auto_scroll and temperature_override_enabled:
|
||||
// - If user has a preference set, use that
|
||||
// - Otherwise, use the workspace setting if available
|
||||
const mergeUserPreferences = useCallback(
|
||||
(currentUser: User | null): User | null => {
|
||||
if (!currentUser) return null;
|
||||
return {
|
||||
...currentUser,
|
||||
preferences: {
|
||||
...currentUser.preferences,
|
||||
auto_scroll:
|
||||
currentUser.preferences?.auto_scroll ??
|
||||
updatedSettings?.settings?.auto_scroll ??
|
||||
false,
|
||||
temperature_override_enabled:
|
||||
currentUser.preferences?.temperature_override_enabled ??
|
||||
updatedSettings?.settings?.temperature_override_enabled ??
|
||||
false,
|
||||
},
|
||||
};
|
||||
},
|
||||
[updatedSettings]
|
||||
function mergeUserPreferences(
|
||||
currentUser: User | null,
|
||||
currentSettings: CombinedSettings | null
|
||||
): User | null {
|
||||
if (!currentUser) return null;
|
||||
return {
|
||||
...currentUser,
|
||||
preferences: {
|
||||
...currentUser.preferences,
|
||||
auto_scroll:
|
||||
currentUser.preferences?.auto_scroll ??
|
||||
currentSettings?.settings?.auto_scroll ??
|
||||
false,
|
||||
temperature_override_enabled:
|
||||
currentUser.preferences?.temperature_override_enabled ??
|
||||
currentSettings?.settings?.temperature_override_enabled ??
|
||||
false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const [upToDateUser, setUpToDateUser] = useState<User | null>(
|
||||
mergeUserPreferences(user, settings)
|
||||
);
|
||||
|
||||
const [upToDateUser, setUpToDateUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setUpToDateUser(mergeUserPreferences(fetchedUser ?? null));
|
||||
}, [fetchedUser, mergeUserPreferences]);
|
||||
setUpToDateUser(mergeUserPreferences(user, updatedSettings));
|
||||
}, [user, updatedSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!posthog) return;
|
||||
|
||||
if (fetchedUser?.id) {
|
||||
if (user?.id) {
|
||||
const identifyData: Record<string, any> = {
|
||||
email: fetchedUser.email,
|
||||
email: user.email,
|
||||
};
|
||||
if (fetchedUser.team_name) {
|
||||
identifyData.team_name = fetchedUser.team_name;
|
||||
if (user.team_name) {
|
||||
identifyData.team_name = user.team_name;
|
||||
}
|
||||
posthog.identify(fetchedUser.id, identifyData);
|
||||
posthog.identify(user.id, identifyData);
|
||||
} else {
|
||||
posthog.reset();
|
||||
}
|
||||
}, [posthog, fetchedUser]);
|
||||
}, [posthog, user]);
|
||||
|
||||
// Use the custom token refresh hook — on refresh failure, revalidate via SWR
|
||||
// so the result goes through mergeUserPreferences
|
||||
const onRefreshFail = useCallback(async () => {
|
||||
await mutateUser();
|
||||
}, [mutateUser]);
|
||||
useTokenRefresh(upToDateUser, authTypeMetadata, onRefreshFail);
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const currentUser = await getCurrentUser();
|
||||
setUpToDateUser(currentUser);
|
||||
} catch (error) {
|
||||
console.error("Error fetching current user:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Use the custom token refresh hook
|
||||
useTokenRefresh(upToDateUser, authTypeMetadata, fetchUser);
|
||||
|
||||
// Sync user's theme preference from DB to next-themes on load
|
||||
const { setTheme, theme } = useTheme();
|
||||
@@ -499,7 +510,7 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
await mutateUser();
|
||||
await fetchUser();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -68,7 +68,6 @@ const settingsValue: CombinedSettings = {
|
||||
webVersion: null,
|
||||
webDomain: null,
|
||||
isSearchModeAvailable: true,
|
||||
settingsLoading: false,
|
||||
};
|
||||
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { OnyxIcon, OnyxLogoTypeIcon } from "@/components/icons/icons";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
LOGO_FOLDED_SIZE_PX,
|
||||
LOGO_UNFOLDED_SIZE_PX,
|
||||
@@ -25,34 +26,31 @@ export default function Logo({ folded, size, className }: LogoProps) {
|
||||
const logoDisplayStyle = settings.enterpriseSettings?.logo_display_style;
|
||||
const applicationName = settings.enterpriseSettings?.application_name;
|
||||
|
||||
// Cache-buster: the logo URL never changes (/api/enterprise-settings/logo)
|
||||
// so the browser serves the in-memory cached image even after an admin
|
||||
// uploads a new one. Generating a fresh timestamp each time enterprise
|
||||
// settings are revalidated by SWR appends a unique query param to force
|
||||
// the browser to re-fetch the image.
|
||||
const logoBuster = useMemo(
|
||||
() => Date.now(),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[settings.enterpriseSettings]
|
||||
);
|
||||
|
||||
const logo = settings.enterpriseSettings?.use_custom_logo ? (
|
||||
<div
|
||||
className={cn(
|
||||
"aspect-square rounded-full overflow-hidden relative flex-shrink-0",
|
||||
className
|
||||
)}
|
||||
style={{ height: foldedSize, width: foldedSize }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
alt="Logo"
|
||||
src={`/api/enterprise-settings/logo?v=${logoBuster}`}
|
||||
className="object-cover object-center w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<OnyxIcon size={foldedSize} className={cn("flex-shrink-0", className)} />
|
||||
const logo = useMemo(
|
||||
() =>
|
||||
settings.enterpriseSettings?.use_custom_logo ? (
|
||||
<div
|
||||
className={cn(
|
||||
"aspect-square rounded-full overflow-hidden relative flex-shrink-0",
|
||||
className
|
||||
)}
|
||||
style={{ height: foldedSize, width: foldedSize }}
|
||||
>
|
||||
<Image
|
||||
alt="Logo"
|
||||
src="/api/enterprise-settings/logo"
|
||||
fill
|
||||
className="object-cover object-center"
|
||||
sizes={`${foldedSize}px`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<OnyxIcon
|
||||
size={foldedSize}
|
||||
className={cn("flex-shrink-0", className)}
|
||||
/>
|
||||
),
|
||||
[className, foldedSize, settings.enterpriseSettings?.use_custom_logo]
|
||||
);
|
||||
|
||||
const renderNameAndPoweredBy = (opts: {
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import InputSearch from "./InputSearch";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
const meta: Meta<typeof InputSearch> = {
|
||||
title: "refresh-components/inputs/InputSearch",
|
||||
component: InputSearch,
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<TooltipPrimitive.Provider>
|
||||
<div style={{ width: 320 }}>
|
||||
<Story />
|
||||
</div>
|
||||
</TooltipPrimitive.Provider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof InputSearch>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: function DefaultStory() {
|
||||
const [value, setValue] = React.useState("");
|
||||
return (
|
||||
<InputSearch
|
||||
placeholder="Search..."
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValue: Story = {
|
||||
render: function WithValueStory() {
|
||||
const [value, setValue] = React.useState("Search Value");
|
||||
return (
|
||||
<InputSearch
|
||||
placeholder="Search..."
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<InputSearch
|
||||
placeholder="Search..."
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import InputTypeIn, {
|
||||
InputTypeInProps,
|
||||
} from "@/refresh-components/inputs/InputTypeIn";
|
||||
|
||||
/**
|
||||
* InputSearch Component
|
||||
*
|
||||
* A subtle search input that follows the "Subtle Input Styles" spec:
|
||||
* no border by default, border appears on hover/focus/active.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Basic usage
|
||||
* <InputSearch
|
||||
* placeholder="Search..."
|
||||
* value={search}
|
||||
* onChange={(e) => setSearch(e.target.value)}
|
||||
* />
|
||||
*
|
||||
* // Disabled state
|
||||
* <InputSearch
|
||||
* disabled
|
||||
* placeholder="Search..."
|
||||
* value=""
|
||||
* onChange={() => {}}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export interface InputSearchProps
|
||||
extends Omit<InputTypeInProps, "variant" | "leftSearchIcon"> {
|
||||
/**
|
||||
* Ref to the underlying input element.
|
||||
*/
|
||||
ref?: React.Ref<HTMLInputElement>;
|
||||
/**
|
||||
* Whether the input is disabled.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function InputSearch({
|
||||
ref,
|
||||
disabled,
|
||||
className,
|
||||
...props
|
||||
}: InputSearchProps) {
|
||||
return (
|
||||
<InputTypeIn
|
||||
ref={ref}
|
||||
variant={disabled ? "disabled" : "internal"}
|
||||
leftSearchIcon
|
||||
className={cn(
|
||||
"[&_input]:font-main-ui-muted [&_input]:text-text-02 [&_input]:placeholder:text-text-02",
|
||||
!disabled && [
|
||||
"border border-transparent",
|
||||
"hover:border-border-03",
|
||||
"active:border-border-05",
|
||||
"focus-within:shadow-[0px_0px_0px_2px_var(--background-tint-04)]",
|
||||
"focus-within:hover:border-border-03",
|
||||
],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -77,7 +77,6 @@ function buildColumns(onMutate: () => void) {
|
||||
return [
|
||||
tc.qualifier({
|
||||
content: "icon",
|
||||
iconSize: "lg",
|
||||
getContent: (row) => {
|
||||
const user = {
|
||||
email: row.email,
|
||||
|
||||
@@ -56,8 +56,7 @@ function buildItems(
|
||||
settings: CombinedSettings | null,
|
||||
kgExposed: boolean,
|
||||
customAnalyticsEnabled: boolean,
|
||||
hasSubscription: boolean,
|
||||
hooksEnabled: boolean
|
||||
hasSubscription: boolean
|
||||
): SidebarItemEntry[] {
|
||||
const vectorDbEnabled = settings?.settings.vector_db_enabled !== false;
|
||||
const items: SidebarItemEntry[] = [];
|
||||
@@ -123,9 +122,6 @@ function buildItems(
|
||||
add(SECTIONS.INTEGRATIONS, ADMIN_ROUTES.API_KEYS);
|
||||
add(SECTIONS.INTEGRATIONS, ADMIN_ROUTES.SLACK_BOTS);
|
||||
add(SECTIONS.INTEGRATIONS, ADMIN_ROUTES.DISCORD_BOTS);
|
||||
if (hooksEnabled) {
|
||||
add(SECTIONS.INTEGRATIONS, ADMIN_ROUTES.HOOKS);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Permissions
|
||||
@@ -206,7 +202,6 @@ export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
|
||||
(billingData && hasActiveSubscription(billingData)) ||
|
||||
licenseData?.has_license
|
||||
);
|
||||
const hooksEnabled = settings?.settings.hooks_enabled ?? false;
|
||||
|
||||
const allItems = buildItems(
|
||||
isCurator,
|
||||
@@ -215,8 +210,7 @@ export default function AdminSidebar({ enableCloudSS }: AdminSidebarProps) {
|
||||
settings,
|
||||
kgExposed,
|
||||
customAnalyticsEnabled,
|
||||
hasSubscriptionOrLicense,
|
||||
hooksEnabled
|
||||
hasSubscriptionOrLicense
|
||||
);
|
||||
|
||||
const itemExtractor = useCallback((item: SidebarItemEntry) => item.name, []);
|
||||
|
||||
Reference in New Issue
Block a user