Compare commits

..

1 Commits

Author SHA1 Message Date
Evan Lohn
151e81441e chore: coerce doc metadata (#8703) 2026-02-23 17:57:07 -08:00
88 changed files with 3324 additions and 3793 deletions

View File

@@ -1,31 +0,0 @@
"""code interpreter server model
Revision ID: 7cb492013621
Revises: 0bb4558f35df
Create Date: 2026-02-22 18:54:54.007265
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "7cb492013621"
down_revision = "0bb4558f35df"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"code_interpreter_server",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column(
"server_enabled", sa.Boolean, nullable=False, server_default=sa.true()
),
)
def downgrade() -> None:
op.drop_table("code_interpreter_server")

View File

@@ -9,7 +9,6 @@ from sqlalchemy import Select
from sqlalchemy import select
from sqlalchemy import update
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from ee.onyx.server.user_group.models import SetCuratorRequest
@@ -19,15 +18,11 @@ from onyx.db.connector_credential_pair import get_connector_credential_pair_from
from onyx.db.enums import AccessType
from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.models import ConnectorCredentialPair
from onyx.db.models import Credential
from onyx.db.models import Credential__UserGroup
from onyx.db.models import Document
from onyx.db.models import DocumentByConnectorCredentialPair
from onyx.db.models import DocumentSet
from onyx.db.models import DocumentSet__UserGroup
from onyx.db.models import FederatedConnector__DocumentSet
from onyx.db.models import LLMProvider__UserGroup
from onyx.db.models import Persona
from onyx.db.models import Persona__UserGroup
from onyx.db.models import TokenRateLimit__UserGroup
from onyx.db.models import User
@@ -200,60 +195,8 @@ def fetch_user_group(db_session: Session, user_group_id: int) -> UserGroup | Non
return db_session.scalar(stmt)
def _add_user_group_snapshot_eager_loads(
stmt: Select,
) -> Select:
"""Add eager loading options needed by UserGroup.from_model snapshot creation."""
return stmt.options(
selectinload(UserGroup.users),
selectinload(UserGroup.user_group_relationships),
selectinload(UserGroup.cc_pair_relationships)
.selectinload(UserGroup__ConnectorCredentialPair.cc_pair)
.options(
selectinload(ConnectorCredentialPair.connector),
selectinload(ConnectorCredentialPair.credential).selectinload(
Credential.user
),
),
selectinload(UserGroup.document_sets).options(
selectinload(DocumentSet.connector_credential_pairs).selectinload(
ConnectorCredentialPair.connector
),
selectinload(DocumentSet.users),
selectinload(DocumentSet.groups),
selectinload(DocumentSet.federated_connectors).selectinload(
FederatedConnector__DocumentSet.federated_connector
),
),
selectinload(UserGroup.personas).options(
selectinload(Persona.tools),
selectinload(Persona.hierarchy_nodes),
selectinload(Persona.attached_documents).selectinload(
Document.parent_hierarchy_node
),
selectinload(Persona.labels),
selectinload(Persona.document_sets).options(
selectinload(DocumentSet.connector_credential_pairs).selectinload(
ConnectorCredentialPair.connector
),
selectinload(DocumentSet.users),
selectinload(DocumentSet.groups),
selectinload(DocumentSet.federated_connectors).selectinload(
FederatedConnector__DocumentSet.federated_connector
),
),
selectinload(Persona.user),
selectinload(Persona.user_files),
selectinload(Persona.users),
selectinload(Persona.groups),
),
)
def fetch_user_groups(
db_session: Session,
only_up_to_date: bool = True,
eager_load_for_snapshot: bool = False,
db_session: Session, only_up_to_date: bool = True
) -> Sequence[UserGroup]:
"""
Fetches user groups from the database.
@@ -266,8 +209,6 @@ def fetch_user_groups(
db_session (Session): The SQLAlchemy session used to query the database.
only_up_to_date (bool, optional): Flag to determine whether to filter the results
to include only up to date user groups. Defaults to `True`.
eager_load_for_snapshot: If True, adds eager loading for all relationships
needed by UserGroup.from_model snapshot creation.
Returns:
Sequence[UserGroup]: A sequence of `UserGroup` objects matching the query criteria.
@@ -275,16 +216,11 @@ def fetch_user_groups(
stmt = select(UserGroup)
if only_up_to_date:
stmt = stmt.where(UserGroup.is_up_to_date == True) # noqa: E712
if eager_load_for_snapshot:
stmt = _add_user_group_snapshot_eager_loads(stmt)
return db_session.scalars(stmt).unique().all()
return db_session.scalars(stmt).all()
def fetch_user_groups_for_user(
db_session: Session,
user_id: UUID,
only_curator_groups: bool = False,
eager_load_for_snapshot: bool = False,
db_session: Session, user_id: UUID, only_curator_groups: bool = False
) -> Sequence[UserGroup]:
stmt = (
select(UserGroup)
@@ -294,9 +230,7 @@ def fetch_user_groups_for_user(
)
if only_curator_groups:
stmt = stmt.where(User__UserGroup.is_curator == True) # noqa: E712
if eager_load_for_snapshot:
stmt = _add_user_group_snapshot_eager_loads(stmt)
return db_session.scalars(stmt).unique().all()
return db_session.scalars(stmt).all()
def construct_document_id_select_by_usergroup(

View File

@@ -37,15 +37,12 @@ def list_user_groups(
db_session: Session = Depends(get_session),
) -> list[UserGroup]:
if user.role == UserRole.ADMIN:
user_groups = fetch_user_groups(
db_session, only_up_to_date=False, eager_load_for_snapshot=True
)
user_groups = fetch_user_groups(db_session, only_up_to_date=False)
else:
user_groups = fetch_user_groups_for_user(
db_session=db_session,
user_id=user.id,
only_curator_groups=user.role == UserRole.CURATOR,
eager_load_for_snapshot=True,
)
return [UserGroup.from_model(user_group) for user_group in user_groups]

View File

@@ -53,8 +53,7 @@ class UserGroup(BaseModel):
id=cc_pair_relationship.cc_pair.id,
name=cc_pair_relationship.cc_pair.name,
connector=ConnectorSnapshot.from_connector_db_model(
cc_pair_relationship.cc_pair.connector,
credential_ids=[cc_pair_relationship.cc_pair.credential_id],
cc_pair_relationship.cc_pair.connector
),
credential=CredentialSnapshot.from_credential_db_model(
cc_pair_relationship.cc_pair.credential

View File

@@ -190,7 +190,7 @@ def _build_user_information_section(
if not sections:
return ""
return USER_INFORMATION_HEADER + "\n".join(sections)
return USER_INFORMATION_HEADER + "".join(sections)
def build_system_prompt(
@@ -228,21 +228,23 @@ def build_system_prompt(
system_prompt += REQUIRE_CITATION_GUIDANCE
if include_all_guidance:
tool_sections = [
TOOL_DESCRIPTION_SEARCH_GUIDANCE,
INTERNAL_SEARCH_GUIDANCE,
WEB_SEARCH_GUIDANCE.format(
system_prompt += (
TOOL_SECTION_HEADER
+ TOOL_DESCRIPTION_SEARCH_GUIDANCE
+ INTERNAL_SEARCH_GUIDANCE
+ WEB_SEARCH_GUIDANCE.format(
site_colon_disabled=WEB_SEARCH_SITE_DISABLED_GUIDANCE
),
OPEN_URLS_GUIDANCE,
PYTHON_TOOL_GUIDANCE,
GENERATE_IMAGE_GUIDANCE,
MEMORY_GUIDANCE,
]
system_prompt += TOOL_SECTION_HEADER + "\n".join(tool_sections)
)
+ OPEN_URLS_GUIDANCE
+ PYTHON_TOOL_GUIDANCE
+ GENERATE_IMAGE_GUIDANCE
+ MEMORY_GUIDANCE
)
return system_prompt
if tools:
system_prompt += TOOL_SECTION_HEADER
has_web_search = any(isinstance(tool, WebSearchTool) for tool in tools)
has_internal_search = any(isinstance(tool, SearchTool) for tool in tools)
has_open_urls = any(isinstance(tool, OpenURLTool) for tool in tools)
@@ -252,14 +254,12 @@ def build_system_prompt(
)
has_memory = any(isinstance(tool, MemoryTool) for tool in tools)
tool_guidance_sections: list[str] = []
if has_web_search or has_internal_search or include_all_guidance:
tool_guidance_sections.append(TOOL_DESCRIPTION_SEARCH_GUIDANCE)
system_prompt += TOOL_DESCRIPTION_SEARCH_GUIDANCE
# These are not included at the Tool level because the ordering may matter.
if has_internal_search or include_all_guidance:
tool_guidance_sections.append(INTERNAL_SEARCH_GUIDANCE)
system_prompt += INTERNAL_SEARCH_GUIDANCE
if has_web_search or include_all_guidance:
site_disabled_guidance = ""
@@ -269,23 +269,20 @@ def build_system_prompt(
)
if web_search_tool and not web_search_tool.supports_site_filter:
site_disabled_guidance = WEB_SEARCH_SITE_DISABLED_GUIDANCE
tool_guidance_sections.append(
WEB_SEARCH_GUIDANCE.format(site_colon_disabled=site_disabled_guidance)
system_prompt += WEB_SEARCH_GUIDANCE.format(
site_colon_disabled=site_disabled_guidance
)
if has_open_urls or include_all_guidance:
tool_guidance_sections.append(OPEN_URLS_GUIDANCE)
system_prompt += OPEN_URLS_GUIDANCE
if has_python or include_all_guidance:
tool_guidance_sections.append(PYTHON_TOOL_GUIDANCE)
system_prompt += PYTHON_TOOL_GUIDANCE
if has_generate_image or include_all_guidance:
tool_guidance_sections.append(GENERATE_IMAGE_GUIDANCE)
system_prompt += GENERATE_IMAGE_GUIDANCE
if has_memory or include_all_guidance:
tool_guidance_sections.append(MEMORY_GUIDANCE)
if tool_guidance_sections:
system_prompt += TOOL_SECTION_HEADER + "\n".join(tool_guidance_sections)
system_prompt += MEMORY_GUIDANCE
return system_prompt

View File

@@ -6,6 +6,7 @@ from typing import cast
from pydantic import BaseModel
from pydantic import Field
from pydantic import field_validator
from pydantic import model_validator
from onyx.access.models import ExternalAccess
@@ -167,6 +168,14 @@ class DocumentBase(BaseModel):
# list of strings.
metadata: dict[str, str | list[str]]
@field_validator("metadata", mode="before")
@classmethod
def _coerce_metadata_values(cls, v: dict[str, Any]) -> dict[str, str | list[str]]:
return {
key: [str(item) for item in val] if isinstance(val, list) else str(val)
for key, val in v.items()
}
# UTC time
doc_updated_at: datetime | None = None
chunk_count: int | None = None

View File

@@ -244,12 +244,6 @@ class SharepointConnectorCheckpoint(ConnectorCheckpoint):
current_drive_name: str | None = None
# Drive's web_url from the API - used as raw_node_id for DRIVE hierarchy nodes
current_drive_web_url: str | None = None
# Resolved drive ID — avoids re-resolving on checkpoint resume
current_drive_id: str | None = None
# Next delta API page URL for per-page checkpointing within a drive.
# When set, Phase 3b fetches one page at a time so progress is persisted
# between pages. None means BFS path or no active delta traversal.
current_drive_delta_next_link: str | None = None
process_site_pages: bool = False
@@ -1409,87 +1403,6 @@ class SharepointConnector(
if not page_url:
break
def _build_delta_start_url(
self,
drive_id: str,
start: datetime | None = None,
page_size: int = 200,
) -> str:
"""Build the initial delta API URL with query parameters embedded.
Embeds ``$top`` (and optionally a timestamp ``token``) directly in the
URL so that the returned string is fully self-contained and can be
stored in a checkpoint without needing a separate params dict.
"""
base_url = f"{self.graph_api_base}/drives/{drive_id}/root/delta"
params = [f"$top={page_size}"]
if start is not None and start > _EPOCH:
token = quote(start.isoformat(timespec="seconds"))
params.append(f"token={token}")
return f"{base_url}?{'&'.join(params)}"
def _fetch_one_delta_page(
self,
page_url: str,
drive_id: str,
start: datetime | None = None,
end: datetime | None = None,
page_size: int = 200,
) -> tuple[list[DriveItemData], str | None]:
"""Fetch a single page of delta API results.
Returns ``(items, next_page_url)``. *next_page_url* is ``None`` when
the delta enumeration is complete (deltaLink with no nextLink).
On 410 Gone (expired token) returns ``([], full_resync_url)`` so
the caller can store the resync URL in the checkpoint and retry on
the next cycle.
"""
try:
data = self._graph_api_get_json(page_url)
except requests.HTTPError as e:
if e.response is not None and e.response.status_code == 410:
logger.warning(
"Delta token expired (410 Gone) for drive '%s'. "
"Will restart with full delta enumeration.",
drive_id,
)
full_url = (
f"{self.graph_api_base}/drives/{drive_id}/root/delta"
f"?$top={page_size}"
)
return [], full_url
raise
items: list[DriveItemData] = []
for item in data.get("value", []):
if "folder" in item or "deleted" in item:
continue
if start is not None or end is not None:
raw_ts = item.get("lastModifiedDateTime")
if raw_ts:
mod_dt = datetime.fromisoformat(raw_ts.replace("Z", "+00:00"))
if start is not None and mod_dt < start:
continue
if end is not None and mod_dt > end:
continue
items.append(DriveItemData.from_graph_json(item))
next_url = data.get("@odata.nextLink")
if next_url:
return items, next_url
return items, None
@staticmethod
def _clear_drive_checkpoint_state(
checkpoint: "SharepointConnectorCheckpoint",
) -> None:
"""Reset all drive-level fields in the checkpoint."""
checkpoint.current_drive_name = None
checkpoint.current_drive_id = None
checkpoint.current_drive_web_url = None
checkpoint.current_drive_delta_next_link = None
def _fetch_slim_documents_from_sharepoint(self) -> GenerateSlimDocumentOutput:
site_descriptors = self.site_descriptors or self.fetch_sites()
@@ -1931,13 +1844,14 @@ class SharepointConnector(
# Return checkpoint to allow persistence after drive initialization
return checkpoint
# Phase 3a: Initialize the next drive for processing
# Phase 3: Process documents from current drive
if (
checkpoint.current_site_descriptor
and checkpoint.cached_drive_names
and len(checkpoint.cached_drive_names) > 0
and checkpoint.current_drive_name is None
):
checkpoint.current_drive_name = checkpoint.cached_drive_names.popleft()
start_dt = datetime.fromtimestamp(start, tz=timezone.utc)
@@ -1945,8 +1859,7 @@ class SharepointConnector(
site_descriptor = checkpoint.current_site_descriptor
logger.info(
f"Processing drive '{checkpoint.current_drive_name}' "
f"in site: {site_descriptor.url}"
f"Processing drive '{checkpoint.current_drive_name}' in site: {site_descriptor.url}"
)
logger.debug(f"Time range: {start_dt} to {end_dt}")
@@ -1955,35 +1868,35 @@ class SharepointConnector(
logger.warning("Current drive name is None, skipping")
return checkpoint
driveitems: Iterable[DriveItemData] = iter(())
drive_web_url: str | None = None
try:
logger.info(
f"Fetching drive items for drive name: {current_drive_name}"
)
result = self._resolve_drive(site_descriptor, current_drive_name)
if result is None:
logger.warning(f"Drive '{current_drive_name}' not found, skipping")
self._clear_drive_checkpoint_state(checkpoint)
return checkpoint
drive_id, drive_web_url = result
checkpoint.current_drive_id = drive_id
checkpoint.current_drive_web_url = drive_web_url
if result is not None:
drive_id, drive_web_url = result
driveitems = self._get_drive_items_for_drive_id(
site_descriptor, drive_id, start_dt, end_dt
)
checkpoint.current_drive_web_url = drive_web_url
except Exception as e:
logger.error(
f"Failed to retrieve items from drive '{current_drive_name}' "
f"in site: {site_descriptor.url}: {e}"
f"Failed to retrieve items from drive '{current_drive_name}' in site: {site_descriptor.url}: {e}"
)
yield _create_entity_failure(
f"{site_descriptor.url}|{current_drive_name}",
f"Failed to access drive '{current_drive_name}' "
f"in site '{site_descriptor.url}': {str(e)}",
f"Failed to access drive '{current_drive_name}' in site '{site_descriptor.url}': {str(e)}",
(start_dt, end_dt),
e,
)
self._clear_drive_checkpoint_state(checkpoint)
checkpoint.current_drive_name = None
checkpoint.current_drive_web_url = None
return checkpoint
display_drive_name = SHARED_DOCUMENTS_MAP.get(
# Normalize drive name (e.g., "Documents" -> "Shared Documents")
current_drive_name = SHARED_DOCUMENTS_MAP.get(
current_drive_name, current_drive_name
)
@@ -1991,74 +1904,10 @@ class SharepointConnector(
yield from self._yield_drive_hierarchy_node(
site_descriptor.url,
drive_web_url,
display_drive_name,
current_drive_name,
checkpoint,
)
# For non-folder-scoped drives, use delta API with per-page
# checkpointing. Build the initial URL and fall through to 3b.
if not site_descriptor.folder_path:
checkpoint.current_drive_delta_next_link = self._build_delta_start_url(
drive_id, start_dt
)
# else: BFS path — delta_next_link stays None;
# Phase 3b will use _iter_drive_items_paged.
# Phase 3b: Process items from the current drive
if (
checkpoint.current_site_descriptor
and checkpoint.current_drive_name is not None
and checkpoint.current_drive_id is not None
):
site_descriptor = checkpoint.current_site_descriptor
start_dt = datetime.fromtimestamp(start, tz=timezone.utc)
end_dt = datetime.fromtimestamp(end, tz=timezone.utc)
current_drive_name = SHARED_DOCUMENTS_MAP.get(
checkpoint.current_drive_name, checkpoint.current_drive_name
)
drive_web_url = checkpoint.current_drive_web_url
# --- determine item source ---
driveitems: Iterable[DriveItemData]
has_more_delta_pages = False
if checkpoint.current_drive_delta_next_link:
# Delta path: fetch one page at a time for checkpointing
try:
page_items, next_url = self._fetch_one_delta_page(
page_url=checkpoint.current_drive_delta_next_link,
drive_id=checkpoint.current_drive_id,
start=start_dt,
end=end_dt,
)
except Exception as e:
logger.error(
f"Failed to fetch delta page for drive "
f"'{current_drive_name}': {e}"
)
yield _create_entity_failure(
f"{site_descriptor.url}|{current_drive_name}",
f"Failed to fetch delta page for drive "
f"'{current_drive_name}': {str(e)}",
(start_dt, end_dt),
e,
)
self._clear_drive_checkpoint_state(checkpoint)
return checkpoint
driveitems = page_items
has_more_delta_pages = next_url is not None
if next_url:
checkpoint.current_drive_delta_next_link = next_url
else:
# BFS path (folder-scoped): process all items at once
driveitems = self._iter_drive_items_paged(
drive_id=checkpoint.current_drive_id,
folder_path=site_descriptor.folder_path,
start=start_dt,
end=end_dt,
)
item_count = 0
for driveitem in driveitems:
item_count += 1
@@ -2100,6 +1949,8 @@ class SharepointConnector(
if include_permissions:
ctx = self._create_rest_client_context(site_descriptor.url)
# Re-acquire token in case it expired during a long traversal
# MSAL has a cache that returns the same token while still valid.
access_token = self._get_graph_access_token()
doc_or_failure = _convert_driveitem_to_document_with_permissions(
driveitem,
@@ -2135,11 +1986,8 @@ class SharepointConnector(
)
logger.info(f"Processed {item_count} items in drive '{current_drive_name}'")
if has_more_delta_pages:
return checkpoint
self._clear_drive_checkpoint_state(checkpoint)
checkpoint.current_drive_name = None
checkpoint.current_drive_web_url = None
# Phase 4: Progression logic - determine next step
# If we have more drives in current site, continue with current site

View File

@@ -32,7 +32,6 @@ from onyx.context.search.federated.slack_search_utils import should_include_mess
from onyx.context.search.models import ChunkIndexRequest
from onyx.context.search.models import InferenceChunk
from onyx.db.document import DocumentSource
from onyx.db.models import SearchSettings
from onyx.db.search_settings import get_current_search_settings
from onyx.document_index.document_index_utils import (
get_multipass_config,
@@ -906,15 +905,13 @@ def convert_slack_score(slack_score: float) -> float:
def slack_retrieval(
query: ChunkIndexRequest,
access_token: str,
db_session: Session | None = None,
db_session: Session,
connector: FederatedConnectorDetail | None = None, # noqa: ARG001
entities: dict[str, Any] | None = None,
limit: int | None = None,
slack_event_context: SlackContext | None = None,
bot_token: str | None = None, # Add bot token parameter
team_id: str | None = None,
# Pre-fetched data — when provided, avoids DB query (no session needed)
search_settings: SearchSettings | None = None,
) -> list[InferenceChunk]:
"""
Main entry point for Slack federated search with entity filtering.
@@ -928,7 +925,7 @@ def slack_retrieval(
Args:
query: Search query object
access_token: User OAuth access token
db_session: Database session (optional if search_settings provided)
db_session: Database session
connector: Federated connector detail (unused, kept for backwards compat)
entities: Connector-level config (entity filtering configuration)
limit: Maximum number of results
@@ -1156,10 +1153,7 @@ def slack_retrieval(
# chunk index docs into doc aware chunks
# a single index doc can get split into multiple chunks
if search_settings is None:
if db_session is None:
raise ValueError("Either db_session or search_settings must be provided")
search_settings = get_current_search_settings(db_session)
search_settings = get_current_search_settings(db_session)
embedder = DefaultIndexingEmbedder.from_db_search_settings(
search_settings=search_settings
)

View File

@@ -18,10 +18,8 @@ from onyx.context.search.utils import inference_section_from_chunks
from onyx.db.models import Persona
from onyx.db.models import User
from onyx.document_index.interfaces import DocumentIndex
from onyx.federated_connectors.federated_retrieval import FederatedRetrievalInfo
from onyx.llm.interfaces import LLM
from onyx.natural_language_processing.english_stopwords import strip_stopwords
from onyx.natural_language_processing.search_nlp_models import EmbeddingModel
from onyx.secondary_llm_flows.source_filter import extract_source_filter
from onyx.secondary_llm_flows.time_filter import extract_time_filter
from onyx.utils.logger import setup_logger
@@ -43,7 +41,7 @@ def _build_index_filters(
user_file_ids: list[UUID] | None,
persona_document_sets: list[str] | None,
persona_time_cutoff: datetime | None,
db_session: Session | None = None,
db_session: Session,
auto_detect_filters: bool = False,
query: str | None = None,
llm: LLM | None = None,
@@ -51,8 +49,6 @@ def _build_index_filters(
# Assistant knowledge filters
attached_document_ids: list[str] | None = None,
hierarchy_node_ids: list[int] | None = None,
# Pre-fetched ACL filters (skips DB query when provided)
acl_filters: list[str] | None = None,
) -> IndexFilters:
if auto_detect_filters and (llm is None or query is None):
raise RuntimeError("LLM and query are required for auto detect filters")
@@ -107,14 +103,9 @@ def _build_index_filters(
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:
user_acl_filters = acl_filters
else:
if db_session is None:
raise ValueError("Either db_session or acl_filters must be provided")
user_acl_filters = build_access_filters_for_user(user, db_session)
user_acl_filters = (
None if bypass_acl else build_access_filters_for_user(user, db_session)
)
final_filters = IndexFilters(
user_file_ids=user_file_ids,
@@ -261,15 +252,11 @@ def search_pipeline(
user: User,
# Used for default filters and settings
persona: Persona | None,
db_session: Session | None = None,
db_session: Session,
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,
# 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
@@ -310,7 +297,6 @@ def search_pipeline(
bypass_acl=chunk_search_request.bypass_acl,
attached_document_ids=attached_document_ids,
hierarchy_node_ids=hierarchy_node_ids,
acl_filters=acl_filters,
)
query_keywords = strip_stopwords(chunk_search_request.query)
@@ -329,8 +315,6 @@ def search_pipeline(
user_id=user.id if user else None,
document_index=document_index,
db_session=db_session,
embedding_model=embedding_model,
prefetched_federated_retrieval_infos=prefetched_federated_retrieval_infos,
)
# For some specific connectors like Salesforce, a user that has access to an object doesn't mean

View File

@@ -14,11 +14,9 @@ from onyx.context.search.utils import get_query_embedding
from onyx.context.search.utils import inference_section_from_chunks
from onyx.document_index.interfaces import DocumentIndex
from onyx.document_index.interfaces import VespaChunkRequest
from onyx.federated_connectors.federated_retrieval import FederatedRetrievalInfo
from onyx.federated_connectors.federated_retrieval import (
get_federated_retrieval_functions,
)
from onyx.natural_language_processing.search_nlp_models import EmbeddingModel
from onyx.utils.logger import setup_logger
from onyx.utils.threadpool_concurrency import run_functions_tuples_in_parallel
@@ -52,14 +50,9 @@ def combine_retrieval_results(
def _embed_and_search(
query_request: ChunkIndexRequest,
document_index: DocumentIndex,
db_session: Session | None = None,
embedding_model: EmbeddingModel | None = None,
db_session: Session,
) -> list[InferenceChunk]:
query_embedding = get_query_embedding(
query_request.query,
db_session=db_session,
embedding_model=embedding_model,
)
query_embedding = get_query_embedding(query_request.query, db_session)
hybrid_alpha = query_request.hybrid_alpha or HYBRID_ALPHA
@@ -85,9 +78,7 @@ def search_chunks(
query_request: ChunkIndexRequest,
user_id: UUID | None,
document_index: DocumentIndex,
db_session: Session | None = None,
embedding_model: EmbeddingModel | None = None,
prefetched_federated_retrieval_infos: list[FederatedRetrievalInfo] | None = None,
db_session: Session,
) -> list[InferenceChunk]:
run_queries: list[tuple[Callable, tuple]] = []
@@ -97,22 +88,14 @@ def search_chunks(
else None
)
# Federated retrieval — use pre-fetched if available, otherwise query DB
if prefetched_federated_retrieval_infos is not None:
federated_retrieval_infos = prefetched_federated_retrieval_infos
else:
if db_session is None:
raise ValueError(
"Either db_session or prefetched_federated_retrieval_infos "
"must be provided"
)
federated_retrieval_infos = get_federated_retrieval_functions(
db_session=db_session,
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 retrieval
federated_retrieval_infos = get_federated_retrieval_functions(
db_session=db_session,
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(
federated_retrieval_info.source.to_non_federated_source()
@@ -131,10 +114,7 @@ def search_chunks(
if normal_search_enabled:
run_queries.append(
(
_embed_and_search,
(query_request, document_index, db_session, embedding_model),
)
(_embed_and_search, (query_request, document_index, db_session))
)
parallel_search_results = run_functions_tuples_in_parallel(run_queries)

View File

@@ -64,34 +64,23 @@ def inference_section_from_single_chunk(
)
def get_query_embeddings(
queries: list[str],
db_session: Session | None = None,
embedding_model: EmbeddingModel | None = None,
) -> list[Embedding]:
if embedding_model is None:
if db_session is None:
raise ValueError("Either db_session or embedding_model must be provided")
search_settings = get_current_search_settings(db_session)
embedding_model = EmbeddingModel.from_db_model(
search_settings=search_settings,
server_host=MODEL_SERVER_HOST,
server_port=MODEL_SERVER_PORT,
)
def get_query_embeddings(queries: list[str], db_session: Session) -> list[Embedding]:
search_settings = get_current_search_settings(db_session)
query_embedding = embedding_model.encode(queries, text_type=EmbedTextType.QUERY)
model = EmbeddingModel.from_db_model(
search_settings=search_settings,
# The below are globally set, this flow always uses the indexing one
server_host=MODEL_SERVER_HOST,
server_port=MODEL_SERVER_PORT,
)
query_embedding = model.encode(queries, text_type=EmbedTextType.QUERY)
return query_embedding
@log_function_time(print_only=True, debug_only=True)
def get_query_embedding(
query: str,
db_session: Session | None = None,
embedding_model: EmbeddingModel | None = None,
) -> Embedding:
return get_query_embeddings(
[query], db_session=db_session, embedding_model=embedding_model
)[0]
def get_query_embedding(query: str, db_session: Session) -> Embedding:
return get_query_embeddings([query], db_session)[0]
def convert_inference_sections_to_search_docs(

View File

@@ -4,7 +4,6 @@ from fastapi_users.password import PasswordHelper
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.auth.api_key import ApiKeyDescriptor
@@ -55,7 +54,6 @@ async def fetch_user_for_api_key(
select(User)
.join(ApiKey, ApiKey.user_id == User.id)
.where(ApiKey.hashed_api_key == hashed_api_key)
.options(selectinload(User.memories))
)

View File

@@ -13,7 +13,6 @@ from sqlalchemy import func
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.auth.schemas import UserRole
@@ -98,11 +97,6 @@ async def get_user_count(only_admin_users: bool = False) -> int:
# Need to override this because FastAPI Users doesn't give flexibility for backend field creation logic in OAuth flow
class SQLAlchemyUserAdminDB(SQLAlchemyUserDatabase[UP, ID]):
async def _get_user(self, statement: Select) -> UP | None:
statement = statement.options(selectinload(User.memories))
results = await self.session.execute(statement)
return results.unique().scalar_one_or_none()
async def create(
self,
create_dict: Dict[str, Any],

View File

@@ -116,15 +116,12 @@ def get_connector_credential_pairs_for_user(
order_by_desc: bool = False,
source: DocumentSource | None = None,
processing_mode: ProcessingMode | None = ProcessingMode.REGULAR,
defer_connector_config: bool = False,
) -> list[ConnectorCredentialPair]:
"""Get connector credential pairs for a user.
Args:
processing_mode: Filter by processing mode. Defaults to REGULAR to hide
FILE_SYSTEM connectors from standard admin UI. Pass None to get all.
defer_connector_config: If True, skips loading Connector.connector_specific_config
to avoid fetching large JSONB blobs when they aren't needed.
"""
if eager_load_user:
assert (
@@ -133,10 +130,7 @@ def get_connector_credential_pairs_for_user(
stmt = select(ConnectorCredentialPair).distinct()
if eager_load_connector:
connector_load = selectinload(ConnectorCredentialPair.connector)
if defer_connector_config:
connector_load = connector_load.defer(Connector.connector_specific_config)
stmt = stmt.options(connector_load)
stmt = stmt.options(selectinload(ConnectorCredentialPair.connector))
if eager_load_credential:
load_opts = selectinload(ConnectorCredentialPair.credential)
@@ -176,7 +170,6 @@ def get_connector_credential_pairs_for_user_parallel(
order_by_desc: bool = False,
source: DocumentSource | None = None,
processing_mode: ProcessingMode | None = ProcessingMode.REGULAR,
defer_connector_config: bool = False,
) -> list[ConnectorCredentialPair]:
with get_session_with_current_tenant() as db_session:
return get_connector_credential_pairs_for_user(
@@ -190,7 +183,6 @@ def get_connector_credential_pairs_for_user_parallel(
order_by_desc=order_by_desc,
source=source,
processing_mode=processing_mode,
defer_connector_config=defer_connector_config,
)

View File

@@ -554,19 +554,10 @@ def fetch_all_document_sets_for_user(
stmt = (
select(DocumentSetDBModel)
.distinct()
.options(
selectinload(DocumentSetDBModel.connector_credential_pairs).selectinload(
ConnectorCredentialPair.connector
),
selectinload(DocumentSetDBModel.users),
selectinload(DocumentSetDBModel.groups),
selectinload(DocumentSetDBModel.federated_connectors).selectinload(
FederatedConnector__DocumentSet.federated_connector
),
)
.options(selectinload(DocumentSetDBModel.federated_connectors))
)
stmt = _add_user_filters(stmt, user, get_editable=get_editable)
return db_session.scalars(stmt).unique().all()
return db_session.scalars(stmt).all()
def fetch_documents_for_document_set_paginated(

View File

@@ -287,7 +287,7 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
# relationships
credentials: Mapped[list["Credential"]] = relationship(
"Credential", back_populates="user"
"Credential", back_populates="user", lazy="joined"
)
chat_sessions: Mapped[list["ChatSession"]] = relationship(
"ChatSession", back_populates="user"
@@ -321,6 +321,7 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
"Memory",
back_populates="user",
cascade="all, delete-orphan",
lazy="selectin",
order_by="desc(Memory.id)",
)
oauth_user_tokens: Mapped[list["OAuthUserToken"]] = relationship(
@@ -4978,12 +4979,3 @@ class ScimGroupMapping(Base):
user_group: Mapped[UserGroup] = relationship(
"UserGroup", foreign_keys=[user_group_id]
)
class CodeInterpreterServer(Base):
"""Details about the code interpreter server"""
__tablename__ = "code_interpreter_server"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
server_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)

View File

@@ -8,7 +8,6 @@ from uuid import UUID
from sqlalchemy import select
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.auth.pat import build_displayable_pat
@@ -32,59 +31,53 @@ async def fetch_user_for_pat(
NOTE: This is async since it's used during auth (which is necessarily async due to FastAPI Users).
NOTE: Expired includes both naturally expired and user-revoked tokens (revocation sets expires_at=NOW()).
Uses select(User) as primary entity so that joined-eager relationships (e.g. oauth_accounts)
are loaded correctly — matching the pattern in fetch_user_for_api_key.
"""
# Single joined query with all filters pushed to database
now = datetime.now(timezone.utc)
user = await async_db_session.scalar(
select(User)
.join(PersonalAccessToken, PersonalAccessToken.user_id == User.id)
result = await async_db_session.execute(
select(PersonalAccessToken, User)
.join(User, PersonalAccessToken.user_id == User.id)
.where(PersonalAccessToken.hashed_token == hashed_token)
.where(User.is_active) # type: ignore
.where(
(PersonalAccessToken.expires_at.is_(None))
| (PersonalAccessToken.expires_at > now)
)
.options(selectinload(User.memories))
.limit(1)
)
if not user:
row = result.first()
if not row:
return None
_schedule_pat_last_used_update(hashed_token, now)
return user
pat, user = row
# Throttle last_used_at updates to reduce DB load (5-minute granularity sufficient for auditing)
# For request-level auditing, use application logs or a dedicated audit table
should_update = (
pat.last_used_at is None or (now - pat.last_used_at).total_seconds() > 300
)
def _schedule_pat_last_used_update(hashed_token: str, now: datetime) -> None:
"""Fire-and-forget update of last_used_at, throttled to 5-minute granularity."""
async def _update() -> None:
try:
tenant_id = get_current_tenant_id()
async with get_async_session_context_manager(tenant_id) as session:
pat = await session.scalar(
select(PersonalAccessToken).where(
PersonalAccessToken.hashed_token == hashed_token
if should_update:
# Update in separate session to avoid transaction coupling (fire-and-forget)
async def _update_last_used() -> None:
try:
tenant_id = get_current_tenant_id()
async with get_async_session_context_manager(
tenant_id
) as separate_session:
await separate_session.execute(
update(PersonalAccessToken)
.where(PersonalAccessToken.hashed_token == hashed_token)
.values(last_used_at=now)
)
)
if not pat:
return
if (
pat.last_used_at is not None
and (now - pat.last_used_at).total_seconds() <= 300
):
return
await session.execute(
update(PersonalAccessToken)
.where(PersonalAccessToken.hashed_token == hashed_token)
.values(last_used_at=now)
)
await session.commit()
except Exception as e:
logger.warning(f"Failed to update last_used_at for PAT: {e}")
await separate_session.commit()
except Exception as e:
logger.warning(f"Failed to update last_used_at for PAT: {e}")
asyncio.create_task(_update())
asyncio.create_task(_update_last_used())
return user
def create_pat(

View File

@@ -28,7 +28,6 @@ from onyx.db.document_access import get_accessible_documents_by_ids
from onyx.db.models import ConnectorCredentialPair
from onyx.db.models import Document
from onyx.db.models import DocumentSet
from onyx.db.models import FederatedConnector__DocumentSet
from onyx.db.models import HierarchyNode
from onyx.db.models import Persona
from onyx.db.models import Persona__User
@@ -421,16 +420,9 @@ def get_minimal_persona_snapshots_for_user(
stmt = stmt.options(
selectinload(Persona.tools),
selectinload(Persona.labels),
selectinload(Persona.document_sets).options(
selectinload(DocumentSet.connector_credential_pairs).selectinload(
ConnectorCredentialPair.connector
),
selectinload(DocumentSet.users),
selectinload(DocumentSet.groups),
selectinload(DocumentSet.federated_connectors).selectinload(
FederatedConnector__DocumentSet.federated_connector
),
),
selectinload(Persona.document_sets)
.selectinload(DocumentSet.connector_credential_pairs)
.selectinload(ConnectorCredentialPair.connector),
selectinload(Persona.hierarchy_nodes),
selectinload(Persona.attached_documents).selectinload(
Document.parent_hierarchy_node
@@ -461,16 +453,7 @@ def get_persona_snapshots_for_user(
Document.parent_hierarchy_node
),
selectinload(Persona.labels),
selectinload(Persona.document_sets).options(
selectinload(DocumentSet.connector_credential_pairs).selectinload(
ConnectorCredentialPair.connector
),
selectinload(DocumentSet.users),
selectinload(DocumentSet.groups),
selectinload(DocumentSet.federated_connectors).selectinload(
FederatedConnector__DocumentSet.federated_connector
),
),
selectinload(Persona.document_sets),
selectinload(Persona.user),
selectinload(Persona.user_files),
selectinload(Persona.users),
@@ -567,16 +550,9 @@ def get_minimal_persona_snapshots_paginated(
Document.parent_hierarchy_node
),
selectinload(Persona.labels),
selectinload(Persona.document_sets).options(
selectinload(DocumentSet.connector_credential_pairs).selectinload(
ConnectorCredentialPair.connector
),
selectinload(DocumentSet.users),
selectinload(DocumentSet.groups),
selectinload(DocumentSet.federated_connectors).selectinload(
FederatedConnector__DocumentSet.federated_connector
),
),
selectinload(Persona.document_sets)
.selectinload(DocumentSet.connector_credential_pairs)
.selectinload(ConnectorCredentialPair.connector),
selectinload(Persona.user),
)
@@ -635,16 +611,7 @@ def get_persona_snapshots_paginated(
Document.parent_hierarchy_node
),
selectinload(Persona.labels),
selectinload(Persona.document_sets).options(
selectinload(DocumentSet.connector_credential_pairs).selectinload(
ConnectorCredentialPair.connector
),
selectinload(DocumentSet.users),
selectinload(DocumentSet.groups),
selectinload(DocumentSet.federated_connectors).selectinload(
FederatedConnector__DocumentSet.federated_connector
),
),
selectinload(Persona.document_sets),
selectinload(Persona.user),
selectinload(Persona.user_files),
selectinload(Persona.users),

View File

@@ -1,7 +1,5 @@
import json
import pathlib
import threading
import time
from onyx.llm.constants import LlmProviderNames
from onyx.llm.constants import PROVIDER_DISPLAY_NAMES
@@ -25,11 +23,6 @@ from onyx.utils.logger import setup_logger
logger = setup_logger()
_RECOMMENDATIONS_CACHE_TTL_SECONDS = 300
_recommendations_cache_lock = threading.Lock()
_cached_recommendations: LLMRecommendations | None = None
_cached_recommendations_time: float = 0.0
def _get_provider_to_models_map() -> dict[str, list[str]]:
"""Lazy-load provider model mappings to avoid importing litellm at module level.
@@ -48,40 +41,19 @@ def _get_provider_to_models_map() -> dict[str, list[str]]:
}
def _load_bundled_recommendations() -> LLMRecommendations:
def get_recommendations() -> LLMRecommendations:
"""Get the recommendations from the GitHub config."""
recommendations_from_github = fetch_llm_recommendations_from_github()
if recommendations_from_github:
return recommendations_from_github
# Fall back to json bundled with code
json_path = pathlib.Path(__file__).parent / "recommended-models.json"
with open(json_path, "r") as f:
json_config = json.load(f)
return LLMRecommendations.model_validate(json_config)
def get_recommendations() -> LLMRecommendations:
"""Get the recommendations, with an in-memory cache to avoid
hitting GitHub on every API request."""
global _cached_recommendations, _cached_recommendations_time
now = time.monotonic()
if (
_cached_recommendations is not None
and (now - _cached_recommendations_time) < _RECOMMENDATIONS_CACHE_TTL_SECONDS
):
return _cached_recommendations
with _recommendations_cache_lock:
# Double-check after acquiring lock
if (
_cached_recommendations is not None
and (time.monotonic() - _cached_recommendations_time)
< _RECOMMENDATIONS_CACHE_TTL_SECONDS
):
return _cached_recommendations
recommendations_from_github = fetch_llm_recommendations_from_github()
result = recommendations_from_github or _load_bundled_recommendations()
_cached_recommendations = result
_cached_recommendations_time = time.monotonic()
return result
recommendations_from_json = LLMRecommendations.model_validate(json_config)
return recommendations_from_json
def is_obsolete_model(model_name: str, provider: str) -> bool:

View File

@@ -1,6 +1,6 @@
# ruff: noqa: E501, W605 start
# If there are any tools, this section is included, the sections below are for the available tools
TOOL_SECTION_HEADER = "\n# Tools\n\n"
TOOL_SECTION_HEADER = "\n\n# Tools\n"
# This section is included if there are search type tools, currently internal_search and web_search
@@ -16,10 +16,11 @@ When searching for information, if the initial results cannot fully answer the u
Do not repeat the same or very similar queries if it already has been run in the chat history.
If it is unclear which tool to use, consider using multiple in parallel to be efficient with time.
""".lstrip()
"""
INTERNAL_SEARCH_GUIDANCE = """
## internal_search
Use the `internal_search` tool to search connected applications for information. Some examples of when to use `internal_search` include:
- Internal information: any time where there may be some information stored in internal applications that could help better answer the query.
@@ -27,31 +28,34 @@ Use the `internal_search` tool to search connected applications for information.
- Keyword Queries: queries that are heavily keyword based are often internal document search queries.
- Ambiguity: questions about something that is not widely known or understood.
Never provide more than 3 queries at once to `internal_search`.
""".lstrip()
"""
WEB_SEARCH_GUIDANCE = """
## web_search
Use the `web_search` tool to access up-to-date information from the web. Some examples of when to use `web_search` include:
- Freshness: when the answer might be enhanced by up-to-date information on a topic. Very important for topics that are changing or evolving.
- Accuracy: if the cost of outdated/inaccurate information is high.
- Niche Information: when detailed info is not widely known or understood (but is likely found on the internet).{site_colon_disabled}
""".lstrip()
"""
WEB_SEARCH_SITE_DISABLED_GUIDANCE = """
Do not use the "site:" operator in your web search queries.
""".lstrip()
""".rstrip()
OPEN_URLS_GUIDANCE = """
## open_url
Use the `open_url` tool to read the content of one or more URLs. Use this tool to access the contents of the most promising web pages from your web searches or user specified URLs. \
You can open many URLs at once by passing multiple URLs in the array if multiple pages seem promising. Prioritize the most promising pages and reputable sources. \
Do not open URLs that are image files like .png, .jpg, etc.
You should almost always use open_url after a web_search call. Use this tool when a user asks about a specific provided URL.
""".lstrip()
"""
PYTHON_TOOL_GUIDANCE = """
## python
Use the `python` tool to execute Python code in an isolated sandbox. The tool will respond with the output of the execution or time out after 60.0 seconds.
Any files uploaded to the chat will be automatically be available in the execution environment's current directory. \
@@ -60,21 +64,23 @@ Use this to give the user a way to download the file OR to display generated ima
Internet access for this session is disabled. Do not make external web requests or API calls as they will fail.
Use `openpyxl` to read and write Excel files. You have access to libraries like numpy, pandas, scipy, matplotlib, and PIL.
IMPORTANT: each call to this tool is independent. Variables from previous calls will NOT be available in the current call.
""".lstrip()
"""
GENERATE_IMAGE_GUIDANCE = """
## generate_image
NEVER use generate_image unless the user specifically requests an image.
For edits/variations of a previously generated image, pass `reference_image_file_ids` with
the `file_id` values returned by earlier `generate_image` tool results.
""".lstrip()
"""
MEMORY_GUIDANCE = """
## add_memory
Use the `add_memory` tool for facts shared by the user that should be remembered for future conversations. \
Only add memories that are specific, likely to remain true, and likely to be useful later. \
Focus on enduring preferences, long-term goals, stable constraints, and explicit "remember this" type requests.
""".lstrip()
"""
TOOL_CALL_FAILURE_PROMPT = """
LLM attempted to call a tool but failed. Most likely the tool name or arguments were misspelled.

View File

@@ -1,36 +1,40 @@
# ruff: noqa: E501, W605 start
USER_INFORMATION_HEADER = "\n# User Information\n\n"
USER_INFORMATION_HEADER = "\n\n# User Information\n"
BASIC_INFORMATION_PROMPT = """
## Basic Information
User name: {user_name}
User email: {user_email}{user_role}
""".lstrip()
"""
# This line only shows up if the user has configured their role.
USER_ROLE_PROMPT = """
User role: {user_role}
""".lstrip()
"""
# Team information should be a paragraph style description of the user's team.
TEAM_INFORMATION_PROMPT = """
## Team Information
{team_information}
""".lstrip()
"""
# User preferences should be a paragraph style description of the user's preferences.
USER_PREFERENCES_PROMPT = """
## User Preferences
{user_preferences}
""".lstrip()
"""
# User memories should look something like:
# - Memory 1
# - Memory 2
# - Memory 3
USER_MEMORIES_PROMPT = """
## User Memories
{user_memories}
""".lstrip()
"""
# ruff: noqa: E501, W605 end

View File

@@ -988,7 +988,6 @@ def get_connector_status(
user=user,
eager_load_connector=True,
eager_load_credential=True,
eager_load_user=True,
get_editable=False,
)
@@ -1002,23 +1001,11 @@ def get_connector_status(
relationship.user_group_id
)
# Pre-compute credential_ids per connector to avoid N+1 lazy loads
connector_to_credential_ids: dict[int, list[int]] = {}
for cc_pair in cc_pairs:
connector_to_credential_ids.setdefault(cc_pair.connector_id, []).append(
cc_pair.credential_id
)
return [
ConnectorStatus(
cc_pair_id=cc_pair.id,
name=cc_pair.name,
connector=ConnectorSnapshot.from_connector_db_model(
cc_pair.connector,
credential_ids=connector_to_credential_ids.get(
cc_pair.connector_id, []
),
),
connector=ConnectorSnapshot.from_connector_db_model(cc_pair.connector),
credential=CredentialSnapshot.from_credential_db_model(cc_pair.credential),
access_type=cc_pair.access_type,
groups=group_cc_pair_relationships_dict.get(cc_pair.id, []),
@@ -1073,27 +1060,15 @@ def get_connector_indexing_status(
parallel_functions: list[tuple[CallableProtocol, tuple[Any, ...]]] = [
# Get editable connector/credential pairs
(
lambda: get_connector_credential_pairs_for_user_parallel(
user, True, None, True, True, False, True, request.source
),
(),
get_connector_credential_pairs_for_user_parallel,
(user, True, None, True, True, True, True, request.source),
),
# Get federated connectors
(fetch_all_federated_connectors_parallel, ()),
# Get most recent index attempts
(
lambda: get_latest_index_attempts_parallel(
request.secondary_index, True, False
),
(),
),
(get_latest_index_attempts_parallel, (request.secondary_index, True, False)),
# Get most recent finished index attempts
(
lambda: get_latest_index_attempts_parallel(
request.secondary_index, True, True
),
(),
),
(get_latest_index_attempts_parallel, (request.secondary_index, True, True)),
]
if user and user.role == UserRole.ADMIN:
@@ -1110,10 +1085,8 @@ def get_connector_indexing_status(
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
),
(),
get_connector_credential_pairs_for_user_parallel,
(user, False, None, True, True, True, True, request.source),
),
)
@@ -1939,7 +1912,6 @@ Tenant ID: {tenant_id}
class BasicCCPairInfo(BaseModel):
has_successful_run: bool
source: DocumentSource
status: ConnectorCredentialPairStatus
@router.get("/connector-status", tags=PUBLIC_API_TAGS)
@@ -1959,7 +1931,6 @@ def get_basic_connector_indexing_status(
BasicCCPairInfo(
has_successful_run=cc_pair.last_successful_index_time is not None,
source=cc_pair.connector.source,
status=cc_pair.status,
)
for cc_pair in cc_pairs
if cc_pair.connector.source != DocumentSource.INGESTION_API

View File

@@ -365,8 +365,7 @@ class CCPairFullInfo(BaseModel):
in_repeated_error_state=cc_pair_model.in_repeated_error_state,
num_docs_indexed=num_docs_indexed,
connector=ConnectorSnapshot.from_connector_db_model(
cc_pair_model.connector,
credential_ids=[cc_pair_model.credential_id],
cc_pair_model.connector
),
credential=CredentialSnapshot.from_credential_db_model(
cc_pair_model.credential

View File

@@ -111,8 +111,7 @@ class DocumentSet(BaseModel):
id=cc_pair.id,
name=cc_pair.name,
connector=ConnectorSnapshot.from_connector_db_model(
cc_pair.connector,
credential_ids=[cc_pair.credential_id],
cc_pair.connector
),
credential=CredentialSnapshot.from_credential_db_model(
cc_pair.credential

View File

@@ -57,7 +57,6 @@ class Settings(BaseModel):
anonymous_user_enabled: bool | None = None
invite_only_enabled: bool = False
deep_research_enabled: bool | None = None
search_ui_enabled: bool | None = None
# Enterprise features flag - set by license enforcement at runtime
# When LICENSE_ENFORCEMENT_ENABLED=true, this reflects license status

View File

@@ -171,8 +171,10 @@ def construct_tools(
if not search_tool_config:
search_tool_config = SearchToolConfig()
# TODO concerning passing the db_session here.
search_tool = SearchTool(
tool_id=db_tool_model.id,
db_session=db_session,
emitter=emitter,
user=user,
persona=persona,
@@ -420,6 +422,7 @@ def construct_tools(
search_tool = SearchTool(
tool_id=search_tool_db_model.id,
db_session=db_session,
emitter=emitter,
user=user,
persona=persona,

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor
from onyx.file_processing.html_utils import ParsedHTML
from onyx.file_processing.html_utils import web_html_cleanup
@@ -22,22 +21,10 @@ from onyx.utils.web_content import title_from_url
logger = setup_logger()
DEFAULT_READ_TIMEOUT_SECONDS = 15
DEFAULT_CONNECT_TIMEOUT_SECONDS = 5
DEFAULT_TIMEOUT_SECONDS = 15
DEFAULT_USER_AGENT = "OnyxWebCrawler/1.0 (+https://www.onyx.app)"
DEFAULT_MAX_PDF_SIZE_BYTES = 50 * 1024 * 1024 # 50 MB
DEFAULT_MAX_HTML_SIZE_BYTES = 20 * 1024 * 1024 # 20 MB
DEFAULT_MAX_WORKERS = 5
def _failed_result(url: str) -> WebContent:
return WebContent(
title="",
link=url,
full_content="",
published_date=None,
scrape_successful=False,
)
class OnyxWebCrawler(WebContentProvider):
@@ -50,14 +37,12 @@ class OnyxWebCrawler(WebContentProvider):
def __init__(
self,
*,
timeout_seconds: int = DEFAULT_READ_TIMEOUT_SECONDS,
connect_timeout_seconds: int = DEFAULT_CONNECT_TIMEOUT_SECONDS,
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
user_agent: str = DEFAULT_USER_AGENT,
max_pdf_size_bytes: int | None = None,
max_html_size_bytes: int | None = None,
) -> None:
self._read_timeout_seconds = timeout_seconds
self._connect_timeout_seconds = connect_timeout_seconds
self._timeout_seconds = timeout_seconds
self._max_pdf_size_bytes = max_pdf_size_bytes
self._max_html_size_bytes = max_html_size_bytes
self._headers = {
@@ -66,68 +51,75 @@ class OnyxWebCrawler(WebContentProvider):
}
def contents(self, urls: Sequence[str]) -> list[WebContent]:
if not urls:
return []
max_workers = min(DEFAULT_MAX_WORKERS, len(urls))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
return list(executor.map(self._fetch_url_safe, urls))
def _fetch_url_safe(self, url: str) -> WebContent:
"""Wrapper that catches all exceptions so one bad URL doesn't kill the batch."""
try:
return self._fetch_url(url)
except Exception as exc:
logger.warning(
"Onyx crawler unexpected error for %s (%s)",
url,
exc.__class__.__name__,
)
return _failed_result(url)
results: list[WebContent] = []
for url in urls:
results.append(self._fetch_url(url))
return results
def _fetch_url(self, url: str) -> WebContent:
try:
# Use SSRF-safe request to prevent DNS rebinding attacks
response = ssrf_safe_get(
url,
headers=self._headers,
timeout=(self._connect_timeout_seconds, self._read_timeout_seconds),
url, headers=self._headers, timeout=self._timeout_seconds
)
except SSRFException as exc:
logger.error(
"SSRF protection blocked request to %s (%s)",
"SSRF protection blocked request to %s: %s",
url,
exc.__class__.__name__,
str(exc),
)
return _failed_result(url)
except Exception as exc:
return WebContent(
title="",
link=url,
full_content="",
published_date=None,
scrape_successful=False,
)
except Exception as exc: # pragma: no cover - network failures vary
logger.warning(
"Onyx crawler failed to fetch %s (%s)",
url,
exc.__class__.__name__,
)
return _failed_result(url)
return WebContent(
title="",
link=url,
full_content="",
published_date=None,
scrape_successful=False,
)
if response.status_code >= 400:
logger.warning("Onyx crawler received %s for %s", response.status_code, url)
return _failed_result(url)
return WebContent(
title="",
link=url,
full_content="",
published_date=None,
scrape_successful=False,
)
content_type = response.headers.get("Content-Type", "")
content = response.content
content_sniff = content[:1024] if content else None
content_sniff = response.content[:1024] if response.content else None
if is_pdf_resource(url, content_type, content_sniff):
if (
self._max_pdf_size_bytes is not None
and len(content) > self._max_pdf_size_bytes
and len(response.content) > self._max_pdf_size_bytes
):
logger.warning(
"PDF content too large (%d bytes) for %s, max is %d",
len(content),
len(response.content),
url,
self._max_pdf_size_bytes,
)
return _failed_result(url)
text_content, metadata = extract_pdf_text(content)
return WebContent(
title="",
link=url,
full_content="",
published_date=None,
scrape_successful=False,
)
text_content, metadata = extract_pdf_text(response.content)
title = title_from_pdf_metadata(metadata) or title_from_url(url)
return WebContent(
title=title,
@@ -139,19 +131,25 @@ class OnyxWebCrawler(WebContentProvider):
if (
self._max_html_size_bytes is not None
and len(content) > self._max_html_size_bytes
and len(response.content) > self._max_html_size_bytes
):
logger.warning(
"HTML content too large (%d bytes) for %s, max is %d",
len(content),
len(response.content),
url,
self._max_html_size_bytes,
)
return _failed_result(url)
return WebContent(
title="",
link=url,
full_content="",
published_date=None,
scrape_successful=False,
)
try:
decoded_html = decode_html_bytes(
content,
response.content,
content_type=content_type,
fallback_encoding=response.apparent_encoding or response.encoding,
)

File diff suppressed because it is too large Load Diff

View File

@@ -146,7 +146,7 @@ MAX_REDIRECTS = 10
def _make_ssrf_safe_request(
url: str,
headers: dict[str, str] | None = None,
timeout: float | tuple[float, float] = 15,
timeout: int = 15,
**kwargs: Any,
) -> requests.Response:
"""
@@ -204,7 +204,7 @@ def _make_ssrf_safe_request(
def ssrf_safe_get(
url: str,
headers: dict[str, str] | None = None,
timeout: float | tuple[float, float] = 15,
timeout: int = 15,
follow_redirects: bool = True,
**kwargs: Any,
) -> requests.Response:

View File

@@ -3,8 +3,8 @@ set -e
cleanup() {
echo "Error occurred. Cleaning up..."
docker stop onyx_postgres onyx_vespa onyx_redis onyx_minio onyx_code_interpreter 2>/dev/null || true
docker rm onyx_postgres onyx_vespa onyx_redis onyx_minio onyx_code_interpreter 2>/dev/null || true
docker stop onyx_postgres onyx_vespa onyx_redis onyx_minio 2>/dev/null || true
docker rm onyx_postgres onyx_vespa onyx_redis onyx_minio 2>/dev/null || true
}
# Trap errors and output a message, then cleanup
@@ -20,8 +20,8 @@ MINIO_VOLUME=${4:-""} # Default is empty if not provided
# Stop and remove the existing containers
echo "Stopping and removing existing containers..."
docker stop onyx_postgres onyx_vespa onyx_redis onyx_minio onyx_code_interpreter 2>/dev/null || true
docker rm onyx_postgres onyx_vespa onyx_redis onyx_minio onyx_code_interpreter 2>/dev/null || true
docker stop onyx_postgres onyx_vespa onyx_redis onyx_minio 2>/dev/null || true
docker rm onyx_postgres onyx_vespa onyx_redis onyx_minio 2>/dev/null || true
# Start the PostgreSQL container with optional volume
echo "Starting PostgreSQL container..."
@@ -55,10 +55,6 @@ else
docker run --detach --name onyx_minio --publish 9004:9000 --publish 9005:9001 -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin minio/minio server /data --console-address ":9001"
fi
# Start the Code Interpreter container
echo "Starting Code Interpreter container..."
docker run --detach --name onyx_code_interpreter --publish 8000:8000 --user root -v /var/run/docker.sock:/var/run/docker.sock onyxdotapp/code-interpreter:latest bash ./entrypoint.sh code-interpreter-api
# Ensure alembic runs in the correct directory (backend/)
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
PARENT_DIR="$(dirname "$SCRIPT_DIR")"

View File

@@ -243,12 +243,12 @@ USAGE_LIMIT_CHUNKS_INDEXED_PAID = int(
)
# Per-week API calls using API keys or Personal Access Tokens
USAGE_LIMIT_API_CALLS_TRIAL = int(os.environ.get("USAGE_LIMIT_API_CALLS_TRIAL", "0"))
USAGE_LIMIT_API_CALLS_TRIAL = int(os.environ.get("USAGE_LIMIT_API_CALLS_TRIAL", "400"))
USAGE_LIMIT_API_CALLS_PAID = int(os.environ.get("USAGE_LIMIT_API_CALLS_PAID", "40000"))
# Per-week non-streaming API calls (more expensive, so lower limits)
USAGE_LIMIT_NON_STREAMING_CALLS_TRIAL = int(
os.environ.get("USAGE_LIMIT_NON_STREAMING_CALLS_TRIAL", "0")
os.environ.get("USAGE_LIMIT_NON_STREAMING_CALLS_TRIAL", "80")
)
USAGE_LIMIT_NON_STREAMING_CALLS_PAID = int(
os.environ.get("USAGE_LIMIT_NON_STREAMING_CALLS_PAID", "160")

View File

@@ -2,7 +2,6 @@ from collections.abc import Callable
from collections.abc import Generator
from contextlib import contextmanager
from typing import Any
from unittest.mock import MagicMock
from unittest.mock import patch
from pydantic import BaseModel
@@ -13,13 +12,9 @@ from onyx.context.search.models import ChunkSearchRequest
from onyx.context.search.models import InferenceChunk
from onyx.context.search.models import SearchDoc
from onyx.db.models import Persona
from onyx.db.models import SearchSettings
from onyx.db.models import User
from onyx.document_index.interfaces import DocumentIndex
from onyx.federated_connectors.federated_retrieval import FederatedRetrievalInfo
from onyx.llm.interfaces import LLM
from onyx.natural_language_processing.search_nlp_models import EmbeddingModel
from onyx.tools.tool_implementations.search.search_tool import SearchTool
def run_functions_tuples_sequential(
@@ -140,25 +135,13 @@ def use_mock_search_pipeline(
document_index: DocumentIndex, # noqa: ARG001
user: User | None, # noqa: ARG001
persona: Persona | None, # noqa: ARG001
db_session: Session | None = None, # noqa: ARG001
db_session: Session, # noqa: ARG001
auto_detect_filters: bool = False, # noqa: ARG001
llm: LLM | None = None, # noqa: ARG001
project_id: int | None = None, # noqa: ARG001
# Pre-fetched data (used by SearchTool to avoid DB access in parallel)
acl_filters: list[str] | None = None, # noqa: ARG001
embedding_model: EmbeddingModel | None = None, # noqa: ARG001
prefetched_federated_retrieval_infos: ( # noqa: ARG001
list[FederatedRetrievalInfo] | None
) = None,
) -> list[InferenceChunk]:
return controller.get_search_results(chunk_search_request.query)
# Mock the pre-fetch session and DB queries in SearchTool.run() so
# tests don't need a fully initialised DB with search settings.
@contextmanager
def mock_get_session() -> Generator[MagicMock, None, None]:
yield MagicMock(spec=Session)
with (
patch(
"onyx.tools.tool_implementations.search.search_tool.search_pipeline",
@@ -200,31 +183,5 @@ def use_mock_search_pipeline(
"onyx.db.connector.fetch_unique_document_sources",
new=mock_fetch_unique_document_sources,
),
# Mock the pre-fetch phase of SearchTool.run()
patch(
"onyx.tools.tool_implementations.search.search_tool.get_session_with_current_tenant",
new=mock_get_session,
),
patch(
"onyx.tools.tool_implementations.search.search_tool.build_access_filters_for_user",
return_value=[],
),
patch(
"onyx.tools.tool_implementations.search.search_tool.get_current_search_settings",
return_value=MagicMock(spec=SearchSettings),
),
patch(
"onyx.tools.tool_implementations.search.search_tool.EmbeddingModel.from_db_model",
return_value=MagicMock(spec=EmbeddingModel),
),
patch(
"onyx.tools.tool_implementations.search.search_tool.get_federated_retrieval_functions",
return_value=[],
),
patch.object(
SearchTool,
"_prefetch_slack_data",
return_value=(None, None, {}),
),
):
yield controller

View File

@@ -1,459 +0,0 @@
"""Tests for per-page delta checkpointing in the SharePoint connector (P1-1).
Validates that:
- Delta drives process one page per _load_from_checkpoint call
- Checkpoints persist the delta next_link for resumption
- Crash + resume skips already-processed pages
- BFS (folder-scoped) drives process all items in one call
- 410 Gone triggers a full-resync URL in the checkpoint
"""
from __future__ import annotations
from collections import deque
from collections.abc import Generator
from datetime import datetime
from datetime import timezone
from typing import Any
import pytest
from onyx.connectors.models import ConnectorFailure
from onyx.connectors.models import Document
from onyx.connectors.models import DocumentSource
from onyx.connectors.models import TextSection
from onyx.connectors.sharepoint.connector import DriveItemData
from onyx.connectors.sharepoint.connector import SharepointConnector
from onyx.connectors.sharepoint.connector import SharepointConnectorCheckpoint
from onyx.connectors.sharepoint.connector import SiteDescriptor
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SITE_URL = "https://example.sharepoint.com/sites/sample"
DRIVE_WEB_URL = f"{SITE_URL}/Shared Documents"
DRIVE_ID = "fake-drive-id"
# Use a start time in the future so delta URLs include a timestamp token
_START_TS = datetime(2025, 6, 1, tzinfo=timezone.utc).timestamp()
_END_TS = datetime(2026, 1, 1, tzinfo=timezone.utc).timestamp()
# For BFS tests we use epoch so no token is generated
_EPOCH_START: float = 0.0
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_item(item_id: str, name: str = "doc.pdf") -> DriveItemData:
return DriveItemData(
id=item_id,
name=name,
web_url=f"{SITE_URL}/{name}",
parent_reference_path="/drives/d1/root:",
drive_id=DRIVE_ID,
)
def _make_document(item: DriveItemData) -> Document:
return Document(
id=item.id,
source=DocumentSource.SHAREPOINT,
semantic_identifier=item.name,
metadata={},
sections=[TextSection(link=item.web_url, text="content")],
)
def _consume_generator(
gen: Generator[Any, None, SharepointConnectorCheckpoint],
) -> tuple[list[Any], SharepointConnectorCheckpoint]:
"""Exhaust a _load_from_checkpoint generator.
Returns (yielded_items, returned_checkpoint).
"""
yielded: list[Any] = []
try:
while True:
yielded.append(next(gen))
except StopIteration as e:
return yielded, e.value
def _docs_from(yielded: list[Any]) -> list[Document]:
return [y for y in yielded if isinstance(y, Document)]
def _failures_from(yielded: list[Any]) -> list[ConnectorFailure]:
return [y for y in yielded if isinstance(y, ConnectorFailure)]
def _build_ready_checkpoint(
drive_names: list[str] | None = None,
folder_path: str | None = None,
) -> SharepointConnectorCheckpoint:
"""Checkpoint ready for Phase 3 (sites initialised, drives queued)."""
cp = SharepointConnectorCheckpoint(has_more=True)
cp.cached_site_descriptors = deque()
cp.current_site_descriptor = SiteDescriptor(
url=SITE_URL,
drive_name=None,
folder_path=folder_path,
)
cp.cached_drive_names = deque(drive_names or ["Documents"])
cp.process_site_pages = False
return cp
def _setup_connector(monkeypatch: pytest.MonkeyPatch) -> SharepointConnector:
"""Create a connector with common methods mocked."""
connector = SharepointConnector()
connector._graph_client = object()
connector.include_site_pages = False
def fake_resolve_drive(
self: SharepointConnector, # noqa: ARG001
site_descriptor: SiteDescriptor, # noqa: ARG001
drive_name: str, # noqa: ARG001
) -> tuple[str, str | None]:
return (DRIVE_ID, DRIVE_WEB_URL)
def fake_get_access_token(self: SharepointConnector) -> str: # noqa: ARG001
return "fake-access-token"
monkeypatch.setattr(SharepointConnector, "_resolve_drive", fake_resolve_drive)
monkeypatch.setattr(
SharepointConnector, "_get_graph_access_token", fake_get_access_token
)
return connector
def _mock_convert(monkeypatch: pytest.MonkeyPatch) -> None:
"""Replace _convert_driveitem_to_document_with_permissions with a trivial stub."""
def fake_convert(
driveitem: DriveItemData,
drive_name: str, # noqa: ARG001
ctx: Any = None, # noqa: ARG001
graph_client: Any = None, # noqa: ARG001
graph_api_base: str = "", # noqa: ARG001
include_permissions: bool = False, # noqa: ARG001
parent_hierarchy_raw_node_id: str | None = None, # noqa: ARG001
access_token: str | None = None, # noqa: ARG001
) -> Document:
return _make_document(driveitem)
monkeypatch.setattr(
"onyx.connectors.sharepoint.connector"
"._convert_driveitem_to_document_with_permissions",
fake_convert,
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestDeltaPerPageCheckpointing:
"""Delta (non-folder-scoped) drives should process one API page per
_load_from_checkpoint call, persisting the next-link in between."""
def test_processes_one_page_per_cycle(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
connector = _setup_connector(monkeypatch)
_mock_convert(monkeypatch)
items_p1 = [_make_item("a"), _make_item("b")]
items_p2 = [_make_item("c")]
items_p3 = [_make_item("d"), _make_item("e")]
call_count = 0
def fake_fetch_page(
self: SharepointConnector, # noqa: ARG001
page_url: str, # noqa: ARG001
drive_id: str, # noqa: ARG001
start: datetime | None = None, # noqa: ARG001
end: datetime | None = None, # noqa: ARG001
page_size: int = 200, # noqa: ARG001
) -> tuple[list[DriveItemData], str | None]:
nonlocal call_count
call_count += 1
if call_count == 1:
return items_p1, "https://graph.microsoft.com/next2"
if call_count == 2:
return items_p2, "https://graph.microsoft.com/next3"
return items_p3, None
monkeypatch.setattr(
SharepointConnector, "_fetch_one_delta_page", fake_fetch_page
)
checkpoint = _build_ready_checkpoint()
# Call 1: Phase 3a inits drive, Phase 3b processes page 1
gen = connector._load_from_checkpoint(
_START_TS, _END_TS, checkpoint, include_permissions=False
)
yielded, checkpoint = _consume_generator(gen)
assert len(_docs_from(yielded)) == 2
assert (
checkpoint.current_drive_delta_next_link
== "https://graph.microsoft.com/next2"
)
assert checkpoint.current_drive_id == DRIVE_ID
assert checkpoint.has_more is True
# Call 2: Phase 3b processes page 2
gen = connector._load_from_checkpoint(
_START_TS, _END_TS, checkpoint, include_permissions=False
)
yielded, checkpoint = _consume_generator(gen)
assert len(_docs_from(yielded)) == 1
assert (
checkpoint.current_drive_delta_next_link
== "https://graph.microsoft.com/next3"
)
# Call 3: Phase 3b processes page 3 (last)
gen = connector._load_from_checkpoint(
_START_TS, _END_TS, checkpoint, include_permissions=False
)
yielded, checkpoint = _consume_generator(gen)
assert len(_docs_from(yielded)) == 2
assert checkpoint.current_drive_name is None
assert checkpoint.current_drive_id is None
assert checkpoint.current_drive_delta_next_link is None
def test_resume_after_simulated_crash(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Serialise the checkpoint after page 1, create a fresh connector,
and verify page 2 is fetched using the saved next-link."""
connector = _setup_connector(monkeypatch)
_mock_convert(monkeypatch)
captured_urls: list[str] = []
call_count = 0
def fake_fetch_page(
self: SharepointConnector, # noqa: ARG001
page_url: str,
drive_id: str, # noqa: ARG001
start: datetime | None = None, # noqa: ARG001
end: datetime | None = None, # noqa: ARG001
page_size: int = 200, # noqa: ARG001
) -> tuple[list[DriveItemData], str | None]:
nonlocal call_count
call_count += 1
captured_urls.append(page_url)
if call_count == 1:
return [_make_item("a")], "https://graph.microsoft.com/next2"
return [_make_item("b")], None
monkeypatch.setattr(
SharepointConnector, "_fetch_one_delta_page", fake_fetch_page
)
# Process page 1
checkpoint = _build_ready_checkpoint()
gen = connector._load_from_checkpoint(
_START_TS, _END_TS, checkpoint, include_permissions=False
)
_, checkpoint = _consume_generator(gen)
assert (
checkpoint.current_drive_delta_next_link
== "https://graph.microsoft.com/next2"
)
# --- Simulate crash: serialise & deserialise checkpoint ---
saved_json = checkpoint.model_dump_json()
restored = SharepointConnectorCheckpoint.model_validate_json(saved_json)
# New connector instance (as if process restarted)
connector2 = _setup_connector(monkeypatch)
_mock_convert(monkeypatch)
monkeypatch.setattr(
SharepointConnector, "_fetch_one_delta_page", fake_fetch_page
)
# Resume — should pick up from next2
gen = connector2._load_from_checkpoint(
_START_TS, _END_TS, restored, include_permissions=False
)
yielded, final_cp = _consume_generator(gen)
docs = _docs_from(yielded)
assert len(docs) == 1
assert docs[0].id == "b"
assert captured_urls[-1] == "https://graph.microsoft.com/next2"
assert final_cp.current_drive_name is None
assert final_cp.current_drive_delta_next_link is None
def test_single_page_drive_completes_in_one_cycle(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A drive with only one delta page should init + process + clear
in a single _load_from_checkpoint call."""
connector = _setup_connector(monkeypatch)
_mock_convert(monkeypatch)
def fake_fetch_page(
self: SharepointConnector, # noqa: ARG001
page_url: str, # noqa: ARG001
drive_id: str, # noqa: ARG001
start: datetime | None = None, # noqa: ARG001
end: datetime | None = None, # noqa: ARG001
page_size: int = 200, # noqa: ARG001
) -> tuple[list[DriveItemData], str | None]:
return [_make_item("only")], None
monkeypatch.setattr(
SharepointConnector, "_fetch_one_delta_page", fake_fetch_page
)
checkpoint = _build_ready_checkpoint()
gen = connector._load_from_checkpoint(
_START_TS, _END_TS, checkpoint, include_permissions=False
)
yielded, final_cp = _consume_generator(gen)
assert len(_docs_from(yielded)) == 1
assert final_cp.current_drive_name is None
assert final_cp.current_drive_id is None
assert final_cp.current_drive_delta_next_link is None
class TestBfsPathNoCheckpointing:
"""Folder-scoped (BFS) drives should process all items in one call
because the BFS queue cannot be cheaply serialised."""
def test_bfs_processes_all_at_once(self, monkeypatch: pytest.MonkeyPatch) -> None:
connector = _setup_connector(monkeypatch)
_mock_convert(monkeypatch)
items = [_make_item("x"), _make_item("y"), _make_item("z")]
def fake_iter_paged(
self: SharepointConnector, # noqa: ARG001
drive_id: str, # noqa: ARG001
folder_path: str | None = None, # noqa: ARG001
start: datetime | None = None, # noqa: ARG001
end: datetime | None = None, # noqa: ARG001
page_size: int = 200, # noqa: ARG001
) -> Generator[DriveItemData, None, None]:
yield from items
monkeypatch.setattr(
SharepointConnector, "_iter_drive_items_paged", fake_iter_paged
)
checkpoint = _build_ready_checkpoint(folder_path="Engineering/Docs")
gen = connector._load_from_checkpoint(
_EPOCH_START, _END_TS, checkpoint, include_permissions=False
)
yielded, final_cp = _consume_generator(gen)
assert len(_docs_from(yielded)) == 3
assert final_cp.current_drive_name is None
assert final_cp.current_drive_id is None
assert final_cp.current_drive_delta_next_link is None
class TestDelta410GoneResync:
"""On 410 Gone the checkpoint should be updated with a full-resync URL
and the next cycle should re-enumerate from scratch."""
def test_410_stores_full_resync_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
connector = _setup_connector(monkeypatch)
_mock_convert(monkeypatch)
call_count = 0
def fake_fetch_page(
self: SharepointConnector, # noqa: ARG001
page_url: str, # noqa: ARG001
drive_id: str,
start: datetime | None = None, # noqa: ARG001
end: datetime | None = None, # noqa: ARG001
page_size: int = 200,
) -> tuple[list[DriveItemData], str | None]:
nonlocal call_count
call_count += 1
if call_count == 1:
# Simulate the 410 handler returning a full-resync URL
full_url = (
f"https://graph.microsoft.com/v1.0/drives/{drive_id}"
f"/root/delta?$top={page_size}"
)
return [], full_url
return [_make_item("recovered")], None
monkeypatch.setattr(
SharepointConnector, "_fetch_one_delta_page", fake_fetch_page
)
checkpoint = _build_ready_checkpoint()
# Call 1: 3a inits, 3b gets empty page + resync URL
gen = connector._load_from_checkpoint(
_START_TS, _END_TS, checkpoint, include_permissions=False
)
yielded, checkpoint = _consume_generator(gen)
assert len(_docs_from(yielded)) == 0
assert checkpoint.current_drive_delta_next_link is not None
assert "token=" not in checkpoint.current_drive_delta_next_link
# Call 2: processes the full resync
gen = connector._load_from_checkpoint(
_START_TS, _END_TS, checkpoint, include_permissions=False
)
yielded, checkpoint = _consume_generator(gen)
docs = _docs_from(yielded)
assert len(docs) == 1
assert docs[0].id == "recovered"
assert checkpoint.current_drive_name is None
class TestDeltaPageFetchFailure:
"""If _fetch_one_delta_page raises, the drive should be abandoned with a
ConnectorFailure and the checkpoint should be cleared for the next drive."""
def test_page_fetch_error_yields_failure_and_clears_state(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
connector = _setup_connector(monkeypatch)
_mock_convert(monkeypatch)
def fake_fetch_page(
self: SharepointConnector, # noqa: ARG001
page_url: str, # noqa: ARG001
drive_id: str, # noqa: ARG001
start: datetime | None = None, # noqa: ARG001
end: datetime | None = None, # noqa: ARG001
page_size: int = 200, # noqa: ARG001
) -> tuple[list[DriveItemData], str | None]:
raise RuntimeError("network blip")
monkeypatch.setattr(
SharepointConnector, "_fetch_one_delta_page", fake_fetch_page
)
checkpoint = _build_ready_checkpoint()
gen = connector._load_from_checkpoint(
_START_TS, _END_TS, checkpoint, include_permissions=False
)
yielded, final_cp = _consume_generator(gen)
failures = _failures_from(yielded)
assert len(failures) == 1
assert "network blip" in failures[0].failure_message
assert final_cp.current_drive_name is None
assert final_cp.current_drive_id is None
assert final_cp.current_drive_delta_next_link is None

View File

@@ -192,15 +192,14 @@ def test_load_from_checkpoint_maps_drive_name(monkeypatch: pytest.MonkeyPatch) -
"https://example.sharepoint.com/sites/sample/Documents",
)
def fake_fetch_one_delta_page(
def fake_get_drive_items(
self: SharepointConnector, # noqa: ARG001
page_url: str, # noqa: ARG001
site_descriptor: SiteDescriptor, # noqa: ARG001
drive_id: str, # noqa: ARG001
start: datetime | None = None, # noqa: ARG001
end: datetime | None = None, # noqa: ARG001
page_size: int = 200, # noqa: ARG001
) -> tuple[list[DriveItemData], str | None]:
return [sample_item], None
start: datetime | None, # noqa: ARG001
end: datetime | None, # noqa: ARG001
) -> Generator[DriveItemData, None, None]:
yield sample_item
def fake_convert(
driveitem: DriveItemData, # noqa: ARG001
@@ -231,8 +230,8 @@ def test_load_from_checkpoint_maps_drive_name(monkeypatch: pytest.MonkeyPatch) -
)
monkeypatch.setattr(
SharepointConnector,
"_fetch_one_delta_page",
fake_fetch_one_delta_page,
"_get_drive_items_for_drive_id",
fake_get_drive_items,
)
monkeypatch.setattr(
"onyx.connectors.sharepoint.connector._convert_driveitem_to_document_with_permissions",

View File

@@ -0,0 +1,95 @@
from onyx.configs.constants import DocumentSource
from onyx.connectors.models import Document
from onyx.connectors.models import DocumentBase
from onyx.connectors.models import TextSection
def _minimal_doc_kwargs(metadata: dict) -> dict:
return {
"id": "test-doc",
"sections": [TextSection(text="hello", link="http://example.com")],
"source": DocumentSource.NOT_APPLICABLE,
"semantic_identifier": "Test Doc",
"metadata": metadata,
}
def test_int_values_coerced_to_str() -> None:
doc = Document(**_minimal_doc_kwargs({"count": 42}))
assert doc.metadata == {"count": "42"}
def test_float_values_coerced_to_str() -> None:
doc = Document(**_minimal_doc_kwargs({"score": 3.14}))
assert doc.metadata == {"score": "3.14"}
def test_bool_values_coerced_to_str() -> None:
doc = Document(**_minimal_doc_kwargs({"active": True}))
assert doc.metadata == {"active": "True"}
def test_list_of_ints_coerced_to_list_of_str() -> None:
doc = Document(**_minimal_doc_kwargs({"ids": [1, 2, 3]}))
assert doc.metadata == {"ids": ["1", "2", "3"]}
def test_list_of_mixed_types_coerced_to_list_of_str() -> None:
doc = Document(**_minimal_doc_kwargs({"tags": ["a", 1, True, 2.5]}))
assert doc.metadata == {"tags": ["a", "1", "True", "2.5"]}
def test_list_of_dicts_coerced_to_list_of_str() -> None:
raw = {"nested": [{"key": "val"}, {"key2": "val2"}]}
doc = Document(**_minimal_doc_kwargs(raw))
assert doc.metadata == {"nested": ["{'key': 'val'}", "{'key2': 'val2'}"]}
def test_dict_value_coerced_to_str() -> None:
raw = {"info": {"inner_key": "inner_val"}}
doc = Document(**_minimal_doc_kwargs(raw))
assert doc.metadata == {"info": "{'inner_key': 'inner_val'}"}
def test_none_value_coerced_to_str() -> None:
doc = Document(**_minimal_doc_kwargs({"empty": None}))
assert doc.metadata == {"empty": "None"}
def test_already_valid_str_values_unchanged() -> None:
doc = Document(**_minimal_doc_kwargs({"key": "value"}))
assert doc.metadata == {"key": "value"}
def test_already_valid_list_of_str_unchanged() -> None:
doc = Document(**_minimal_doc_kwargs({"tags": ["a", "b", "c"]}))
assert doc.metadata == {"tags": ["a", "b", "c"]}
def test_empty_metadata_unchanged() -> None:
doc = Document(**_minimal_doc_kwargs({}))
assert doc.metadata == {}
def test_mixed_metadata_values() -> None:
raw = {
"str_val": "hello",
"int_val": 99,
"list_val": [1, "two", 3.0],
"dict_val": {"nested": True},
}
doc = Document(**_minimal_doc_kwargs(raw))
assert doc.metadata == {
"str_val": "hello",
"int_val": "99",
"list_val": ["1", "two", "3.0"],
"dict_val": "{'nested': True}",
}
def test_coercion_works_on_base_class() -> None:
kwargs = _minimal_doc_kwargs({"count": 42})
kwargs.pop("source")
kwargs.pop("id")
doc = DocumentBase(**kwargs)
assert doc.metadata == {"count": "42"}

View File

@@ -1,19 +1,9 @@
from __future__ import annotations
import time
from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
from pydantic import BaseModel
import onyx.tools.tool_implementations.open_url.onyx_web_crawler as crawler_module
from onyx.tools.tool_implementations.open_url.onyx_web_crawler import (
DEFAULT_CONNECT_TIMEOUT_SECONDS,
)
from onyx.tools.tool_implementations.open_url.onyx_web_crawler import (
DEFAULT_READ_TIMEOUT_SECONDS,
)
from onyx.tools.tool_implementations.open_url.onyx_web_crawler import OnyxWebCrawler
@@ -191,163 +181,3 @@ def test_fetch_url_html_within_size_limit(monkeypatch: pytest.MonkeyPatch) -> No
assert "hello world" in result.full_content
assert result.scrape_successful is True
# ---------------------------------------------------------------------------
# Helpers for parallel / failure-isolation / timeout tests
# ---------------------------------------------------------------------------
def _make_mock_response(
*,
status_code: int = 200,
content: bytes = b"<html><body>Hello</body></html>",
content_type: str = "text/html",
delay: float = 0.0,
) -> MagicMock:
"""Create a mock response that behaves like a requests.Response."""
resp = MagicMock()
resp.status_code = status_code
resp.headers = {"Content-Type": content_type}
if delay:
original_content = content
@property # type: ignore[misc]
def _delayed_content(_self: object) -> bytes:
time.sleep(delay)
return original_content
type(resp).content = _delayed_content
else:
resp.content = content
resp.apparent_encoding = None
resp.encoding = None
return resp
class TestParallelExecution:
"""Verify that contents() fetches URLs in parallel."""
@patch("onyx.tools.tool_implementations.open_url.onyx_web_crawler.ssrf_safe_get")
def test_multiple_urls_fetched_concurrently(self, mock_get: MagicMock) -> None:
"""With a per-URL delay, parallel execution should be much faster than sequential."""
per_url_delay = 0.3
num_urls = 5
urls = [f"http://example.com/page{i}" for i in range(num_urls)]
mock_get.return_value = _make_mock_response(delay=per_url_delay)
crawler = OnyxWebCrawler()
start = time.monotonic()
results = crawler.contents(urls)
elapsed = time.monotonic() - start
# Sequential would take ~1.5s; parallel should be well under that
assert elapsed < per_url_delay * num_urls * 0.7
assert len(results) == num_urls
assert all(r.scrape_successful for r in results)
@patch("onyx.tools.tool_implementations.open_url.onyx_web_crawler.ssrf_safe_get")
def test_empty_urls_returns_empty(self, mock_get: MagicMock) -> None:
crawler = OnyxWebCrawler()
results = crawler.contents([])
assert results == []
mock_get.assert_not_called()
@patch("onyx.tools.tool_implementations.open_url.onyx_web_crawler.ssrf_safe_get")
def test_single_url(self, mock_get: MagicMock) -> None:
mock_get.return_value = _make_mock_response()
crawler = OnyxWebCrawler()
results = crawler.contents(["http://example.com"])
assert len(results) == 1
assert results[0].scrape_successful
class TestFailureIsolation:
"""Verify that one URL failure doesn't affect others in the batch."""
@patch("onyx.tools.tool_implementations.open_url.onyx_web_crawler.ssrf_safe_get")
def test_one_failure_doesnt_kill_batch(self, mock_get: MagicMock) -> None:
good_resp = _make_mock_response()
bad_resp = _make_mock_response(status_code=500)
# First and third URLs succeed, second fails
mock_get.side_effect = [good_resp, bad_resp, good_resp]
crawler = OnyxWebCrawler()
results = crawler.contents(["http://a.com", "http://b.com", "http://c.com"])
assert len(results) == 3
assert results[0].scrape_successful
assert not results[1].scrape_successful
assert results[2].scrape_successful
@patch("onyx.tools.tool_implementations.open_url.onyx_web_crawler.ssrf_safe_get")
def test_exception_doesnt_kill_batch(self, mock_get: MagicMock) -> None:
good_resp = _make_mock_response()
# Second URL raises an exception
mock_get.side_effect = [
good_resp,
RuntimeError("connection reset"),
_make_mock_response(),
]
crawler = OnyxWebCrawler()
results = crawler.contents(["http://a.com", "http://b.com", "http://c.com"])
assert len(results) == 3
assert results[0].scrape_successful
assert not results[1].scrape_successful
assert results[2].scrape_successful
@patch("onyx.tools.tool_implementations.open_url.onyx_web_crawler.ssrf_safe_get")
def test_ssrf_exception_doesnt_kill_batch(self, mock_get: MagicMock) -> None:
from onyx.utils.url import SSRFException
good_resp = _make_mock_response()
mock_get.side_effect = [
good_resp,
SSRFException("blocked"),
_make_mock_response(),
]
crawler = OnyxWebCrawler()
results = crawler.contents(
["http://a.com", "http://internal.local", "http://c.com"]
)
assert len(results) == 3
assert results[0].scrape_successful
assert not results[1].scrape_successful
assert results[2].scrape_successful
class TestTupleTimeout:
"""Verify that separate connect and read timeouts are passed correctly."""
@patch("onyx.tools.tool_implementations.open_url.onyx_web_crawler.ssrf_safe_get")
def test_default_tuple_timeout(self, mock_get: MagicMock) -> None:
mock_get.return_value = _make_mock_response()
crawler = OnyxWebCrawler()
crawler.contents(["http://example.com"])
call_kwargs = mock_get.call_args
assert call_kwargs.kwargs["timeout"] == (
DEFAULT_CONNECT_TIMEOUT_SECONDS,
DEFAULT_READ_TIMEOUT_SECONDS,
)
@patch("onyx.tools.tool_implementations.open_url.onyx_web_crawler.ssrf_safe_get")
def test_custom_tuple_timeout(self, mock_get: MagicMock) -> None:
mock_get.return_value = _make_mock_response()
crawler = OnyxWebCrawler(timeout_seconds=30, connect_timeout_seconds=3)
crawler.contents(["http://example.com"])
call_kwargs = mock_get.call_args
assert call_kwargs.kwargs["timeout"] == (3, 30)

View File

@@ -291,7 +291,7 @@ class TestSsrfSafeGet:
assert call_args[1]["headers"]["User-Agent"] == "TestBot/1.0"
def test_passes_timeout(self) -> None:
"""Test that timeout is passed through, including tuple form."""
"""Test that timeout is passed through."""
mock_response = MagicMock()
mock_response.is_redirect = False
@@ -301,7 +301,7 @@ class TestSsrfSafeGet:
with patch("onyx.utils.url.requests.get") as mock_get:
mock_get.return_value = mock_response
ssrf_safe_get("http://example.com/", timeout=(5, 15))
ssrf_safe_get("http://example.com/", timeout=30)
call_args = mock_get.call_args
assert call_args[1]["timeout"] == (5, 15)
assert call_args[1]["timeout"] == 30

View File

@@ -147,7 +147,7 @@ Add clear comments:
## Trunk-based development and feature flags
- **PRs should contain no more than 500 lines of real change.**
- **PRs should contain no more than 500 lines of real change**
- **Merge to main frequently.** Avoid long-lived feature branches—they create merge conflicts and integration pain.
- **Use feature flags for incremental rollout.**
- Large features should be merged in small, shippable increments behind a flag.
@@ -155,11 +155,3 @@ Add clear comments:
- **Keep flags short-lived.** Once a feature is fully rolled out, remove the flag and dead code paths promptly.
- **Flag at the right level.** Prefer flagging at API/UI entry points rather than deep in business logic.
- **Test both flag states.** Ensure the codebase works correctly with the flag on and off.
---
## Misc
- Any TODOs you add in the code must be accompanied by either the name/username
of the owner of that TODO, or an issue number for an issue referencing that
piece of work.

View File

@@ -31,7 +31,6 @@ import Button from "@/refresh-components/buttons/Button";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import Text from "@/refresh-components/texts/Text";
import { SvgEdit, SvgKey, SvgRefreshCw } from "@opal/icons";
import { useCloudSubscription } from "@/hooks/useCloudSubscription";
function Main() {
const {
@@ -40,8 +39,6 @@ function Main() {
error,
} = useSWR<APIKey[]>("/api/admin/api-key", errorHandlingFetcher);
const canCreateKeys = useCloudSubscription();
const [fullApiKey, setFullApiKey] = useState<string | null>(null);
const [keyIsGenerating, setKeyIsGenerating] = useState(false);
const [showCreateUpdateForm, setShowCreateUpdateForm] = useState(false);
@@ -73,23 +70,12 @@ function Main() {
const introSection = (
<div className="flex flex-col items-start gap-4">
<Text as="p">
API Keys allow you to access Onyx APIs programmatically.
{canCreateKeys
? " Click the button below to generate a new API Key."
: ""}
API Keys allow you to access Onyx APIs programmatically. Click the
button below to generate a new API Key.
</Text>
{canCreateKeys ? (
<CreateButton onClick={() => setShowCreateUpdateForm(true)}>
Create API Key
</CreateButton>
) : (
<div className="flex flex-col gap-2 rounded-lg bg-background-tint-02 p-4">
<Text as="p" text04>
This feature requires an active paid subscription.
</Text>
<Button href="/admin/billing">Upgrade Plan</Button>
</div>
)}
<CreateButton onClick={() => setShowCreateUpdateForm(true)}>
Create API Key
</CreateButton>
</div>
);
@@ -123,7 +109,7 @@ function Main() {
title="New API Key"
icon={SvgKey}
onClose={() => setFullApiKey(null)}
description="Make sure you copy your new API key. You won't be able to see this key again."
description="Make sure you copy your new API key. You wont be able to see this key again."
/>
<Modal.Body>
<Text as="p" className="break-all flex-1">
@@ -138,94 +124,88 @@ function Main() {
{introSection}
{canCreateKeys && (
<>
<Separator />
<Separator />
<Title className="mt-6">Existing API Keys</Title>
<Table className="overflow-visible">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>API Key</TableHead>
<TableHead>Role</TableHead>
<TableHead>Regenerate</TableHead>
<TableHead>Delete</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredApiKeys.map((apiKey) => (
<TableRow key={apiKey.api_key_id}>
<TableCell>
<Button
internal
onClick={() => handleEdit(apiKey)}
leftIcon={SvgEdit}
>
{apiKey.api_key_name || <i>null</i>}
</Button>
</TableCell>
<TableCell className="max-w-64">
{apiKey.api_key_display}
</TableCell>
<TableCell className="max-w-64">
{apiKey.api_key_role.toUpperCase()}
</TableCell>
<TableCell>
<Button
internal
leftIcon={SvgRefreshCw}
onClick={async () => {
setKeyIsGenerating(true);
const response = await regenerateApiKey(apiKey);
setKeyIsGenerating(false);
if (!response.ok) {
const errorMsg = await response.text();
toast.error(
`Failed to regenerate API Key: ${errorMsg}`
);
return;
}
const newKey = (await response.json()) as APIKey;
setFullApiKey(newKey.api_key);
mutate("/api/admin/api-key");
}}
>
Refresh
</Button>
</TableCell>
<TableCell>
<DeleteButton
onClick={async () => {
const response = await deleteApiKey(apiKey.api_key_id);
if (!response.ok) {
const errorMsg = await response.text();
toast.error(`Failed to delete API Key: ${errorMsg}`);
return;
}
mutate("/api/admin/api-key");
}}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Title className="mt-6">Existing API Keys</Title>
<Table className="overflow-visible">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>API Key</TableHead>
<TableHead>Role</TableHead>
<TableHead>Regenerate</TableHead>
<TableHead>Delete</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredApiKeys.map((apiKey) => (
<TableRow key={apiKey.api_key_id}>
<TableCell>
<Button
internal
onClick={() => handleEdit(apiKey)}
leftIcon={SvgEdit}
>
{apiKey.api_key_name || <i>null</i>}
</Button>
</TableCell>
<TableCell className="max-w-64">
{apiKey.api_key_display}
</TableCell>
<TableCell className="max-w-64">
{apiKey.api_key_role.toUpperCase()}
</TableCell>
<TableCell>
<Button
internal
leftIcon={SvgRefreshCw}
onClick={async () => {
setKeyIsGenerating(true);
const response = await regenerateApiKey(apiKey);
setKeyIsGenerating(false);
if (!response.ok) {
const errorMsg = await response.text();
toast.error(`Failed to regenerate API Key: ${errorMsg}`);
return;
}
const newKey = (await response.json()) as APIKey;
setFullApiKey(newKey.api_key);
mutate("/api/admin/api-key");
}}
>
Refresh
</Button>
</TableCell>
<TableCell>
<DeleteButton
onClick={async () => {
const response = await deleteApiKey(apiKey.api_key_id);
if (!response.ok) {
const errorMsg = await response.text();
toast.error(`Failed to delete API Key: ${errorMsg}`);
return;
}
mutate("/api/admin/api-key");
}}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{showCreateUpdateForm && (
<OnyxApiKeyForm
onCreateApiKey={(apiKey) => {
setFullApiKey(apiKey.api_key);
}}
onClose={() => {
setShowCreateUpdateForm(false);
setSelectedApiKey(undefined);
mutate("/api/admin/api-key");
}}
apiKey={selectedApiKey}
/>
)}
</>
{showCreateUpdateForm && (
<OnyxApiKeyForm
onCreateApiKey={(apiKey) => {
setFullApiKey(apiKey.api_key);
}}
onClose={() => {
setShowCreateUpdateForm(false);
setSelectedApiKey(undefined);
mutate("/api/admin/api-key");
}}
apiKey={selectedApiKey}
/>
)}
</>
);

View File

@@ -112,6 +112,18 @@ function MainContent({
<CreateButton href="/app/agents/create?admin=true">
Create Your First Assistant
</CreateButton>
<div className="mt-6 pt-6 border-t border-border">
<Text className="text-subtle text-sm">
OR go{" "}
<a
href="/admin/configuration/default-assistant"
className="text-link underline"
>
here
</a>{" "}
to adjust the Default Assistant
</Text>
</div>
</div>
)}
</div>

View File

@@ -5,6 +5,7 @@ import { SlackBot, ValidSources } from "@/lib/types";
import { useRouter } from "next/navigation";
import { useState, useEffect, useRef } from "react";
import { updateSlackBotField } from "@/lib/updateSlackBotField";
import { Checkbox } from "@/app/admin/settings/SettingsForm";
import { SlackTokensForm } from "./SlackTokensForm";
import { SourceIcon } from "@/components/SourceIcon";
import { EditableStringFieldDisplay } from "@/components/EditableStringFieldDisplay";
@@ -14,28 +15,6 @@ import Button from "@/refresh-components/buttons/Button";
import { cn } from "@/lib/utils";
import { SvgChevronDownSmall, SvgTrash } from "@opal/icons";
function Checkbox({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
return (
<label className="flex text-xs cursor-pointer">
<input
checked={checked}
onChange={onChange}
type="checkbox"
className="mr-2 w-3.5 h-3.5 my-auto"
/>
<span className="block font-medium text-text-700 text-sm">{label}</span>
</label>
);
}
export const ExistingSlackBotForm = ({
existingSlackBot,
refreshSlackBot,

View File

@@ -1,7 +0,0 @@
"use client";
import ChatPreferencesPage from "@/refresh-pages/admin/ChatPreferencesPage";
export default function Page() {
return <ChatPreferencesPage />;
}

View File

@@ -0,0 +1,307 @@
"use client";
import { useState } from "react";
import { Formik, Form } from "formik";
import { ThreeDotsLoader } from "@/components/Loading";
import { useRouter } from "next/navigation";
import { AdminPageTitle } from "@/components/admin/Title";
import { errorHandlingFetcher } from "@/lib/fetcher";
import Text from "@/refresh-components/texts/Text";
import useSWR, { mutate } from "swr";
import { ErrorCallout } from "@/components/ErrorCallout";
import { toast } from "@/hooks/useToast";
import { useAgents } from "@/hooks/useAgents";
import Separator from "@/refresh-components/Separator";
import { SubLabel } from "@/components/Field";
import Button from "@/refresh-components/buttons/Button";
import { useSettingsContext } from "@/providers/SettingsProvider";
import Link from "next/link";
import { Callout } from "@/components/ui/callout";
import { ToolSnapshot, MCPServersResponse } from "@/lib/tools/interfaces";
import { ToolSelector } from "@/components/admin/assistants/ToolSelector";
import InputTextArea from "@/refresh-components/inputs/InputTextArea";
import { HoverPopup } from "@/components/HoverPopup";
import { Info } from "lucide-react";
import { SvgOnyxLogo } from "@opal/icons";
interface DefaultAssistantConfiguration {
tool_ids: number[];
system_prompt: string | null;
default_system_prompt: string;
}
interface DefaultAssistantUpdateRequest {
tool_ids?: number[];
system_prompt?: string | null;
}
function DefaultAssistantConfig() {
const router = useRouter();
const { refresh: refreshAgents } = useAgents();
const combinedSettings = useSettingsContext();
const {
data: config,
isLoading,
error,
} = useSWR<DefaultAssistantConfiguration>(
"/api/admin/default-assistant/configuration",
errorHandlingFetcher
);
// Use the same endpoint as regular assistant editor
const { data: tools } = useSWR<ToolSnapshot[]>(
"/api/tool",
errorHandlingFetcher
);
const { data: mcpServersResponse } = useSWR<MCPServersResponse>(
"/api/admin/mcp/servers",
errorHandlingFetcher
);
const [isSubmitting, setIsSubmitting] = useState(false);
const persistConfiguration = async (
updates: DefaultAssistantUpdateRequest
) => {
const response = await fetch("/api/admin/default-assistant", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || "Failed to update assistant");
}
};
if (isLoading) {
return <ThreeDotsLoader />;
}
if (error) {
return (
<ErrorCallout
errorTitle="Failed to load configuration"
errorMsg="Unable to fetch the default assistant configuration."
/>
);
}
if (combinedSettings?.settings?.disable_default_assistant) {
return (
<div>
<Callout type="notice">
<p className="mb-3">
The default assistant is currently disabled in your workspace
settings.
</p>
<p>
To configure the default assistant, you must first enable it in{" "}
<Link href="/admin/settings" className="text-link font-medium">
Workspace Settings
</Link>
.
</p>
</Callout>
</div>
);
}
if (!config || !tools) {
return <ThreeDotsLoader />;
}
const enabledToolsMap: { [key: number]: boolean } = {};
tools.forEach((tool) => {
enabledToolsMap[tool.id] = config.tool_ids.includes(tool.id);
});
return (
<div>
<Formik
enableReinitialize
initialValues={{
enabled_tools_map: enabledToolsMap,
// Display the default prompt when system_prompt is null
system_prompt: config.system_prompt ?? config.default_system_prompt,
// Track if we're using the default (null in DB)
isUsingDefault: config.system_prompt === null,
}}
onSubmit={async (values) => {
setIsSubmitting(true);
try {
const enabledToolIds = Object.keys(values.enabled_tools_map)
.map((id) => Number(id))
.filter((id) => values.enabled_tools_map[id]);
const updates: DefaultAssistantUpdateRequest = {
tool_ids: enabledToolIds,
};
// Determine if we need to send system_prompt
// Use config directly since it reflects the original DB state
const wasUsingDefault = config.system_prompt === null;
const initialPrompt =
config.system_prompt ?? config.default_system_prompt;
const isNowUsingDefault = values.isUsingDefault;
const promptChanged = values.system_prompt !== initialPrompt;
if (wasUsingDefault && isNowUsingDefault && !promptChanged) {
// Was default, still default, no changes - don't send
} else if (isNowUsingDefault) {
// User clicked reset - send null to set DB to null (use default)
updates.system_prompt = null;
} else if (promptChanged || wasUsingDefault !== isNowUsingDefault) {
// Prompt changed or switched from default to custom
updates.system_prompt = values.system_prompt;
}
await persistConfiguration(updates);
await mutate("/api/admin/default-assistant/configuration");
router.refresh();
await refreshAgents();
toast.success("Default assistant updated successfully!");
} catch (error: any) {
toast.error(error.message || "Failed to update assistant");
} finally {
setIsSubmitting(false);
}
}}
>
{({ values, setFieldValue }) => (
<Form>
<div className="space-y-6">
<div className="mt-4">
<Text as="p" className="text-text-dark">
Configure which capabilities are enabled for the default
assistant in chat. These settings apply to all users who
haven&apos;t customized their assistant preferences.
</Text>
</div>
<Separator />
<div className="max-w-4xl">
<div className="flex gap-x-2 items-center">
<Text
as="p"
mainUiBody
text04
className="font-medium text-sm"
>
Instructions
</Text>
</div>
<div className="flex items-start gap-1.5 mb-1">
<SubLabel>
Add instructions to tailor the behavior of the assistant.
</SubLabel>
<HoverPopup
mainContent={
<Info className="h-3.5 w-3.5 text-text-400 cursor-help" />
}
popupContent={
<div className="text-xs space-y-1.5 max-w-xs bg-background-neutral-dark-03 text-text-light-05">
<div>You can use placeholders in your prompt:</div>
<div>
<span className="font-mono font-semibold">
{"{{CURRENT_DATETIME}}"}
</span>{" "}
- Injects the current date and day of the week in a
human/LLM readable format.
</div>
<div>
<span className="font-mono font-semibold">
{"{{CITATION_GUIDANCE}}"}
</span>{" "}
- Injects instructions to provide citations for facts
found from search tools. This is not included if no
search tools are called.
</div>
<div>
<span className="font-mono font-semibold">
{"{{REMINDER_TAG_DESCRIPTION}}"}
</span>{" "}
- Injects instructions for how the Agent should handle
system reminder tags.
</div>
</div>
}
direction="bottom"
/>
</div>
<div>
<InputTextArea
rows={8}
value={values.system_prompt}
onChange={(event) => {
setFieldValue("system_prompt", event.target.value);
// Mark as no longer using default when user edits
if (values.isUsingDefault) {
setFieldValue("isUsingDefault", false);
}
}}
placeholder="You are a professional email writing assistant that always uses a polite enthusiastic tone, emphasizes action items, and leaves blanks for the human to fill in when you have unknowns"
/>
<div className="flex justify-between items-center mt-2">
<button
type="button"
className="text-sm text-link hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
disabled={values.isUsingDefault}
onClick={() => {
setFieldValue(
"system_prompt",
config.default_system_prompt
);
setFieldValue("isUsingDefault", true);
}}
>
Reset to Default
</button>
<Text as="p" mainUiMuted text03 className="text-sm">
{values.system_prompt.length} characters
</Text>
</div>
</div>
</div>
<Separator />
<ToolSelector
tools={tools}
mcpServers={mcpServersResponse?.mcp_servers}
enabledToolsMap={values.enabled_tools_map}
setFieldValue={setFieldValue}
hideSearchTool={
combinedSettings?.settings.vector_db_enabled === false
}
/>
<div className="flex justify-end pt-4">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</Form>
)}
</Formik>
</div>
);
}
export default function Page() {
return (
<>
<AdminPageTitle
title="Default Assistant"
icon={<SvgOnyxLogo size={32} className="my-auto stroke-text-04" />}
/>
<DefaultAssistantConfig />
</>
);
}

View File

@@ -0,0 +1,124 @@
"use client";
import useSWR from "swr";
import { useContext, useState } from "react";
import { toast } from "@/hooks/useToast";
import Button from "@/refresh-components/buttons/Button";
import { SettingsContext } from "@/providers/SettingsProvider";
import { Card } from "@/refresh-components/cards";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import * as GeneralLayouts from "@/layouts/general-layouts";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
export function AnonymousUserPath() {
const settings = useContext(SettingsContext);
const [customPath, setCustomPath] = useState<string | null>(null);
const {
data: anonymousUserPath,
error,
mutate,
isLoading,
} = useSWR("/api/tenants/anonymous-user-path", (url) =>
fetch(url)
.then((res) => {
return res.json();
})
.then((data) => {
return data.anonymous_user_path;
})
);
if (error) {
console.error("Failed to fetch anonymous user path:", error);
}
async function handleCustomPathUpdate() {
try {
// Validate custom path
if (!customPath || !customPath.trim()) {
toast.error("Custom path cannot be empty");
return;
}
if (!/^[a-zA-Z0-9-]+$/.test(customPath)) {
toast.error(
"Custom path can only contain letters, numbers, and hyphens"
);
return;
}
const response = await fetch(
`/api/tenants/anonymous-user-path?anonymous_user_path=${encodeURIComponent(
customPath
)}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
}
);
if (!response.ok) {
const detail = await response.json();
toast.error(detail.detail || "Failed to update anonymous user path");
return;
}
mutate(); // Revalidate the SWR cache
toast.success("Anonymous user path updated successfully!");
} catch (error) {
toast.error(`Failed to update anonymous user path: ${error}`);
console.error("Error updating anonymous user path:", error);
}
}
return (
<div className="max-w-xl">
<Card gap={0}>
<GeneralLayouts.Section alignItems="start" gap={0.5}>
<Text headingH3>Anonymous User Access</Text>
<Text secondaryBody text03>
Enable this to allow anonymous users to access all public connectors
in your workspace. Anonymous users will not be able to access
private or restricted content.
</Text>
</GeneralLayouts.Section>
{isLoading ? (
<SimpleLoader className="self-center animate-spin mt-4" />
) : (
<>
<GeneralLayouts.Section flexDirection="row" gap={0.5}>
<Text mainContentBody text03>
{settings?.webDomain}/anonymous/
</Text>
<InputTypeIn
placeholder="your-custom-path"
value={customPath ?? anonymousUserPath ?? ""}
onChange={(e) => setCustomPath(e.target.value)}
showClearButton={false}
/>
</GeneralLayouts.Section>
<GeneralLayouts.Section
flexDirection="row"
gap={0.5}
justifyContent="start"
>
<Button onClick={handleCustomPathUpdate}>Update Path</Button>
<CopyIconButton
getCopyText={() =>
`${settings?.webDomain}/anonymous/${anonymousUserPath ?? ""}`
}
tooltip="Copy invite link"
prominence="secondary"
/>
</GeneralLayouts.Section>
</>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,453 @@
"use client";
import { Label, SubLabel } from "@/components/Field";
import { toast } from "@/hooks/useToast";
import Title from "@/components/ui/title";
import Button from "@/refresh-components/buttons/Button";
import { Settings } from "./interfaces";
import { useRouter } from "next/navigation";
import React, { useContext, useState, useEffect } from "react";
import { SettingsContext } from "@/providers/SettingsProvider";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import Modal from "@/refresh-components/Modal";
import { AuthType, NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
import { AnonymousUserPath } from "./AnonymousUserPath";
import LLMSelector from "@/components/llm/LLMSelector";
import { useVisionProviders } from "./hooks/useVisionProviders";
import InputTextArea from "@/refresh-components/inputs/InputTextArea";
import { SvgAlertTriangle } from "@opal/icons";
import { useUser } from "@/providers/UserProvider";
export function Checkbox({
label,
sublabel,
checked,
onChange,
}: {
label: string;
sublabel?: string;
checked: boolean;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
return (
<label className="flex text-xs cursor-pointer">
<input
checked={checked}
onChange={onChange}
type="checkbox"
className="mr-2 w-3.5 h-3.5 my-auto"
/>
<div>
<span className="block font-medium text-text-700 dark:text-neutral-100 text-sm">
{label}
</span>
{sublabel && <SubLabel>{sublabel}</SubLabel>}
</div>
</label>
);
}
function IntegerInput({
label,
sublabel,
value,
onChange,
id,
placeholder = "Enter a number", // Default placeholder if none is provided
}: {
label: string;
sublabel: string;
value: number | null;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
id?: string;
placeholder?: string;
}) {
return (
<label className="flex flex-col text-sm mb-4">
<Label>{label}</Label>
<SubLabel>{sublabel}</SubLabel>
<input
type="number"
className="mt-1 p-2 border rounded w-full max-w-xs"
value={value ?? ""}
onChange={onChange}
min="1"
step="1"
id={id}
placeholder={placeholder}
/>
</label>
);
}
export function SettingsForm() {
const router = useRouter();
const { authTypeMetadata } = useUser();
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [settings, setSettings] = useState<Settings | null>(null);
const [chatRetention, setChatRetention] = useState("");
const [companyName, setCompanyName] = useState("");
const [companyDescription, setCompanyDescription] = useState("");
const isEnterpriseEnabled = usePaidEnterpriseFeaturesEnabled();
const {
visionProviders,
visionLLM,
setVisionLLM,
updateDefaultVisionProvider,
} = useVisionProviders();
const combinedSettings = useContext(SettingsContext);
useEffect(() => {
if (combinedSettings) {
setSettings(combinedSettings.settings);
setChatRetention(
combinedSettings.settings.maximum_chat_retention_days?.toString() || ""
);
setCompanyName(combinedSettings.settings.company_name || "");
setCompanyDescription(
combinedSettings.settings.company_description || ""
);
}
// We don't need to fetch vision providers here anymore as the hook handles it
}, []);
if (!settings) {
return null;
}
const showInviteOnlyModeToggle =
authTypeMetadata.authType === AuthType.BASIC ||
authTypeMetadata.authType === AuthType.GOOGLE_OAUTH;
async function updateSettingField(
updateRequests: { fieldName: keyof Settings; newValue: any }[]
) {
// Optimistically update the local state
const newSettings: Settings | null = settings
? {
...settings,
...updateRequests.reduce((acc, { fieldName, newValue }) => {
acc[fieldName] = newValue ?? settings[fieldName];
return acc;
}, {} as Partial<Settings>),
}
: null;
setSettings(newSettings);
try {
const response = await fetch("/api/admin/settings", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(newSettings),
});
if (!response.ok) {
const errorMsg = (await response.json()).detail;
throw new Error(errorMsg);
}
router.refresh();
toast.success("Settings updated successfully!");
} catch (error) {
// Revert the optimistic update
setSettings(settings);
console.error("Error updating settings:", error);
toast.error("Failed to update settings");
}
}
function handleToggleSettingsField(
fieldName: keyof Settings,
checked: boolean
) {
if (fieldName === "anonymous_user_enabled" && checked) {
setShowConfirmModal(true);
} else {
const updates: { fieldName: keyof Settings; newValue: any }[] = [
{ fieldName, newValue: checked },
];
updateSettingField(updates);
}
}
function handleConfirmAnonymousUsers() {
const updates: { fieldName: keyof Settings; newValue: any }[] = [
{ fieldName: "anonymous_user_enabled", newValue: true },
];
updateSettingField(updates);
setShowConfirmModal(false);
}
function handleSetChatRetention() {
const newValue = chatRetention === "" ? null : parseInt(chatRetention, 10);
updateSettingField([
{ fieldName: "maximum_chat_retention_days", newValue },
]);
}
function handleClearChatRetention() {
setChatRetention("");
updateSettingField([
{ fieldName: "maximum_chat_retention_days", newValue: null },
]);
}
function handleCompanyNameBlur() {
const originalValue = settings?.company_name || "";
if (companyName !== originalValue) {
updateSettingField([
{ fieldName: "company_name", newValue: companyName || null },
]);
}
}
function handleCompanyDescriptionBlur() {
const originalValue = settings?.company_description || "";
if (companyDescription !== originalValue) {
updateSettingField([
{
fieldName: "company_description",
newValue: companyDescription || null,
},
]);
}
}
return (
<>
<Title className="mb-4">Workspace Settings</Title>
<label className="flex flex-col text-sm mb-4">
<Label>Company Name</Label>
<SubLabel>
Set the company name used for search and chat context.
</SubLabel>
<input
type="text"
className="mt-1 p-2 border rounded w-full max-w-xl"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
onBlur={handleCompanyNameBlur}
placeholder="Enter company name"
/>
</label>
<label className="flex flex-col text-sm mb-4">
<Label>Company Description</Label>
<SubLabel>
Provide a short description of the company for search and chat
context.
</SubLabel>
<InputTextArea
className="mt-1 w-full max-w-xl"
value={companyDescription}
onChange={(event) => setCompanyDescription(event.target.value)}
onBlur={handleCompanyDescriptionBlur}
placeholder="Enter company description"
rows={4}
/>
</label>
<Checkbox
label="Auto-scroll"
sublabel="If set, the chat window will automatically scroll to the bottom as new lines of text are generated by the AI model. This can be overridden by individual user settings."
checked={settings.auto_scroll}
onChange={(e) =>
handleToggleSettingsField("auto_scroll", e.target.checked)
}
/>
<Checkbox
label="Override default temperature"
sublabel="If set, users will be able to override the default temperature for each assistant."
checked={settings.temperature_override_enabled}
onChange={(e) =>
handleToggleSettingsField(
"temperature_override_enabled",
e.target.checked
)
}
/>
<Checkbox
label="Anonymous Users"
sublabel="If set, users will not be required to sign in to use Onyx."
checked={settings.anonymous_user_enabled}
onChange={(e) =>
handleToggleSettingsField("anonymous_user_enabled", e.target.checked)
}
/>
{showInviteOnlyModeToggle && (
<Checkbox
label="Whitelist / Invite-only"
sublabel="If set, only users on the invite list can join this workspace. If unset, users from your normal sign-up domain flow can still join even if invites exist."
checked={settings.invite_only_enabled}
onChange={(e) =>
handleToggleSettingsField("invite_only_enabled", e.target.checked)
}
/>
)}
<Checkbox
label="Deep Research"
sublabel="Enables a button to run deep research - a more complex and time intensive flow. Note: this costs >10x more in tokens to normal questions."
checked={settings.deep_research_enabled ?? true}
onChange={(e) =>
handleToggleSettingsField("deep_research_enabled", e.target.checked)
}
/>
<Checkbox
label="Disable Default Assistant"
sublabel="When enabled, the 'New Session' button will start a new chat with the current agent instead of the default assistant. The default assistant will be hidden from all users."
checked={settings.disable_default_assistant ?? false}
onChange={(e) =>
handleToggleSettingsField(
"disable_default_assistant",
e.target.checked
)
}
/>
{NEXT_PUBLIC_CLOUD_ENABLED && settings.anonymous_user_enabled && (
<AnonymousUserPath />
)}
{showConfirmModal && (
<Modal open onOpenChange={() => setShowConfirmModal(false)}>
<Modal.Content>
<Modal.Header
icon={SvgAlertTriangle}
title="Enable Anonymous Users"
onClose={() => setShowConfirmModal(false)}
/>
<Modal.Body>
<p>
Are you sure you want to enable anonymous users? This will allow
anyone to use Onyx without signing in.
</p>
</Modal.Body>
<Modal.Footer>
<Button secondary onClick={() => setShowConfirmModal(false)}>
Cancel
</Button>
<Button onClick={handleConfirmAnonymousUsers}>Confirm</Button>
</Modal.Footer>
</Modal.Content>
</Modal>
)}
{isEnterpriseEnabled && (
<>
<Title className="mt-8 mb-4">Chat Settings</Title>
<IntegerInput
label="Chat Retention"
sublabel="Enter the maximum number of days you would like Onyx to retain chat messages. Leaving this field empty will cause Onyx to never delete chat messages."
value={chatRetention === "" ? null : Number(chatRetention)}
onChange={(e) => {
const numValue = parseInt(e.target.value, 10);
if (numValue >= 1 || e.target.value === "") {
setChatRetention(e.target.value);
}
}}
id="chatRetentionInput"
placeholder="Infinite Retention"
/>
<div className="mr-auto flex gap-2">
<Button onClick={handleSetChatRetention} className="mr-auto">
Set Retention Limit
</Button>
<Button onClick={handleClearChatRetention} className="mr-auto">
Retain All
</Button>
</div>
</>
)}
{/* Image Processing Settings */}
<Title className="mt-8 mb-4">Image Processing</Title>
<div className="flex flex-col gap-2">
<Checkbox
label="Enable Image Extraction and Analysis"
sublabel="Extract and analyze images from documents during indexing. This allows the system to process images and create searchable descriptions of them."
checked={settings.image_extraction_and_analysis_enabled ?? false}
onChange={(e) =>
handleToggleSettingsField(
"image_extraction_and_analysis_enabled",
e.target.checked
)
}
/>
<Checkbox
label="Enable Search-time Image Analysis"
sublabel="Analyze images at search time when a user asks about images. This provides more detailed and query-specific image analysis but may increase search-time latency."
checked={settings.search_time_image_analysis_enabled ?? false}
onChange={(e) =>
handleToggleSettingsField(
"search_time_image_analysis_enabled",
e.target.checked
)
}
/>
<IntegerInput
label="Maximum Image Size for Analysis (MB)"
sublabel="Images larger than this size will not be analyzed to prevent excessive resource usage."
value={settings.image_analysis_max_size_mb ?? null}
onChange={(e) => {
const value = e.target.value ? parseInt(e.target.value) : null;
if (value !== null && !isNaN(value) && value > 0) {
updateSettingField([
{ fieldName: "image_analysis_max_size_mb", newValue: value },
]);
}
}}
id="image-analysis-max-size"
placeholder="Enter maximum size in MB"
/>
{/* Default Vision LLM Section */}
<div className="mt-4">
<Label>Default Vision LLM</Label>
<SubLabel>
Select the default LLM to use for image analysis. This model will be
utilized during image indexing and at query time for search results,
if the above settings are enabled.
</SubLabel>
<div className="mt-2 max-w-xs">
{!visionProviders || visionProviders.length === 0 ? (
<div className="text-sm text-gray-500">
No vision providers found. Please add a vision provider.
</div>
) : visionProviders.length > 0 ? (
<>
<LLMSelector
userSettings={false}
llmProviders={visionProviders.map((provider) => ({
...provider,
model_names: provider.vision_models,
display_model_names: provider.vision_models,
}))}
currentLlm={visionLLM}
onSelect={(value) => setVisionLLM(value)}
/>
<Button
onClick={() => updateDefaultVisionProvider(visionLLM)}
className="mt-2"
>
Set Default Vision LLM
</Button>
</>
) : (
<div className="text-sm text-gray-500">
No vision-capable LLMs found. Please add an LLM provider that
supports image input.
</div>
)}
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,109 @@
import { useState, useEffect, useCallback } from "react";
import { VisionProvider } from "@/app/admin/configuration/llm/interfaces";
import {
fetchVisionProviders,
setDefaultVisionProvider,
} from "@/lib/llm/visionLLM";
import { parseLlmDescriptor, structureValue } from "@/lib/llm/utils";
import { toast } from "@/hooks/useToast";
export function useVisionProviders() {
const [visionProviders, setVisionProviders] = useState<VisionProvider[]>([]);
const [visionLLM, setVisionLLM] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadVisionProviders = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await fetchVisionProviders();
setVisionProviders(data);
// Find the default vision provider and set it
const defaultProvider = data.find(
(provider) => provider.is_default_vision_provider
);
if (defaultProvider) {
const modelToUse =
defaultProvider.default_vision_model ||
defaultProvider.default_model_name;
if (modelToUse && defaultProvider.vision_models.includes(modelToUse)) {
setVisionLLM(
structureValue(
defaultProvider.name,
defaultProvider.provider,
modelToUse
)
);
}
}
} catch (error) {
console.error("Error fetching vision providers:", error);
setError(
error instanceof Error ? error.message : "Unknown error occurred"
);
toast.error(
`Failed to load vision providers: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
} finally {
setIsLoading(false);
}
}, []);
const updateDefaultVisionProvider = useCallback(
async (llmValue: string | null) => {
if (!llmValue) {
toast.error("Please select a valid vision model");
return false;
}
try {
const { name, modelName } = parseLlmDescriptor(llmValue);
// Find the provider ID
const providerObj = visionProviders.find((p) => p.name === name);
if (!providerObj) {
throw new Error("Provider not found");
}
await setDefaultVisionProvider(providerObj.id, modelName);
toast.success("Default vision provider updated successfully!");
setVisionLLM(llmValue);
// Refresh the list to reflect the change
await loadVisionProviders();
return true;
} catch (error: unknown) {
console.error("Error setting default vision provider:", error);
const errorMessage =
error instanceof Error ? error.message : "Unknown error occurred";
toast.error(
`Failed to update default vision provider: ${errorMessage}`
);
return false;
}
},
[visionProviders, loadVisionProviders]
);
// Load providers on mount
useEffect(() => {
loadVisionProviders();
}, [loadVisionProviders]);
return {
visionProviders,
visionLLM,
isLoading,
error,
setVisionLLM,
updateDefaultVisionProvider,
refreshVisionProviders: loadVisionProviders,
};
}

View File

@@ -26,7 +26,6 @@ export interface Settings {
query_history_type: QueryHistoryType;
deep_research_enabled?: boolean;
search_ui_enabled?: boolean;
// Image processing settings
image_extraction_and_analysis_enabled?: boolean;
@@ -118,16 +117,4 @@ export interface CombinedSettings {
isMobile?: boolean;
webVersion: string | null;
webDomain: string | null;
/**
* NOTE (@raunakab):
* Whether search mode is actually available to users.
*
* Prefer this over reading `settings.search_ui_enabled` directly.
* `search_ui_enabled` only reflects the admin's *preference* it does not
* account for prerequisites like connectors being configured. This derived
* flag combines the admin setting with runtime checks (e.g. connectors
* exist) so consumers get a single, accurate boolean.
*/
isSearchModeAvailable: boolean;
}

View File

@@ -0,0 +1,20 @@
"use client";
import { AdminPageTitle } from "@/components/admin/Title";
import { SettingsForm } from "@/app/admin/settings/SettingsForm";
import Text from "@/components/ui/text";
import { SvgSettings } from "@opal/icons";
export default function Page() {
return (
<>
<AdminPageTitle title="Workspace Settings" icon={SvgSettings} />
<Text className="mb-8">
Manage general Onyx settings applicable to all users in the workspace.
</Text>
<SettingsForm />
</>
);
}

View File

@@ -1,7 +1,6 @@
import React, { JSX, memo } from "react";
import {
ChatPacket,
ImageGenerationToolPacket,
Packet,
PacketType,
ReasoningPacket,
@@ -29,7 +28,7 @@ import { InternalSearchToolRenderer } from "./timeline/renderers/search/Internal
import { SearchToolStart } from "../../services/streamingModels";
// Different types of chat packets using discriminated unions
interface GroupedPackets {
export interface GroupedPackets {
packets: Packet[];
}
@@ -154,53 +153,6 @@ export function findRenderer(
return null;
}
// Handles display groups containing both chat text and image generation packets
function MixedContentHandler({
chatPackets,
imagePackets,
chatState,
onComplete,
animate,
stopPacketSeen,
stopReason,
children,
}: {
chatPackets: Packet[];
imagePackets: Packet[];
chatState: FullChatState;
onComplete: () => void;
animate: boolean;
stopPacketSeen: boolean;
stopReason?: StopReason;
children: (result: RendererOutput) => JSX.Element;
}) {
return (
<MessageTextRenderer
packets={chatPackets as ChatPacket[]}
state={chatState}
onComplete={() => {}}
animate={animate}
renderType={RenderType.FULL}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
>
{(textResults) => (
<ImageToolRenderer
packets={imagePackets as ImageGenerationToolPacket[]}
state={chatState}
onComplete={onComplete}
animate={animate}
renderType={RenderType.FULL}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
>
{(imageResults) => children([...textResults, ...imageResults])}
</ImageToolRenderer>
)}
</MessageTextRenderer>
);
}
// Props interface for RendererComponent
interface RendererComponentProps {
packets: Packet[];
@@ -209,6 +161,7 @@ interface RendererComponentProps {
animate: boolean;
stopPacketSeen: boolean;
stopReason?: StopReason;
useShortRenderer?: boolean;
children: (result: RendererOutput) => JSX.Element;
}
@@ -222,6 +175,7 @@ function areRendererPropsEqual(
prev.stopPacketSeen === next.stopPacketSeen &&
prev.stopReason === next.stopReason &&
prev.animate === next.animate &&
prev.useShortRenderer === next.useShortRenderer &&
prev.chatState.assistant?.id === next.chatState.assistant?.id
// Skip: onComplete, children (function refs), chatState (memoized upstream)
);
@@ -235,47 +189,11 @@ export const RendererComponent = memo(function RendererComponent({
animate,
stopPacketSeen,
stopReason,
useShortRenderer = false,
children,
}: RendererComponentProps) {
// Detect mixed display groups (both chat text and image generation)
const hasChatPackets = packets.some((p) => isChatPacket(p));
const hasImagePackets = packets.some((p) => isImageToolPacket(p));
if (hasChatPackets && hasImagePackets) {
const sharedTypes = new Set<string>([
PacketType.SECTION_END,
PacketType.ERROR,
]);
const chatPackets = packets.filter(
(p) =>
isChatPacket(p) ||
p.obj.type === PacketType.CITATION_INFO ||
sharedTypes.has(p.obj.type as string)
);
const imagePackets = packets.filter(
(p) =>
isImageToolPacket(p) ||
p.obj.type === PacketType.IMAGE_GENERATION_TOOL_DELTA ||
sharedTypes.has(p.obj.type as string)
);
return (
<MixedContentHandler
chatPackets={chatPackets}
imagePackets={imagePackets}
chatState={chatState}
onComplete={onComplete}
animate={animate}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
>
{children}
</MixedContentHandler>
);
}
const RendererFn = findRenderer({ packets });
const renderType = useShortRenderer ? RenderType.HIGHLIGHT : RenderType.FULL;
if (!RendererFn) {
return children([{ icon: null, status: null, content: <></> }]);
@@ -287,7 +205,7 @@ export const RendererComponent = memo(function RendererComponent({
state={chatState}
onComplete={onComplete}
animate={animate}
renderType={RenderType.FULL}
renderType={renderType}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
>

View File

@@ -25,6 +25,7 @@ export const CollapsedStreamingContent = React.memo(
stopReason,
renderTypeOverride,
}: CollapsedStreamingContentProps) {
const noopComplete = useCallback(() => {}, []);
const renderContentOnly = useCallback(
(results: TimelineRendererOutput) => (
<>
@@ -43,6 +44,7 @@ export const CollapsedStreamingContent = React.memo(
key={`${step.key}-compact`}
packets={step.packets}
chatState={chatState}
onComplete={noopComplete}
animate={true}
stopPacketSeen={false}
stopReason={stopReason}

View File

@@ -37,6 +37,8 @@ interface TimelineStepProps {
isStreaming?: boolean;
}
const noopCallback = () => {};
const TimelineStep = React.memo(function TimelineStep({
step,
chatState,
@@ -102,6 +104,7 @@ const TimelineStep = React.memo(function TimelineStep({
<TimelineRendererComponent
packets={step.packets}
chatState={chatState}
onComplete={noopCallback}
animate={!stopPacketSeen}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}

View File

@@ -51,6 +51,8 @@ export function ParallelTimelineTabs({
const handleToggle = useCallback(() => setIsExpanded((prev) => !prev), []);
const handleHeaderEnter = useCallback(() => setIsHover(true), []);
const handleHeaderLeave = useCallback(() => setIsHover(false), []);
const noopComplete = useCallback(() => {}, []);
const topSpacerVariant = isFirstTurnGroup ? "first" : "none";
const shouldShowResults = !(!isExpanded && stopPacketSeen);
@@ -163,6 +165,7 @@ export function ParallelTimelineTabs({
key={`${activeTab}-${isExpanded}`}
packets={activeStep.packets}
chatState={chatState}
onComplete={noopComplete}
animate={!stopPacketSeen}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}

View File

@@ -34,6 +34,8 @@ export interface TimelineRendererComponentProps {
packets: Packet[];
/** Chat state for rendering */
chatState: FullChatState;
/** Completion callback */
onComplete: () => void;
/** Whether to animate streaming */
animate: boolean;
/** Whether stop packet has been seen */
@@ -75,6 +77,7 @@ export const TimelineRendererComponent = React.memo(
function TimelineRendererComponent({
packets,
chatState,
onComplete,
animate,
stopPacketSeen,
stopReason,
@@ -122,7 +125,7 @@ export const TimelineRendererComponent = React.memo(
<RendererFn
packets={packets as any}
state={chatState}
onComplete={() => {}}
onComplete={onComplete}
animate={animate}
renderType={renderType}
stopPacketSeen={stopPacketSeen}

View File

@@ -349,11 +349,11 @@ function processPacket(state: ProcessorState, packet: Packet): void {
if (isDisplayPacket(packet)) {
state.displayGroupKeys.add(groupKey);
}
}
// Track image generation for header display (regardless of group position)
if (packet.obj.type === PacketType.IMAGE_GENERATION_TOOL_START) {
state.isGeneratingImage = true;
// Track image generation for header display
if (packet.obj.type === PacketType.IMAGE_GENERATION_TOOL_START) {
state.isGeneratingImage = true;
}
}
// Count generated images from DELTA packets

View File

@@ -169,6 +169,8 @@ export const ResearchAgentRenderer: MessageRenderer<
);
// Stable callbacks to avoid creating new functions on every render
const noopComplete = useCallback(() => {}, []);
// renderReport renders the processed content
// Uses pre-computed processedReportContent since ExpandableTextDisplay
// passes the same fullReportContent that we processed above
@@ -219,6 +221,7 @@ export const ResearchAgentRenderer: MessageRenderer<
key={latestGroup.sub_turn_index}
packets={latestGroup.packets}
chatState={state}
onComplete={noopComplete}
animate={!stopPacketSeen && !latestGroup.isComplete}
stopPacketSeen={stopPacketSeen}
defaultExpanded={false}
@@ -324,6 +327,7 @@ export const ResearchAgentRenderer: MessageRenderer<
key={group.sub_turn_index}
packets={group.packets}
chatState={state}
onComplete={noopComplete}
animate={!stopPacketSeen && !group.isComplete}
stopPacketSeen={stopPacketSeen}
defaultExpanded={true}

View File

@@ -12,7 +12,7 @@ import { SettingsContext } from "@/providers/SettingsProvider";
import { toast } from "@/hooks/useToast";
import { Formik, Form } from "formik";
import * as Yup from "yup";
import { EnterpriseSettings } from "@/interfaces/settings";
import { EnterpriseSettings } from "@/app/admin/settings/interfaces";
import { useRouter } from "next/navigation";
const CHAR_LIMITS = {

View File

@@ -14,7 +14,10 @@ import {
import { Metadata } from "next";
import { buildClientUrl } from "@/lib/utilsSS";
import { Inter } from "next/font/google";
import { EnterpriseSettings, ApplicationStatus } from "@/interfaces/settings";
import {
EnterpriseSettings,
ApplicationStatus,
} from "./admin/settings/interfaces";
import AppProvider from "@/providers/AppProvider";
import { PHProvider } from "./providers";
import { getAuthTypeMetadataSS, getCurrentUserSS } from "@/lib/userSS";

View File

@@ -3,9 +3,8 @@
import AdminSidebar from "@/sections/sidebar/AdminSidebar";
import { usePathname } from "next/navigation";
import { useSettingsContext } from "@/providers/SettingsProvider";
import { ApplicationStatus } from "@/interfaces/settings";
import { ApplicationStatus } from "@/app/admin/settings/interfaces";
import Button from "@/refresh-components/buttons/Button";
import { cn } from "@/lib/utils";
export interface ClientLayoutProps {
children: React.ReactNode;
@@ -13,22 +12,6 @@ export interface ClientLayoutProps {
enableCloud: boolean;
}
// TODO (@raunakab): Migrate ALL admin pages to use SettingsLayouts from
// `@/layouts/settings-layouts`. Once every page manages its own layout,
// the `py-10 px-4 md:px-12` padding below can be removed entirely and
// this prefix list can be deleted.
const SETTINGS_LAYOUT_PREFIXES = [
"/admin/configuration/chat-preferences",
"/admin/configuration/image-generation",
"/admin/configuration/web-search",
"/admin/actions/mcp",
"/admin/actions/open-api",
"/admin/billing",
"/admin/document-index-migration",
"/admin/discord-bot",
"/admin/theme",
];
export function ClientLayout({
children,
enableEnterprise,
@@ -43,11 +26,6 @@ export function ClientLayout({
pathname.startsWith("/admin/connectors") ||
pathname.startsWith("/admin/embeddings");
// Pages using SettingsLayouts handle their own padding/centering.
const hasOwnLayout = SETTINGS_LAYOUT_PREFIXES.some((prefix) =>
pathname.startsWith(prefix)
);
return (
<div className="h-screen w-screen flex overflow-hidden">
{settings.settings.application_status ===
@@ -71,12 +49,7 @@ export function ClientLayout({
enableCloudSS={enableCloud}
enableEnterpriseSS={enableEnterprise}
/>
<div
className={cn(
"flex flex-1 flex-col min-w-0 min-h-0 overflow-y-auto",
!hasOwnLayout && "py-10 px-4 md:px-12"
)}
>
<div className="flex flex-1 flex-col min-w-0 min-h-0 overflow-y-auto py-10 px-4 md:px-12">
{children}
</div>
</>

View File

@@ -0,0 +1,228 @@
"use client";
import React, { memo } from "react";
import { FastField, useFormikContext } from "formik";
import { TextFormField } from "@/components/Field";
import { FiChevronRight, FiChevronDown } from "react-icons/fi";
import Checkbox from "@/refresh-components/inputs/Checkbox";
import { DOCS_ADMINS_PATH } from "@/lib/constants";
const MAX_DESCRIPTION_LENGTH = 600;
import { useState, useEffect } from "react";
// Isolated Name Field that only re-renders when its value changes
export const NameField = memo(function NameField() {
return (
<FastField name="name">
{({ field }: any) => (
<TextFormField
{...field}
maxWidth="max-w-lg"
name="name"
label="Name"
placeholder="Email Assistant"
aria-label="assistant-name-input"
className="[&_input]:placeholder:text-text-muted/50"
/>
)}
</FastField>
);
});
// Isolated Description Field
export const DescriptionField = memo(function DescriptionField() {
return (
<FastField name="description">
{({ field }: any) => (
<TextFormField
{...field}
maxWidth="max-w-lg"
name="description"
label="Description"
placeholder="Use this Assistant to help draft professional emails"
className="[&_input]:placeholder:text-text-muted/50"
/>
)}
</FastField>
);
});
// Isolated System Prompt Field
export const SystemPromptField = memo(function SystemPromptField() {
return (
<FastField name="system_prompt">
{({ field }: any) => (
<TextFormField
{...field}
maxWidth="max-w-4xl"
name="system_prompt"
label="Instructions"
isTextArea={true}
placeholder="You are a professional email writing assistant that always uses a polite enthusiastic tone, emphasizes action items, and leaves blanks for the human to fill in when you have unknowns"
data-testid="assistant-instructions-input"
className="[&_textarea]:placeholder:text-text-muted/50"
/>
)}
</FastField>
);
});
// Isolated Task Prompt Field
export const TaskPromptField = memo(function TaskPromptField() {
return (
<FastField name="task_prompt">
{({ field, form }: any) => (
<TextFormField
{...field}
maxWidth="max-w-4xl"
name="task_prompt"
label="[Optional] Reminders"
isTextArea={true}
placeholder="Remember to reference all of the points mentioned in my message to you and focus on identifying action items that can move things forward"
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
form.setFieldValue("task_prompt", e.target.value);
}}
explanationText="Learn about prompting in our docs!"
explanationLink={`${DOCS_ADMINS_PATH}/agents/overview`}
className="[&_textarea]:placeholder:text-text-muted/50"
/>
)}
</FastField>
);
});
// Memoized MCP Server Section that only re-renders when its specific data changes
export const MCPServerSection = memo(function MCPServerSection({
serverId,
serverTools,
serverName,
serverUrl,
isCollapsed,
onToggleCollapse,
onToggleServerTools,
}: {
serverId: number;
serverTools: any[];
serverName: string;
serverUrl: string;
isCollapsed: boolean;
onToggleCollapse: (serverId: number) => void;
onToggleServerTools: () => void;
}) {
const { values } = useFormikContext<any>();
const [expandedToolDescriptions, setExpandedToolDescriptions] = useState<
Record<number, boolean>
>({});
// Calculate checkbox state locally
const enabledCount = serverTools.filter(
(tool) => values.enabled_tools_map[tool.id]
).length;
const checkboxState =
enabledCount === 0
? false
: enabledCount === serverTools.length
? true
: "indeterminate";
return (
<div
className="border rounded-lg p-4 space-y-3 dark:border-gray-700"
data-testid={`mcp-server-section-${serverId}`}
>
<div className="flex items-center space-x-3">
<button
type="button"
onClick={() => onToggleCollapse(serverId)}
className="flex-shrink-0 p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
data-testid={`mcp-server-toggle-${serverId}`}
aria-expanded={!isCollapsed}
>
{isCollapsed ? (
<FiChevronRight className="w-4 h-4 text-gray-600 dark:text-gray-400" />
) : (
<FiChevronDown className="w-4 h-4 text-gray-600 dark:text-gray-400" />
)}
</button>
<Checkbox
checked={checkboxState === true}
indeterminate={checkboxState === "indeterminate"}
onCheckedChange={onToggleServerTools}
aria-label="mcp-server-select-all-tools-checkbox"
/>
<div className="flex-grow">
<div className="font-medium text-sm text-gray-900 dark:text-gray-100">
{serverName}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">
{serverUrl} ({serverTools.length} tools)
</div>
</div>
</div>
{!isCollapsed && (
<div className="ml-7 space-y-2">
{serverTools.map((tool) => (
<FastField
key={`${tool.id}-${
expandedToolDescriptions[tool.id] ? "expanded" : "collapsed"
}`}
name={`enabled_tools_map.${tool.id}`}
>
{({ field, form }: any) => (
<label className="flex items-center space-x-2">
<div className="pt-0.5">
<Checkbox
checked={field.value || false}
onCheckedChange={(checked) => {
form.setFieldValue(field.name, checked);
}}
aria-label={`mcp-server-tool-checkbox-${tool.display_name}`}
/>
</div>
<div>
<div className="text-sm font-medium">
{tool.display_name}
</div>
<div className="text-xs text-gray-600">
{tool.description &&
tool.description.length > MAX_DESCRIPTION_LENGTH ? (
<>
{expandedToolDescriptions[tool.id]
? tool.description
: `${tool.description.slice(
0,
MAX_DESCRIPTION_LENGTH
)}... `}
<button
type="button"
className="ml-1 text-blue-500 underline text-xs focus:outline-none"
onClick={() =>
setExpandedToolDescriptions(
(prev: Record<number, boolean>) => ({
...prev,
[tool.id]: !prev[tool.id],
})
)
}
tabIndex={0}
>
{expandedToolDescriptions[tool.id]
? "Show less"
: "Expand"}
</button>
</>
) : (
tool.description
)}
</div>
</div>
</label>
)}
</FastField>
))}
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,76 @@
"use client";
import React, { memo } from "react";
import { BooleanFormField } from "@/components/Field";
import { ToolSnapshot } from "@/lib/tools/interfaces";
import { FastField } from "formik";
const MAX_DESCRIPTION_LENGTH = 300;
// Memoized individual tool checkbox - only re-renders when its specific props change
const MemoizedToolCheckbox = memo(function MemoizedToolCheckbox({
toolId,
displayName,
description,
}: {
toolId: number;
displayName: string;
description: string;
}) {
return (
<FastField name={`enabled_tools_map.${toolId}`}>
{() => (
<BooleanFormField
name={`enabled_tools_map.${toolId}`}
label={displayName}
subtext={description}
/>
)}
</FastField>
);
});
// Memoized tool list component
export const MemoizedToolList = memo(function MemoizedToolList({
tools,
}: {
tools: ToolSnapshot[];
}) {
return (
<>
{tools.map((tool) => (
<MemoizedToolCheckbox
key={tool.id}
toolId={tool.id}
displayName={tool.display_name}
description={
tool.description && tool.description.length > MAX_DESCRIPTION_LENGTH
? tool.description.slice(0, MAX_DESCRIPTION_LENGTH) + "…"
: tool.description
}
/>
))}
</>
);
});
// Memoized MCP server tools section
export const MemoizedMCPServerTools = memo(function MemoizedMCPServerTools({
serverId,
serverTools,
}: {
serverId: number;
serverTools: ToolSnapshot[];
}) {
return (
<div className="ml-7 space-y-2">
{serverTools.map((tool) => (
<MemoizedToolCheckbox
key={tool.id}
toolId={tool.id}
displayName={tool.display_name}
description={tool.description}
/>
))}
</div>
);
});

View File

@@ -0,0 +1,304 @@
"use client";
import React, {
useMemo,
useCallback,
useState,
useRef,
useEffect,
} from "react";
import { BooleanFormField } from "@/components/Field";
import { ToolSnapshot, MCPServer } from "@/lib/tools/interfaces";
import { MCPServerSection } from "./FormSections";
import { MemoizedToolList } from "./MemoizedToolCheckboxes";
import Text from "@/refresh-components/texts/Text";
import {
SEARCH_TOOL_ID,
WEB_SEARCH_TOOL_ID,
IMAGE_GENERATION_TOOL_ID,
PYTHON_TOOL_ID,
OPEN_URL_TOOL_ID,
FILE_READER_TOOL_ID,
} from "@/app/app/components/tools/constants";
import { HoverPopup } from "@/components/HoverPopup";
import { Info } from "lucide-react";
interface ToolSelectorProps {
tools: ToolSnapshot[];
mcpServers?: MCPServer[];
enabledToolsMap: { [key: number]: boolean };
setFieldValue?: (field: string, value: any) => void;
imageGenerationDisabled?: boolean;
imageGenerationDisabledTooltip?: string;
searchToolDisabled?: boolean;
searchToolDisabledTooltip?: string;
hideSearchTool?: boolean;
}
export function ToolSelector({
tools,
mcpServers = [],
enabledToolsMap,
setFieldValue,
imageGenerationDisabled = false,
imageGenerationDisabledTooltip,
searchToolDisabled = false,
searchToolDisabledTooltip,
hideSearchTool = false,
}: ToolSelectorProps) {
const searchTool = tools.find((t) => t.in_code_tool_id === SEARCH_TOOL_ID);
const webSearchTool = tools.find(
(t) => t.in_code_tool_id === WEB_SEARCH_TOOL_ID
);
const imageGenerationTool = tools.find(
(t) => t.in_code_tool_id === IMAGE_GENERATION_TOOL_ID
);
const pythonTool = tools.find((t) => t.in_code_tool_id === PYTHON_TOOL_ID);
const openUrlTool = tools.find((t) => t.in_code_tool_id === OPEN_URL_TOOL_ID);
const fileReaderTool = tools.find(
(t) => t.in_code_tool_id === FILE_READER_TOOL_ID
);
// Check if Web Search is enabled - if so, OpenURL must be enabled
const isWebSearchEnabled = webSearchTool && enabledToolsMap[webSearchTool.id];
const isOpenUrlForced = isWebSearchEnabled;
const { mcpTools, customTools, mcpToolsByServer } = useMemo(() => {
const allCustom = tools.filter(
(tool) =>
tool.in_code_tool_id !== SEARCH_TOOL_ID &&
tool.in_code_tool_id !== IMAGE_GENERATION_TOOL_ID &&
tool.in_code_tool_id !== WEB_SEARCH_TOOL_ID &&
tool.in_code_tool_id !== PYTHON_TOOL_ID &&
tool.in_code_tool_id !== OPEN_URL_TOOL_ID &&
tool.in_code_tool_id !== FILE_READER_TOOL_ID
);
const mcp = allCustom.filter((tool) => tool.mcp_server_id);
const custom = allCustom.filter((tool) => !tool.mcp_server_id);
const groups: { [serverId: number]: ToolSnapshot[] } = {};
mcp.forEach((tool) => {
if (tool.mcp_server_id) {
if (!groups[tool.mcp_server_id]) {
groups[tool.mcp_server_id] = [];
}
groups[tool.mcp_server_id]!.push(tool);
}
});
return { mcpTools: mcp, customTools: custom, mcpToolsByServer: groups };
}, [tools]);
const [collapsedServers, setCollapsedServers] = useState<Set<number>>(
() => new Set(Object.keys(mcpToolsByServer).map((id) => parseInt(id, 10)))
);
const seenServerIdsRef = useRef<Set<number>>(
new Set(Object.keys(mcpToolsByServer).map((id) => parseInt(id, 10)))
);
useEffect(() => {
const serverIds = Object.keys(mcpToolsByServer).map((id) =>
parseInt(id, 10)
);
const unseenIds = serverIds.filter(
(id) => !seenServerIdsRef.current.has(id)
);
if (unseenIds.length === 0) return;
const updatedSeen = new Set(seenServerIdsRef.current);
unseenIds.forEach((id) => updatedSeen.add(id));
seenServerIdsRef.current = updatedSeen;
setCollapsedServers((prev) => {
const next = new Set(prev);
unseenIds.forEach((id) => next.add(id));
return next;
});
}, [mcpToolsByServer]);
const toggleServerCollapse = useCallback((serverId: number) => {
setCollapsedServers((prev) => {
const next = new Set(prev);
if (next.has(serverId)) {
next.delete(serverId);
} else {
next.add(serverId);
}
return next;
});
}, []);
const toggleMCPServerTools = useCallback(
(serverId: number) => {
if (!setFieldValue) return;
const serverTools = mcpToolsByServer[serverId] || [];
const enabledCount = serverTools.filter(
(tool) => enabledToolsMap[tool.id]
).length;
const shouldEnable = enabledCount !== serverTools.length;
const updatedMap = { ...enabledToolsMap };
serverTools.forEach((tool) => {
updatedMap[tool.id] = shouldEnable;
});
setFieldValue("enabled_tools_map", updatedMap);
},
[mcpToolsByServer, enabledToolsMap, setFieldValue]
);
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5 mb-2">
<Text as="p" mainUiBody text04>
Built-in Actions
</Text>
<HoverPopup
mainContent={
<Info className="h-3.5 w-3.5 text-text-400 cursor-help" />
}
popupContent={
<div className="text-xs space-y-2 max-w-xs bg-background-neutral-dark-03 text-text-light-05">
<div>
<span className="font-semibold">Internal Search:</span> Requires
at least one connector to be configured to search your
organization&apos;s knowledge base.
</div>
<div>
<span className="font-semibold">Web Search:</span> Configure a
provider on the Web Search admin page to enable this tool.
</div>
<div>
<span className="font-semibold">Image Generation:</span> Add an
OpenAI LLM provider with an API key under Admin Configuration
LLM.
</div>
<div>
<span className="font-semibold">Code Interpreter:</span>{" "}
Requires the Code Interpreter service to be configured with a
valid base URL.
</div>
<div>
<span className="font-semibold">Open URL:</span> Open and read
the content of URLs provided in the conversation.
</div>
</div>
}
direction="bottom"
/>
</div>
{!hideSearchTool && searchTool && (
<BooleanFormField
name={`enabled_tools_map.${searchTool.id}`}
label={searchTool.display_name}
subtext="Search through your organization's knowledge base and documents"
disabled={searchToolDisabled}
disabledTooltip={searchToolDisabledTooltip}
disabledTooltipSide="bottom"
/>
)}
{webSearchTool && (
<BooleanFormField
name={`enabled_tools_map.${webSearchTool.id}`}
label={webSearchTool.display_name}
subtext="Access real-time information and search the web for up-to-date results"
onChange={(checked) => {
// When enabling Web Search, also enable OpenURL
if (checked && openUrlTool && setFieldValue) {
setFieldValue(`enabled_tools_map.${openUrlTool.id}`, true);
}
}}
/>
)}
{openUrlTool && setFieldValue && (
<BooleanFormField
name={`enabled_tools_map.${openUrlTool.id}`}
label="Open URL"
subtext="Open and read the content of URLs provided in the conversation"
disabled={isOpenUrlForced}
disabledTooltip="Required for Web Search"
disabledTooltipSide="bottom"
/>
)}
{imageGenerationTool && (
<BooleanFormField
name={`enabled_tools_map.${imageGenerationTool.id}`}
label={imageGenerationTool.display_name}
subtext="Generate and manipulate images using AI-powered tools."
disabled={imageGenerationDisabled}
disabledTooltip={imageGenerationDisabledTooltip}
disabledTooltipSide="bottom"
/>
)}
{pythonTool && (
<BooleanFormField
name={`enabled_tools_map.${pythonTool.id}`}
label={pythonTool.display_name}
subtext={
"Execute Python code in a secure, isolated environment to " +
"analyze data, create visualizations, and perform computations"
}
/>
)}
{fileReaderTool && (
<BooleanFormField
name={`enabled_tools_map.${fileReaderTool.id}`}
label={fileReaderTool.display_name}
subtext="Read sections of uploaded files. Required for files that exceed the context window."
/>
)}
{customTools.length > 0 && (
<>
<Text as="p" mainUiBody text04 className="mb-2">
OpenAPI Actions
</Text>
<MemoizedToolList tools={customTools} />
</>
)}
{Object.keys(mcpToolsByServer).length > 0 && (
<>
<Text as="p" mainUiBody text04 className="mb-2">
MCP Actions
</Text>
{Object.entries(mcpToolsByServer).map(([serverId, serverTools]) => {
const serverIdNum = parseInt(serverId);
const serverInfo =
mcpServers.find((server) => server.id === serverIdNum) || null;
const isCollapsed = collapsedServers.has(serverIdNum);
const firstTool = serverTools[0];
const serverName =
serverInfo?.name ||
firstTool?.name?.split("_").slice(0, -1).join("_") ||
`MCP Server ${serverId}`;
const serverUrl = serverInfo?.server_url || "Unknown URL";
return (
<MCPServerSection
key={`mcp-server-${serverId}`}
serverId={serverIdNum}
serverTools={serverTools}
serverName={serverName}
serverUrl={serverUrl}
isCollapsed={isCollapsed}
onToggleCollapse={toggleServerCollapse}
onToggleServerTools={() => toggleMCPServerTools(serverIdNum)}
/>
);
})}
</>
)}
</div>
);
}

View File

@@ -4,7 +4,7 @@ import {
ApplicationStatus,
Settings,
QueryHistoryType,
} from "@/interfaces/settings";
} from "@/app/admin/settings/interfaces";
import {
CUSTOM_ANALYTICS_ENABLED,
HOST_URL,
@@ -126,9 +126,6 @@ export async function fetchSettingsSS(): Promise<CombinedSettings | null> {
customAnalyticsScript,
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,
};
return combinedSettings;

View File

@@ -4,7 +4,6 @@ import React, { useState, useCallback } from "react";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { AppModeContext, AppMode } from "@/providers/AppModeProvider";
import { useUser } from "@/providers/UserProvider";
import { useSettingsContext } from "@/providers/SettingsProvider";
export interface AppModeProviderProps {
children: React.ReactNode;
@@ -18,17 +17,14 @@ export interface AppModeProviderProps {
* - **chat**: Forces chat mode - conversation with follow-up questions
*
* The initial mode is read from the user's persisted `default_app_mode` preference.
* When search mode is unavailable (admin setting or no connectors), the mode is locked to "chat".
*/
export function AppModeProvider({ children }: AppModeProviderProps) {
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
const { user } = useUser();
const settings = useSettingsContext();
const { isSearchModeAvailable } = settings;
const persistedMode = user?.preferences?.default_app_mode;
const initialMode: AppMode =
isPaidEnterpriseFeaturesEnabled && isSearchModeAvailable && persistedMode
isPaidEnterpriseFeaturesEnabled && persistedMode
? (persistedMode.toLowerCase() as AppMode)
: "chat";
@@ -36,10 +32,10 @@ export function AppModeProvider({ children }: AppModeProviderProps) {
const setAppMode = useCallback(
(mode: AppMode) => {
if (!isPaidEnterpriseFeaturesEnabled || !isSearchModeAvailable) return;
if (!isPaidEnterpriseFeaturesEnabled) return;
setAppModeState(mode);
},
[isPaidEnterpriseFeaturesEnabled, isSearchModeAvailable]
[isPaidEnterpriseFeaturesEnabled]
);
return (

View File

@@ -11,7 +11,6 @@ import { classifyQuery, searchDocuments } from "@/ee/lib/search/svc";
import { useAppMode } from "@/providers/AppModeProvider";
import useAppFocus from "@/hooks/useAppFocus";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { useSettingsContext } from "@/providers/SettingsProvider";
import {
QueryControllerContext,
QueryClassification,
@@ -28,8 +27,6 @@ export function QueryControllerProvider({
const { appMode, setAppMode } = useAppMode();
const appFocus = useAppFocus();
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
const settings = useSettingsContext();
const { isSearchModeAvailable: searchUiEnabled } = settings;
// Query state
const [query, setQuery] = useState<string | null>(null);
@@ -152,17 +149,13 @@ export function QueryControllerProvider({
// We always route through chat if we're not Enterprise Enabled.
//
// 2.
// We always route through chat if the admin has disabled the Search UI.
//
// 3.
// We only go down the classification route if we're in the "New Session" tab.
// Everywhere else, we always use the chat-flow.
//
// 4.
// 3.
// If we're in the "New Session" tab and the app-mode is "Chat", we continue with the chat-flow anyways.
if (
!isPaidEnterpriseFeaturesEnabled ||
!searchUiEnabled ||
!appFocus.isNewSession() ||
appMode === "chat"
) {
@@ -225,7 +218,6 @@ export function QueryControllerProvider({
performClassification,
performSearch,
isPaidEnterpriseFeaturesEnabled,
searchUiEnabled,
]
);

View File

@@ -1,25 +0,0 @@
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
import { hasPaidSubscription } from "@/lib/billing/interfaces";
import { useBillingInformation } from "@/hooks/useBillingInformation";
/**
* Returns whether the current tenant has an active paid subscription on cloud.
*
* Self-hosted deployments always return true (no billing gate).
* Cloud deployments check billing status via the billing API.
* Returns true while loading to avoid flashing the upgrade prompt.
*/
export function useCloudSubscription(): boolean {
const { data: billingData, isLoading } = useBillingInformation();
if (!NEXT_PUBLIC_CLOUD_ENABLED) {
return true;
}
// Treat loading as subscribed to avoid UI flash
if (isLoading || billingData == null) {
return true;
}
return hasPaidSubscription(billingData);
}

View File

@@ -3,7 +3,7 @@
import { useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { SEARCH_PARAM_NAMES } from "@/app/app/services/searchParams";
import { CombinedSettings } from "@/interfaces/settings";
import { CombinedSettings } from "@/app/admin/settings/interfaces";
import { ChatSession } from "@/app/app/interfaces";
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
import { DEFAULT_ASSISTANT_ID } from "@/lib/constants";

View File

@@ -314,7 +314,6 @@ function Header() {
/>
)}
{isPaidEnterpriseFeaturesEnabled &&
settings.isSearchModeAvailable &&
appFocus.isNewSession() &&
!classification && (
<Popover open={modePopoverOpen} onOpenChange={setModePopoverOpen}>

View File

@@ -36,6 +36,7 @@
import BackButton from "@/refresh-components/buttons/BackButton";
import { cn } from "@/lib/utils";
import Separator from "@/refresh-components/Separator";
import Spacer from "@/refresh-components/Spacer";
import Text from "@/refresh-components/texts/Text";
import { WithoutStyles } from "@/types";
import { IconProps } from "@opal/types";
@@ -104,7 +105,7 @@ function SettingsRoot({ width = "md", ...props }: SettingsRootProps) {
* - Sticky positioning at the top of the page
* - Icon display (1.75rem size)
* - Title (headingH2 style)
* - Optional description (string)
* - Optional description (supports any React node for dynamic content)
* - Optional right-aligned action buttons via rightChildren
* - Optional children content below title/description
* - Optional back button
@@ -154,18 +155,24 @@ function SettingsRoot({ width = "md", ...props }: SettingsRootProps) {
* backButton
* />
*
* // With string description
* // With dynamic description content
* <SettingsLayouts.Header
* icon={SvgDatabase}
* title="API Keys"
* description="Manage your API keys"
* description={
* <div>
* <Text as="p" secondaryBody text03>
* Manage your API keys. Last updated: {lastUpdated}
* </Text>
* </div>
* }
* />
* ```
*/
export interface SettingsHeaderProps {
icon: React.FunctionComponent<IconProps>;
title: string;
description?: string;
description?: React.ReactNode;
children?: React.ReactNode;
rightChildren?: React.ReactNode;
backButton?: boolean;
@@ -229,23 +236,27 @@ function SettingsHeader({
<Icon className="stroke-text-04 h-[1.75rem] w-[1.75rem]" />
{rightChildren}
</div>
<div className={cn("flex flex-col", separator ? "pb-6" : "pb-2")}>
<div className="flex flex-col">
<div aria-label="admin-page-title">
<Text as="p" headingH2>
{title}
</Text>
</div>
{description && (
<Text secondaryBody text03>
{description}
</Text>
)}
{description &&
(typeof description === "string" ? (
<Text as="p" secondaryBody text03>
{description}
</Text>
) : (
description
))}
</div>
</div>
{children}
</div>
{separator && (
<>
<Spacer rem={1.5} />
<Separator noPadding className="px-4" />
</>
)}

View File

@@ -133,19 +133,6 @@ export function hasActiveSubscription(
return data.status !== null;
}
/**
* Check if the response indicates an active *paid* subscription.
* Returns true only for status === "active" (excludes trialing, past_due, etc.).
*/
export function hasPaidSubscription(
data: BillingInformation | SubscriptionStatus
): data is BillingInformation {
if ("subscribed" in data) {
return false;
}
return data.status === BillingStatus.ACTIVE;
}
/**
* Check if a license is valid and active.
*/

View File

@@ -1,4 +1,4 @@
import { CombinedSettings } from "@/interfaces/settings";
import { CombinedSettings } from "@/app/admin/settings/interfaces";
import { ChatSession, toChatSession } from "@/app/app/interfaces";
import { fetchSettingsSS } from "@/components/settings/lib";
import { fetchBackendChatSessionSS } from "@/lib/chat/fetchBackendChatSessionSS";

View File

@@ -300,7 +300,6 @@ export interface OAuthConfluenceFinalizeResponse {
export interface CCPairBasicInfo {
has_successful_run: boolean;
source: ValidSources;
status: ConnectorCredentialPairStatus;
}
export type ConnectorSummary = {

View File

@@ -57,7 +57,7 @@
*/
"use client";
import { CombinedSettings } from "@/interfaces/settings";
import { CombinedSettings } from "@/app/admin/settings/interfaces";
import { UserProvider } from "@/providers/UserProvider";
import { ProviderContextProvider } from "@/components/chat/ProviderContext";
import { SettingsProvider } from "@/providers/SettingsProvider";

View File

@@ -1,15 +1,7 @@
"use client";
import { CombinedSettings } from "@/interfaces/settings";
import {
createContext,
useContext,
useEffect,
useState,
useMemo,
JSX,
} from "react";
import useCCPairs from "@/hooks/useCCPairs";
import { CombinedSettings } from "@/app/admin/settings/interfaces";
import { createContext, useContext, useEffect, useState, JSX } from "react";
export function SettingsProvider({
children,
@@ -19,7 +11,6 @@ export function SettingsProvider({
settings: CombinedSettings;
}) {
const [isMobile, setIsMobile] = useState<boolean | undefined>();
const { ccPairs } = useCCPairs();
useEffect(() => {
const checkMobile = () => {
@@ -31,24 +22,8 @@ export function SettingsProvider({
return () => window.removeEventListener("resize", checkMobile);
}, []);
/**
* NOTE (@raunakab):
* Whether search mode is actually available to users.
*
* Prefer `isSearchModeAvailable` over `settings.search_ui_enabled`.
* The raw setting only captures the admin's *intent*. This derived value
* also checks runtime prerequisites (connectors must exist) so that
* consumers don't need to independently verify availability.
*/
const isSearchModeAvailable = useMemo(
() => settings.settings.search_ui_enabled !== false && ccPairs.length > 0,
[settings.settings.search_ui_enabled, ccPairs.length]
);
return (
<SettingsContext.Provider
value={{ ...settings, isMobile, isSearchModeAvailable }}
>
<SettingsContext.Provider value={{ ...settings, isMobile }}>
{children}
</SettingsContext.Provider>
);

View File

@@ -15,7 +15,7 @@ import {
} from "@/lib/types";
import { getCurrentUser } from "@/lib/user";
import { usePostHog } from "posthog-js/react";
import { CombinedSettings } from "@/interfaces/settings";
import { CombinedSettings } from "@/app/admin/settings/interfaces";
import { SettingsContext } from "@/providers/SettingsProvider";
import { useTokenRefresh } from "@/hooks/useTokenRefresh";
import { AuthTypeMetadata } from "@/lib/userSS";

View File

@@ -68,32 +68,24 @@ export interface DisabledProps {
* - Sets aria-disabled attribute for accessibility
* - Uses Radix Slot to avoid extra DOM nodes
* - When disabled is false/undefined, renders child unchanged
* - Forwards refs so it can be used as a tooltip trigger
*/
const Disabled = React.forwardRef<
HTMLElement,
DisabledProps & React.HTMLAttributes<HTMLElement>
>(function Disabled({ disabled, allowClick = false, children, ...rest }, ref) {
// When not disabled, render child unchanged (still forward ref + props
// so parent Slots like TooltipTrigger asChild work correctly)
export function Disabled({
disabled,
allowClick = false,
children,
}: DisabledProps) {
// When not disabled, render child unchanged
if (!disabled) {
return (
<Slot ref={ref} {...rest}>
{children}
</Slot>
);
return children;
}
const styles = allowClick ? DISABLED_ALLOW_CLICK_STYLES : DISABLED_STYLES;
return (
<Slot
ref={ref}
{...rest}
className={cn(
// Get existing className from child if present
(children.props as { className?: string }).className,
rest.className,
styles
)}
aria-disabled="true"
@@ -101,7 +93,6 @@ const Disabled = React.forwardRef<
{children}
</Slot>
);
});
}
export { Disabled };
export default Disabled;

View File

@@ -199,11 +199,11 @@ const Header = React.forwardRef<HTMLDivElement, SimpleCollapsibleHeaderProps>(
{...props}
>
<div ref={boundingRef} className="flex flex-col w-full">
<Text mainContentEmphasis text04>
<Text as="p" mainContentEmphasis>
{title}
</Text>
{description && (
<Text secondaryBody text03>
<Text as="p" secondaryBody text03>
{description}
</Text>
)}

View File

@@ -177,11 +177,6 @@ export default function ActionsPopover({
const isDefaultAgent = selectedAssistant.id === 0;
// Check if the search tool is explicitly enabled on this persona (admin enabled "Use Knowledge")
const hasSearchTool = selectedAssistant.tools.some(
(tool) => tool.in_code_tool_id === SEARCH_TOOL_ID
);
// Get sources the agent has access to via document sets, hierarchy nodes, and attached documents
// Default agent has access to all sources
const agentAccessibleSources = useMemo(() => {
@@ -215,29 +210,16 @@ export default function ActionsPopover({
sourceSet.add(normalized);
});
// If agent has search tool but no specific sources, it can search everything
if (sourceSet.size === 0 && hasSearchTool) {
return null;
}
// No specific sources selected means everything is searchable
if (sourceSet.size === 0) return null;
return sourceSet;
}, [
isDefaultAgent,
selectedAssistant.document_sets,
selectedAssistant.knowledge_sources,
hasSearchTool,
]);
// Check if non-default agent has no knowledge sources (Internal Search should be disabled)
// Knowledge sources include document sets, hierarchy nodes, and attached documents
// If the search tool is present, the admin intentionally enabled knowledge search
const hasNoKnowledgeSources =
!isDefaultAgent &&
!hasSearchTool &&
selectedAssistant.document_sets.length === 0 &&
(selectedAssistant.hierarchy_node_count ?? 0) === 0 &&
(selectedAssistant.attached_document_count ?? 0) === 0;
// Store MCP server auth/loading state (tools are part of selectedAssistant.tools)
const [mcpServerData, setMcpServerData] = useState<{
[serverId: number]: {
@@ -429,9 +411,6 @@ export default function ActionsPopover({
// Filter out tools that are not chat-selectable (visibility set by backend)
if (!tool.chat_selectable) return false;
// Always hide File Reader from the actions popover
if (tool.in_code_tool_id === FILE_READER_TOOL_ID) return false;
// Special handling for Project Search
// Ensure Project Search is hidden if no files exist
if (tool.in_code_tool_id === SEARCH_TOOL_ID && !!currentProjectId) {
@@ -458,6 +437,14 @@ export default function ActionsPopover({
return false;
}
// Hide File Reader entirely when it's not available (i.e. DISABLE_VECTOR_DB is off)
if (
tool.in_code_tool_id === FILE_READER_TOOL_ID &&
!availableToolIdSet.has(tool.id)
) {
return false;
}
return true;
});

View File

@@ -31,6 +31,7 @@ import {
PYTHON_TOOL_ID,
SEARCH_TOOL_ID,
OPEN_URL_TOOL_ID,
FILE_READER_TOOL_ID,
} from "@/app/app/components/tools/constants";
import Text from "@/refresh-components/texts/Text";
import { Card } from "@/refresh-components/cards";
@@ -524,6 +525,9 @@ export default function AgentEditorPage({
const codeInterpreterTool = availableTools?.find(
(t) => t.in_code_tool_id === PYTHON_TOOL_ID
);
const fileReaderTool = availableTools?.find(
(t) => t.in_code_tool_id === FILE_READER_TOOL_ID
);
const isImageGenerationAvailable = !!imageGenTool;
const imageGenerationDisabledTooltip = isImageGenerationAvailable
? undefined
@@ -611,6 +615,14 @@ export default function AgentEditorPage({
(tool) => tool.in_code_tool_id === PYTHON_TOOL_ID
) ??
false),
file_reader:
!!fileReaderTool &&
(existingAgent?.tools?.some(
(tool) => tool.in_code_tool_id === FILE_READER_TOOL_ID
) ??
// Default to enabled for new assistants when the tool is available
!!fileReaderTool),
// MCP servers - dynamically add fields for each server with nested tool fields
...Object.fromEntries(
mcpServersWithTools.map(({ server, tools }) => {
@@ -745,6 +757,9 @@ export default function AgentEditorPage({
toolIds.push(searchTool.id);
}
}
if (values.file_reader && fileReaderTool) {
toolIds.push(fileReaderTool.id);
}
if (values.image_generation && imageGenTool) {
toolIds.push(imageGenTool.id);
}
@@ -1308,6 +1323,24 @@ export default function AgentEditorPage({
</InputLayouts.Horizontal>
</Card>
<Card
variant={
!!fileReaderTool ? undefined : "disabled"
}
>
<InputLayouts.Horizontal
name="file_reader"
title="File Reader"
description="Read sections of uploaded files. Required for files that exceed the context window."
disabled={!fileReaderTool}
>
<SwitchField
name="file_reader"
disabled={!fileReaderTool}
/>
</InputLayouts.Horizontal>
</Card>
{/* Tools */}
<>
{/* render the separator if there is at least one mcp-server or open-api-tool */}

View File

@@ -43,7 +43,6 @@ import { Button as OpalButton } from "@opal/components";
import useFederatedOAuthStatus from "@/hooks/useFederatedOAuthStatus";
import useCCPairs from "@/hooks/useCCPairs";
import { ValidSources } from "@/lib/types";
import { ConnectorCredentialPairStatus } from "@/app/admin/connector/[ccPairId]/types";
import Separator from "@/refresh-components/Separator";
import Text from "@/refresh-components/texts/Text";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
@@ -63,9 +62,6 @@ import { SvgCheck } from "@opal/icons";
import { cn } from "@/lib/utils";
import { Interactive } from "@opal/core";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { useSettingsContext } from "@/providers/SettingsProvider";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import { useCloudSubscription } from "@/hooks/useCloudSubscription";
interface PAT {
id: number;
@@ -739,8 +735,6 @@ function ChatPreferencesSettings() {
updateUserDefaultAppMode,
} = useUser();
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
const settings = useSettingsContext();
const { isSearchModeAvailable: searchUiEnabled } = settings;
const llmManager = useLlmManager();
const {
@@ -796,35 +790,24 @@ function ChatPreferencesSettings() {
</InputLayouts.Horizontal>
{isPaidEnterpriseFeaturesEnabled && (
<SimpleTooltip
tooltip={
searchUiEnabled
? undefined
: "Search UI is disabled and can only be enabled by an admin."
}
side="top"
<InputLayouts.Horizontal
title="Default App Mode"
description="Choose whether new sessions start in Search or Chat mode."
center
>
<InputLayouts.Horizontal
title="Default App Mode"
description="Choose whether new sessions start in Search or Chat mode."
center
disabled={!searchUiEnabled}
<InputSelect
value={user?.preferences.default_app_mode ?? "CHAT"}
onValueChange={(value) => {
void updateUserDefaultAppMode(value as "CHAT" | "SEARCH");
}}
>
<InputSelect
value={user?.preferences.default_app_mode ?? "CHAT"}
onValueChange={(value) => {
void updateUserDefaultAppMode(value as "CHAT" | "SEARCH");
}}
disabled={!searchUiEnabled}
>
<InputSelect.Trigger />
<InputSelect.Content>
<InputSelect.Item value="CHAT">Chat</InputSelect.Item>
<InputSelect.Item value="SEARCH">Search</InputSelect.Item>
</InputSelect.Content>
</InputSelect>
</InputLayouts.Horizontal>
</SimpleTooltip>
<InputSelect.Trigger />
<InputSelect.Content>
<InputSelect.Item value="CHAT">Chat</InputSelect.Item>
<InputSelect.Item value="SEARCH">Search</InputSelect.Item>
</InputSelect.Content>
</InputSelect>
</InputLayouts.Horizontal>
)}
</Card>
</Section>
@@ -938,8 +921,6 @@ function AccountsAccessSettings() {
useState<CreatedTokenState | null>(null);
const [tokenToDelete, setTokenToDelete] = useState<PAT | null>(null);
const canCreateTokens = useCloudSubscription();
const showPasswordSection = Boolean(user?.password_configured);
const showTokensSection = authType !== null;
@@ -1248,104 +1229,93 @@ function AccountsAccessSettings() {
{showTokensSection && (
<Section gap={0.75}>
<InputLayouts.Title title="Access Tokens" />
{canCreateTokens ? (
<Card padding={0.25}>
<Section gap={0}>
<Section flexDirection="row" padding={0.25} gap={0.5}>
{pats.length === 0 ? (
<Section padding={0.5} alignItems="start">
<Text text03 secondaryBody>
{isLoading
? "Loading tokens..."
: "No access tokens created."}
</Text>
</Section>
) : (
<InputTypeIn
placeholder="Search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
leftSearchIcon
variant="internal"
/>
)}
<CreateButton
onClick={() => setShowCreateModal(true)}
secondary={false}
internal
transient={showCreateModal}
rightIcon
>
New Access Token
</CreateButton>
</Section>
<Card padding={0.25}>
<Section gap={0}>
{/* Header with search/empty state and create button */}
<Section flexDirection="row" padding={0.25} gap={0.5}>
{pats.length === 0 ? (
<Section padding={0.5} alignItems="start">
<Text as="span" text03 secondaryBody>
{isLoading
? "Loading tokens..."
: "No access tokens created."}
</Text>
</Section>
) : (
<InputTypeIn
placeholder="Search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
leftSearchIcon
variant="internal"
/>
)}
<CreateButton
onClick={() => setShowCreateModal(true)}
secondary={false}
internal
transient={showCreateModal}
rightIcon
>
New Access Token
</CreateButton>
</Section>
<Section gap={0.25}>
{filteredPats.map((pat) => {
const now = new Date();
const createdDate = new Date(pat.created_at);
const daysSinceCreation = Math.floor(
(now.getTime() - createdDate.getTime()) /
{/* Token List */}
<Section gap={0.25}>
{filteredPats.map((pat) => {
const now = new Date();
const createdDate = new Date(pat.created_at);
const daysSinceCreation = Math.floor(
(now.getTime() - createdDate.getTime()) /
(1000 * 60 * 60 * 24)
);
let expiryText = "Never expires";
if (pat.expires_at) {
const expiresDate = new Date(pat.expires_at);
const daysUntilExpiry = Math.ceil(
(expiresDate.getTime() - now.getTime()) /
(1000 * 60 * 60 * 24)
);
expiryText = `Expires in ${daysUntilExpiry} day${
daysUntilExpiry === 1 ? "" : "s"
}`;
}
let expiryText = "Never expires";
if (pat.expires_at) {
const expiresDate = new Date(pat.expires_at);
const daysUntilExpiry = Math.ceil(
(expiresDate.getTime() - now.getTime()) /
(1000 * 60 * 60 * 24)
);
expiryText = `Expires in ${daysUntilExpiry} day${
daysUntilExpiry === 1 ? "" : "s"
}`;
}
const middleText = `Created ${daysSinceCreation} day${
daysSinceCreation === 1 ? "" : "s"
} ago - ${expiryText}`;
const middleText = `Created ${daysSinceCreation} day${
daysSinceCreation === 1 ? "" : "s"
} ago - ${expiryText}`;
return (
<Interactive.Container
key={pat.id}
heightVariant="fit"
widthVariant="full"
>
<div className="w-full bg-background-tint-01">
<AttachmentItemLayout
icon={SvgKey}
title={pat.name}
description={pat.token_display}
middleText={middleText}
rightChildren={
<OpalButton
icon={SvgTrash}
onClick={() => setTokenToDelete(pat)}
prominence="tertiary"
size="sm"
aria-label={`Delete token ${pat.name}`}
/>
}
/>
</div>
</Interactive.Container>
);
})}
</Section>
return (
<Interactive.Container
key={pat.id}
heightVariant="fit"
widthVariant="full"
>
<div className="w-full bg-background-tint-01">
<AttachmentItemLayout
icon={SvgKey}
title={pat.name}
description={pat.token_display}
middleText={middleText}
rightChildren={
<OpalButton
icon={SvgTrash}
onClick={() => setTokenToDelete(pat)}
prominence="tertiary"
size="sm"
aria-label={`Delete token ${pat.name}`}
/>
}
/>
</div>
</Interactive.Container>
);
})}
</Section>
</Card>
) : (
<Card>
<Section flexDirection="row" justifyContent="between">
<Text text03 secondaryBody>
Access tokens require an active paid subscription.
</Text>
<Button secondary href="/admin/billing">
Upgrade Plan
</Button>
</Section>
</Card>
)}
</Section>
</Card>
</Section>
)}
</Section>
@@ -1355,10 +1325,10 @@ function AccountsAccessSettings() {
interface IndexedConnectorCardProps {
source: ValidSources;
isActive: boolean;
count: number;
}
function IndexedConnectorCard({ source, isActive }: IndexedConnectorCardProps) {
function IndexedConnectorCard({ source, count }: IndexedConnectorCardProps) {
const sourceMetadata = getSourceMetadata(source);
return (
@@ -1366,7 +1336,7 @@ function IndexedConnectorCard({ source, isActive }: IndexedConnectorCardProps) {
<LineItemLayout
icon={sourceMetadata.icon}
title={sourceMetadata.displayName}
description={isActive ? "Connected" : "Paused"}
description={count > 1 ? `${count} connectors active` : "Connected"}
/>
</Card>
);
@@ -1480,23 +1450,19 @@ function ConnectorsSettings() {
} = useFederatedOAuthStatus();
const { ccPairs } = useCCPairs();
const ACTIVE_STATUSES: ConnectorCredentialPairStatus[] = [
ConnectorCredentialPairStatus.ACTIVE,
ConnectorCredentialPairStatus.SCHEDULED,
ConnectorCredentialPairStatus.INITIAL_INDEXING,
];
// Group indexed connectors by source
const groupedConnectors = ccPairs.reduce(
(acc, ccPair) => {
if (!acc[ccPair.source]) {
acc[ccPair.source] = {
source: ccPair.source,
hasActiveConnector: false,
count: 0,
hasSuccessfulRun: false,
};
}
if (ACTIVE_STATUSES.includes(ccPair.status)) {
acc[ccPair.source]!.hasActiveConnector = true;
acc[ccPair.source]!.count++;
if (ccPair.has_successful_run) {
acc[ccPair.source]!.hasSuccessfulRun = true;
}
return acc;
},
@@ -1504,7 +1470,8 @@ function ConnectorsSettings() {
string,
{
source: ValidSources;
hasActiveConnector: boolean;
count: number;
hasSuccessfulRun: boolean;
}
>
);
@@ -1523,7 +1490,7 @@ function ConnectorsSettings() {
<IndexedConnectorCard
key={connector.source}
source={connector.source}
isActive={connector.hasActiveConnector}
count={connector.count}
/>
))}

View File

@@ -1,828 +0,0 @@
"use client";
import React, { useCallback, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Formik, Form, useFormikContext } from "formik";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import * as InputLayouts from "@/layouts/input-layouts";
import { Section, LineItemLayout } from "@/layouts/general-layouts";
import Card from "@/refresh-components/cards/Card";
import Separator from "@/refresh-components/Separator";
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import SwitchField from "@/refresh-components/form/SwitchField";
import InputTypeInField from "@/refresh-components/form/InputTypeInField";
import InputTextAreaField from "@/refresh-components/form/InputTextAreaField";
import InputSelectField from "@/refresh-components/form/InputSelectField";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import {
SvgBubbleText,
SvgAddLines,
SvgActions,
SvgExpand,
SvgFold,
SvgExternalLink,
} from "@opal/icons";
import { Content } from "@opal/layouts";
import { useSettingsContext } from "@/providers/SettingsProvider";
import useCCPairs from "@/hooks/useCCPairs";
import { getSourceMetadata } from "@/lib/sources";
import EmptyMessage from "@/refresh-components/EmptyMessage";
import { Settings } from "@/interfaces/settings";
import { toast } from "@/hooks/useToast";
import { useAvailableTools } from "@/hooks/useAvailableTools";
import {
SEARCH_TOOL_ID,
IMAGE_GENERATION_TOOL_ID,
WEB_SEARCH_TOOL_ID,
PYTHON_TOOL_ID,
OPEN_URL_TOOL_ID,
} from "@/app/app/components/tools/constants";
import { Button } from "@opal/components";
import Modal from "@/refresh-components/Modal";
import InputTextArea from "@/refresh-components/inputs/InputTextArea";
import Switch from "@/refresh-components/inputs/Switch";
import useMcpServersForAgentEditor from "@/hooks/useMcpServersForAgentEditor";
import useOpenApiTools from "@/hooks/useOpenApiTools";
import * as ExpandableCard from "@/layouts/expandable-card-layouts";
import * as ActionsLayouts from "@/layouts/actions-layouts";
import { getActionIcon } from "@/lib/tools/mcpUtils";
import Disabled from "@/refresh-components/Disabled";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import useFilter from "@/hooks/useFilter";
import { MCPServer } from "@/lib/tools/interfaces";
import type { IconProps } from "@opal/types";
interface DefaultAssistantConfiguration {
tool_ids: number[];
system_prompt: string | null;
default_system_prompt: string;
}
interface ChatPreferencesFormValues {
// Features
search_ui_enabled: boolean;
deep_research_enabled: boolean;
auto_scroll: boolean;
// Team context
company_name: string;
company_description: string;
// Advanced
maximum_chat_retention_days: string;
anonymous_user_enabled: boolean;
disable_default_assistant: boolean;
}
interface MCPServerCardTool {
id: number;
icon: React.FunctionComponent<IconProps>;
name: string;
description: string;
}
interface MCPServerCardProps {
server: MCPServer;
tools: MCPServerCardTool[];
isToolEnabled: (toolDbId: number) => boolean;
onToggleTool: (toolDbId: number, enabled: boolean) => void;
onToggleTools: (toolDbIds: number[], enabled: boolean) => void;
}
function MCPServerCard({
server,
tools,
isToolEnabled,
onToggleTool,
onToggleTools,
}: MCPServerCardProps) {
const [isFolded, setIsFolded] = useState(true);
const {
query,
setQuery,
filtered: filteredTools,
} = useFilter(tools, (tool) => `${tool.name} ${tool.description}`);
const allToolIds = tools.map((t) => t.id);
const serverEnabled =
tools.length > 0 && tools.some((t) => isToolEnabled(t.id));
return (
<ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
<ActionsLayouts.Header
title={server.name}
description={server.description}
icon={getActionIcon(server.server_url, server.name)}
rightChildren={
<Switch
checked={serverEnabled}
onCheckedChange={(checked) => onToggleTools(allToolIds, checked)}
/>
}
>
{tools.length > 0 && (
<Section flexDirection="row" gap={0.5}>
<InputTypeIn
placeholder="Search tools..."
variant="internal"
leftSearchIcon
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<Button
rightIcon={isFolded ? SvgExpand : SvgFold}
onClick={() => setIsFolded((prev) => !prev)}
prominence="internal"
size="lg"
>
{isFolded ? "Expand" : "Fold"}
</Button>
</Section>
)}
</ActionsLayouts.Header>
{tools.length > 0 && filteredTools.length > 0 && (
<ActionsLayouts.Content>
<div className="flex flex-col gap-2">
{filteredTools.map((tool) => (
<ActionsLayouts.Tool
key={tool.id}
title={tool.name}
description={tool.description}
icon={tool.icon}
rightChildren={
<Switch
checked={isToolEnabled(tool.id)}
onCheckedChange={(checked) =>
onToggleTool(tool.id, checked)
}
/>
}
/>
))}
</div>
</ActionsLayouts.Content>
)}
</ExpandableCard.Root>
);
}
/**
* Inner form component that uses useFormikContext to access values
* and create save handlers for settings fields.
*/
function ChatPreferencesForm() {
const router = useRouter();
const settings = useSettingsContext();
const { values } = useFormikContext<ChatPreferencesFormValues>();
// Track initial text values to avoid unnecessary saves on blur
const initialCompanyName = useRef(values.company_name);
const initialCompanyDescription = useRef(values.company_description);
// Tools availability
const { tools: availableTools } = useAvailableTools();
const vectorDbEnabled = settings?.settings.vector_db_enabled !== false;
const searchTool = availableTools.find(
(t) => t.in_code_tool_id === SEARCH_TOOL_ID
);
const imageGenTool = availableTools.find(
(t) => t.in_code_tool_id === IMAGE_GENERATION_TOOL_ID
);
const webSearchTool = availableTools.find(
(t) => t.in_code_tool_id === WEB_SEARCH_TOOL_ID
);
const openURLTool = availableTools.find(
(t) => t.in_code_tool_id === OPEN_URL_TOOL_ID
);
const codeInterpreterTool = availableTools.find(
(t) => t.in_code_tool_id === PYTHON_TOOL_ID
);
// Connectors
const { ccPairs } = useCCPairs();
const uniqueSources = Array.from(new Set(ccPairs.map((p) => p.source)));
// MCP servers and OpenAPI tools
const { mcpData } = useMcpServersForAgentEditor();
const { openApiTools: openApiToolsRaw } = useOpenApiTools();
const mcpServers = mcpData?.mcp_servers ?? [];
const openApiTools = openApiToolsRaw ?? [];
const mcpServersWithTools = mcpServers.map((server) => ({
server,
tools: availableTools
.filter((tool) => tool.mcp_server_id === server.id)
.map((tool) => ({
id: tool.id,
icon: getActionIcon(server.server_url, server.name),
name: tool.display_name || tool.name,
description: tool.description,
})),
}));
// Default assistant configuration (system prompt)
const { data: defaultAssistantConfig, mutate: mutateDefaultAssistant } =
useSWR<DefaultAssistantConfiguration>(
"/api/admin/default-assistant/configuration",
errorHandlingFetcher
);
const enabledToolIds = defaultAssistantConfig?.tool_ids ?? [];
const isToolEnabled = useCallback(
(toolDbId: number) => enabledToolIds.includes(toolDbId),
[enabledToolIds]
);
const saveToolIds = useCallback(
async (newToolIds: number[]) => {
// Optimistic update so subsequent toggles read fresh state
const optimisticData = defaultAssistantConfig
? { ...defaultAssistantConfig, tool_ids: newToolIds }
: undefined;
try {
await mutateDefaultAssistant(
async () => {
const response = await fetch("/api/admin/default-assistant", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tool_ids: newToolIds }),
});
if (!response.ok) {
const errorMsg = (await response.json()).detail;
throw new Error(errorMsg);
}
return optimisticData;
},
{ optimisticData, revalidate: true }
);
toast.success("Tools updated");
} catch {
toast.error("Failed to update tools");
}
},
[defaultAssistantConfig, mutateDefaultAssistant]
);
const toggleTool = useCallback(
(toolDbId: number, enabled: boolean) => {
const newToolIds = enabled
? [...enabledToolIds, toolDbId]
: enabledToolIds.filter((id) => id !== toolDbId);
void saveToolIds(newToolIds);
},
[enabledToolIds, saveToolIds]
);
const toggleTools = useCallback(
(toolDbIds: number[], enabled: boolean) => {
const idsSet = new Set(toolDbIds);
const withoutIds = enabledToolIds.filter((id) => !idsSet.has(id));
const newToolIds = enabled ? [...withoutIds, ...toolDbIds] : withoutIds;
void saveToolIds(newToolIds);
},
[enabledToolIds, saveToolIds]
);
// System prompt modal state
const [systemPromptModalOpen, setSystemPromptModalOpen] = useState(false);
const [systemPromptValue, setSystemPromptValue] = useState("");
const saveSettings = useCallback(
async (updates: Partial<Settings>) => {
const currentSettings = settings?.settings;
if (!currentSettings) return;
const newSettings = { ...currentSettings, ...updates };
try {
const response = await fetch("/api/admin/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newSettings),
});
if (!response.ok) {
const errorMsg = (await response.json()).detail;
throw new Error(errorMsg);
}
router.refresh();
toast.success("Settings updated");
} catch (error) {
toast.error("Failed to update settings");
}
},
[settings, router]
);
return (
<>
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgBubbleText}
title="Chat Preferences"
description="Organization-wide chat settings and defaults. Users can override some of these in their personal settings."
separator
/>
<SettingsLayouts.Body>
{/* Team Context */}
<Section gap={1}>
<InputLayouts.Vertical
title="Team Name"
subDescription="This is added to all chat sessions as additional context to provide a richer/customized experience."
>
<InputTypeInField
name="company_name"
placeholder="Enter team name"
onBlur={() => {
if (values.company_name !== initialCompanyName.current) {
void saveSettings({
company_name: values.company_name || null,
});
initialCompanyName.current = values.company_name;
}
}}
/>
</InputLayouts.Vertical>
<InputLayouts.Vertical
title="Team Context"
subDescription="Users can also provide additional individual context in their personal settings."
>
<InputTextAreaField
name="company_description"
placeholder="Describe your team and how Onyx should behave."
rows={4}
maxRows={10}
autoResize
onBlur={() => {
if (
values.company_description !==
initialCompanyDescription.current
) {
void saveSettings({
company_description: values.company_description || null,
});
initialCompanyDescription.current =
values.company_description;
}
}}
/>
</InputLayouts.Vertical>
</Section>
<InputLayouts.Horizontal
title="System Prompt"
description="Base prompt for all chats, agents, and projects. Modify with caution: Significant changes may degrade response quality."
>
<Button
prominence="tertiary"
icon={SvgAddLines}
onClick={() => {
setSystemPromptValue(
defaultAssistantConfig?.system_prompt ??
defaultAssistantConfig?.default_system_prompt ??
""
);
setSystemPromptModalOpen(true);
}}
>
Modify Prompt
</Button>
</InputLayouts.Horizontal>
<Separator noPadding />
{/* Features */}
<Section gap={0.75}>
<InputLayouts.Title title="Features" />
<Card>
<SimpleTooltip
tooltip={
uniqueSources.length === 0
? "Set up connectors to use Search Mode"
: undefined
}
side="top"
>
<Disabled disabled={uniqueSources.length === 0} allowClick>
<div className="w-full">
<InputLayouts.Horizontal
title="Search Mode"
description="UI mode for quick document search across your organization."
disabled={uniqueSources.length === 0}
>
<SwitchField
name="search_ui_enabled"
onCheckedChange={(checked) => {
void saveSettings({ search_ui_enabled: checked });
}}
disabled={uniqueSources.length === 0}
/>
</InputLayouts.Horizontal>
</div>
</Disabled>
</SimpleTooltip>
<InputLayouts.Horizontal
title="Deep Research"
description="Agentic research system that works across the web and connected sources. Uses significantly more tokens per query."
>
<SwitchField
name="deep_research_enabled"
onCheckedChange={(checked) => {
void saveSettings({ deep_research_enabled: checked });
}}
/>
</InputLayouts.Horizontal>
<InputLayouts.Horizontal
title="Chat Auto-Scroll"
description="Automatically scroll to new content as chat generates response. Users can override this in their personal settings."
>
<SwitchField
name="auto_scroll"
onCheckedChange={(checked) => {
void saveSettings({ auto_scroll: checked });
}}
/>
</InputLayouts.Horizontal>
</Card>
</Section>
<Separator noPadding />
<Disabled disabled={values.disable_default_assistant}>
<div>
<Section gap={1.5}>
{/* Connectors */}
<Section gap={0.75}>
<InputLayouts.Title title="Connectors" />
<Section
flexDirection="row"
justifyContent="start"
alignItems="center"
gap={0.25}
>
{uniqueSources.length === 0 ? (
<EmptyMessage title="No connectors set up" />
) : (
<>
{uniqueSources.slice(0, 3).map((source) => {
const meta = getSourceMetadata(source);
return (
<Card
key={source}
padding={0.75}
className="w-[10rem]"
>
<Content
icon={meta.icon}
title={meta.displayName}
sizePreset="main-ui"
/>
</Card>
);
})}
<Button
href="/admin/indexing/status"
prominence="tertiary"
rightIcon={SvgExternalLink}
>
Manage All
</Button>
</>
)}
</Section>
</Section>
{/* Actions & Tools */}
<SimpleCollapsible>
<SimpleCollapsible.Header
title="Actions & Tools"
description="Tools and capabilities available for chat to use. This does not apply to agents."
/>
<SimpleCollapsible.Content>
<Section gap={0.5}>
{vectorDbEnabled && searchTool && (
<Card>
<InputLayouts.Horizontal
title="Internal Search"
description="Search through your organization's connected knowledge base and documents."
>
<Switch
checked={isToolEnabled(searchTool.id)}
onCheckedChange={(checked) =>
void toggleTool(searchTool.id, checked)
}
/>
</InputLayouts.Horizontal>
</Card>
)}
<SimpleTooltip
tooltip={
imageGenTool
? undefined
: "Image generation requires a configured model. Set one up under Configuration > Image Generation, or ask an admin."
}
side="top"
>
<Card variant={imageGenTool ? undefined : "disabled"}>
<InputLayouts.Horizontal
title="Image Generation"
description="Generate and manipulate images using AI-powered tools."
disabled={!imageGenTool}
>
<Switch
checked={
imageGenTool
? isToolEnabled(imageGenTool.id)
: false
}
onCheckedChange={(checked) =>
imageGenTool &&
void toggleTool(imageGenTool.id, checked)
}
disabled={!imageGenTool}
/>
</InputLayouts.Horizontal>
</Card>
</SimpleTooltip>
<Card variant={webSearchTool ? undefined : "disabled"}>
<InputLayouts.Horizontal
title="Web Search"
description="Search the web for real-time information and up-to-date results."
disabled={!webSearchTool}
>
<Switch
checked={
webSearchTool
? isToolEnabled(webSearchTool.id)
: false
}
onCheckedChange={(checked) =>
webSearchTool &&
void toggleTool(webSearchTool.id, checked)
}
disabled={!webSearchTool}
/>
</InputLayouts.Horizontal>
</Card>
<Card variant={openURLTool ? undefined : "disabled"}>
<InputLayouts.Horizontal
title="Open URL"
description="Fetch and read content from web URLs."
disabled={!openURLTool}
>
<Switch
checked={
openURLTool
? isToolEnabled(openURLTool.id)
: false
}
onCheckedChange={(checked) =>
openURLTool &&
void toggleTool(openURLTool.id, checked)
}
disabled={!openURLTool}
/>
</InputLayouts.Horizontal>
</Card>
<Card
variant={codeInterpreterTool ? undefined : "disabled"}
>
<InputLayouts.Horizontal
title="Code Interpreter"
description="Generate and run code."
disabled={!codeInterpreterTool}
>
<Switch
checked={
codeInterpreterTool
? isToolEnabled(codeInterpreterTool.id)
: false
}
onCheckedChange={(checked) =>
codeInterpreterTool &&
void toggleTool(codeInterpreterTool.id, checked)
}
disabled={!codeInterpreterTool}
/>
</InputLayouts.Horizontal>
</Card>
</Section>
{/* Separator between built-in tools and MCP/OpenAPI tools */}
{(mcpServersWithTools.length > 0 ||
openApiTools.length > 0) && (
<Separator noPadding className="py-3" />
)}
{/* MCP Servers & OpenAPI Tools */}
<Section gap={0.5}>
{mcpServersWithTools.map(({ server, tools }) => (
<MCPServerCard
key={server.id}
server={server}
tools={tools}
isToolEnabled={isToolEnabled}
onToggleTool={toggleTool}
onToggleTools={toggleTools}
/>
))}
{openApiTools.map((tool) => (
<ExpandableCard.Root key={tool.id} defaultFolded>
<ActionsLayouts.Header
title={tool.display_name || tool.name}
description={tool.description}
icon={SvgActions}
rightChildren={
<Switch
checked={isToolEnabled(tool.id)}
onCheckedChange={(checked) =>
toggleTool(tool.id, checked)
}
/>
}
/>
</ExpandableCard.Root>
))}
</Section>
</SimpleCollapsible.Content>
</SimpleCollapsible>
</Section>
</div>
</Disabled>
<Separator noPadding />
{/* Advanced Options */}
<SimpleCollapsible defaultOpen={false}>
<SimpleCollapsible.Header title="Advanced Options" />
<SimpleCollapsible.Content>
<Section gap={1}>
<Card>
<InputLayouts.Horizontal
title="Keep Chat History"
description="Specify how long Onyx should retain chats in your organization."
>
<InputSelectField
name="maximum_chat_retention_days"
onValueChange={(value) => {
void saveSettings({
maximum_chat_retention_days:
value === "forever" ? null : parseInt(value, 10),
});
}}
>
<InputSelect.Trigger />
<InputSelect.Content>
<InputSelect.Item value="forever">
Forever
</InputSelect.Item>
<InputSelect.Item value="7">7 days</InputSelect.Item>
<InputSelect.Item value="30">30 days</InputSelect.Item>
<InputSelect.Item value="90">90 days</InputSelect.Item>
<InputSelect.Item value="365">
365 days
</InputSelect.Item>
</InputSelect.Content>
</InputSelectField>
</InputLayouts.Horizontal>
</Card>
<Card>
<InputLayouts.Horizontal
title="Allow Anonymous Users"
description="Allow anyone to start chats without logging in. They do not see any other chats and cannot create agents or update settings."
>
<SwitchField
name="anonymous_user_enabled"
onCheckedChange={(checked) => {
void saveSettings({ anonymous_user_enabled: checked });
}}
/>
</InputLayouts.Horizontal>
<InputLayouts.Horizontal
title="Always Start with an Agent"
description="This removes the default chat. Users will always start in an agent, and new chats will be created in their last active agent. Set featured agents to help new users get started."
>
<SwitchField
name="disable_default_assistant"
onCheckedChange={(checked) => {
void saveSettings({
disable_default_assistant: checked,
});
}}
/>
</InputLayouts.Horizontal>
</Card>
</Section>
</SimpleCollapsible.Content>
</SimpleCollapsible>
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<Modal
open={systemPromptModalOpen}
onOpenChange={setSystemPromptModalOpen}
>
<Modal.Content width="md" height="fit">
<Modal.Header
icon={SvgAddLines}
title="System Prompt"
description="This base prompt is prepended to all chats, agents, and projects."
onClose={() => setSystemPromptModalOpen(false)}
/>
<Modal.Body>
<InputTextArea
value={systemPromptValue}
onChange={(e) => setSystemPromptValue(e.target.value)}
placeholder="Enter your system prompt..."
rows={8}
maxRows={20}
autoResize
/>
</Modal.Body>
<Modal.Footer>
<Button
prominence="secondary"
onClick={() => setSystemPromptModalOpen(false)}
>
Cancel
</Button>
<Button
prominence="primary"
onClick={async () => {
try {
const response = await fetch("/api/admin/default-assistant", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
system_prompt: systemPromptValue,
}),
});
if (!response.ok) {
const errorMsg = (await response.json()).detail;
throw new Error(errorMsg);
}
await mutateDefaultAssistant();
setSystemPromptModalOpen(false);
toast.success("System prompt updated");
} catch {
toast.error("Failed to update system prompt");
}
}}
>
Save
</Button>
</Modal.Footer>
</Modal.Content>
</Modal>
</>
);
}
export default function ChatPreferencesPage() {
const settings = useSettingsContext();
const initialValues: ChatPreferencesFormValues = {
// Features
search_ui_enabled: settings.settings.search_ui_enabled ?? false,
deep_research_enabled: settings.settings.deep_research_enabled ?? true,
auto_scroll: settings.settings.auto_scroll ?? false,
// Team context
company_name: settings.settings.company_name ?? "",
company_description: settings.settings.company_description ?? "",
// Advanced
maximum_chat_retention_days:
settings.settings.maximum_chat_retention_days?.toString() ?? "forever",
anonymous_user_enabled: settings.settings.anonymous_user_enabled ?? false,
disable_default_assistant:
settings.settings.disable_default_assistant ?? false,
};
return (
<Formik
initialValues={initialValues}
onSubmit={() => {}}
enableReinitialize
>
<Form className="h-full w-full">
<ChatPreferencesForm />
</Form>
</Formik>
);
}

View File

@@ -22,7 +22,7 @@ import {
SlackIconSkeleton,
BrainIcon,
} from "@/components/icons/icons";
import { CombinedSettings } from "@/interfaces/settings";
import { CombinedSettings } from "@/app/admin/settings/interfaces";
import SidebarTab from "@/refresh-components/buttons/SidebarTab";
import SidebarBody from "@/sections/sidebar/SidebarBody";
import {
@@ -30,7 +30,6 @@ import {
SvgActivity,
SvgArrowUpCircle,
SvgBarChart,
SvgBubbleText,
SvgCpu,
SvgFileText,
SvgFolder,
@@ -38,9 +37,11 @@ import {
SvgArrowExchange,
SvgImage,
SvgKey,
SvgOnyxLogo,
SvgOnyxOctagon,
SvgSearch,
SvgServer,
SvgSettings,
SvgShield,
SvgThumbsUp,
SvgUploadCloud,
@@ -188,9 +189,9 @@ const collections = (
name: "Configuration",
items: [
{
name: "Chat Preferences",
icon: SvgBubbleText,
link: "/admin/configuration/chat-preferences",
name: "Default Assistant",
icon: SvgOnyxLogo,
link: "/admin/configuration/default-assistant",
},
{
name: "LLM",
@@ -297,6 +298,11 @@ const collections = (
{
name: "Settings",
items: [
{
name: "Workspace Settings",
icon: SvgSettings,
link: "/admin/settings",
},
...(enableEnterprise
? [
{

View File

@@ -72,7 +72,10 @@ import BuildModeIntroContent from "@/app/craft/components/IntroContent";
import { CRAFT_PATH } from "@/app/craft/v1/constants";
import { usePostHog } from "posthog-js/react";
import { motion, AnimatePresence } from "motion/react";
import { Notification, NotificationType } from "@/interfaces/settings";
import {
Notification,
NotificationType,
} from "@/app/admin/settings/interfaces";
import { errorHandlingFetcher } from "@/lib/fetcher";
import UserAvatarPopover from "@/sections/sidebar/UserAvatarPopover";
import ChatSearchCommandMenu from "@/sections/sidebar/ChatSearchCommandMenu";

View File

@@ -4,7 +4,10 @@ import useSWR from "swr";
import { useRouter } from "next/navigation";
import { Route } from "next";
import { usePostHog } from "posthog-js/react";
import { Notification, NotificationType } from "@/interfaces/settings";
import {
Notification,
NotificationType,
} from "@/app/admin/settings/interfaces";
import { errorHandlingFetcher } from "@/lib/fetcher";
import Text from "@/refresh-components/texts/Text";
import LineItem from "@/refresh-components/buttons/LineItem";

View File

@@ -2,7 +2,7 @@
import { useState } from "react";
import { ANONYMOUS_USER_NAME, LOGOUT_DISABLED } from "@/lib/constants";
import { Notification } from "@/interfaces/settings";
import { Notification } from "@/app/admin/settings/interfaces";
import useSWR, { preload } from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { checkUserIsNoAuthUser, logout } from "@/lib/user";

View File

@@ -1,5 +1,4 @@
import { test, expect } from "@playwright/test";
import type { Page, Locator } from "@playwright/test";
import { loginAs } from "@tests/e2e/utils/auth";
import {
TOOL_IDS,
@@ -8,37 +7,7 @@ import {
} from "@tests/e2e/utils/tools";
import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient";
/**
* Locate the Switch toggle for a built-in tool by its display name.
* Each tool sits inside its own `<label>` wrapper created by InputLayouts.Horizontal.
*/
function getToolSwitch(page: Page, toolName: string): Locator {
return page
.locator("label")
.filter({ has: page.getByText(toolName, { exact: true }) })
.locator('button[role="switch"]')
.first();
}
/**
* Click a tool switch and wait for the PATCH response to complete.
* Uses waitForResponse set up *before* the click to avoid race conditions.
*/
async function clickToolSwitchAndWaitForSave(
page: Page,
switchLocator: Locator
): Promise<void> {
const patchPromise = page.waitForResponse(
(r) =>
r.url().includes("/api/admin/default-assistant") &&
r.request().method() === "PATCH",
{ timeout: 8000 }
);
await switchLocator.click();
await patchPromise;
}
test.describe("Chat Preferences Admin Page", () => {
test.describe("Default Assistant Admin Page", () => {
let testCcPairId: number | null = null;
let webSearchProviderId: number | null = null;
let imageGenConfigId: string | null = null;
@@ -68,17 +37,14 @@ test.describe("Chat Preferences Admin Page", () => {
console.warn(`Failed to create tool providers: ${error}`);
}
// Navigate to chat preferences
await page.goto("/admin/configuration/chat-preferences");
await page.waitForURL("**/admin/configuration/chat-preferences**");
// Navigate to default assistant
await page.goto("/admin/configuration/default-assistant");
await page.waitForURL("**/admin/configuration/default-assistant**");
// Attach basic API logging for this spec
page.on("response", async (resp) => {
const url = resp.url();
if (
url.includes("/api/admin/default-assistant") ||
url.includes("/api/admin/settings")
) {
if (url.includes("/api/admin/default-assistant")) {
const method = resp.request().method();
const status = resp.status();
let body = "";
@@ -156,239 +122,290 @@ test.describe("Chat Preferences Admin Page", () => {
}
});
test("should load chat preferences page for admin users", async ({
test("should load default assistant page for admin users", async ({
page,
}) => {
// Verify page loads with expected content
await expect(page.locator('[aria-label="admin-page-title"]')).toHaveText(
"Chat Preferences"
"Default Assistant"
);
await expect(page.getByText("Actions & Tools")).toBeVisible();
// Avoid strict mode collision from multiple "Actions" elements
await expect(page.getByText("Instructions", { exact: true })).toBeVisible();
await expect(page.getByText("Instructions", { exact: true })).toBeVisible();
});
test("should toggle Internal Search tool on and off", async ({ page }) => {
await page.waitForSelector("text=Internal Search", { timeout: 10000 });
const searchSwitch = getToolSwitch(page, "Internal Search");
// Find the Internal Search checkbox using a more robust selector
const searchCheckbox = page.getByLabel("internal-search-checkbox").first();
// Get initial state
const initialState = await searchSwitch.getAttribute("aria-checked");
const initialState = await searchCheckbox.getAttribute("aria-checked");
const isDisabled = initialState === "false";
console.log(
`[toggle] Internal Search initial aria-checked=${initialState}`
`[toggle] Internal Search initial data-state=${initialState} disabled=${isDisabled}`
);
// Set up response listener before the click to avoid race conditions
const patchRespPromise = page.waitForResponse(
// Toggle it
await searchCheckbox.click();
await page.waitForTimeout(500);
// Save changes
const saveButton = page.getByRole("button", { name: "Save Changes" });
await expect(saveButton).toBeVisible({ timeout: 5000 });
await saveButton.click();
// Wait for PATCH to complete
const patchResp = await page.waitForResponse(
(r) =>
r.url().includes("/api/admin/default-assistant") &&
r.request().method() === "PATCH",
{ timeout: 8000 }
);
// Toggle it — auto-saves immediately
await searchSwitch.click();
// Wait for PATCH to complete
const patchResp = await patchRespPromise;
console.log(
`[toggle] Internal Search PATCH status=${patchResp.status()} body=${(
await patchResp.text()
).slice(0, 300)}`
);
// Wait for success toast
await expect(page.getByText("Tools updated").first()).toBeVisible({
// Wait for success message
await expect(page.getByText(/successfully/i)).toBeVisible({
timeout: 5000,
});
await page.waitForTimeout(500);
// Refresh page to verify persistence
await page.reload();
await page.waitForSelector("text=Internal Search", { timeout: 10000 });
// Wait for SWR data to load and React to re-render with the persisted state
const expectedState = initialState === "true" ? "false" : "true";
await expect(searchSwitch).toHaveAttribute("aria-checked", expectedState, {
timeout: 10000,
});
console.log(
`[toggle] Internal Search after reload aria-checked=${expectedState}`
);
const newState = await searchCheckbox.getAttribute("aria-checked");
console.log(`[toggle] Internal Search after reload data-state=${newState}`);
// State should have changed
expect(initialState).not.toBe(newState);
// Toggle back to original state
await clickToolSwitchAndWaitForSave(page, searchSwitch);
await searchCheckbox.click();
await page.waitForTimeout(500);
// Save the restoration
const saveButtonRestore = page.getByRole("button", {
name: "Save Changes",
});
await expect(saveButtonRestore).toBeVisible({ timeout: 5000 });
await saveButtonRestore.click();
await expect(page.getByText(/successfully/i)).toBeVisible({
timeout: 5000,
});
});
test("should toggle Web Search tool on and off", async ({ page }) => {
await page.waitForSelector("text=Web Search", { timeout: 10000 });
const webSearchSwitch = getToolSwitch(page, "Web Search");
// Find the Web Search checkbox using a more robust selector
const webSearchCheckbox = page.getByLabel("web-search-checkbox").first();
// Get initial state
const initialState = await webSearchSwitch.getAttribute("aria-checked");
console.log(`[toggle] Web Search initial aria-checked=${initialState}`);
const initialState = await webSearchCheckbox.getAttribute("aria-checked");
const isDisabled = initialState === "false";
console.log(
`[toggle] Web Search initial data-state=${initialState} disabled=${isDisabled}`
);
// Set up response listener before the click to avoid race conditions
const patchRespPromise = page.waitForResponse(
// Toggle it
await webSearchCheckbox.click();
await page.waitForTimeout(500);
// Save changes
const saveButton = page.getByRole("button", { name: "Save Changes" });
await expect(saveButton).toBeVisible({ timeout: 5000 });
await saveButton.click();
// Wait for PATCH to complete
const patchResp = await page.waitForResponse(
(r) =>
r.url().includes("/api/admin/default-assistant") &&
r.request().method() === "PATCH",
{ timeout: 8000 }
);
// Toggle it
await webSearchSwitch.click();
// Wait for PATCH to complete
const patchResp = await patchRespPromise;
console.log(
`[toggle] Web Search PATCH status=${patchResp.status()} body=${(
await patchResp.text()
).slice(0, 300)}`
);
// Wait for success toast
await expect(page.getByText("Tools updated").first()).toBeVisible({
// Wait for success message
await expect(page.getByText(/successfully/i)).toBeVisible({
timeout: 5000,
});
await page.waitForTimeout(500);
// Refresh page to verify persistence
await page.reload();
await page.waitForSelector("text=Web Search", { timeout: 10000 });
// Wait for SWR data to load and React to re-render with the persisted state
const expectedState = initialState === "true" ? "false" : "true";
await expect(webSearchSwitch).toHaveAttribute(
"aria-checked",
expectedState,
{ timeout: 10000 }
);
console.log(
`[toggle] Web Search after reload aria-checked=${expectedState}`
);
// Check that state persisted
const newState = await webSearchCheckbox.getAttribute("aria-checked");
console.log(`[toggle] Web Search after reload data-state=${newState}`);
// State should have changed
expect(initialState).not.toBe(newState);
// Toggle back to original state
await clickToolSwitchAndWaitForSave(page, webSearchSwitch);
await webSearchCheckbox.click();
await page.waitForTimeout(500);
// Save the restoration
const saveButtonRestore = page.getByRole("button", {
name: "Save Changes",
});
await expect(saveButtonRestore).toBeVisible({ timeout: 5000 });
await saveButtonRestore.click();
await expect(page.getByText(/successfully/i)).toBeVisible({
timeout: 5000,
});
});
test("should toggle Image Generation tool on and off", async ({ page }) => {
await page.waitForSelector("text=Image Generation", { timeout: 10000 });
const imageGenSwitch = getToolSwitch(page, "Image Generation");
// Find the Image Generation checkbox using a more robust selector
const imageGenCheckbox = page
.getByLabel("image-generation-checkbox")
.first();
// Get initial state
const initialState = await imageGenSwitch.getAttribute("aria-checked");
const initialState = await imageGenCheckbox.getAttribute("aria-checked");
const isDisabled = initialState === "false";
console.log(
`[toggle] Image Generation initial aria-checked=${initialState}`
`[toggle] Image Generation initial data-state=${initialState} disabled=${isDisabled}`
);
// Set up response listener before the click to avoid race conditions
const patchRespPromise = page.waitForResponse(
// Toggle it
await imageGenCheckbox.click();
await page.waitForTimeout(500);
// Save changes
const saveButton = page.getByRole("button", { name: "Save Changes" });
await expect(saveButton).toBeVisible({ timeout: 5000 });
await saveButton.click();
// Wait for PATCH to complete
const patchResp = await page.waitForResponse(
(r) =>
r.url().includes("/api/admin/default-assistant") &&
r.request().method() === "PATCH",
{ timeout: 8000 }
);
// Toggle it
await imageGenSwitch.click();
// Wait for PATCH to complete
const patchResp = await patchRespPromise;
console.log(
`[toggle] Image Generation PATCH status=${patchResp.status()} body=${(
await patchResp.text()
).slice(0, 300)}`
);
// Wait for success toast
await expect(page.getByText("Tools updated").first()).toBeVisible({
// Wait for success message
await expect(page.getByText(/successfully/i)).toBeVisible({
timeout: 5000,
});
await page.waitForTimeout(500);
// Refresh page to verify persistence
await page.reload();
await page.waitForSelector("text=Image Generation", { timeout: 10000 });
// Wait for SWR data to load and React to re-render with the persisted state
const expectedState = initialState === "true" ? "false" : "true";
await expect(imageGenSwitch).toHaveAttribute(
"aria-checked",
expectedState,
{ timeout: 10000 }
);
// Check that state persisted
const newState = await imageGenCheckbox.getAttribute("aria-checked");
console.log(
`[toggle] Image Generation after reload aria-checked=${expectedState}`
`[toggle] Image Generation after reload data-state=${newState}`
);
// State should have changed
expect(initialState).not.toBe(newState);
// Toggle back to original state
await clickToolSwitchAndWaitForSave(page, imageGenSwitch);
await imageGenCheckbox.click();
await page.waitForTimeout(500);
// Save the restoration
const saveButtonRestore = page.getByRole("button", {
name: "Save Changes",
});
await expect(saveButtonRestore).toBeVisible({ timeout: 5000 });
await saveButtonRestore.click();
await expect(page.getByText(/successfully/i)).toBeVisible({
timeout: 5000,
});
});
test("should edit and save system prompt", async ({ page }) => {
// Click "Modify Prompt" to open the system prompt modal
await page.getByText("Modify Prompt").click();
await page.waitForSelector("text=Instructions", { timeout: 10000 });
// Wait for modal to appear
const modal = page.getByRole("dialog");
await expect(modal).toBeVisible({ timeout: 5000 });
// Find the textarea using a more flexible selector
const textarea = page.locator(
'textarea[placeholder*="professional email writing assistant"]'
);
// Fill textarea with random suffix to ensure uniqueness
// Get initial value
const initialValue = await textarea.inputValue();
// Clear and enter new text with random suffix to ensure uniqueness
const testPrompt = `This is a test system prompt for the E2E test. ${Math.floor(
Math.random() * 1000000
)}`;
const textarea = modal.getByPlaceholder("Enter your system prompt...");
await textarea.fill(testPrompt);
// Set up response listener before the click to avoid race conditions
const patchRespPromise = page.waitForResponse(
(r) =>
r.url().includes("/api/admin/default-assistant") &&
r.request().method() === "PATCH",
{ timeout: 8000 }
);
// Save changes
const saveButton = page.locator("text=Save Changes");
await saveButton.click();
const patchResp = await Promise.race([
page.waitForResponse(
(r) =>
r.url().includes("/api/admin/default-assistant") &&
r.request().method() === "PATCH",
{ timeout: 8000 }
),
page.waitForTimeout(8500).then(() => null),
]);
if (patchResp) {
console.log(
`[prompt] Save PATCH status=${patchResp.status()} body=${(
await patchResp.text()
).slice(0, 300)}`
);
} else {
console.log(`[prompt] Did not observe PATCH response on save`);
}
// Click Save in the modal footer
await modal.getByRole("button", { name: "Save" }).click();
// Wait for PATCH to complete
const patchResp = await patchRespPromise;
console.log(
`[prompt] Save PATCH status=${patchResp.status()} body=${(
await patchResp.text()
).slice(0, 300)}`
);
// Wait for success toast
await expect(page.getByText("System prompt updated")).toBeVisible({
// Wait for success message
await expect(page.getByText(/successfully/i)).toBeVisible({
timeout: 5000,
});
// Modal should close after save
await expect(modal).not.toBeVisible();
// Refresh page to verify persistence
await page.reload();
await page.waitForLoadState("networkidle");
await page.waitForSelector("text=Instructions", { timeout: 10000 });
// Reopen modal and verify
await page.getByText("Modify Prompt").click();
const modalAfter = page.getByRole("dialog");
await expect(modalAfter).toBeVisible({ timeout: 5000 });
await expect(
modalAfter.getByPlaceholder("Enter your system prompt...")
).toHaveValue(testPrompt);
// Check that new value persisted
const textareaAfter = page.locator(
'textarea[placeholder*="professional email writing assistant"]'
);
await expect(textareaAfter).toHaveValue(testPrompt);
// Close modal without saving to clean up
await modalAfter.getByRole("button", { name: "Cancel" }).click();
// Restore original value
await textareaAfter.fill(initialValue);
const saveButtonAfter = page.locator("text=Save Changes");
await saveButtonAfter.click();
await expect(page.getByText(/successfully/i)).toBeVisible();
});
test("should allow empty system prompt", async ({ page }) => {
// Open system prompt modal
await page.getByText("Modify Prompt").click();
const modal = page.getByRole("dialog");
await expect(modal).toBeVisible({ timeout: 5000 });
await page.waitForSelector("text=Instructions", { timeout: 10000 });
const textarea = modal.getByPlaceholder("Enter your system prompt...");
// Find the textarea using a more flexible selector
const textarea = page.locator(
'textarea[placeholder*="professional email writing assistant"]'
);
// Get initial value to restore later
const initialValue = await textarea.inputValue();
@@ -396,132 +413,155 @@ test.describe("Chat Preferences Admin Page", () => {
// If already empty, add some text first
if (initialValue === "") {
await textarea.fill("Temporary text");
await modal.getByRole("button", { name: "Save" }).click();
await expect(page.getByText("System prompt updated")).toBeVisible({
timeout: 5000,
});
await page.waitForTimeout(500);
// Reopen modal
await page.getByText("Modify Prompt").click();
await expect(modal).toBeVisible({ timeout: 5000 });
const tempSaveButton = page.locator("text=Save Changes");
await tempSaveButton.click();
const patchResp1 = await page.waitForResponse(
(r) =>
r.url().includes("/api/admin/default-assistant") &&
r.request().method() === "PATCH"
);
console.log(
`[prompt-empty] Temp save PATCH status=${patchResp1.status()} body=${(
await patchResp1.text()
).slice(0, 300)}`
);
await expect(page.getByText(/successfully/i)).toBeVisible();
await page.waitForTimeout(1000);
}
// Clear the textarea
// Now clear the textarea
await textarea.fill("");
// Set up response listener before the click to avoid race conditions
const patchRespPromise = page.waitForResponse(
// Save changes
const saveButton = page.locator("text=Save Changes");
await saveButton.click();
const patchResp2 = await page.waitForResponse(
(r) =>
r.url().includes("/api/admin/default-assistant") &&
r.request().method() === "PATCH",
{ timeout: 8000 }
r.request().method() === "PATCH"
);
// Save
await modal.getByRole("button", { name: "Save" }).click();
const patchResp = await patchRespPromise;
console.log(
`[prompt-empty] Save empty PATCH status=${patchResp.status()} body=${(
await patchResp.text()
`[prompt-empty] Save empty PATCH status=${patchResp2.status()} body=${(
await patchResp2.text()
).slice(0, 300)}`
);
await expect(page.getByText("System prompt updated")).toBeVisible({
// Wait for success message
await expect(page.getByText(/successfully/i)).toBeVisible({
timeout: 5000,
});
// Refresh page to verify persistence
await page.reload();
await page.waitForLoadState("networkidle");
await page.waitForSelector("text=Instructions", { timeout: 10000 });
// Reopen modal and check
await page.getByText("Modify Prompt").click();
const modalAfter = page.getByRole("dialog");
await expect(modalAfter).toBeVisible({ timeout: 5000 });
// The modal pre-populates with default prompt when system_prompt is empty/null,
// so we just verify the modal opens without error
const textareaAfter = modalAfter.getByPlaceholder(
"Enter your system prompt..."
// Check that empty value persisted
const textareaAfter = page.locator(
'textarea[placeholder*="professional email writing assistant"]'
);
await expect(textareaAfter).toBeVisible();
await expect(textareaAfter).toHaveValue("");
// Restore original value if it wasn't already empty
if (initialValue !== "") {
await textareaAfter.fill(initialValue);
await modalAfter.getByRole("button", { name: "Save" }).click();
await expect(page.getByText("System prompt updated")).toBeVisible({
timeout: 5000,
});
} else {
await modalAfter.getByRole("button", { name: "Cancel" }).click();
const saveButtonAfter = page.locator("text=Save Changes");
await saveButtonAfter.click();
const patchResp3 = await page.waitForResponse(
(r) =>
r.url().includes("/api/admin/default-assistant") &&
r.request().method() === "PATCH"
);
console.log(
`[prompt-empty] Restore PATCH status=${patchResp3.status()} body=${(
await patchResp3.text()
).slice(0, 300)}`
);
await expect(page.getByText(/successfully/i)).toBeVisible();
}
});
test("should handle very long system prompt gracefully", async ({ page }) => {
// Open system prompt modal
await page.getByText("Modify Prompt").click();
const modal = page.getByRole("dialog");
await expect(modal).toBeVisible({ timeout: 5000 });
await page.waitForSelector("text=Instructions", { timeout: 10000 });
const textarea = modal.getByPlaceholder("Enter your system prompt...");
// Find the textarea using a more flexible selector
const textarea = page.locator(
'textarea[placeholder*="professional email writing assistant"]'
);
// Get initial value to restore later
const initialValue = await textarea.inputValue();
// Create a very long prompt (~4800 characters)
const longPrompt = "This is a test. ".repeat(300);
// Create a very long prompt (5000 characters)
const longPrompt = "This is a test. ".repeat(300); // ~4800 characters
await textarea.fill(longPrompt);
// If the current value is already the long prompt, use a different one
if (initialValue === longPrompt) {
const differentPrompt = "Different test. ".repeat(300);
await textarea.fill(differentPrompt);
} else {
await textarea.fill(longPrompt);
}
// Set up response listener before the click to avoid race conditions
const patchRespPromise = page.waitForResponse(
// Save changes
const saveButton = page.locator("text=Save Changes");
await saveButton.click();
const patchResp = await page.waitForResponse(
(r) =>
r.url().includes("/api/admin/default-assistant") &&
r.request().method() === "PATCH",
{ timeout: 8000 }
r.request().method() === "PATCH"
);
// Save
await modal.getByRole("button", { name: "Save" }).click();
const patchResp = await patchRespPromise;
console.log(
`[prompt-long] Save PATCH status=${patchResp.status()} body=${(
await patchResp.text()
).slice(0, 300)}`
);
await expect(page.getByText("System prompt updated")).toBeVisible({
// Wait for success message
await expect(page.getByText(/successfully/i)).toBeVisible({
timeout: 5000,
});
// Verify persistence after reload
await page.reload();
await page.waitForLoadState("networkidle");
// Verify character count is displayed
const currentValue = await textarea.inputValue();
const charCount = page.locator("text=characters");
await expect(charCount).toContainText(currentValue.length.toString());
await page.getByText("Modify Prompt").click();
const modalAfter = page.getByRole("dialog");
await expect(modalAfter).toBeVisible({ timeout: 5000 });
await expect(
modalAfter.getByPlaceholder("Enter your system prompt...")
).toHaveValue(longPrompt);
// Restore original value
if (initialValue !== longPrompt) {
const restoreTextarea = modalAfter.getByPlaceholder(
"Enter your system prompt..."
// Restore original value if it's different
if (initialValue !== currentValue) {
await textarea.fill(initialValue);
await saveButton.click();
const patchRespRestore = await page.waitForResponse(
(r) =>
r.url().includes("/api/admin/default-assistant") &&
r.request().method() === "PATCH"
);
await restoreTextarea.fill(initialValue);
await modalAfter.getByRole("button", { name: "Save" }).click();
await expect(page.getByText("System prompt updated")).toBeVisible({
timeout: 5000,
});
} else {
await modalAfter.getByRole("button", { name: "Cancel" }).click();
console.log(
`[prompt-long] Restore PATCH status=${patchRespRestore.status()} body=${(
await patchRespRestore.text()
).slice(0, 300)}`
);
await expect(page.getByText(/successfully/i)).toBeVisible();
}
});
test("should display character count for system prompt", async ({ page }) => {
await page.waitForSelector("text=Instructions", { timeout: 10000 });
// Find the textarea using a more flexible selector
const textarea = page.locator(
'textarea[placeholder*="professional email writing assistant"]'
);
// Type some text
const testText = "Test text for character counting";
await textarea.fill(testText);
// Check character count is displayed correctly
await expect(page.locator("text=characters")).toContainText(
testText.length.toString()
);
});
test("should reject invalid tool IDs via API", async ({ page }) => {
// Use browser console to send invalid tool IDs
// This simulates what would happen if someone tried to bypass the UI
@@ -587,8 +627,10 @@ test.describe("Chat Preferences Admin Page", () => {
"Web Search",
"Image Generation",
]) {
const toolSwitch = getToolSwitch(page, toolName);
const state = await toolSwitch.getAttribute("aria-checked");
const toolCheckbox = page
.getByLabel(`${toolName.toLowerCase().replace(" ", "-")}-checkbox`)
.first();
const state = await toolCheckbox.getAttribute("aria-checked");
toolStates[toolName] = state;
console.log(`[toggle-all] Initial state for ${toolName}: ${state}`);
}
@@ -599,21 +641,33 @@ test.describe("Chat Preferences Admin Page", () => {
"Web Search",
"Image Generation",
]) {
const toolSwitch = getToolSwitch(page, toolName);
const currentState = await toolSwitch.getAttribute("aria-checked");
const toolCheckbox = page
.getByLabel(`${toolName.toLowerCase().replace(" ", "-")}-checkbox`)
.first();
const currentState = await toolCheckbox.getAttribute("aria-checked");
if (currentState === "true") {
await clickToolSwitchAndWaitForSave(page, toolSwitch);
const newState = await toolSwitch.getAttribute("aria-checked");
await toolCheckbox.click();
await page.waitForTimeout(300);
const newState = await toolCheckbox.getAttribute("aria-checked");
console.log(`[toggle-all] Clicked ${toolName}, new state=${newState}`);
}
}
// Save changes
const saveButton = page.getByRole("button", { name: "Save Changes" });
await expect(saveButton).toBeVisible({ timeout: 5000 });
await saveButton.click();
await expect(page.getByText(/successfully/i)).toBeVisible({
timeout: 5000,
});
await page.waitForTimeout(500);
// Navigate to app to verify tools are disabled and initial load greeting
await page.goto("/app");
await waitForUnifiedGreeting(page);
// Go back and re-enable all tools
await page.goto("/admin/configuration/chat-preferences");
await page.goto("/admin/configuration/default-assistant");
await page.waitForLoadState("networkidle");
// Reload to ensure the page has the updated tools list (after providers were created)
await page.reload();
@@ -625,15 +679,28 @@ test.describe("Chat Preferences Admin Page", () => {
"Web Search",
"Image Generation",
]) {
const toolSwitch = getToolSwitch(page, toolName);
const currentState = await toolSwitch.getAttribute("aria-checked");
const toolCheckbox = page
.getByLabel(`${toolName.toLowerCase().replace(" ", "-")}-checkbox`)
.first();
const currentState = await toolCheckbox.getAttribute("aria-checked");
if (currentState === "false") {
await clickToolSwitchAndWaitForSave(page, toolSwitch);
const newState = await toolSwitch.getAttribute("aria-checked");
await toolCheckbox.click();
const newState = await toolCheckbox.getAttribute("aria-checked");
console.log(`[toggle-all] Clicked ${toolName}, new state=${newState}`);
}
}
// Save changes
const saveButtonRenable = page.getByRole("button", {
name: "Save Changes",
});
await expect(saveButtonRenable).toBeVisible({ timeout: 5000 });
await saveButtonRenable.click();
await expect(page.getByText(/successfully/i)).toBeVisible({
timeout: 5000,
});
await page.waitForTimeout(500);
// Navigate to app and verify the Action Management toggle and actions exist
await page.goto("/app");
await page.waitForLoadState("networkidle");
@@ -708,7 +775,7 @@ test.describe("Chat Preferences Admin Page", () => {
// Web Search and Image Generation form state when providers are created in beforeEach.
// This is being tracked separately as a potential Formik/form state bug.
await page.goto("/admin/configuration/chat-preferences");
await page.goto("/admin/configuration/default-assistant");
// Restore original states
let needsSave = false;
@@ -717,31 +784,49 @@ test.describe("Chat Preferences Admin Page", () => {
"Web Search",
"Image Generation",
]) {
const toolSwitch = getToolSwitch(page, toolName);
const currentState = await toolSwitch.getAttribute("aria-checked");
const toolCheckbox = page
.getByLabel(`${toolName.toLowerCase().replace(" ", "-")}-checkbox`)
.first();
const currentState = await toolCheckbox.getAttribute("aria-checked");
const originalState = toolStates[toolName];
if (currentState !== originalState) {
await clickToolSwitchAndWaitForSave(page, toolSwitch);
await toolCheckbox.click();
await page.waitForTimeout(300);
needsSave = true;
}
}
// Save if any changes were made
if (needsSave) {
const saveButtonRestore = page.getByRole("button", {
name: "Save Changes",
});
await expect(saveButtonRestore).toBeVisible({ timeout: 5000 });
await saveButtonRestore.click();
await expect(page.getByText(/successfully/i)).toBeVisible({
timeout: 5000,
});
await page.waitForTimeout(500);
}
// Cleanup is now handled in afterEach
});
});
test.describe("Chat Preferences Non-Admin Access", () => {
test.describe("Default Assistant Non-Admin Access", () => {
test("should redirect non-authenticated users", async ({ page }) => {
// Clear cookies to ensure we're not authenticated
await page.context().clearCookies();
// Try to navigate directly to chat preferences without logging in
await page.goto("/admin/configuration/chat-preferences");
// Try to navigate directly to default assistant without logging in
await page.goto("/admin/configuration/default-assistant");
// Wait for navigation to settle
await page.waitForTimeout(2000);
// Should be redirected away from admin page
const url = page.url();
expect(!url.includes("/admin/configuration/chat-preferences")).toBe(true);
expect(!url.includes("/admin/configuration/default-assistant")).toBe(true);
});
});

View File

@@ -3,41 +3,11 @@ import { loginAs } from "@tests/e2e/utils/auth";
import { createAssistant } from "@tests/e2e/utils/assistantUtils";
import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient";
const DISABLE_DEFAULT_ASSISTANT_LABEL =
'label:has-text("Disable Default Assistant") input[type="checkbox"]';
const MAX_SETTING_SAVE_ATTEMPTS = 5;
const SETTING_SAVE_RETRY_DELAY_MS = 750;
/**
* Expand the "Advanced Options" collapsible section on the Chat Preferences page.
* The section is closed by default (`defaultOpen={false}`).
* Only expands if not already open (checks for the switch element visibility).
*/
async function expandAdvancedOptions(page: Page): Promise<void> {
// Wait for the page title to be visible, signalling the form has loaded
await expect(page.locator('[aria-label="admin-page-title"]')).toBeVisible({
timeout: 10000,
});
// Check if the switch is already visible (section already expanded)
const switchEl = page.locator("#disable_default_assistant");
const alreadyVisible = await switchEl.isVisible().catch(() => false);
if (alreadyVisible) return;
const header = page.getByText("Advanced Options", { exact: true });
await expect(header).toBeVisible({ timeout: 10000 });
await header.scrollIntoViewIfNeeded();
await header.click();
// Wait for the collapsible content to expand and switch to appear
await expect(switchEl).toBeVisible({ timeout: 5000 });
}
/**
* Toggle the "Always Start with an Agent" setting (formerly "Disable Default Assistant")
* on the Chat Preferences page. Uses auto-save via the SwitchField.
*
* The switch is a SwitchField with name="disable_default_assistant" which renders
* `<button role="switch" id="disable_default_assistant" aria-checked="...">`.
*/
async function setDisableDefaultAssistantSetting(
page: Page,
isDisabled: boolean
@@ -45,41 +15,29 @@ async function setDisableDefaultAssistantSetting(
let lastCheckedState = false;
for (let attempt = 0; attempt < MAX_SETTING_SAVE_ATTEMPTS; attempt += 1) {
await page.goto("/admin/configuration/chat-preferences");
await page.waitForLoadState("networkidle");
await page.goto("/admin/settings");
await page.waitForURL("/admin/settings");
// Expand "Advanced Options" collapsible (closed by default)
await expandAdvancedOptions(page);
const switchEl = page.locator("#disable_default_assistant");
await expect(switchEl).toBeVisible({ timeout: 5000 });
const currentState = await switchEl.getAttribute("aria-checked");
lastCheckedState = currentState === "true";
const disableDefaultAssistantCheckbox = page.locator(
DISABLE_DEFAULT_ASSISTANT_LABEL
);
lastCheckedState = await disableDefaultAssistantCheckbox.isChecked();
if (lastCheckedState === isDisabled) {
return;
}
// Toggle the switch
await switchEl.click();
// Wait for auto-save toast
await expect(page.getByText("Settings updated")).toBeVisible({
timeout: 5000,
});
await disableDefaultAssistantCheckbox.click();
if (isDisabled) {
await expect(disableDefaultAssistantCheckbox).toBeChecked();
} else {
await expect(disableDefaultAssistantCheckbox).not.toBeChecked();
}
await page.waitForTimeout(SETTING_SAVE_RETRY_DELAY_MS);
// Verify persistence after reload
await page.reload();
await page.waitForLoadState("networkidle");
// Re-expand Advanced Options (closed by default after reload)
await expandAdvancedOptions(page);
const newState = await switchEl.getAttribute("aria-checked");
lastCheckedState = newState === "true";
await page.waitForURL("/admin/settings");
lastCheckedState = await disableDefaultAssistantCheckbox.isChecked();
if (lastCheckedState === isDisabled) {
return;
@@ -87,7 +45,7 @@ async function setDisableDefaultAssistantSetting(
}
throw new Error(
`Failed to persist Always Start with an Agent setting after ${MAX_SETTING_SAVE_ATTEMPTS} attempts (expected ${isDisabled}, last=${lastCheckedState}).`
`Failed to persist Disable Default Assistant setting after ${MAX_SETTING_SAVE_ATTEMPTS} attempts (expected ${isDisabled}, last=${lastCheckedState}).`
);
}
@@ -108,14 +66,15 @@ test.describe("Disable Default Assistant Setting @exclusive", () => {
createdAssistantId = null;
}
// Ensure default assistant is enabled (switch unchecked) after each test
// Ensure default assistant is enabled (checkbox unchecked) after each test
// to avoid interfering with other tests
await setDisableDefaultAssistantSetting(page, false);
});
test("admin can enable and disable the setting in chat preferences", async ({
test("admin can enable and disable the setting in workspace settings", async ({
page,
}) => {
// Navigate to settings page
await setDisableDefaultAssistantSetting(page, true);
await setDisableDefaultAssistantSetting(page, false);
await setDisableDefaultAssistantSetting(page, true);
@@ -178,54 +137,33 @@ test.describe("Disable Default Assistant Setting @exclusive", () => {
}
});
test("chat preferences shows disabled state when setting is enabled", async ({
test("default assistant config panel shows message when setting is enabled", async ({
page,
}) => {
// First enable the setting
await setDisableDefaultAssistantSetting(page, true);
// Navigate to chat preferences configuration page
await page.goto("/admin/configuration/chat-preferences");
await page.waitForLoadState("networkidle");
// Navigate to default assistant configuration page
await page.goto("/admin/configuration/default-assistant");
await page.waitForURL("/admin/configuration/default-assistant");
// Wait for the page to fully render (page title signals form is loaded)
await expect(page.locator('[aria-label="admin-page-title"]')).toHaveText(
"Chat Preferences",
{ timeout: 10000 }
);
// Verify informative message is shown
await expect(
page.getByText(
"The default assistant is currently disabled in your workspace settings."
)
).toBeVisible();
// The new page wraps Connectors + Actions & Tools in <Disabled disabled={values.disable_default_assistant}>
// When disabled, the section should have reduced opacity / disabled styling
// The "Modify Prompt" button should still be accessible (it's outside the Disabled wrapper)
// Use text locator (Opal Button wraps text in Interactive.Base > Slot which may
// not expose role="button" to Playwright's getByRole)
await expect(page.getByText("Modify Prompt")).toBeVisible({
timeout: 5000,
});
// Verify link to Settings is present
const settingsLinks = page.locator('a[href="/admin/settings"]');
await expect(settingsLinks).toHaveCount(2);
await expect(settingsLinks.first()).toBeVisible();
await expect(settingsLinks.nth(1)).toBeVisible();
// The "Actions & Tools" section text should still be present but visually disabled
await expect(page.getByText("Actions & Tools")).toBeVisible();
});
test("chat preferences shows full configuration UI when setting is disabled", async ({
page,
}) => {
// Ensure setting is disabled
await setDisableDefaultAssistantSetting(page, false);
// Navigate to chat preferences configuration page
await page.goto("/admin/configuration/chat-preferences");
await page.waitForLoadState("networkidle");
// Verify configuration UI is shown (Actions & Tools section should be visible and enabled)
await expect(page.getByText("Actions & Tools")).toBeVisible({
timeout: 10000,
});
// Verify the page title
await expect(page.locator('[aria-label="admin-page-title"]')).toHaveText(
"Chat Preferences"
);
// Verify actual configuration UI is hidden (Instructions textarea should not be visible)
await expect(
page.locator('textarea[placeholder*="professional email"]')
).not.toBeVisible();
});
test("default assistant is available again when setting is disabled", async ({
@@ -240,6 +178,8 @@ test.describe("Disable Default Assistant Setting @exclusive", () => {
// The default assistant (ID 0) should be available
// We can verify this by checking that the app loads successfully
// and doesn't force navigation to a specific assistant
const currentUrl = page.url();
// URL might not have assistantId, or it might be 0, or might redirect to default behavior
expect(page.url()).toContain("/app");
// Verify the new session button navigates to /app without assistantId
@@ -252,4 +192,34 @@ test.describe("Disable Default Assistant Setting @exclusive", () => {
const newUrl = page.url();
expect(newUrl).toContain("/app");
});
test("default assistant config panel shows configuration UI when setting is disabled", async ({
page,
}) => {
// Navigate to settings and ensure setting is disabled
await page.goto("/admin/settings");
await page.waitForURL("/admin/settings");
const disableDefaultAssistantCheckbox = page.locator(
'label:has-text("Disable Default Assistant") input[type="checkbox"]'
);
const isEnabled = await disableDefaultAssistantCheckbox.isChecked();
if (isEnabled) {
await disableDefaultAssistantCheckbox.click();
}
// Navigate to default assistant configuration page
await page.goto("/admin/configuration/default-assistant");
await page.waitForURL("/admin/configuration/default-assistant");
// Verify configuration UI is shown (Instructions section should be visible)
await expect(page.getByText("Instructions", { exact: true })).toBeVisible();
// Verify informative message is NOT shown
await expect(
page.getByText(
"The default assistant is currently disabled in your workspace settings."
)
).not.toBeVisible();
});
});

View File

@@ -319,19 +319,19 @@ test.describe("Default Assistant MCP Integration", () => {
);
});
test("Admin adds MCP tools to default assistant via chat preferences page", async ({
test("Admin adds MCP tools to default assistant via default assistant page", async ({
page,
}) => {
test.skip(!serverId, "MCP server must be created first");
await page.context().clearCookies();
await loginAs(page, "admin");
console.log(`[test] Logged in as admin for chat preferences config`);
console.log(`[test] Logged in as admin for default assistant config`);
// Navigate to chat preferences page
await page.goto("/admin/configuration/chat-preferences");
await page.waitForURL("**/admin/configuration/chat-preferences**");
console.log(`[test] Navigated to chat preferences page`);
// Navigate to default assistant page
await page.goto("/admin/configuration/default-assistant");
await page.waitForURL("**/admin/configuration/default-assistant**");
console.log(`[test] Navigated to default assistant page`);
// Wait for page to load
await expect(page.locator('[aria-label="admin-page-title"]')).toBeVisible({
@@ -339,36 +339,57 @@ test.describe("Default Assistant MCP Integration", () => {
});
console.log(`[test] Page loaded`);
// Scroll to the Actions & Tools section (open by default)
// Scroll to actions section
await page.evaluate(() => {
window.scrollTo(0, document.body.scrollHeight);
});
await page.waitForTimeout(300);
// Find the MCP server section
const mcpServerSection = page.getByTestId(`mcp-server-section-${serverId}`);
await expect(mcpServerSection).toBeVisible({ timeout: 10000 });
console.log(`[test] MCP server section found for server ID ${serverId}`);
// Scroll section into view
await mcpServerSection.scrollIntoViewIfNeeded();
// Expand the MCP server if collapsed
const toggleButton = page.getByTestId(`mcp-server-toggle-${serverId}`);
const isExpanded = await toggleButton.getAttribute("aria-expanded");
console.log(`[test] MCP server section expanded: ${isExpanded}`);
if (isExpanded === "false") {
await toggleButton.click();
await page.waitForTimeout(300);
console.log(`[test] Expanded MCP server section`);
}
// Select the MCP server checkbox (to enable all tools)
const serverCheckbox = mcpServerSection.getByRole("checkbox", {
name: "mcp-server-select-all-tools-checkbox",
});
await expect(serverCheckbox).toBeVisible({ timeout: 5000 });
await serverCheckbox.scrollIntoViewIfNeeded();
if ((await serverCheckbox.getAttribute("aria-checked")) !== "true") {
await serverCheckbox.click();
}
await expect(serverCheckbox).toHaveAttribute("aria-checked", "true");
console.log(`[test] Checked MCP server checkbox`);
// Scroll to bottom to find Save button
await scrollToBottom(page);
// Find the MCP server card by name text
// The server name appears inside a label within the ActionsLayouts.Header
const serverLabel = page
.locator("label")
.filter({ has: page.getByText(serverName, { exact: true }) });
await expect(serverLabel.first()).toBeVisible({ timeout: 10000 });
console.log(`[test] MCP server card found for server: ${serverName}`);
// Save the form
const saveButton = page.getByRole("button", { name: "Save Changes" });
await expect(saveButton).toBeVisible({ timeout: 5000 });
await saveButton.scrollIntoViewIfNeeded();
await saveButton.click();
console.log(`[test] Clicked Save Changes`);
// Scroll server card into view
await serverLabel.first().scrollIntoViewIfNeeded();
// Wait for success message
await expect(page.getByText(/successfully/i)).toBeVisible({
timeout: 10000,
});
// The server-level Switch in the header toggles ALL tools
const serverSwitch = serverLabel
.first()
.locator('button[role="switch"]')
.first();
await expect(serverSwitch).toBeVisible({ timeout: 5000 });
// Enable all tools by toggling the server switch ON
const serverState = await serverSwitch.getAttribute("aria-checked");
if (serverState !== "true") {
await serverSwitch.click();
// Auto-save triggers immediately
await expect(page.getByText("Tools updated").first()).toBeVisible({
timeout: 10000,
});
}
console.log(`[test] MCP tools successfully added to default assistant`);
});
@@ -639,147 +660,130 @@ test.describe("Default Assistant MCP Integration", () => {
await loginAs(page, "admin");
console.log(`[test] Testing tool modification`);
// Navigate to chat preferences page
await page.goto("/admin/configuration/chat-preferences");
await page.waitForURL("**/admin/configuration/chat-preferences**");
// Navigate to default assistant page
await page.goto("/admin/configuration/default-assistant");
await page.waitForURL("**/admin/configuration/default-assistant**");
// Scroll to Actions & Tools section
// Scroll to actions section
await scrollToBottom(page);
// Find the MCP server card by name
const serverLabel = page
.locator("label")
.filter({ has: page.getByText(serverName, { exact: true }) });
await expect(serverLabel.first()).toBeVisible({ timeout: 10000 });
await serverLabel.first().scrollIntoViewIfNeeded();
// Find the MCP server section
const mcpServerSection = page.getByTestId(`mcp-server-section-${serverId}`);
await expect(mcpServerSection).toBeVisible({ timeout: 10000 });
await mcpServerSection.scrollIntoViewIfNeeded();
// Click "Expand" to reveal individual tools
const expandButton = page.getByRole("button", { name: "Expand" }).first();
const isExpandVisible = await expandButton.isVisible().catch(() => false);
if (isExpandVisible) {
await expandButton.click();
// Expand if needed
const toggleButton = page.getByTestId(`mcp-server-toggle-${serverId}`);
const isExpanded = await toggleButton.getAttribute("aria-expanded");
if (isExpanded === "false") {
await toggleButton.click();
await page.waitForTimeout(300);
console.log(`[test] Expanded MCP server card`);
console.log(`[test] Expanded MCP server section`);
}
// Find a specific tool by name inside the expanded card content
// Individual tools are rendered as ActionsLayouts.Tool with their own Card > Label
const toolLabel = page
.locator("label")
.filter({ has: page.getByText("tool_0", { exact: true }) });
const firstToolSwitch = toolLabel
.first()
.locator('button[role="switch"]')
.first();
// Find a specific tool checkbox
const firstToolCheckbox = mcpServerSection.getByLabel(
`mcp-server-tool-checkbox-tool_0`
);
await expect(firstToolSwitch).toBeVisible({ timeout: 5000 });
await firstToolSwitch.scrollIntoViewIfNeeded();
await expect(firstToolCheckbox).toBeVisible({ timeout: 5000 });
await firstToolCheckbox.scrollIntoViewIfNeeded();
// Get initial state and toggle
const initialChecked = await firstToolSwitch.getAttribute("aria-checked");
const initialChecked = await firstToolCheckbox.getAttribute("aria-checked");
console.log(`[test] Initial tool state: ${initialChecked}`);
await firstToolSwitch.click();
await firstToolCheckbox.click();
await page.waitForTimeout(300);
// Wait for auto-save toast
await expect(page.getByText("Tools updated").first()).toBeVisible({
// Scroll to Save button
await scrollToBottom(page);
// Save changes
const saveButton = page.getByRole("button", { name: "Save Changes" });
await expect(saveButton).toBeVisible({ timeout: 5000 });
await saveButton.scrollIntoViewIfNeeded();
await saveButton.click();
console.log(`[test] Clicked Save Changes`);
// Wait for success
await expect(page.getByText(/successfully/i)).toBeVisible({
timeout: 10000,
});
console.log(`[test] Save successful`);
// Reload and verify persistence
await page.reload();
await page.waitForURL("**/admin/configuration/chat-preferences**");
await page.waitForURL("**/admin/configuration/default-assistant**");
await scrollToBottom(page);
// Re-find the server card
const serverLabelAfter = page
.locator("label")
.filter({ has: page.getByText(serverName, { exact: true }) });
await expect(serverLabelAfter.first()).toBeVisible({ timeout: 10000 });
await serverLabelAfter.first().scrollIntoViewIfNeeded();
// Re-find the section
const mcpServerSectionAfter = page.getByTestId(
`mcp-server-section-${serverId}`
);
await expect(mcpServerSectionAfter).toBeVisible({ timeout: 10000 });
await mcpServerSectionAfter.scrollIntoViewIfNeeded();
// Re-expand the card
const expandButtonAfter = page
.getByRole("button", { name: "Expand" })
.first();
const isExpandVisibleAfter = await expandButtonAfter
.isVisible()
.catch(() => false);
if (isExpandVisibleAfter) {
await expandButtonAfter.click();
// Re-expand the section
const toggleButtonAfter = page.getByTestId(`mcp-server-toggle-${serverId}`);
const isExpandedAfter =
await toggleButtonAfter.getAttribute("aria-expanded");
if (isExpandedAfter === "false") {
await toggleButtonAfter.click();
await page.waitForTimeout(300);
}
// Verify the tool state persisted
const toolLabelAfter = page
.locator("label")
.filter({ has: page.getByText("tool_0", { exact: true }) });
const firstToolSwitchAfter = toolLabelAfter
.first()
.locator('button[role="switch"]')
.first();
await expect(firstToolSwitchAfter).toBeVisible({ timeout: 5000 });
const firstToolCheckboxAfter = mcpServerSectionAfter.getByLabel(
`mcp-server-tool-checkbox-tool_0`
);
await expect(firstToolCheckboxAfter).toBeVisible({ timeout: 5000 });
const finalChecked =
await firstToolSwitchAfter.getAttribute("aria-checked");
await firstToolCheckboxAfter.getAttribute("aria-checked");
console.log(`[test] Final tool state: ${finalChecked}`);
expect(finalChecked).not.toEqual(initialChecked);
});
test("Instructions persist when saving via chat preferences", async ({
test("Instructions persist when saving default assistant", async ({
page,
}) => {
await page.context().clearCookies();
await loginAs(page, "admin");
await page.goto("/admin/configuration/chat-preferences");
await page.waitForURL("**/admin/configuration/chat-preferences**");
await page.goto("/admin/configuration/default-assistant");
await page.waitForURL("**/admin/configuration/default-assistant**");
// Click "Modify Prompt" to open the system prompt modal
const modifyButton = page.getByText("Modify Prompt");
await expect(modifyButton).toBeVisible({ timeout: 5000 });
await modifyButton.click();
// Find the instructions textarea
const instructionsTextarea = page.locator("textarea").first();
await expect(instructionsTextarea).toBeVisible({ timeout: 5000 });
await instructionsTextarea.scrollIntoViewIfNeeded();
const modal = page.getByRole("dialog");
await expect(modal).toBeVisible({ timeout: 5000 });
// Fill instructions in the modal textarea
const testInstructions = `Test instructions for MCP - ${Date.now()}`;
const textarea = modal.getByPlaceholder("Enter your system prompt...");
await textarea.fill(testInstructions);
await instructionsTextarea.fill(testInstructions);
console.log(`[test] Filled instructions`);
// Click Save in the modal footer
await modal.getByRole("button", { name: "Save" }).click();
// Scroll to Save button
await scrollToBottom(page);
await expect(page.getByText("System prompt updated")).toBeVisible({
// Save changes
const saveButton = page.getByRole("button", { name: "Save Changes" });
await expect(saveButton).toBeVisible({ timeout: 5000 });
await saveButton.scrollIntoViewIfNeeded();
await saveButton.click();
await expect(page.getByText(/successfully/i)).toBeVisible({
timeout: 10000,
});
console.log(`[test] Instructions saved successfully`);
// Modal should close
await expect(modal).not.toBeVisible();
// Reload and verify — wait for all data to load before opening modal
// (the modal reads system_prompt from SWR state at click time, so data must be ready)
// Reload and verify
await page.reload();
await page.waitForLoadState("networkidle");
await page.waitForURL("**/admin/configuration/chat-preferences**");
await page.waitForURL("**/admin/configuration/default-assistant**");
// Reopen modal and check persisted value
const modifyButtonAfter = page.getByText("Modify Prompt");
await expect(modifyButtonAfter).toBeVisible({ timeout: 5000 });
await modifyButtonAfter.click();
const modalAfter = page.getByRole("dialog");
await expect(modalAfter).toBeVisible({ timeout: 5000 });
await expect(
modalAfter.getByPlaceholder("Enter your system prompt...")
).toHaveValue(testInstructions);
const instructionsTextareaAfter = page.locator("textarea").first();
await expect(instructionsTextareaAfter).toBeVisible({ timeout: 5000 });
await expect(instructionsTextareaAfter).toHaveValue(testInstructions);
console.log(`[test] Instructions persisted correctly`);
// Close modal
await modalAfter.getByRole("button", { name: "Cancel" }).click();
});
test("MCP tools appear in basic user's chat actions after being added to default assistant", async ({

View File

@@ -72,41 +72,33 @@ export async function pinAssistantByName(
/**
* Ensures the Image Generation tool is enabled in the default assistant configuration.
* If it's not enabled, it will toggle it on.
*
* Navigates to the Chat Preferences page and toggles the Image Generation switch
* inside the "Actions & Tools" collapsible section (open by default).
*/
export async function ensureImageGenerationEnabled(page: Page): Promise<void> {
// Navigate to the chat preferences page
await page.goto("/admin/configuration/chat-preferences");
// Navigate to the default assistant configuration page
await page.goto("/admin/configuration/default-assistant");
// Wait for the page to load
await page.waitForLoadState("networkidle");
// The "Actions & Tools" collapsible is open by default.
// Find the Image Generation tool switch via its label container.
const imageGenSwitch = page
.locator("label")
.filter({ has: page.getByText("Image Generation", { exact: true }) })
.locator('button[role="switch"]')
.first();
await expect(imageGenSwitch).toBeVisible({ timeout: 10000 });
// Find the Image Generation tool checkbox
// The tool display name is "Image Generation" based on the description in the code
// Note: The UI changed from switches to checkboxes
const checkboxElement = page.getByLabel("image-generation-checkbox").first();
// Check if it's already enabled
const currentState = await imageGenSwitch.getAttribute("aria-checked");
const isEnabled = Boolean(await checkboxElement.getAttribute("aria-checked"));
if (currentState !== "true") {
// Toggle it on — auto-saves immediately via PATCH /api/admin/default-assistant
await imageGenSwitch.click();
if (!isEnabled) {
// If not enabled, click to enable it
await checkboxElement.click();
// Wait for the auto-save toast to confirm success
await expect(page.getByText("Tools updated").first()).toBeVisible({
timeout: 5000,
});
// Wait for the toggle to complete
await page.waitForTimeout(1000);
// Verify it's now enabled
const newState = await imageGenSwitch.getAttribute("aria-checked");
if (newState !== "true") {
throw new Error("Failed to enable Image Generation tool");
}
const newState = Boolean(
await checkboxElement.getAttribute("aria-checked")
);
if (!newState) throw new Error("Failed to enable Image Generation tool");
}
}