mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-24 11:15:47 +00:00
Compare commits
1 Commits
ci_script
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
151e81441e |
@@ -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")
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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")"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 won’t 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import ChatPreferencesPage from "@/refresh-pages/admin/ChatPreferencesPage";
|
||||
|
||||
export default function Page() {
|
||||
return <ChatPreferencesPage />;
|
||||
}
|
||||
307
web/src/app/admin/configuration/default-assistant/page.tsx
Normal file
307
web/src/app/admin/configuration/default-assistant/page.tsx
Normal 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'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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
124
web/src/app/admin/settings/AnonymousUserPath.tsx
Normal file
124
web/src/app/admin/settings/AnonymousUserPath.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
453
web/src/app/admin/settings/SettingsForm.tsx
Normal file
453
web/src/app/admin/settings/SettingsForm.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
109
web/src/app/admin/settings/hooks/useVisionProviders.ts
Normal file
109
web/src/app/admin/settings/hooks/useVisionProviders.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
20
web/src/app/admin/settings/page.tsx
Normal file
20
web/src/app/admin/settings/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
228
web/src/components/admin/assistants/FormSections.tsx
Normal file
228
web/src/components/admin/assistants/FormSections.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
304
web/src/components/admin/assistants/ToolSelector.tsx
Normal file
304
web/src/components/admin/assistants/ToolSelector.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -314,7 +314,6 @@ function Header() {
|
||||
/>
|
||||
)}
|
||||
{isPaidEnterpriseFeaturesEnabled &&
|
||||
settings.isSearchModeAvailable &&
|
||||
appFocus.isNewSession() &&
|
||||
!classification && (
|
||||
<Popover open={modePopoverOpen} onOpenChange={setModePopoverOpen}>
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -300,7 +300,6 @@ export interface OAuthConfluenceFinalizeResponse {
|
||||
export interface CCPairBasicInfo {
|
||||
has_successful_run: boolean;
|
||||
source: ValidSources;
|
||||
status: ConnectorCredentialPairStatus;
|
||||
}
|
||||
|
||||
export type ConnectorSummary = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user