Compare commits

..

18 Commits

Author SHA1 Message Date
Jamison Lahman
1e69f66705 fix(chat): improve LLM_SOCKET_READ_TIMEOUT user experience 2026-04-17 12:35:02 -07:00
Jamison Lahman
66c361bd37 fix(deps): install transitive vertexai dependency (#10328) 2026-04-17 12:14:46 -07:00
Wenxi
01cbea8c4b fix: zuplip temp dir init (#10324) 2026-04-17 18:37:30 +00:00
Raunak Bhagat
2dc2b0da84 refactor: Update toast notifications look and feel (#10320) 2026-04-17 17:54:56 +00:00
Raunak Bhagat
4b58c9cda6 fix: chat preferences page spacing and layout cleanup (#10317) 2026-04-17 09:42:36 -07:00
Justin Tahara
7eb945f060 fix(web): Sentry Token Check (#10310) 2026-04-17 15:44:24 +00:00
Raunak Bhagat
e29f948f29 refactor: migrate ActionsLayouts to @opal/layouts's CardLayout.Header (#10301) 2026-04-17 15:39:32 +00:00
Jamison Lahman
7a18b896aa fix(fe): LineItem can disable icon stroke (#10289) 2026-04-17 15:21:23 +00:00
Bo-Onyx
53e00c7989 fix(pruning): Google Drive Connecto - Skip redundant folder metadata calls for orphaned folders (#10304) 2026-04-17 06:06:30 +00:00
Raunak Bhagat
50df53727a refactor: migrate EmptyMessage to @opal/components.EmptyMessageCard (#10302) 2026-04-17 03:41:21 +00:00
Raunak Bhagat
e629574580 feat(opal): opalify ExpandableCard (#10276) 2026-04-17 03:03:55 +00:00
Justin Tahara
8d539cdf3f fix(image): Cap Uploaded File Image Count (#10298) 2026-04-17 02:27:10 +00:00
dependabot[bot]
52524cbe57 chore(deps): bump authlib from 1.6.9 to 1.6.11 (#10293)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-04-16 17:53:49 -07:00
Jamison Lahman
c64def6a9e fix(llm-selector): show each provider instance as its own group (#10292)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 00:20:00 +00:00
Wenxi
2628fe1b93 fix: gmail datetime parsing on unexpected values (#10290) 2026-04-16 23:48:45 +00:00
Raunak Bhagat
96bf344f9c feat(opal): add LinkButton component (#10275) 2026-04-16 23:23:03 +00:00
dependabot[bot]
b92d3a307d chore(deps): bump mako from 1.2.4 to 1.3.11 in /backend/requirements (#10286)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-04-16 15:57:29 -07:00
dependabot[bot]
c55207eeba chore(deps): bump pypdf from 6.10.0 to 6.10.2 in /backend/requirements (#10287)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-04-16 15:56:09 -07:00
94 changed files with 2985 additions and 2779 deletions

View File

@@ -1,27 +0,0 @@
"""Add file_id to documents
Revision ID: 91d150c361f6
Revises: d129f37b3d87
Create Date: 2026-04-16 15:43:30.314823
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "91d150c361f6"
down_revision = "d129f37b3d87"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"document",
sa.Column("file_id", sa.String(), nullable=True),
)
def downgrade() -> None:
op.drop_column("document", "file_id")

View File

@@ -15,7 +15,7 @@ from onyx.background.celery.tasks.shared.RetryDocumentIndex import RetryDocument
from onyx.configs.constants import ONYX_CELERY_BEAT_HEARTBEAT_KEY
from onyx.configs.constants import OnyxCeleryTask
from onyx.db.document import delete_document_by_connector_credential_pair__no_commit
from onyx.db.document import delete_documents_complete
from onyx.db.document import delete_documents_complete__no_commit
from onyx.db.document import fetch_chunk_count_for_document
from onyx.db.document import get_document
from onyx.db.document import get_document_connector_count
@@ -129,10 +129,11 @@ def document_by_cc_pair_cleanup_task(
document_id=document_id,
)
delete_documents_complete(
delete_documents_complete__no_commit(
db_session=db_session,
document_ids=[document_id],
)
db_session.commit()
completion_status = OnyxCeleryTaskCompletionStatus.SUCCEEDED
elif count > 1:

View File

@@ -58,8 +58,6 @@ from onyx.db.indexing_coordination import IndexingCoordination
from onyx.db.models import IndexAttempt
from onyx.file_store.document_batch_storage import DocumentBatchStorage
from onyx.file_store.document_batch_storage import get_document_batch_storage
from onyx.file_store.staging import build_raw_file_callback
from onyx.file_store.staging import RawFileCallback
from onyx.indexing.indexing_heartbeat import IndexingHeartbeatInterface
from onyx.indexing.indexing_pipeline import index_doc_batch_prepare
from onyx.redis.redis_hierarchy import cache_hierarchy_nodes_batch
@@ -92,7 +90,6 @@ def _get_connector_runner(
end_time: datetime,
include_permissions: bool,
leave_connector_active: bool = LEAVE_CONNECTOR_ACTIVE_ON_INITIALIZATION_FAILURE,
raw_file_callback: RawFileCallback | None = None,
) -> ConnectorRunner:
"""
NOTE: `start_time` and `end_time` are only used for poll connectors
@@ -111,7 +108,6 @@ def _get_connector_runner(
input_type=task,
connector_specific_config=attempt.connector_credential_pair.connector.connector_specific_config,
credential=attempt.connector_credential_pair.credential,
raw_file_callback=raw_file_callback,
)
# validate the connector settings
@@ -279,12 +275,6 @@ def run_docfetching_entrypoint(
f"credentials='{credential_id}'"
)
raw_file_callback = build_raw_file_callback(
index_attempt_id=index_attempt_id,
cc_pair_id=connector_credential_pair_id,
tenant_id=tenant_id,
)
connector_document_extraction(
app,
index_attempt_id,
@@ -292,7 +282,6 @@ def run_docfetching_entrypoint(
attempt.search_settings_id,
tenant_id,
callback,
raw_file_callback=raw_file_callback,
)
logger.info(
@@ -312,7 +301,6 @@ def connector_document_extraction(
search_settings_id: int,
tenant_id: str,
callback: IndexingHeartbeatInterface | None = None,
raw_file_callback: RawFileCallback | None = None,
) -> None:
"""Extract documents from connector and queue them for indexing pipeline processing.
@@ -463,7 +451,6 @@ def connector_document_extraction(
start_time=window_start,
end_time=window_end,
include_permissions=should_fetch_permissions_during_indexing,
raw_file_callback=raw_file_callback,
)
# don't use a checkpoint if we're explicitly indexing from

View File

@@ -93,6 +93,7 @@ from onyx.llm.factory import get_llm_for_persona
from onyx.llm.factory import get_llm_token_counter
from onyx.llm.interfaces import LLM
from onyx.llm.interfaces import LLMUserIdentity
from onyx.llm.multi_llm import LLMTimeoutError
from onyx.llm.override_models import LLMOverride
from onyx.llm.request_context import reset_llm_mock_response
from onyx.llm.request_context import set_llm_mock_response
@@ -1166,6 +1167,32 @@ def _run_models(
else:
if item is _MODEL_DONE:
models_remaining -= 1
elif isinstance(item, LLMTimeoutError):
model_llm = setup.llms[model_idx]
error_msg = (
"The LLM took too long to respond. "
"If you're running a local model, try increasing the "
"LLM_SOCKET_READ_TIMEOUT environment variable "
"(current default: 120 seconds)."
)
stack_trace = "".join(
traceback.format_exception(type(item), item, item.__traceback__)
)
if model_llm.config.api_key and len(model_llm.config.api_key) > 2:
stack_trace = stack_trace.replace(
model_llm.config.api_key, "[REDACTED_API_KEY]"
)
yield StreamingError(
error=error_msg,
stack_trace=stack_trace,
error_code="CONNECTION_ERROR",
is_retryable=True,
details={
"model": model_llm.config.model_name,
"provider": model_llm.config.model_provider,
"model_index": model_idx,
},
)
elif isinstance(item, Exception):
# Yield a tagged error for this model but keep the other models running.
# Do NOT decrement models_remaining — _run_model's finally always posts

View File

@@ -843,6 +843,29 @@ MAX_FILE_SIZE_BYTES = int(
os.environ.get("MAX_FILE_SIZE_BYTES") or 2 * 1024 * 1024 * 1024
) # 2GB in bytes
# Maximum embedded images allowed in a single file. PDFs (and other formats)
# with thousands of embedded images can OOM the user-file-processing worker
# because every image is decoded with PIL and then sent to the vision LLM.
# Enforced both at upload time (rejects the file) and during extraction
# (defense-in-depth: caps the number of images materialized).
#
# Clamped to >= 0; a negative env value would turn upload validation into
# always-fail and extraction into always-stop, which is never desired. 0
# disables image extraction entirely, which is a valid (if aggressive) setting.
MAX_EMBEDDED_IMAGES_PER_FILE = max(
0, int(os.environ.get("MAX_EMBEDDED_IMAGES_PER_FILE") or 500)
)
# Maximum embedded images allowed across all files in a single upload batch.
# Protects against the scenario where a user uploads many files that each
# fall under MAX_EMBEDDED_IMAGES_PER_FILE but aggregate to enough work
# (serial-ish celery fan-out plus per-image vision-LLM calls) to OOM the
# worker under concurrency or run up surprise latency/cost. Also clamped
# to >= 0.
MAX_EMBEDDED_IMAGES_PER_UPLOAD = max(
0, int(os.environ.get("MAX_EMBEDDED_IMAGES_PER_UPLOAD") or 1000)
)
# Use document summary for contextual rag
USE_DOCUMENT_SUMMARY = os.environ.get("USE_DOCUMENT_SUMMARY", "true").lower() == "true"
# Use chunk summary for contextual rag

View File

@@ -372,7 +372,6 @@ class FileOrigin(str, Enum):
CONNECTOR_METADATA = "connector_metadata"
GENERATED_REPORT = "generated_report"
INDEXING_CHECKPOINT = "indexing_checkpoint"
INDEXING_STAGING = "indexing_staging"
PLAINTEXT_CACHE = "plaintext_cache"
OTHER = "other"
QUERY_HISTORY_CSV = "query_history_csv"

View File

@@ -3,6 +3,7 @@ from collections.abc import Callable
from collections.abc import Iterator
from datetime import datetime
from datetime import timezone
from email.utils import parsedate_to_datetime
from typing import Any
from typing import TypeVar
from urllib.parse import urljoin
@@ -10,7 +11,6 @@ from urllib.parse import urlparse
import requests
from dateutil.parser import parse
from dateutil.parser import ParserError
from onyx.configs.app_configs import CONNECTOR_LOCALHOST_OVERRIDE
from onyx.configs.constants import DocumentSource
@@ -56,18 +56,16 @@ def time_str_to_utc(datetime_str: str) -> datetime:
if fixed not in candidates:
candidates.append(fixed)
last_exception: Exception | None = None
for candidate in candidates:
try:
dt = parse(candidate)
return datetime_to_utc(dt)
except (ValueError, ParserError) as exc:
last_exception = exc
# dateutil is the primary; the stdlib RFC 2822 parser is a fallback for
# inputs dateutil rejects (e.g. headers concatenated without a CRLF —
# TZ may be dropped, datetime_to_utc then assumes UTC).
for parser in (parse, parsedate_to_datetime):
for candidate in candidates:
try:
return datetime_to_utc(parser(candidate))
except (TypeError, ValueError, OverflowError):
continue
if last_exception is not None:
raise last_exception
# Fallback in case parsing failed without raising (should not happen)
raise ValueError(f"Unable to parse datetime string: {datetime_str}")

View File

@@ -22,7 +22,6 @@ from onyx.db.credentials import backend_update_credential_json
from onyx.db.credentials import fetch_credential_by_id
from onyx.db.enums import AccessType
from onyx.db.models import Credential
from onyx.file_store.staging import RawFileCallback
from shared_configs.contextvars import get_current_tenant_id
@@ -108,7 +107,6 @@ def instantiate_connector(
input_type: InputType,
connector_specific_config: dict[str, Any],
credential: Credential,
raw_file_callback: RawFileCallback | None = None,
) -> BaseConnector:
connector_class = identify_connector_class(source, input_type)
@@ -132,9 +130,6 @@ def instantiate_connector(
connector.set_allow_images(get_image_extraction_and_analysis_enabled())
if raw_file_callback is not None:
connector.set_raw_file_callback(raw_file_callback)
return connector

View File

@@ -253,7 +253,17 @@ def thread_to_document(
updated_at_datetime = None
if updated_at:
updated_at_datetime = time_str_to_utc(updated_at)
try:
updated_at_datetime = time_str_to_utc(updated_at)
except (ValueError, OverflowError) as e:
# Old mailboxes contain RFC-violating Date headers. Drop the
# timestamp instead of aborting the indexing run.
logger.warning(
"Skipping unparseable Gmail Date header on thread %s: %r (%s)",
full_thread.get("id"),
updated_at,
e,
)
id = full_thread.get("id")
if not id:

View File

@@ -502,6 +502,9 @@ class GoogleDriveConnector(
files: list[RetrievedDriveFile],
seen_hierarchy_node_raw_ids: ThreadSafeSet[str],
fully_walked_hierarchy_node_raw_ids: ThreadSafeSet[str],
failed_folder_ids_by_email: (
ThreadSafeDict[str, ThreadSafeSet[str]] | None
) = None,
permission_sync_context: PermissionSyncContext | None = None,
add_prefix: bool = False,
) -> list[HierarchyNode]:
@@ -525,6 +528,9 @@ class GoogleDriveConnector(
seen_hierarchy_node_raw_ids: Set of already-yielded node IDs (modified in place)
fully_walked_hierarchy_node_raw_ids: Set of node IDs where the walk to root
succeeded (modified in place)
failed_folder_ids_by_email: Map of email → folder IDs where that email
previously confirmed no accessible parent. Skips the API call if the same
(folder, email) is encountered again (modified in place).
permission_sync_context: If provided, permissions will be fetched for hierarchy nodes.
Contains google_domain and primary_admin_email needed for permission syncing.
add_prefix: When True, prefix group IDs with source type (for indexing path).
@@ -569,7 +575,7 @@ class GoogleDriveConnector(
# Fetch folder metadata
folder = self._get_folder_metadata(
current_id, file.user_email, field_type
current_id, file.user_email, field_type, failed_folder_ids_by_email
)
if not folder:
# Can't access this folder - stop climbing
@@ -653,7 +659,13 @@ class GoogleDriveConnector(
return new_nodes
def _get_folder_metadata(
self, folder_id: str, retriever_email: str, field_type: DriveFileFieldType
self,
folder_id: str,
retriever_email: str,
field_type: DriveFileFieldType,
failed_folder_ids_by_email: (
ThreadSafeDict[str, ThreadSafeSet[str]] | None
) = None,
) -> GoogleDriveFileType | None:
"""
Fetch metadata for a folder by ID.
@@ -667,6 +679,17 @@ class GoogleDriveConnector(
# Use a set to deduplicate if retriever_email == primary_admin_email
for email in {retriever_email, self.primary_admin_email}:
failed_ids = (
failed_folder_ids_by_email.get(email)
if failed_folder_ids_by_email
else None
)
if failed_ids and folder_id in failed_ids:
logger.debug(
f"Skipping folder {folder_id} using {email} (previously confirmed no parents)"
)
continue
service = get_drive_service(self.creds, email)
folder = get_folder_metadata(service, folder_id, field_type)
@@ -682,6 +705,10 @@ class GoogleDriveConnector(
# Folder has no parents - could be a root OR user lacks access to parent
# Keep this as a fallback but try admin to see if they can see parents
if failed_folder_ids_by_email is not None:
failed_folder_ids_by_email.setdefault(email, ThreadSafeSet()).add(
folder_id
)
if best_folder is None:
best_folder = folder
logger.debug(
@@ -1090,6 +1117,13 @@ class GoogleDriveConnector(
]
yield from parallel_yield(user_retrieval_gens, max_workers=MAX_DRIVE_WORKERS)
# Free per-user cache entries now that this batch is done.
# Skip the admin email — it is shared across all user batches and must
# persist for the duration of the run.
for email in non_completed_org_emails:
if email != self.primary_admin_email:
checkpoint.failed_folder_ids_by_email.pop(email, None)
# if there are more emails to process, don't mark as complete
if not email_batch_takes_us_to_completion:
return
@@ -1546,6 +1580,7 @@ class GoogleDriveConnector(
files=files_batch,
seen_hierarchy_node_raw_ids=checkpoint.seen_hierarchy_node_raw_ids,
fully_walked_hierarchy_node_raw_ids=checkpoint.fully_walked_hierarchy_node_raw_ids,
failed_folder_ids_by_email=checkpoint.failed_folder_ids_by_email,
permission_sync_context=permission_sync_context,
add_prefix=True,
)
@@ -1782,6 +1817,7 @@ class GoogleDriveConnector(
files=files_batch,
seen_hierarchy_node_raw_ids=checkpoint.seen_hierarchy_node_raw_ids,
fully_walked_hierarchy_node_raw_ids=checkpoint.fully_walked_hierarchy_node_raw_ids,
failed_folder_ids_by_email=checkpoint.failed_folder_ids_by_email,
permission_sync_context=permission_sync_context,
)

View File

@@ -167,6 +167,13 @@ class GoogleDriveCheckpoint(ConnectorCheckpoint):
default_factory=ThreadSafeSet
)
# Maps email → set of IDs of folders where that email confirmed no accessible parent.
# Avoids redundant API calls when the same (folder, email) pair is
# encountered again within the same retrieval run.
failed_folder_ids_by_email: ThreadSafeDict[str, ThreadSafeSet[str]] = Field(
default_factory=ThreadSafeDict
)
@field_serializer("completion_map")
def serialize_completion_map(
self, completion_map: ThreadSafeDict[str, StageCompletion], _info: Any
@@ -211,3 +218,25 @@ class GoogleDriveCheckpoint(ConnectorCheckpoint):
if isinstance(v, list):
return ThreadSafeSet(set(v)) # ty: ignore[invalid-return-type]
return ThreadSafeSet()
@field_serializer("failed_folder_ids_by_email")
def serialize_failed_folder_ids_by_email(
self,
failed_folder_ids_by_email: ThreadSafeDict[str, ThreadSafeSet[str]],
_info: Any,
) -> dict[str, set[str]]:
return {
k: inner.copy() for k, inner in failed_folder_ids_by_email.copy().items()
}
@field_validator("failed_folder_ids_by_email", mode="before")
def validate_failed_folder_ids_by_email(
cls, v: Any
) -> ThreadSafeDict[str, ThreadSafeSet[str]]:
if isinstance(v, ThreadSafeDict):
return v
if isinstance(v, dict):
return ThreadSafeDict(
{k: ThreadSafeSet(set(vals)) for k, vals in v.items()}
)
return ThreadSafeDict()

View File

@@ -15,7 +15,6 @@ from onyx.connectors.models import ConnectorFailure
from onyx.connectors.models import Document
from onyx.connectors.models import HierarchyNode
from onyx.connectors.models import SlimDocument
from onyx.file_store.staging import RawFileCallback
from onyx.indexing.indexing_heartbeat import IndexingHeartbeatInterface
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
@@ -43,9 +42,6 @@ class NormalizationResult(BaseModel):
class BaseConnector(abc.ABC, Generic[CT]):
REDIS_KEY_PREFIX = "da_connector_data:"
# Optional raw-file persistence hook to save original file
raw_file_callback: RawFileCallback | None = None
@abc.abstractmethod
def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None:
raise NotImplementedError
@@ -92,15 +88,6 @@ class BaseConnector(abc.ABC, Generic[CT]):
"""Implement if the underlying connector wants to skip/allow image downloading
based on the application level image analysis setting."""
def set_raw_file_callback(self, callback: RawFileCallback) -> None:
"""Inject the per-attempt raw-file persistence callback.
Wired up by the docfetching entrypoint via `instantiate_connector`.
Connectors that don't care about persisting raw bytes can ignore this
— `raw_file_callback` simply stays `None`.
"""
self.raw_file_callback = callback
@classmethod
def normalize_url(cls, url: str) -> "NormalizationResult": # noqa: ARG003
"""Normalize a URL to match the canonical Document.id format used during ingestion.

View File

@@ -231,8 +231,6 @@ class DocumentBase(BaseModel):
# Set during docfetching after hierarchy nodes are cached
parent_hierarchy_node_id: int | None = None
file_id: str | None = None
def get_title_for_document_index(
self,
) -> str | None:
@@ -372,7 +370,6 @@ class Document(DocumentBase):
secondary_owners=base.secondary_owners,
title=base.title,
from_ingestion_api=base.from_ingestion_api,
file_id=base.file_id,
)
def __sizeof__(self) -> int:

View File

@@ -81,9 +81,7 @@ class ZulipConnector(LoadConnector, PollConnector):
# zuliprc file. This reverts them back to newlines.
contents_spaces_to_newlines = contents.replace(" ", "\n")
# create a temporary zuliprc file
tempdir = tempfile.tempdir
if tempdir is None:
raise Exception("Could not determine tempfile directory")
tempdir = tempfile.gettempdir()
config_file = os.path.join(tempdir, f"zuliprc-{self.realm_name}")
with open(config_file, "w") as f:
f.write(contents_spaces_to_newlines)

View File

@@ -52,7 +52,6 @@ from onyx.db.utils import DocumentRow
from onyx.db.utils import model_to_dict
from onyx.db.utils import SortOrder
from onyx.document_index.interfaces import DocumentMetadata
from onyx.file_store.staging import delete_files_best_effort
from onyx.kg.models import KGStage
from onyx.server.documents.models import ConnectorCredentialPairIdentifier
from onyx.utils.logger import setup_logger
@@ -697,7 +696,6 @@ def upsert_documents(
else {}
),
doc_metadata=doc.doc_metadata,
file_id=doc.file_id,
)
)
for doc in seen_documents.values()
@@ -714,7 +712,6 @@ def upsert_documents(
"secondary_owners": insert_stmt.excluded.secondary_owners,
"doc_metadata": insert_stmt.excluded.doc_metadata,
"parent_hierarchy_node_id": insert_stmt.excluded.parent_hierarchy_node_id,
"file_id": insert_stmt.excluded.file_id,
}
if includes_permissions:
# Use COALESCE to preserve existing permissions when new values are NULL.
@@ -928,26 +925,6 @@ def delete_documents__no_commit(db_session: Session, document_ids: list[str]) ->
db_session.execute(delete(DbDocument).where(DbDocument.id.in_(document_ids)))
def get_file_ids_for_document_ids(
db_session: Session,
document_ids: list[str],
) -> list[str]:
"""Return the non-null `file_id` values attached to the given documents.
Used at deletion time to enumerate raw files that need to be reaped from
the file store once their owning document rows are gone.
"""
if not document_ids:
return []
rows = (
db_session.query(DbDocument.file_id)
.filter(DbDocument.id.in_(document_ids))
.filter(DbDocument.file_id.isnot(None))
.all()
)
return [row.file_id for row in rows]
def delete_documents_complete__no_commit(
db_session: Session, document_ids: list[str]
) -> None:
@@ -991,32 +968,6 @@ def delete_documents_complete__no_commit(
delete_documents__no_commit(db_session, document_ids)
def delete_documents_complete(
db_session: Session,
document_ids: list[str],
) -> None:
"""Fully remove documents AND best-effort delete their attached files.
This is the canonical path for "I'm done with these docs" — it captures
file_ids, removes the rows + every FK they hold, commits, then reaps
files. The order matters: file deletion happens after commit so a DB
rollback can never leave a `document` row pointing at a missing file.
Use this instead of `delete_documents_complete__no_commit` unless you
specifically need to compose with other operations in one transaction.
"""
file_ids_to_delete = get_file_ids_for_document_ids(
db_session=db_session,
document_ids=document_ids,
)
delete_documents_complete__no_commit(
db_session=db_session,
document_ids=document_ids,
)
db_session.commit()
delete_files_best_effort(file_ids_to_delete)
def delete_all_documents_for_connector_credential_pair(
db_session: Session,
connector_id: int,
@@ -1048,9 +999,10 @@ def delete_all_documents_for_connector_credential_pair(
if not document_ids:
break
delete_documents_complete(
delete_documents_complete__no_commit(
db_session=db_session, document_ids=list(document_ids)
)
db_session.commit()
if time.monotonic() - start_time > timeout:
raise RuntimeError("Timeout reached while deleting documents")

View File

@@ -62,21 +62,6 @@ def delete_filerecord_by_file_id(
db_session.query(FileRecord).filter_by(file_id=file_id).delete()
def update_filerecord_origin(
file_id: str,
from_origin: FileOrigin,
to_origin: FileOrigin,
db_session: Session,
) -> None:
"""Change a file_record's `file_origin`, filtered on the current origin
so the update is idempotent. Caller owns the commit.
"""
db_session.query(FileRecord).filter(
FileRecord.file_id == file_id,
FileRecord.file_origin == from_origin,
).update({FileRecord.file_origin: to_origin})
def upsert_filerecord(
file_id: str,
display_name: str,

View File

@@ -952,7 +952,6 @@ class Document(Base):
semantic_id: Mapped[str] = mapped_column(NullFilteredString)
# First Section's link
link: Mapped[str | None] = mapped_column(NullFilteredString, nullable=True)
file_id: Mapped[str | None] = mapped_column(String, nullable=True)
# The updated time is also used as a measure of the last successful state of the doc
# pulled from the source (to help skip reindexing already updated docs in case of

View File

@@ -98,9 +98,6 @@ class DocumentMetadata:
# The resolved database ID of the parent hierarchy node (folder/container)
parent_hierarchy_node_id: int | None = None
# Opt-in pointer to the persisted raw file for this document (file_store id).
file_id: str | None = None
@dataclass
class VespaDocumentFields:

View File

@@ -23,6 +23,7 @@ import openpyxl
from openpyxl.worksheet.worksheet import Worksheet
from PIL import Image
from onyx.configs.app_configs import MAX_EMBEDDED_IMAGES_PER_FILE
from onyx.configs.constants import ONYX_METADATA_FILENAME
from onyx.configs.llm_configs import get_image_extraction_and_analysis_enabled
from onyx.file_processing.file_types import OnyxFileExtensions
@@ -191,6 +192,56 @@ def read_text_file(
return file_content_raw, metadata
def count_pdf_embedded_images(file: IO[Any], cap: int) -> int:
"""Return the number of embedded images in a PDF, short-circuiting at cap+1.
Used to reject PDFs whose image count would OOM the user-file-processing
worker during indexing. Returns a value > cap as a sentinel once the count
exceeds the cap, so callers do not iterate thousands of image objects just
to report a number. Returns 0 if the PDF cannot be parsed.
Owner-password-only PDFs (permission restrictions but no open password) are
counted normally — they decrypt with an empty string. Truly password-locked
PDFs are skipped (return 0) since we can't inspect them; the caller should
ensure the password-protected check runs first.
Always restores the file pointer to its original position before returning.
"""
from pypdf import PdfReader
try:
start_pos = file.tell()
except Exception:
start_pos = None
try:
if start_pos is not None:
file.seek(0)
reader = PdfReader(file)
if reader.is_encrypted:
# Try empty password first (owner-password-only PDFs); give up if that fails.
try:
if reader.decrypt("") == 0:
return 0
except Exception:
return 0
count = 0
for page in reader.pages:
for _ in page.images:
count += 1
if count > cap:
return count
return count
except Exception:
logger.warning("Failed to count embedded images in PDF", exc_info=True)
return 0
finally:
if start_pos is not None:
try:
file.seek(start_pos)
except Exception:
pass
def pdf_to_text(file: IO[Any], pdf_pass: str | None = None) -> str:
"""
Extract text from a PDF. For embedded images, a more complex approach is needed.
@@ -254,8 +305,27 @@ def read_pdf_file(
)
if extract_images:
image_cap = MAX_EMBEDDED_IMAGES_PER_FILE
images_processed = 0
cap_reached = False
for page_num, page in enumerate(pdf_reader.pages):
if cap_reached:
break
for image_file_object in page.images:
if images_processed >= image_cap:
# Defense-in-depth backstop. Upload-time validation
# should have rejected files exceeding the cap, but
# we also break here so a single oversized file can
# never pin a worker.
logger.warning(
"PDF embedded image cap reached (%d). "
"Skipping remaining images on page %d and beyond.",
image_cap,
page_num + 1,
)
cap_reached = True
break
image = Image.open(io.BytesIO(image_file_object.data))
img_byte_arr = io.BytesIO()
image.save(img_byte_arr, format=image.format)
@@ -268,6 +338,7 @@ def read_pdf_file(
image_callback(img_bytes, image_name)
else:
extracted_images.append((img_bytes, image_name))
images_processed += 1
return text, metadata, extracted_images

View File

@@ -1,103 +0,0 @@
from collections.abc import Callable
from typing import Any
from typing import IO
from sqlalchemy.orm import Session
from onyx.configs.constants import FileOrigin
from onyx.db.file_record import update_filerecord_origin
from onyx.file_store.file_store import get_default_file_store
from onyx.utils.logger import setup_logger
logger = setup_logger()
# (content, content_type) -> file_id
RawFileCallback = Callable[[IO[bytes], str], str]
def stage_raw_file(
content: IO,
content_type: str,
*,
metadata: dict[str, Any],
) -> str:
"""Persist raw bytes to the file store with FileOrigin.INDEXING_STAGING.
`metadata` is attached to the file_record so that downstream promotion
(in docprocessing) and orphan reaping (TTL janitor) can locate the file
by its originating context.
"""
file_store = get_default_file_store()
file_id = file_store.save_file(
content=content,
display_name=None,
file_origin=FileOrigin.INDEXING_STAGING,
file_type=content_type,
file_metadata=metadata,
)
return file_id
def build_raw_file_callback(
*,
index_attempt_id: int,
cc_pair_id: int,
tenant_id: str,
) -> RawFileCallback:
"""Build a per-attempt callback that connectors can invoke to opt in to
raw-file persistence. The closure binds the attempt-level context as the
staging metadata so the connector only needs to pass per-call info
(bytes, content_type) and gets back a file_id to attach to its Document.
"""
metadata: dict[str, Any] = {
"index_attempt_id": index_attempt_id,
"cc_pair_id": cc_pair_id,
"tenant_id": tenant_id,
}
def _callback(content: IO[bytes], content_type: str) -> str:
return stage_raw_file(
content=content,
content_type=content_type,
metadata=metadata,
)
return _callback
def delete_files_best_effort(file_ids: list[str]) -> None:
"""Delete a list of files from the file store, logging individual
failures rather than raising.
Used at document-deletion time to reap raw files attached via
`Document.file_id`. The corresponding document rows have already been
deleted by the caller, so a failure here just leaves a recoverable
orphan rather than a broken pointer.
"""
if not file_ids:
return
file_store = get_default_file_store()
for file_id in file_ids:
try:
file_store.delete_file(file_id, error_on_missing=False)
except Exception:
logger.exception(
f"Failed to delete file_id={file_id} during document cleanup"
)
def promote_staged_file(db_session: Session, file_id: str) -> None:
"""Mark a previously-staged file as `FileOrigin.CONNECTOR`.
Idempotent — the underlying update filters on the STAGING origin so
repeated calls no-op once the file has already been promoted or removed.
Caller owns the commit so promotion stays transactional with whatever
document-level bookkeeping the caller is doing.
"""
update_filerecord_origin(
file_id=file_id,
from_origin=FileOrigin.INDEXING_STAGING,
to_origin=FileOrigin.CONNECTOR,
db_session=db_session,
)

View File

@@ -49,7 +49,6 @@ from onyx.document_index.interfaces import DocumentMetadata
from onyx.document_index.interfaces import IndexBatchParams
from onyx.file_processing.image_summarization import summarize_image_with_error_handling
from onyx.file_store.file_store import get_default_file_store
from onyx.file_store.staging import promote_staged_file
from onyx.hooks.executor import execute_hook
from onyx.hooks.executor import HookSkipped
from onyx.hooks.executor import HookSoftFailed
@@ -155,7 +154,6 @@ def _upsert_documents_in_db(
doc_metadata=doc.doc_metadata,
# parent_hierarchy_node_id is resolved in docfetching using Redis cache
parent_hierarchy_node_id=doc.parent_hierarchy_node_id,
file_id=doc.file_id,
)
document_metadata_list.append(db_doc_metadata)
@@ -366,39 +364,6 @@ def index_doc_batch_with_handler(
return index_pipeline_result
def _apply_file_id_transitions(
documents: list[Document],
previous_file_ids: dict[str, str],
db_session: Session,
) -> None:
"""Finalize file_id lifecycle for the batch.
`document.file_id` is already written by `upsert_documents`. For each doc
whose file_id changed, promote the new staged file to `CONNECTOR` (so the
TTL janitor leaves it alone) and delete the replaced one. The delete is
best-effort; if it fails the janitor will reap the orphan.
"""
file_store = get_default_file_store()
for doc in documents:
new_file_id = doc.file_id
old_file_id = previous_file_ids.get(doc.id)
if new_file_id == old_file_id:
continue
if new_file_id is not None:
promote_staged_file(db_session=db_session, file_id=new_file_id)
if old_file_id is not None:
try:
file_store.delete_file(old_file_id, error_on_missing=False)
except Exception:
logger.exception(
f"Failed to delete replaced file_id={old_file_id}; "
"will be reaped by janitor."
)
def index_doc_batch_prepare(
documents: list[Document],
index_attempt_metadata: IndexAttemptMetadata,
@@ -417,11 +382,6 @@ def index_doc_batch_prepare(
document_ids=document_ids,
)
# Capture previous file_ids BEFORE any writes so we know what to reap.
previous_file_ids: dict[str, str] = {
db_doc.id: db_doc.file_id for db_doc in db_docs if db_doc.file_id is not None
}
updatable_docs = (
get_doc_ids_to_update(documents=documents, db_docs=db_docs)
if not ignore_time_skip
@@ -444,11 +404,6 @@ def index_doc_batch_prepare(
index_attempt_metadata=index_attempt_metadata,
db_session=db_session,
)
_apply_file_id_transitions(
documents=updatable_docs,
previous_file_ids=previous_file_ids,
db_session=db_session,
)
logger.info(
f"Upserted {len(updatable_docs)} changed docs out of {len(documents)} total docs into the DB"

View File

@@ -290,7 +290,11 @@ def litellm_exception_to_error_msg(
error_code = "BUDGET_EXCEEDED"
is_retryable = False
elif isinstance(core_exception, Timeout):
error_msg = "Request timed out: The operation took too long to complete. Please try again."
error_msg = (
"The LLM took too long to respond. "
"If you're running a local model, try increasing the "
"LLM_SOCKET_READ_TIMEOUT environment variable (current default: 120 seconds)."
)
error_code = "CONNECTION_ERROR"
is_retryable = True
elif isinstance(core_exception, APIError):

View File

@@ -40,6 +40,8 @@ from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
from onyx.background.celery.versioned_apps.client import app as celery_app
from onyx.configs.app_configs import MAX_EMBEDDED_IMAGES_PER_FILE
from onyx.configs.app_configs import MAX_EMBEDDED_IMAGES_PER_UPLOAD
from onyx.configs.constants import DocumentSource
from onyx.configs.constants import OnyxCeleryQueues
from onyx.configs.constants import OnyxCeleryTask
@@ -51,6 +53,9 @@ from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.enums import Permission
from onyx.db.models import User
from onyx.document_index.interfaces import DocumentMetadata
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.file_processing.extract_file_text import count_pdf_embedded_images
from onyx.server.features.build.configs import USER_LIBRARY_MAX_FILE_SIZE_BYTES
from onyx.server.features.build.configs import USER_LIBRARY_MAX_FILES_PER_UPLOAD
from onyx.server.features.build.configs import USER_LIBRARY_MAX_TOTAL_SIZE_BYTES
@@ -128,6 +133,49 @@ class DeleteFileResponse(BaseModel):
# =============================================================================
def _looks_like_pdf(filename: str, content_type: str | None) -> bool:
"""True if either the filename or the content-type indicates a PDF.
Client-supplied ``content_type`` can be spoofed (e.g. a PDF uploaded with
``Content-Type: application/octet-stream``), so we also fall back to
extension-based detection via ``mimetypes.guess_type`` on the filename.
"""
if content_type == "application/pdf":
return True
guessed, _ = mimetypes.guess_type(filename)
return guessed == "application/pdf"
def _check_pdf_image_caps(
filename: str, content: bytes, content_type: str | None, batch_total: int
) -> int:
"""Enforce per-file and per-batch embedded-image caps for PDFs.
Returns the number of embedded images in this file (0 for non-PDFs) so
callers can update their running batch total. Raises OnyxError(INVALID_INPUT)
if either cap is exceeded.
"""
if not _looks_like_pdf(filename, content_type):
return 0
file_cap = MAX_EMBEDDED_IMAGES_PER_FILE
batch_cap = MAX_EMBEDDED_IMAGES_PER_UPLOAD
# Short-circuit at the larger cap so we get a useful count for both checks.
count = count_pdf_embedded_images(BytesIO(content), max(file_cap, batch_cap))
if count > file_cap:
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
f"PDF '{filename}' contains too many embedded images "
f"(more than {file_cap}). Try splitting the document into smaller files.",
)
if batch_total + count > batch_cap:
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
f"Upload would exceed the {batch_cap}-image limit across all "
f"files in this batch. Try uploading fewer image-heavy files at once.",
)
return count
def _sanitize_path(path: str) -> str:
"""Sanitize a file path, removing traversal attempts and normalizing.
@@ -356,6 +404,7 @@ async def upload_files(
uploaded_entries: list[LibraryEntryResponse] = []
total_size = 0
batch_image_total = 0
now = datetime.now(timezone.utc)
# Sanitize the base path
@@ -375,6 +424,14 @@ async def upload_files(
detail=f"File '{file.filename}' exceeds maximum size of {USER_LIBRARY_MAX_FILE_SIZE_BYTES // (1024 * 1024)}MB",
)
# Reject PDFs with an unreasonable per-file or per-batch image count
batch_image_total += _check_pdf_image_caps(
filename=file.filename or "unnamed",
content=content,
content_type=file.content_type,
batch_total=batch_image_total,
)
# Validate cumulative storage (existing + this upload batch)
total_size += file_size
if existing_usage + total_size > USER_LIBRARY_MAX_TOTAL_SIZE_BYTES:
@@ -473,6 +530,7 @@ async def upload_zip(
uploaded_entries: list[LibraryEntryResponse] = []
total_size = 0
batch_image_total = 0
# Extract zip contents into a subfolder named after the zip file
zip_name = api_sanitize_filename(file.filename or "upload")
@@ -511,6 +569,36 @@ async def upload_zip(
logger.warning(f"Skipping '{zip_info.filename}' - exceeds max size")
continue
# Skip PDFs that would trip the per-file or per-batch image
# cap (would OOM the user-file-processing worker). Matches
# /upload behavior but uses skip-and-warn to stay consistent
# with the zip path's handling of oversized files.
zip_file_name = zip_info.filename.split("/")[-1]
zip_content_type, _ = mimetypes.guess_type(zip_file_name)
if zip_content_type == "application/pdf":
image_count = count_pdf_embedded_images(
BytesIO(file_content),
max(
MAX_EMBEDDED_IMAGES_PER_FILE,
MAX_EMBEDDED_IMAGES_PER_UPLOAD,
),
)
if image_count > MAX_EMBEDDED_IMAGES_PER_FILE:
logger.warning(
"Skipping '%s' - exceeds %d per-file embedded-image cap",
zip_info.filename,
MAX_EMBEDDED_IMAGES_PER_FILE,
)
continue
if batch_image_total + image_count > MAX_EMBEDDED_IMAGES_PER_UPLOAD:
logger.warning(
"Skipping '%s' - would exceed %d per-batch embedded-image cap",
zip_info.filename,
MAX_EMBEDDED_IMAGES_PER_UPLOAD,
)
continue
batch_image_total += image_count
total_size += file_size
# Validate cumulative storage

View File

@@ -9,7 +9,10 @@ from pydantic import ConfigDict
from pydantic import Field
from sqlalchemy.orm import Session
from onyx.configs.app_configs import MAX_EMBEDDED_IMAGES_PER_FILE
from onyx.configs.app_configs import MAX_EMBEDDED_IMAGES_PER_UPLOAD
from onyx.db.llm import fetch_default_llm_model
from onyx.file_processing.extract_file_text import count_pdf_embedded_images
from onyx.file_processing.extract_file_text import extract_file_text
from onyx.file_processing.extract_file_text import get_file_ext
from onyx.file_processing.file_types import OnyxFileExtensions
@@ -190,6 +193,11 @@ def categorize_uploaded_files(
token_threshold_k * 1000 if token_threshold_k else None
) # 0 → None = no limit
# Running total of embedded images across PDFs in this batch. Once the
# aggregate cap is reached, subsequent PDFs in the same upload are
# rejected even if they'd individually fit under MAX_EMBEDDED_IMAGES_PER_FILE.
batch_image_total = 0
for upload in files:
try:
filename = get_safe_filename(upload)
@@ -252,6 +260,47 @@ def categorize_uploaded_files(
)
continue
# Reject PDFs with an unreasonable number of embedded images
# (either per-file or accumulated across this upload batch).
# A PDF with thousands of embedded images can OOM the
# user-file-processing celery worker because every image is
# decoded with PIL and then sent to the vision LLM.
if extension == ".pdf":
file_cap = MAX_EMBEDDED_IMAGES_PER_FILE
batch_cap = MAX_EMBEDDED_IMAGES_PER_UPLOAD
# Use the larger of the two caps as the short-circuit
# threshold so we get a useful count for both checks.
# count_pdf_embedded_images restores the stream position.
count = count_pdf_embedded_images(
upload.file, max(file_cap, batch_cap)
)
if count > file_cap:
results.rejected.append(
RejectedFile(
filename=filename,
reason=(
f"PDF contains too many embedded images "
f"(more than {file_cap}). Try splitting "
f"the document into smaller files."
),
)
)
continue
if batch_image_total + count > batch_cap:
results.rejected.append(
RejectedFile(
filename=filename,
reason=(
f"Upload would exceed the "
f"{batch_cap}-image limit across all "
f"files in this batch. Try uploading "
f"fewer image-heavy files at once."
),
)
)
continue
batch_image_total += count
text_content = extract_file_text(
file=upload.file,
file_name=filename,

View File

@@ -13,7 +13,7 @@ from onyx.configs.constants import PUBLIC_API_TAGS
from onyx.connectors.models import Document
from onyx.connectors.models import IndexAttemptMetadata
from onyx.db.connector_credential_pair import get_connector_credential_pair_from_id
from onyx.db.document import delete_documents_complete
from onyx.db.document import delete_documents_complete__no_commit
from onyx.db.document import get_document
from onyx.db.document import get_documents_by_cc_pair
from onyx.db.document import get_ingestion_documents
@@ -210,4 +210,5 @@ def delete_ingestion_doc(
)
# Delete from database
delete_documents_complete(db_session, [document_id])
delete_documents_complete__no_commit(db_session, [document_id])
db_session.commit()

View File

@@ -34,6 +34,7 @@ R = TypeVar("R")
KT = TypeVar("KT") # Key type
VT = TypeVar("VT") # Value type
_T = TypeVar("_T") # Default type
_MISSING: object = object()
class ThreadSafeDict(MutableMapping[KT, VT]):
@@ -117,10 +118,10 @@ class ThreadSafeDict(MutableMapping[KT, VT]):
with self.lock:
return self._dict.get(key, default)
def pop(self, key: KT, default: Any = None) -> Any:
def pop(self, key: KT, default: Any = _MISSING) -> Any:
"""Remove and return a value with optional default, atomically."""
with self.lock:
if default is None:
if default is _MISSING:
return self._dict.pop(key)
return self._dict.pop(key, default)

View File

@@ -60,7 +60,7 @@ attrs==25.4.0
# jsonschema
# referencing
# zeep
authlib==1.6.9
authlib==1.6.11
# via fastmcp
azure-cognitiveservices-speech==1.38.0
babel==2.17.0
@@ -214,7 +214,9 @@ distro==1.9.0
dnspython==2.8.0
# via email-validator
docstring-parser==0.17.0
# via cyclopts
# via
# cyclopts
# google-cloud-aiplatform
docutils==0.22.3
# via rich-rst
dropbox==12.0.2
@@ -270,7 +272,13 @@ gitdb==4.0.12
gitpython==3.1.45
# via braintrust
google-api-core==2.28.1
# via google-api-python-client
# via
# google-api-python-client
# google-cloud-aiplatform
# google-cloud-bigquery
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
google-api-python-client==2.86.0
google-auth==2.48.0
# via
@@ -278,21 +286,61 @@ google-auth==2.48.0
# google-api-python-client
# google-auth-httplib2
# google-auth-oauthlib
# google-cloud-aiplatform
# google-cloud-bigquery
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
# google-genai
# kubernetes
google-auth-httplib2==0.1.0
# via google-api-python-client
google-auth-oauthlib==1.0.0
google-cloud-aiplatform==1.133.0
# via litellm
google-cloud-bigquery==3.41.0
# via google-cloud-aiplatform
google-cloud-core==2.5.1
# via
# google-cloud-bigquery
# google-cloud-storage
google-cloud-resource-manager==1.17.0
# via google-cloud-aiplatform
google-cloud-storage==3.10.1
# via google-cloud-aiplatform
google-crc32c==1.8.0
# via
# google-cloud-storage
# google-resumable-media
google-genai==1.52.0
# via onyx
# via
# google-cloud-aiplatform
# onyx
google-resumable-media==2.8.2
# via
# google-cloud-bigquery
# google-cloud-storage
googleapis-common-protos==1.72.0
# via
# google-api-core
# grpc-google-iam-v1
# grpcio-status
# opentelemetry-exporter-otlp-proto-http
greenlet==3.2.4
# via
# playwright
# sqlalchemy
grpc-google-iam-v1==0.14.4
# via google-cloud-resource-manager
grpcio==1.80.0
# via
# google-api-core
# google-cloud-resource-manager
# googleapis-common-protos
# grpc-google-iam-v1
# grpcio-status
grpcio-status==1.80.0
# via google-api-core
h11==0.16.0
# via
# httpcore
@@ -443,7 +491,7 @@ magika==0.6.3
# via markitdown
makefun==1.16.0
# via fastapi-users
mako==1.2.4
mako==1.3.11
# via alembic
mammoth==1.11.0
# via markitdown
@@ -559,6 +607,8 @@ packaging==24.2
# dask
# distributed
# fastmcp
# google-cloud-aiplatform
# google-cloud-bigquery
# huggingface-hub
# jira
# kombu
@@ -605,12 +655,19 @@ propcache==0.4.1
# aiohttp
# yarl
proto-plus==1.26.1
# via google-api-core
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-resource-manager
protobuf==6.33.5
# via
# ddtrace
# google-api-core
# google-cloud-aiplatform
# google-cloud-resource-manager
# googleapis-common-protos
# grpc-google-iam-v1
# grpcio-status
# onnxruntime
# opentelemetry-proto
# proto-plus
@@ -643,6 +700,7 @@ pydantic==2.11.7
# exa-py
# fastapi
# fastmcp
# google-cloud-aiplatform
# google-genai
# langchain-core
# langfuse
@@ -679,7 +737,7 @@ pynacl==1.6.2
pypandoc-binary==1.16.2
pyparsing==3.2.5
# via httplib2
pypdf==6.10.0
pypdf==6.10.2
# via unstructured-client
pyperclip==1.11.0
# via fastmcp
@@ -701,6 +759,7 @@ python-dateutil==2.8.2
# botocore
# celery
# dateparser
# google-cloud-bigquery
# htmldate
# hubspot-api-client
# kubernetes
@@ -779,6 +838,8 @@ requests==2.33.0
# dropbox
# exa-py
# google-api-core
# google-cloud-bigquery
# google-cloud-storage
# google-genai
# hubspot-api-client
# jira
@@ -951,7 +1012,9 @@ typing-extensions==4.15.0
# exa-py
# exceptiongroup
# fastapi
# google-cloud-aiplatform
# google-genai
# grpcio
# huggingface-hub
# jira
# langchain-core

View File

@@ -114,6 +114,8 @@ distlib==0.4.0
# via virtualenv
distro==1.9.0
# via openai
docstring-parser==0.17.0
# via google-cloud-aiplatform
durationpy==0.10
# via kubernetes
execnet==2.1.2
@@ -141,14 +143,65 @@ frozenlist==1.8.0
# aiosignal
fsspec==2025.10.0
# via huggingface-hub
google-api-core==2.28.1
# via
# google-cloud-aiplatform
# google-cloud-bigquery
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
google-auth==2.48.0
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-bigquery
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
# google-genai
# kubernetes
google-cloud-aiplatform==1.133.0
# via litellm
google-cloud-bigquery==3.41.0
# via google-cloud-aiplatform
google-cloud-core==2.5.1
# via
# google-cloud-bigquery
# google-cloud-storage
google-cloud-resource-manager==1.17.0
# via google-cloud-aiplatform
google-cloud-storage==3.10.1
# via google-cloud-aiplatform
google-crc32c==1.8.0
# via
# google-cloud-storage
# google-resumable-media
google-genai==1.52.0
# via onyx
# via
# google-cloud-aiplatform
# onyx
google-resumable-media==2.8.2
# via
# google-cloud-bigquery
# google-cloud-storage
googleapis-common-protos==1.72.0
# via
# google-api-core
# grpc-google-iam-v1
# grpcio-status
greenlet==3.2.4 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'
# via sqlalchemy
grpc-google-iam-v1==0.14.4
# via google-cloud-resource-manager
grpcio==1.80.0
# via
# google-api-core
# google-cloud-resource-manager
# googleapis-common-protos
# grpc-google-iam-v1
# grpcio-status
grpcio-status==1.80.0
# via google-api-core
h11==0.16.0
# via
# httpcore
@@ -218,7 +271,7 @@ kubernetes==31.0.0
# via onyx
litellm==1.81.6
# via onyx
mako==1.2.4
mako==1.3.11
# via alembic
manygo==0.2.0
markdown-it-py==4.0.0
@@ -267,6 +320,8 @@ openapi-generator-cli==7.17.0
packaging==24.2
# via
# black
# google-cloud-aiplatform
# google-cloud-bigquery
# hatchling
# huggingface-hub
# ipykernel
@@ -307,6 +362,20 @@ propcache==0.4.1
# via
# aiohttp
# yarl
proto-plus==1.26.1
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-resource-manager
protobuf==6.33.5
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-resource-manager
# googleapis-common-protos
# grpc-google-iam-v1
# grpcio-status
# proto-plus
psutil==7.1.3
# via ipykernel
ptyprocess==0.7.0 ; sys_platform != 'emscripten' and sys_platform != 'win32'
@@ -328,6 +397,7 @@ pydantic==2.11.7
# agent-client-protocol
# cohere
# fastapi
# google-cloud-aiplatform
# google-genai
# litellm
# mcp
@@ -364,6 +434,7 @@ python-dateutil==2.8.2
# via
# aiobotocore
# botocore
# google-cloud-bigquery
# jupyter-client
# kubernetes
# matplotlib
@@ -398,6 +469,9 @@ reorder-python-imports-black==3.14.0
requests==2.33.0
# via
# cohere
# google-api-core
# google-cloud-bigquery
# google-cloud-storage
# google-genai
# kubernetes
# requests-oauthlib
@@ -498,7 +572,9 @@ typing-extensions==4.15.0
# celery-types
# cohere
# fastapi
# google-cloud-aiplatform
# google-genai
# grpcio
# huggingface-hub
# ipython
# mcp

View File

@@ -87,6 +87,8 @@ discord-py==2.4.0
# via onyx
distro==1.9.0
# via openai
docstring-parser==0.17.0
# via google-cloud-aiplatform
durationpy==0.10
# via kubernetes
fastapi==0.133.1
@@ -103,12 +105,63 @@ frozenlist==1.8.0
# aiosignal
fsspec==2025.10.0
# via huggingface-hub
google-api-core==2.28.1
# via
# google-cloud-aiplatform
# google-cloud-bigquery
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
google-auth==2.48.0
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-bigquery
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
# google-genai
# kubernetes
google-cloud-aiplatform==1.133.0
# via litellm
google-cloud-bigquery==3.41.0
# via google-cloud-aiplatform
google-cloud-core==2.5.1
# via
# google-cloud-bigquery
# google-cloud-storage
google-cloud-resource-manager==1.17.0
# via google-cloud-aiplatform
google-cloud-storage==3.10.1
# via google-cloud-aiplatform
google-crc32c==1.8.0
# via
# google-cloud-storage
# google-resumable-media
google-genai==1.52.0
# via onyx
# via
# google-cloud-aiplatform
# onyx
google-resumable-media==2.8.2
# via
# google-cloud-bigquery
# google-cloud-storage
googleapis-common-protos==1.72.0
# via
# google-api-core
# grpc-google-iam-v1
# grpcio-status
grpc-google-iam-v1==0.14.4
# via google-cloud-resource-manager
grpcio==1.80.0
# via
# google-api-core
# google-cloud-resource-manager
# googleapis-common-protos
# grpc-google-iam-v1
# grpcio-status
grpcio-status==1.80.0
# via google-api-core
h11==0.16.0
# via
# httpcore
@@ -184,7 +237,10 @@ openai==2.14.0
# litellm
# onyx
packaging==24.2
# via huggingface-hub
# via
# google-cloud-aiplatform
# google-cloud-bigquery
# huggingface-hub
parameterized==0.9.0
# via cohere
posthog==3.7.4
@@ -198,6 +254,20 @@ propcache==0.4.1
# via
# aiohttp
# yarl
proto-plus==1.26.1
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-resource-manager
protobuf==6.33.5
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-resource-manager
# googleapis-common-protos
# grpc-google-iam-v1
# grpcio-status
# proto-plus
py==1.11.0
# via retry
pyasn1==0.6.3
@@ -213,6 +283,7 @@ pydantic==2.11.7
# agent-client-protocol
# cohere
# fastapi
# google-cloud-aiplatform
# google-genai
# litellm
# mcp
@@ -231,6 +302,7 @@ python-dateutil==2.8.2
# via
# aiobotocore
# botocore
# google-cloud-bigquery
# kubernetes
# posthog
python-dotenv==1.1.1
@@ -254,6 +326,9 @@ regex==2025.11.3
requests==2.33.0
# via
# cohere
# google-api-core
# google-cloud-bigquery
# google-cloud-storage
# google-genai
# kubernetes
# posthog
@@ -318,7 +393,9 @@ typing-extensions==4.15.0
# anyio
# cohere
# fastapi
# google-cloud-aiplatform
# google-genai
# grpcio
# huggingface-hub
# mcp
# openai

View File

@@ -102,6 +102,8 @@ discord-py==2.4.0
# via onyx
distro==1.9.0
# via openai
docstring-parser==0.17.0
# via google-cloud-aiplatform
durationpy==0.10
# via kubernetes
einops==0.8.1
@@ -125,12 +127,63 @@ fsspec==2025.10.0
# via
# huggingface-hub
# torch
google-api-core==2.28.1
# via
# google-cloud-aiplatform
# google-cloud-bigquery
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
google-auth==2.48.0
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-bigquery
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
# google-genai
# kubernetes
google-cloud-aiplatform==1.133.0
# via litellm
google-cloud-bigquery==3.41.0
# via google-cloud-aiplatform
google-cloud-core==2.5.1
# via
# google-cloud-bigquery
# google-cloud-storage
google-cloud-resource-manager==1.17.0
# via google-cloud-aiplatform
google-cloud-storage==3.10.1
# via google-cloud-aiplatform
google-crc32c==1.8.0
# via
# google-cloud-storage
# google-resumable-media
google-genai==1.52.0
# via onyx
# via
# google-cloud-aiplatform
# onyx
google-resumable-media==2.8.2
# via
# google-cloud-bigquery
# google-cloud-storage
googleapis-common-protos==1.72.0
# via
# google-api-core
# grpc-google-iam-v1
# grpcio-status
grpc-google-iam-v1==0.14.4
# via google-cloud-resource-manager
grpcio==1.80.0
# via
# google-api-core
# google-cloud-resource-manager
# googleapis-common-protos
# grpc-google-iam-v1
# grpcio-status
grpcio-status==1.80.0
# via google-api-core
h11==0.16.0
# via
# httpcore
@@ -265,6 +318,8 @@ openai==2.14.0
packaging==24.2
# via
# accelerate
# google-cloud-aiplatform
# google-cloud-bigquery
# huggingface-hub
# kombu
# transformers
@@ -282,6 +337,20 @@ propcache==0.4.1
# via
# aiohttp
# yarl
proto-plus==1.26.1
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-resource-manager
protobuf==6.33.5
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-resource-manager
# googleapis-common-protos
# grpc-google-iam-v1
# grpcio-status
# proto-plus
psutil==7.1.3
# via accelerate
py==1.11.0
@@ -299,6 +368,7 @@ pydantic==2.11.7
# agent-client-protocol
# cohere
# fastapi
# google-cloud-aiplatform
# google-genai
# litellm
# mcp
@@ -318,6 +388,7 @@ python-dateutil==2.8.2
# aiobotocore
# botocore
# celery
# google-cloud-bigquery
# kubernetes
python-dotenv==1.1.1
# via
@@ -344,6 +415,9 @@ regex==2025.11.3
requests==2.33.0
# via
# cohere
# google-api-core
# google-cloud-bigquery
# google-cloud-storage
# google-genai
# kubernetes
# requests-oauthlib
@@ -437,7 +511,9 @@ typing-extensions==4.15.0
# anyio
# cohere
# fastapi
# google-cloud-aiplatform
# google-genai
# grpcio
# huggingface-hub
# mcp
# openai

View File

@@ -1,348 +0,0 @@
"""External dependency unit tests for the file_id cleanup that runs alongside
document deletion across the three deletion paths:
1. `document_by_cc_pair_cleanup_task` (pruning + connector deletion)
2. `delete_ingestion_doc` (public ingestion API DELETE)
3. `delete_all_documents_for_connector_credential_pair` (index swap)
Each path captures attached `Document.file_id`s before the row is removed and
best-effort deletes the underlying files after the DB commit.
"""
from collections.abc import Generator
from io import BytesIO
from unittest.mock import MagicMock
from unittest.mock import patch
from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from onyx.background.celery.tasks.shared.tasks import (
document_by_cc_pair_cleanup_task,
)
from onyx.configs.constants import DocumentSource
from onyx.configs.constants import FileOrigin
from onyx.connectors.models import Document
from onyx.connectors.models import IndexAttemptMetadata
from onyx.connectors.models import InputType
from onyx.connectors.models import TextSection
from onyx.db.document import delete_all_documents_for_connector_credential_pair
from onyx.db.document import upsert_document_by_connector_credential_pair
from onyx.db.enums import AccessType
from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.file_record import get_filerecord_by_file_id_optional
from onyx.db.models import Connector
from onyx.db.models import ConnectorCredentialPair
from onyx.db.models import Credential
from onyx.db.models import Document as DBDocument
from onyx.db.models import FileRecord
from onyx.file_store.file_store import get_default_file_store
from onyx.indexing.indexing_pipeline import index_doc_batch_prepare
from onyx.server.onyx_api.ingestion import delete_ingestion_doc
from tests.external_dependency_unit.constants import TEST_TENANT_ID
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_doc(
doc_id: str,
file_id: str | None = None,
from_ingestion_api: bool = False,
) -> Document:
return Document(
id=doc_id,
source=DocumentSource.MOCK_CONNECTOR,
semantic_identifier=f"semantic-{doc_id}",
sections=[TextSection(text="content", link=None)],
metadata={},
file_id=file_id,
from_ingestion_api=from_ingestion_api,
)
def _stage_file(content: bytes = b"raw bytes") -> str:
return get_default_file_store().save_file(
content=BytesIO(content),
display_name=None,
file_origin=FileOrigin.INDEXING_STAGING,
file_type="application/octet-stream",
file_metadata={"test": True},
)
def _get_doc_row(db_session: Session, doc_id: str) -> DBDocument | None:
db_session.expire_all()
return db_session.query(DBDocument).filter(DBDocument.id == doc_id).one_or_none()
def _get_filerecord(db_session: Session, file_id: str) -> FileRecord | None:
db_session.expire_all()
return get_filerecord_by_file_id_optional(file_id=file_id, db_session=db_session)
def _index_doc(
db_session: Session,
doc: Document,
attempt_metadata: IndexAttemptMetadata,
) -> None:
"""Run the doc through the upsert pipeline so the row + cc_pair mapping
exist (so deletion paths have something to find)."""
index_doc_batch_prepare(
documents=[doc],
index_attempt_metadata=attempt_metadata,
db_session=db_session,
ignore_time_skip=True,
)
db_session.commit()
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_cc_pair(db_session: Session) -> ConnectorCredentialPair:
connector = Connector(
name=f"test-connector-{uuid4().hex[:8]}",
source=DocumentSource.MOCK_CONNECTOR,
input_type=InputType.LOAD_STATE,
connector_specific_config={},
refresh_freq=None,
prune_freq=None,
indexing_start=None,
)
db_session.add(connector)
db_session.flush()
credential = Credential(
source=DocumentSource.MOCK_CONNECTOR,
credential_json={},
)
db_session.add(credential)
db_session.flush()
pair = ConnectorCredentialPair(
connector_id=connector.id,
credential_id=credential.id,
name=f"test-cc-pair-{uuid4().hex[:8]}",
status=ConnectorCredentialPairStatus.ACTIVE,
access_type=AccessType.PUBLIC,
auto_sync_options=None,
)
db_session.add(pair)
db_session.commit()
db_session.refresh(pair)
return pair
@pytest.fixture
def cc_pair(
db_session: Session,
tenant_context: None, # noqa: ARG001
initialize_file_store: None, # noqa: ARG001
) -> Generator[ConnectorCredentialPair, None, None]:
yield _make_cc_pair(db_session)
@pytest.fixture
def second_cc_pair(
db_session: Session,
tenant_context: None, # noqa: ARG001
initialize_file_store: None, # noqa: ARG001
) -> Generator[ConnectorCredentialPair, None, None]:
"""A second cc_pair, used to test the count > 1 branch."""
yield _make_cc_pair(db_session)
@pytest.fixture
def attempt_metadata(cc_pair: ConnectorCredentialPair) -> IndexAttemptMetadata:
return IndexAttemptMetadata(
connector_id=cc_pair.connector_id,
credential_id=cc_pair.credential_id,
attempt_id=None,
request_id="test-request",
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestDeleteAllDocumentsForCcPair:
"""Path 3: bulk delete during index swap (`INSTANT` switchover)."""
def test_cleans_up_files_for_all_docs(
self,
db_session: Session,
cc_pair: ConnectorCredentialPair,
attempt_metadata: IndexAttemptMetadata,
) -> None:
file_id_a = _stage_file(content=b"a")
file_id_b = _stage_file(content=b"b")
doc_a = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=file_id_a)
doc_b = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=file_id_b)
_index_doc(db_session, doc_a, attempt_metadata)
_index_doc(db_session, doc_b, attempt_metadata)
assert _get_filerecord(db_session, file_id_a) is not None
assert _get_filerecord(db_session, file_id_b) is not None
delete_all_documents_for_connector_credential_pair(
db_session=db_session,
connector_id=cc_pair.connector_id,
credential_id=cc_pair.credential_id,
)
assert _get_doc_row(db_session, doc_a.id) is None
assert _get_doc_row(db_session, doc_b.id) is None
assert _get_filerecord(db_session, file_id_a) is None
assert _get_filerecord(db_session, file_id_b) is None
def test_handles_mixed_docs_with_and_without_file_ids(
self,
db_session: Session,
cc_pair: ConnectorCredentialPair,
attempt_metadata: IndexAttemptMetadata,
) -> None:
"""Docs without file_id should be cleanly removed — no errors,
no spurious file_store calls."""
file_id = _stage_file()
doc_with = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=file_id)
doc_without = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=None)
_index_doc(db_session, doc_with, attempt_metadata)
_index_doc(db_session, doc_without, attempt_metadata)
delete_all_documents_for_connector_credential_pair(
db_session=db_session,
connector_id=cc_pair.connector_id,
credential_id=cc_pair.credential_id,
)
assert _get_doc_row(db_session, doc_with.id) is None
assert _get_doc_row(db_session, doc_without.id) is None
assert _get_filerecord(db_session, file_id) is None
class TestDeleteIngestionDoc:
"""Path 2: public ingestion API DELETE endpoint."""
def test_cleans_up_file_for_ingestion_api_doc(
self,
db_session: Session,
attempt_metadata: IndexAttemptMetadata,
tenant_context: None, # noqa: ARG002
initialize_file_store: None, # noqa: ARG002
) -> None:
file_id = _stage_file()
doc = _make_doc(
f"doc-{uuid4().hex[:8]}",
file_id=file_id,
from_ingestion_api=True,
)
_index_doc(db_session, doc, attempt_metadata)
assert _get_filerecord(db_session, file_id) is not None
# Patch out Vespa — we're testing the file cleanup, not the document
# index integration.
with patch(
"onyx.server.onyx_api.ingestion.get_all_document_indices",
return_value=[],
):
delete_ingestion_doc(
document_id=doc.id,
_=MagicMock(), # auth dep — not used by the function body
db_session=db_session,
)
assert _get_doc_row(db_session, doc.id) is None
assert _get_filerecord(db_session, file_id) is None
class TestDocumentByCcPairCleanupTask:
"""Path 1: per-doc cleanup task fired by pruning / connector deletion."""
def test_count_1_branch_cleans_up_file(
self,
db_session: Session,
cc_pair: ConnectorCredentialPair,
attempt_metadata: IndexAttemptMetadata,
full_deployment_setup: None, # noqa: ARG002
) -> None:
"""When the doc has exactly one cc_pair reference, the full delete
path runs and the attached file is reaped."""
file_id = _stage_file()
doc = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=file_id)
_index_doc(db_session, doc, attempt_metadata)
assert _get_filerecord(db_session, file_id) is not None
# Patch out Vespa interaction — no chunks were ever written, and we're
# not testing the document index here.
with patch(
"onyx.background.celery.tasks.shared.tasks.get_all_document_indices",
return_value=[],
):
result = document_by_cc_pair_cleanup_task.apply(
args=(
doc.id,
cc_pair.connector_id,
cc_pair.credential_id,
TEST_TENANT_ID,
),
)
assert result.successful(), result.traceback
assert _get_doc_row(db_session, doc.id) is None
assert _get_filerecord(db_session, file_id) is None
def test_count_gt_1_branch_preserves_file(
self,
db_session: Session,
cc_pair: ConnectorCredentialPair,
second_cc_pair: ConnectorCredentialPair,
attempt_metadata: IndexAttemptMetadata,
full_deployment_setup: None, # noqa: ARG002
) -> None:
"""When the doc is referenced by another cc_pair, only the mapping
for the detaching cc_pair is removed. The file MUST stay because
the doc and its file are still owned by the remaining cc_pair."""
file_id = _stage_file()
doc = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=file_id)
_index_doc(db_session, doc, attempt_metadata)
# Attach the same doc to a second cc_pair so refcount becomes 2.
upsert_document_by_connector_credential_pair(
db_session,
second_cc_pair.connector_id,
second_cc_pair.credential_id,
[doc.id],
)
db_session.commit()
with patch(
"onyx.background.celery.tasks.shared.tasks.get_all_document_indices",
return_value=[],
):
result = document_by_cc_pair_cleanup_task.apply(
args=(
doc.id,
cc_pair.connector_id,
cc_pair.credential_id,
TEST_TENANT_ID,
),
)
assert result.successful(), result.traceback
# Document row still exists (other cc_pair owns it).
assert _get_doc_row(db_session, doc.id) is not None
# File MUST still exist.
record = _get_filerecord(db_session, file_id)
assert record is not None

View File

@@ -1,346 +0,0 @@
"""External dependency unit tests for `index_doc_batch_prepare`.
Validates the file_id lifecycle that runs alongside the document upsert:
* `document.file_id` is written on insert AND on conflict (upsert path)
* Newly-staged files get promoted from INDEXING_STAGING -> CONNECTOR
* Replaced files are deleted from both `file_record` and S3
* No-op when the file_id is unchanged
Uses real PostgreSQL + real S3/MinIO via the file store.
"""
from collections.abc import Generator
from io import BytesIO
from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from onyx.configs.constants import DocumentSource
from onyx.configs.constants import FileOrigin
from onyx.connectors.models import Document
from onyx.connectors.models import IndexAttemptMetadata
from onyx.connectors.models import InputType
from onyx.connectors.models import TextSection
from onyx.db.enums import AccessType
from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.file_record import get_filerecord_by_file_id_optional
from onyx.db.models import Connector
from onyx.db.models import ConnectorCredentialPair
from onyx.db.models import Credential
from onyx.db.models import Document as DBDocument
from onyx.db.models import FileRecord
from onyx.file_store.file_store import get_default_file_store
from onyx.indexing.indexing_pipeline import index_doc_batch_prepare
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_doc(doc_id: str, file_id: str | None = None) -> Document:
"""Minimal Document for indexing-pipeline tests. MOCK_CONNECTOR avoids
triggering the hierarchy-node linking branch (NOTION/CONFLUENCE only)."""
return Document(
id=doc_id,
source=DocumentSource.MOCK_CONNECTOR,
semantic_identifier=f"semantic-{doc_id}",
sections=[TextSection(text="content", link=None)],
metadata={},
file_id=file_id,
)
def _stage_file(content: bytes = b"raw bytes") -> str:
"""Write bytes to the file store as INDEXING_STAGING and return the file_id.
Mirrors what the connector raw_file_callback would do during fetch.
"""
return get_default_file_store().save_file(
content=BytesIO(content),
display_name=None,
file_origin=FileOrigin.INDEXING_STAGING,
file_type="application/octet-stream",
file_metadata={"test": True},
)
def _get_doc_row(db_session: Session, doc_id: str) -> DBDocument | None:
"""Reload the document row fresh from DB so we see post-upsert state."""
db_session.expire_all()
return db_session.query(DBDocument).filter(DBDocument.id == doc_id).one_or_none()
def _get_filerecord(db_session: Session, file_id: str) -> FileRecord | None:
db_session.expire_all()
return get_filerecord_by_file_id_optional(file_id=file_id, db_session=db_session)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def cc_pair(
db_session: Session,
tenant_context: None, # noqa: ARG001
initialize_file_store: None, # noqa: ARG001
) -> Generator[ConnectorCredentialPair, None, None]:
"""Create a connector + credential + cc_pair backing the index attempt."""
connector = Connector(
name=f"test-connector-{uuid4().hex[:8]}",
source=DocumentSource.MOCK_CONNECTOR,
input_type=InputType.LOAD_STATE,
connector_specific_config={},
refresh_freq=None,
prune_freq=None,
indexing_start=None,
)
db_session.add(connector)
db_session.flush()
credential = Credential(
source=DocumentSource.MOCK_CONNECTOR,
credential_json={},
)
db_session.add(credential)
db_session.flush()
pair = ConnectorCredentialPair(
connector_id=connector.id,
credential_id=credential.id,
name=f"test-cc-pair-{uuid4().hex[:8]}",
status=ConnectorCredentialPairStatus.ACTIVE,
access_type=AccessType.PUBLIC,
auto_sync_options=None,
)
db_session.add(pair)
db_session.commit()
db_session.refresh(pair)
yield pair
@pytest.fixture
def attempt_metadata(cc_pair: ConnectorCredentialPair) -> IndexAttemptMetadata:
return IndexAttemptMetadata(
connector_id=cc_pair.connector_id,
credential_id=cc_pair.credential_id,
attempt_id=None,
request_id="test-request",
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestNewDocuments:
"""First-time inserts — no previous file_id to reconcile against."""
def test_new_doc_without_file_id(
self,
db_session: Session,
attempt_metadata: IndexAttemptMetadata,
) -> None:
doc = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=None)
index_doc_batch_prepare(
documents=[doc],
index_attempt_metadata=attempt_metadata,
db_session=db_session,
ignore_time_skip=True,
)
db_session.commit()
row = _get_doc_row(db_session, doc.id)
assert row is not None
assert row.file_id is None
def test_new_doc_with_staged_file_id_promotes_to_connector(
self,
db_session: Session,
attempt_metadata: IndexAttemptMetadata,
) -> None:
file_id = _stage_file()
doc = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=file_id)
index_doc_batch_prepare(
documents=[doc],
index_attempt_metadata=attempt_metadata,
db_session=db_session,
ignore_time_skip=True,
)
db_session.commit()
row = _get_doc_row(db_session, doc.id)
assert row is not None and row.file_id == file_id
record = _get_filerecord(db_session, file_id)
assert record is not None
assert record.file_origin == FileOrigin.CONNECTOR
class TestExistingDocuments:
"""Re-index path — a `document` row already exists with some file_id."""
def test_unchanged_file_id_is_noop(
self,
db_session: Session,
attempt_metadata: IndexAttemptMetadata,
) -> None:
file_id = _stage_file()
doc = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=file_id)
# First pass: inserts the row + promotes the file.
index_doc_batch_prepare(
documents=[doc],
index_attempt_metadata=attempt_metadata,
db_session=db_session,
ignore_time_skip=True,
)
db_session.commit()
# Second pass with the same file_id — should not delete or re-promote.
index_doc_batch_prepare(
documents=[doc],
index_attempt_metadata=attempt_metadata,
db_session=db_session,
ignore_time_skip=True,
)
db_session.commit()
record = _get_filerecord(db_session, file_id)
assert record is not None
assert record.file_origin == FileOrigin.CONNECTOR
row = _get_doc_row(db_session, doc.id)
assert row is not None and row.file_id == file_id
def test_swapping_file_id_promotes_new_and_deletes_old(
self,
db_session: Session,
attempt_metadata: IndexAttemptMetadata,
) -> None:
old_file_id = _stage_file(content=b"old bytes")
doc = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=old_file_id)
index_doc_batch_prepare(
documents=[doc],
index_attempt_metadata=attempt_metadata,
db_session=db_session,
ignore_time_skip=True,
)
db_session.commit()
# Re-fetch produces a new staged file_id for the same doc.
new_file_id = _stage_file(content=b"new bytes")
doc_v2 = _make_doc(doc.id, file_id=new_file_id)
index_doc_batch_prepare(
documents=[doc_v2],
index_attempt_metadata=attempt_metadata,
db_session=db_session,
ignore_time_skip=True,
)
db_session.commit()
row = _get_doc_row(db_session, doc.id)
assert row is not None and row.file_id == new_file_id
new_record = _get_filerecord(db_session, new_file_id)
assert new_record is not None
assert new_record.file_origin == FileOrigin.CONNECTOR
# Old file_record + S3 object are gone.
assert _get_filerecord(db_session, old_file_id) is None
def test_clearing_file_id_deletes_old_and_nulls_column(
self,
db_session: Session,
attempt_metadata: IndexAttemptMetadata,
) -> None:
old_file_id = _stage_file()
doc = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=old_file_id)
index_doc_batch_prepare(
documents=[doc],
index_attempt_metadata=attempt_metadata,
db_session=db_session,
ignore_time_skip=True,
)
db_session.commit()
# Connector opts out on next run — yields the doc without a file_id.
doc_v2 = _make_doc(doc.id, file_id=None)
index_doc_batch_prepare(
documents=[doc_v2],
index_attempt_metadata=attempt_metadata,
db_session=db_session,
ignore_time_skip=True,
)
db_session.commit()
row = _get_doc_row(db_session, doc.id)
assert row is not None and row.file_id is None
assert _get_filerecord(db_session, old_file_id) is None
class TestBatchHandling:
"""Mixed batches — multiple docs at different lifecycle states in one call."""
def test_mixed_batch_each_doc_handled_independently(
self,
db_session: Session,
attempt_metadata: IndexAttemptMetadata,
) -> None:
# Pre-seed an existing doc with a file_id we'll swap.
existing_old_id = _stage_file(content=b"existing-old")
existing_doc = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=existing_old_id)
index_doc_batch_prepare(
documents=[existing_doc],
index_attempt_metadata=attempt_metadata,
db_session=db_session,
ignore_time_skip=True,
)
db_session.commit()
# Now: swap the existing one, add a brand-new doc with file_id, and a
# brand-new doc without file_id.
swap_new_id = _stage_file(content=b"existing-new")
new_with_file_id = _stage_file(content=b"new-with-file")
existing_v2 = _make_doc(existing_doc.id, file_id=swap_new_id)
new_with = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=new_with_file_id)
new_without = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=None)
index_doc_batch_prepare(
documents=[existing_v2, new_with, new_without],
index_attempt_metadata=attempt_metadata,
db_session=db_session,
ignore_time_skip=True,
)
db_session.commit()
# Existing doc was swapped: old file gone, new file promoted.
existing_row = _get_doc_row(db_session, existing_doc.id)
assert existing_row is not None and existing_row.file_id == swap_new_id
assert _get_filerecord(db_session, existing_old_id) is None
swap_record = _get_filerecord(db_session, swap_new_id)
assert swap_record is not None
assert swap_record.file_origin == FileOrigin.CONNECTOR
# New doc with file_id: row exists, file promoted.
new_with_row = _get_doc_row(db_session, new_with.id)
assert new_with_row is not None and new_with_row.file_id == new_with_file_id
new_with_record = _get_filerecord(db_session, new_with_file_id)
assert new_with_record is not None
assert new_with_record.file_origin == FileOrigin.CONNECTOR
# New doc without file_id: row exists, no file_record involvement.
new_without_row = _get_doc_row(db_session, new_without.id)
assert new_without_row is not None and new_without_row.file_id is None

View File

@@ -1,262 +0,0 @@
"""Workflow-level test for the INSTANT index swap.
When `check_and_perform_index_swap` runs against an `INSTANT` switchover, it
calls `delete_all_documents_for_connector_credential_pair` for each cc_pair.
This test exercises that full workflow end-to-end and asserts that the
attached `Document.file_id`s are also reaped — not just the document rows.
Mocks Vespa (`get_all_document_indices`) since this is testing the postgres +
file_store side effects of the swap, not the document index integration.
"""
from collections.abc import Generator
from io import BytesIO
from unittest.mock import patch
from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from onyx.configs.constants import DocumentSource
from onyx.configs.constants import FileOrigin
from onyx.connectors.models import Document
from onyx.connectors.models import IndexAttemptMetadata
from onyx.connectors.models import InputType
from onyx.connectors.models import TextSection
from onyx.context.search.models import SavedSearchSettings
from onyx.db.enums import AccessType
from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.enums import EmbeddingPrecision
from onyx.db.enums import SwitchoverType
from onyx.db.file_record import get_filerecord_by_file_id_optional
from onyx.db.models import Connector
from onyx.db.models import ConnectorCredentialPair
from onyx.db.models import Credential
from onyx.db.models import Document as DBDocument
from onyx.db.models import FileRecord
from onyx.db.models import IndexModelStatus
from onyx.db.search_settings import create_search_settings
from onyx.db.swap_index import check_and_perform_index_swap
from onyx.file_store.file_store import get_default_file_store
from onyx.indexing.indexing_pipeline import index_doc_batch_prepare
# ---------------------------------------------------------------------------
# Helpers (kept inline; extract to a shared conftest if a 4th test file shows up)
# ---------------------------------------------------------------------------
def _make_doc(doc_id: str, file_id: str | None = None) -> Document:
return Document(
id=doc_id,
source=DocumentSource.MOCK_CONNECTOR,
semantic_identifier=f"semantic-{doc_id}",
sections=[TextSection(text="content", link=None)],
metadata={},
file_id=file_id,
)
def _stage_file(content: bytes = b"raw bytes") -> str:
return get_default_file_store().save_file(
content=BytesIO(content),
display_name=None,
file_origin=FileOrigin.INDEXING_STAGING,
file_type="application/octet-stream",
file_metadata={"test": True},
)
def _get_doc_row(db_session: Session, doc_id: str) -> DBDocument | None:
db_session.expire_all()
return db_session.query(DBDocument).filter(DBDocument.id == doc_id).one_or_none()
def _get_filerecord(db_session: Session, file_id: str) -> FileRecord | None:
db_session.expire_all()
return get_filerecord_by_file_id_optional(file_id=file_id, db_session=db_session)
def _make_cc_pair(db_session: Session) -> ConnectorCredentialPair:
connector = Connector(
name=f"test-connector-{uuid4().hex[:8]}",
source=DocumentSource.MOCK_CONNECTOR,
input_type=InputType.LOAD_STATE,
connector_specific_config={},
refresh_freq=None,
prune_freq=None,
indexing_start=None,
)
db_session.add(connector)
db_session.flush()
credential = Credential(
source=DocumentSource.MOCK_CONNECTOR,
credential_json={},
)
db_session.add(credential)
db_session.flush()
pair = ConnectorCredentialPair(
connector_id=connector.id,
credential_id=credential.id,
name=f"test-cc-pair-{uuid4().hex[:8]}",
status=ConnectorCredentialPairStatus.ACTIVE,
access_type=AccessType.PUBLIC,
auto_sync_options=None,
)
db_session.add(pair)
db_session.commit()
db_session.refresh(pair)
return pair
def _make_saved_search_settings(
*,
switchover_type: SwitchoverType = SwitchoverType.REINDEX,
) -> SavedSearchSettings:
return SavedSearchSettings(
model_name=f"test-embedding-model-{uuid4().hex[:8]}",
model_dim=768,
normalize=True,
query_prefix="",
passage_prefix="",
provider_type=None,
index_name=f"test_index_{uuid4().hex[:8]}",
multipass_indexing=False,
embedding_precision=EmbeddingPrecision.FLOAT,
reduced_dimension=None,
enable_contextual_rag=False,
contextual_rag_llm_name=None,
contextual_rag_llm_provider=None,
switchover_type=switchover_type,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def cc_pair(
db_session: Session,
tenant_context: None, # noqa: ARG001
initialize_file_store: None, # noqa: ARG001
full_deployment_setup: None, # noqa: ARG001
) -> Generator[ConnectorCredentialPair, None, None]:
yield _make_cc_pair(db_session)
@pytest.fixture
def attempt_metadata(cc_pair: ConnectorCredentialPair) -> IndexAttemptMetadata:
return IndexAttemptMetadata(
connector_id=cc_pair.connector_id,
credential_id=cc_pair.credential_id,
attempt_id=None,
request_id="test-request",
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestInstantIndexSwap:
"""`SwitchoverType.INSTANT` wipes all docs for every cc_pair as part of
the swap. The associated raw files must be reaped too."""
def test_instant_swap_deletes_docs_and_files(
self,
db_session: Session,
attempt_metadata: IndexAttemptMetadata,
) -> None:
# Index two docs with attached files via the normal pipeline.
file_id_a = _stage_file(content=b"alpha")
file_id_b = _stage_file(content=b"beta")
doc_a = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=file_id_a)
doc_b = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=file_id_b)
index_doc_batch_prepare(
documents=[doc_a, doc_b],
index_attempt_metadata=attempt_metadata,
db_session=db_session,
ignore_time_skip=True,
)
db_session.commit()
# Sanity: docs and files exist before the swap.
assert _get_doc_row(db_session, doc_a.id) is not None
assert _get_doc_row(db_session, doc_b.id) is not None
assert _get_filerecord(db_session, file_id_a) is not None
assert _get_filerecord(db_session, file_id_b) is not None
# Stage a FUTURE search settings with INSTANT switchover. The next
# `check_and_perform_index_swap` call will see this and trigger the
# bulk-delete path on every cc_pair.
create_search_settings(
search_settings=_make_saved_search_settings(
switchover_type=SwitchoverType.INSTANT
),
db_session=db_session,
status=IndexModelStatus.FUTURE,
)
# Vespa is patched out — we're testing the postgres + file_store
# side effects, not the document-index integration.
with patch(
"onyx.db.swap_index.get_all_document_indices",
return_value=[],
):
old_settings = check_and_perform_index_swap(db_session)
assert old_settings is not None, "INSTANT swap should have executed"
# Documents are gone.
assert _get_doc_row(db_session, doc_a.id) is None
assert _get_doc_row(db_session, doc_b.id) is None
# Files are gone — the workflow's bulk-delete path correctly
# propagated through to file cleanup.
assert _get_filerecord(db_session, file_id_a) is None
assert _get_filerecord(db_session, file_id_b) is None
def test_instant_swap_with_mixed_docs_does_not_break(
self,
db_session: Session,
attempt_metadata: IndexAttemptMetadata,
) -> None:
"""A mix of docs with and without file_ids must all be swept up
without errors during the swap."""
file_id = _stage_file()
doc_with = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=file_id)
doc_without = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=None)
index_doc_batch_prepare(
documents=[doc_with, doc_without],
index_attempt_metadata=attempt_metadata,
db_session=db_session,
ignore_time_skip=True,
)
db_session.commit()
create_search_settings(
search_settings=_make_saved_search_settings(
switchover_type=SwitchoverType.INSTANT
),
db_session=db_session,
status=IndexModelStatus.FUTURE,
)
with patch(
"onyx.db.swap_index.get_all_document_indices",
return_value=[],
):
old_settings = check_and_perform_index_swap(db_session)
assert old_settings is not None
assert _get_doc_row(db_session, doc_with.id) is None
assert _get_doc_row(db_session, doc_without.id) is None
assert _get_filerecord(db_session, file_id) is None

View File

@@ -0,0 +1,53 @@
import datetime
import pytest
from onyx.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc
def test_time_str_to_utc() -> None:
str_to_dt = {
"Tue, 5 Oct 2021 09:38:25 GMT": datetime.datetime(
2021, 10, 5, 9, 38, 25, tzinfo=datetime.timezone.utc
),
"Sat, 24 Jul 2021 09:21:20 +0000 (UTC)": datetime.datetime(
2021, 7, 24, 9, 21, 20, tzinfo=datetime.timezone.utc
),
"Thu, 29 Jul 2021 04:20:37 -0400 (EDT)": datetime.datetime(
2021, 7, 29, 8, 20, 37, tzinfo=datetime.timezone.utc
),
"30 Jun 2023 18:45:01 +0300": datetime.datetime(
2023, 6, 30, 15, 45, 1, tzinfo=datetime.timezone.utc
),
"22 Mar 2020 20:12:18 +0000 (GMT)": datetime.datetime(
2020, 3, 22, 20, 12, 18, tzinfo=datetime.timezone.utc
),
"Date: Wed, 27 Aug 2025 11:40:00 +0200": datetime.datetime(
2025, 8, 27, 9, 40, 0, tzinfo=datetime.timezone.utc
),
}
for strptime, expected_datetime in str_to_dt.items():
assert time_str_to_utc(strptime) == expected_datetime
def test_time_str_to_utc_recovers_from_concatenated_headers() -> None:
# TZ is dropped during recovery, so the expected result is UTC rather
# than the original offset.
assert time_str_to_utc(
'Sat, 3 Nov 2007 14:33:28 -0200To: "jason" <jason@example.net>'
) == datetime.datetime(2007, 11, 3, 14, 33, 28, tzinfo=datetime.timezone.utc)
assert time_str_to_utc(
"Fri, 20 Feb 2015 10:30:00 +0500Cc: someone@example.com"
) == datetime.datetime(2015, 2, 20, 10, 30, 0, tzinfo=datetime.timezone.utc)
def test_time_str_to_utc_raises_on_impossible_dates() -> None:
for bad in (
"Wed, 33 Sep 2007 13:42:59 +0100",
"Thu, 11 Oct 2007 31:50:55 +0900",
"not a date at all",
"",
):
with pytest.raises(ValueError):
time_str_to_utc(bad)

View File

@@ -1,3 +1,4 @@
import copy
import datetime
import json
import os
@@ -8,7 +9,6 @@ from unittest.mock import patch
from onyx.access.models import ExternalAccess
from onyx.configs.constants import DocumentSource
from onyx.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc
from onyx.connectors.gmail.connector import _build_time_range_query
from onyx.connectors.gmail.connector import GmailCheckpoint
from onyx.connectors.gmail.connector import GmailConnector
@@ -51,29 +51,43 @@ def test_build_time_range_query() -> None:
assert query is None
def test_time_str_to_utc() -> None:
str_to_dt = {
"Tue, 5 Oct 2021 09:38:25 GMT": datetime.datetime(
2021, 10, 5, 9, 38, 25, tzinfo=datetime.timezone.utc
),
"Sat, 24 Jul 2021 09:21:20 +0000 (UTC)": datetime.datetime(
2021, 7, 24, 9, 21, 20, tzinfo=datetime.timezone.utc
),
"Thu, 29 Jul 2021 04:20:37 -0400 (EDT)": datetime.datetime(
2021, 7, 29, 8, 20, 37, tzinfo=datetime.timezone.utc
),
"30 Jun 2023 18:45:01 +0300": datetime.datetime(
2023, 6, 30, 15, 45, 1, tzinfo=datetime.timezone.utc
),
"22 Mar 2020 20:12:18 +0000 (GMT)": datetime.datetime(
2020, 3, 22, 20, 12, 18, tzinfo=datetime.timezone.utc
),
"Date: Wed, 27 Aug 2025 11:40:00 +0200": datetime.datetime(
2025, 8, 27, 9, 40, 0, tzinfo=datetime.timezone.utc
),
}
for strptime, expected_datetime in str_to_dt.items():
assert time_str_to_utc(strptime) == expected_datetime
def _thread_with_date(date_header: str | None) -> dict[str, Any]:
"""Load the fixture thread and replace (or strip, if None) its Date header."""
json_path = os.path.join(os.path.dirname(__file__), "thread.json")
with open(json_path, "r") as f:
thread = cast(dict[str, Any], json.load(f))
thread = copy.deepcopy(thread)
for message in thread["messages"]:
headers: list[dict[str, str]] = message["payload"]["headers"]
if date_header is None:
message["payload"]["headers"] = [
h for h in headers if h.get("name") != "Date"
]
continue
replaced = False
for header in headers:
if header.get("name") == "Date":
header["value"] = date_header
replaced = True
break
if not replaced:
headers.append({"name": "Date", "value": date_header})
return thread
def test_thread_to_document_skips_unparseable_dates() -> None:
for bad_date in (
"Wed, 33 Sep 2007 13:42:59 +0100",
"Thu, 11 Oct 2007 31:50:55 +0900",
"total garbage not even close to a date",
):
doc = thread_to_document(_thread_with_date(bad_date), "admin@example.com")
assert isinstance(doc, Document), f"failed for {bad_date!r}"
assert doc.doc_updated_at is None
assert doc.id == "192edefb315737c3"
def test_gmail_checkpoint_progression() -> None:

View File

@@ -12,12 +12,14 @@ from unittest.mock import patch
from onyx.background.celery.celery_utils import extract_ids_from_runnable_connector
from onyx.connectors.google_drive.connector import GoogleDriveConnector
from onyx.connectors.google_drive.file_retrieval import DriveFileFieldType
from onyx.connectors.google_drive.models import DriveRetrievalStage
from onyx.connectors.google_drive.models import GoogleDriveCheckpoint
from onyx.connectors.interfaces import SlimConnector
from onyx.connectors.interfaces import SlimConnectorWithPermSync
from onyx.connectors.models import SlimDocument
from onyx.utils.threadpool_concurrency import ThreadSafeDict
from onyx.utils.threadpool_concurrency import ThreadSafeSet
def _make_done_checkpoint() -> GoogleDriveCheckpoint:
@@ -198,3 +200,90 @@ class TestCeleryUtilsRouting:
mock_slim.assert_called_once()
mock_perm_sync.assert_not_called()
class TestFailedFolderIdsByEmail:
def _make_failed_map(
self, entries: dict[str, set[str]]
) -> ThreadSafeDict[str, ThreadSafeSet[str]]:
return ThreadSafeDict({k: ThreadSafeSet(v) for k, v in entries.items()})
def test_skips_api_call_for_known_failed_pair(self) -> None:
"""_get_folder_metadata must skip the API call for a (folder, email) pair
that previously confirmed no accessible parent."""
connector = _make_connector()
failed_map = self._make_failed_map(
{
"retriever@example.com": {"folder1"},
"admin@example.com": {"folder1"},
}
)
with patch(
"onyx.connectors.google_drive.connector.get_folder_metadata"
) as mock_api:
result = connector._get_folder_metadata(
folder_id="folder1",
retriever_email="retriever@example.com",
field_type=DriveFileFieldType.SLIM,
failed_folder_ids_by_email=failed_map,
)
mock_api.assert_not_called()
assert result is None
def test_records_failed_pair_when_no_parents(self) -> None:
"""_get_folder_metadata must record (email → folder_id) in the map
when the API returns a folder with no parents."""
connector = _make_connector()
failed_map: ThreadSafeDict[str, ThreadSafeSet[str]] = ThreadSafeDict()
folder_no_parents: dict = {"id": "folder1", "name": "Orphaned"}
with (
patch(
"onyx.connectors.google_drive.connector.get_drive_service",
return_value=MagicMock(),
),
patch(
"onyx.connectors.google_drive.connector.get_folder_metadata",
return_value=folder_no_parents,
),
):
connector._get_folder_metadata(
folder_id="folder1",
retriever_email="retriever@example.com",
field_type=DriveFileFieldType.SLIM,
failed_folder_ids_by_email=failed_map,
)
assert "folder1" in failed_map.get("retriever@example.com", ThreadSafeSet())
assert "folder1" in failed_map.get("admin@example.com", ThreadSafeSet())
def test_does_not_record_when_parents_found(self) -> None:
"""_get_folder_metadata must NOT record a pair when parents are found."""
connector = _make_connector()
failed_map: ThreadSafeDict[str, ThreadSafeSet[str]] = ThreadSafeDict()
folder_with_parents: dict = {
"id": "folder1",
"name": "Normal",
"parents": ["root"],
}
with (
patch(
"onyx.connectors.google_drive.connector.get_drive_service",
return_value=MagicMock(),
),
patch(
"onyx.connectors.google_drive.connector.get_folder_metadata",
return_value=folder_with_parents,
),
):
connector._get_folder_metadata(
folder_id="folder1",
retriever_email="retriever@example.com",
field_type=DriveFileFieldType.SLIM,
failed_folder_ids_by_email=failed_map,
)
assert len(failed_map) == 0

View File

@@ -12,6 +12,10 @@ dependency on pypdf internals (pypdf.generic).
from io import BytesIO
from pathlib import Path
import pytest
from onyx.file_processing import extract_file_text
from onyx.file_processing.extract_file_text import count_pdf_embedded_images
from onyx.file_processing.extract_file_text import pdf_to_text
from onyx.file_processing.extract_file_text import read_pdf_file
from onyx.file_processing.password_validation import is_pdf_protected
@@ -96,6 +100,80 @@ class TestReadPdfFile:
# Returned list is empty when callback is used
assert images == []
def test_image_cap_skips_images_above_limit(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When the embedded-image cap is exceeded, remaining images are skipped.
The cap protects the user-file-processing worker from OOMing on PDFs
with thousands of embedded images. Setting the cap to 0 should yield
zero extracted images even though the fixture has one.
"""
monkeypatch.setattr(extract_file_text, "MAX_EMBEDDED_IMAGES_PER_FILE", 0)
_, _, images = read_pdf_file(_load("with_image.pdf"), extract_images=True)
assert images == []
def test_image_cap_at_limit_extracts_up_to_cap(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A cap >= image count behaves identically to the uncapped path."""
monkeypatch.setattr(extract_file_text, "MAX_EMBEDDED_IMAGES_PER_FILE", 100)
_, _, images = read_pdf_file(_load("with_image.pdf"), extract_images=True)
assert len(images) == 1
def test_image_cap_with_callback_stops_streaming_at_limit(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The cap also short-circuits the streaming callback path."""
monkeypatch.setattr(extract_file_text, "MAX_EMBEDDED_IMAGES_PER_FILE", 0)
collected: list[tuple[bytes, str]] = []
def callback(data: bytes, name: str) -> None:
collected.append((data, name))
read_pdf_file(
_load("with_image.pdf"), extract_images=True, image_callback=callback
)
assert collected == []
# ── count_pdf_embedded_images ────────────────────────────────────────────
class TestCountPdfEmbeddedImages:
def test_returns_count_for_normal_pdf(self) -> None:
assert count_pdf_embedded_images(_load("with_image.pdf"), cap=10) == 1
def test_short_circuits_above_cap(self) -> None:
# with_image.pdf has 1 image. cap=0 means "anything > 0 is over cap" —
# function returns on first increment as the over-cap sentinel.
assert count_pdf_embedded_images(_load("with_image.pdf"), cap=0) == 1
def test_returns_zero_for_pdf_without_images(self) -> None:
assert count_pdf_embedded_images(_load("simple.pdf"), cap=10) == 0
def test_returns_zero_for_invalid_pdf(self) -> None:
assert count_pdf_embedded_images(BytesIO(b"not a pdf"), cap=10) == 0
def test_returns_zero_for_password_locked_pdf(self) -> None:
# encrypted.pdf has an open password; we can't inspect without it, so
# the helper returns 0 — callers rely on the password-protected check
# that runs earlier in the upload pipeline.
assert count_pdf_embedded_images(_load("encrypted.pdf"), cap=10) == 0
def test_inspects_owner_password_only_pdf(self) -> None:
# owner_protected.pdf is encrypted but has no open password. It should
# decrypt with an empty string and count images normally. The fixture
# has zero images, so 0 is a real count (not the "bail on encrypted"
# path).
assert count_pdf_embedded_images(_load("owner_protected.pdf"), cap=10) == 0
def test_preserves_file_position(self) -> None:
pdf = _load("with_image.pdf")
pdf.seek(42)
count_pdf_embedded_images(pdf, cap=10)
assert pdf.tell() == 42
# ── pdf_to_text ──────────────────────────────────────────────────────────

View File

@@ -172,7 +172,7 @@ LOG_ONYX_MODEL_INTERACTIONS=False
## Gen AI Settings
# GEN_AI_MAX_TOKENS=
# LLM_SOCKET_READ_TIMEOUT=
LLM_SOCKET_READ_TIMEOUT=120
# MAX_CHUNKS_FED_TO_CHAT=
# DISABLE_LITELLM_STREAMING=
# LITELLM_EXTRA_HEADERS=

View File

@@ -1262,7 +1262,7 @@ configMap:
S3_FILE_STORE_BUCKET_NAME: ""
# Gen AI Settings
GEN_AI_MAX_TOKENS: ""
LLM_SOCKET_READ_TIMEOUT: "60"
LLM_SOCKET_READ_TIMEOUT: "120"
MAX_CHUNKS_FED_TO_CHAT: ""
# Query Options
DOC_TIME_DECAY: ""

View File

@@ -12,7 +12,7 @@ dependencies = [
"cohere==5.6.1",
"fastapi==0.133.1",
"google-genai==1.52.0",
"litellm==1.81.6",
"litellm[google]==1.81.6",
"openai==2.14.0",
"pydantic==2.11.7",
"prometheus_client>=0.21.1",
@@ -69,7 +69,7 @@ backend = [
"langchain-core==1.2.28",
"lazy_imports==1.0.1",
"lxml==5.3.0",
"Mako==1.2.4",
"Mako==1.3.11",
# NOTE: Do not update without understanding the patching behavior in
# get_markitdown_converter in
# backend/onyx/file_processing/extract_file_text.py and what impacts
@@ -96,7 +96,7 @@ backend = [
"python-gitlab==5.6.0",
"python-pptx==0.6.23",
"pypandoc_binary==1.16.2",
"pypdf==6.10.0",
"pypdf==6.10.2",
"pytest-mock==3.12.0",
"pytest-playwright==0.7.2",
"python-docx==1.1.2",

251
uv.lock generated
View File

@@ -447,14 +447,14 @@ wheels = [
[[package]]
name = "authlib"
version = "1.6.9"
version = "1.6.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" }
sdist = { url = "https://files.pythonhosted.org/packages/28/10/b325d58ffe86815b399334a101e63bc6fa4e1953921cb23703b48a0a0220/authlib-1.6.11.tar.gz", hash = "sha256:64db35b9b01aeccb4715a6c9a6613a06f2bd7be2ab9d2eb89edd1dfc7580a38f", size = 165359, upload-time = "2026-04-16T07:22:50.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" },
{ url = "https://files.pythonhosted.org/packages/57/2f/55fca558f925a51db046e5b929deb317ddb05afed74b22d89f4eca578980/authlib-1.6.11-py2.py3-none-any.whl", hash = "sha256:c8687a9a26451c51a34a06fa17bb97cb15bba46a6a626755e2d7f50da8bff3e3", size = 244469, upload-time = "2026-04-16T07:22:48.413Z" },
]
[[package]]
@@ -2124,6 +2124,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/d4/90197b416cb61cefd316964fd9e7bd8324bcbafabf40eef14a9f20b81974/google_api_core-2.28.1-py3-none-any.whl", hash = "sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c", size = 173706, upload-time = "2025-10-28T21:34:50.151Z" },
]
[package.optional-dependencies]
grpc = [
{ name = "grpcio" },
{ name = "grpcio-status" },
]
[[package]]
name = "google-api-python-client"
version = "2.86.0"
@@ -2181,6 +2187,124 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/07/8d9a8186e6768b55dfffeb57c719bc03770cf8a970a074616ae6f9e26a57/google_auth_oauthlib-1.0.0-py2.py3-none-any.whl", hash = "sha256:95880ca704928c300f48194d1770cf5b1462835b6e49db61445a520f793fd5fb", size = 18926, upload-time = "2023-02-07T20:53:18.837Z" },
]
[[package]]
name = "google-cloud-aiplatform"
version = "1.133.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "docstring-parser" },
{ name = "google-api-core", extra = ["grpc"] },
{ name = "google-auth" },
{ name = "google-cloud-bigquery" },
{ name = "google-cloud-resource-manager" },
{ name = "google-cloud-storage" },
{ name = "google-genai" },
{ name = "packaging" },
{ name = "proto-plus" },
{ name = "protobuf" },
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d4/be/31ce7fd658ddebafbe5583977ddee536b2bacc491ad10b5a067388aec66f/google_cloud_aiplatform-1.133.0.tar.gz", hash = "sha256:3a6540711956dd178daaab3c2c05db476e46d94ac25912b8cf4f59b00b058ae0", size = 9921309, upload-time = "2026-01-08T22:11:25.079Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/5b/ef74ff65aebb74eaba51078e33ddd897247ba0d1197fd5a7953126205519/google_cloud_aiplatform-1.133.0-py2.py3-none-any.whl", hash = "sha256:dfc81228e987ca10d1c32c7204e2131b3c8d6b7c8e0b4e23bf7c56816bc4c566", size = 8184595, upload-time = "2026-01-08T22:11:22.067Z" },
]
[[package]]
name = "google-cloud-bigquery"
version = "3.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core", extra = ["grpc"] },
{ name = "google-auth" },
{ name = "google-cloud-core" },
{ name = "google-resumable-media" },
{ name = "packaging" },
{ name = "python-dateutil" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/13/6515c7aab55a4a0cf708ffd309fb9af5bab54c13e32dc22c5acd6497193c/google_cloud_bigquery-3.41.0.tar.gz", hash = "sha256:2217e488b47ed576360c9b2cc07d59d883a54b83167c0ef37f915c26b01a06fe", size = 513434, upload-time = "2026-03-30T22:50:55.347Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/33/1d3902efadef9194566d499d61507e1f038454e0b55499d2d7f8ab2a4fee/google_cloud_bigquery-3.41.0-py3-none-any.whl", hash = "sha256:2a5b5a737b401cbd824a6e5eac7554100b878668d908e6548836b5d8aaa4dcaa", size = 262343, upload-time = "2026-03-30T22:48:45.444Z" },
]
[[package]]
name = "google-cloud-core"
version = "2.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core" },
{ name = "google-auth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/24/6ca08b0a03c7b0c620427503ab00353a4ae806b848b93bcea18b6b76fde6/google_cloud_core-2.5.1.tar.gz", hash = "sha256:3dc94bdec9d05a31d9f355045ed0f369fbc0d8c665076c734f065d729800f811", size = 36078, upload-time = "2026-03-30T22:50:08.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/d9/5bb050cb32826466aa9b25f79e2ca2879fe66cb76782d4ed798dd7506151/google_cloud_core-2.5.1-py3-none-any.whl", hash = "sha256:ea62cdf502c20e3e14be8a32c05ed02113d7bef454e40ff3fab6fe1ec9f1f4e7", size = 29452, upload-time = "2026-03-30T22:48:31.567Z" },
]
[[package]]
name = "google-cloud-resource-manager"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core", extra = ["grpc"] },
{ name = "google-auth" },
{ name = "grpc-google-iam-v1" },
{ name = "grpcio" },
{ name = "proto-plus" },
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/1a/13060cabf553d52d151d2afc26b39561e82853380d499dd525a0d422d9f0/google_cloud_resource_manager-1.17.0.tar.gz", hash = "sha256:0f486b62e2c58ff992a3a50fa0f4a96eef7750aa6c971bb373398ccb91828660", size = 464971, upload-time = "2026-03-26T22:17:29.204Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/f7/661d7a9023e877a226b5683429c3662f75a29ef45cb1464cf39adb689218/google_cloud_resource_manager-1.17.0-py3-none-any.whl", hash = "sha256:e479baf4b014a57f298e01b8279e3290b032e3476d69c8e5e1427af8f82739a5", size = 404403, upload-time = "2026-03-26T22:15:26.57Z" },
]
[[package]]
name = "google-cloud-storage"
version = "3.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core" },
{ name = "google-auth" },
{ name = "google-cloud-core" },
{ name = "google-crc32c" },
{ name = "google-resumable-media" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4c/47/205eb8e9a1739b5345843e5a425775cbdc472cc38e7eda082ba5b8d02450/google_cloud_storage-3.10.1.tar.gz", hash = "sha256:97db9aa4460727982040edd2bd13ff3d5e2260b5331ad22895802da1fc2a5286", size = 17309950, upload-time = "2026-03-23T09:35:23.409Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/ff/ca9ab2417fa913d75aae38bf40bf856bb2749a604b2e0f701b37cfcd23cc/google_cloud_storage-3.10.1-py3-none-any.whl", hash = "sha256:a72f656759b7b99bda700f901adcb3425a828d4a29f911bc26b3ea79c5b1217f", size = 324453, upload-time = "2026-03-23T09:35:21.368Z" },
]
[[package]]
name = "google-crc32c"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" },
{ url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" },
{ url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" },
{ url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" },
{ url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" },
{ url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" },
{ url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" },
{ url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" },
{ url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" },
{ url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" },
{ url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" },
{ url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" },
{ url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" },
{ url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" },
{ url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" },
{ url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" },
{ url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" },
{ url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" },
{ url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" },
{ url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" },
]
[[package]]
name = "google-genai"
version = "1.52.0"
@@ -2200,6 +2324,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/66/03f663e7bca7abe9ccfebe6cb3fe7da9a118fd723a5abb278d6117e7990e/google_genai-1.52.0-py3-none-any.whl", hash = "sha256:c8352b9f065ae14b9322b949c7debab8562982f03bf71d44130cd2b798c20743", size = 261219, upload-time = "2025-11-21T02:18:54.515Z" },
]
[[package]]
name = "google-resumable-media"
version = "2.8.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-crc32c" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3f/d1/b1ea14b93b6b78f57fc580125de44e9f593ab88dd2460f1a8a8d18f74754/google_resumable_media-2.8.2.tar.gz", hash = "sha256:f3354a182ebd193ae3f42e3ef95e6c9b10f128320de23ac7637236713b1acd70", size = 2164510, upload-time = "2026-03-30T23:34:25.369Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/f8/50bfaf4658431ff9de45c5c3935af7ab01157a4903c603cd0eee6e78e087/google_resumable_media-2.8.2-py3-none-any.whl", hash = "sha256:82b6d8ccd11765268cdd2a2123f417ec806b8eef3000a9a38dfe3033da5fb220", size = 81511, upload-time = "2026-03-30T23:34:09.671Z" },
]
[[package]]
name = "googleapis-common-protos"
version = "1.72.0"
@@ -2212,6 +2348,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
]
[package.optional-dependencies]
grpc = [
{ name = "grpcio" },
]
[[package]]
name = "greenlet"
version = "3.2.4"
@@ -2262,6 +2403,85 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
]
[[package]]
name = "grpc-google-iam-v1"
version = "0.14.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos", extra = ["grpc"] },
{ name = "grpcio" },
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/4f/d098419ad0bfc06c9ce440575f05aa22d8973b6c276e86ac7890093d3c37/grpc_google_iam_v1-0.14.4.tar.gz", hash = "sha256:392b3796947ed6334e61171d9ab06bf7eb357f554e5fc7556ad7aab6d0e17038", size = 23706, upload-time = "2026-04-01T01:57:49.813Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/22/c2dd50c09bf679bd38173656cd4402d2511e563b33bc88f90009cf50613c/grpc_google_iam_v1-0.14.4-py3-none-any.whl", hash = "sha256:412facc320fcbd94034b4df3d557662051d4d8adfa86e0ddb4dca70a3f739964", size = 32675, upload-time = "2026-04-01T01:57:47.69Z" },
]
[[package]]
name = "grpcio"
version = "1.80.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" },
{ url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" },
{ url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" },
{ url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" },
{ url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" },
{ url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" },
{ url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" },
{ url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" },
{ url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" },
{ url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" },
{ url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" },
{ url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" },
{ url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" },
{ url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" },
{ url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" },
{ url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" },
{ url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" },
{ url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" },
{ url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" },
{ url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" },
{ url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" },
{ url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" },
{ url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" },
{ url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" },
{ url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" },
{ url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" },
{ url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" },
{ url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" },
{ url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" },
{ url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" },
{ url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" },
{ url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" },
{ url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" },
{ url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" },
{ url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" },
{ url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" },
{ url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" },
]
[[package]]
name = "grpcio-status"
version = "1.80.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
{ name = "grpcio" },
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/ed/105f619bdd00cb47a49aa2feea6232ea2bbb04199d52a22cc6a7d603b5cb/grpcio_status-1.80.0.tar.gz", hash = "sha256:df73802a4c89a3ea88aa2aff971e886fccce162bc2e6511408b3d67a144381cd", size = 13901, upload-time = "2026-03-30T08:54:34.784Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/80/58cd2dfc19a07d022abe44bde7c365627f6c7cb6f692ada6c65ca437d09a/grpcio_status-1.80.0-py3-none-any.whl", hash = "sha256:4b56990363af50dbf2c2ebb80f1967185c07d87aa25aa2bea45ddb75fc181dbe", size = 14638, upload-time = "2026-03-30T08:54:01.569Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -3164,6 +3384,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/05/3516cc7386b220d388aa0bd833308c677e94eceb82b2756dd95e06f6a13f/litellm-1.81.6-py3-none-any.whl", hash = "sha256:573206ba194d49a1691370ba33f781671609ac77c35347f8a0411d852cf6341a", size = 12224343, upload-time = "2026-02-01T04:02:23.704Z" },
]
[package.optional-dependencies]
google = [
{ name = "google-cloud-aiplatform" },
]
[[package]]
name = "locket"
version = "1.0.0"
@@ -3278,14 +3503,14 @@ wheels = [
[[package]]
name = "mako"
version = "1.2.4"
version = "1.3.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/05/5f/2ba6e026d33a0e6ddc1dddf9958677f76f5f80c236bd65309d280b166d3e/Mako-1.2.4.tar.gz", hash = "sha256:d60a3903dc3bb01a18ad6a89cdbe2e4eadc69c0bc8ef1e3773ba53d44c3f7a34", size = 497021, upload-time = "2022-11-15T14:37:51.327Z" }
sdist = { url = "https://files.pythonhosted.org/packages/59/8a/805404d0c0b9f3d7a326475ca008db57aea9c5c9f2e1e39ed0faa335571c/mako-1.3.11.tar.gz", hash = "sha256:071eb4ab4c5010443152255d77db7faa6ce5916f35226eb02dc34479b6858069", size = 399811, upload-time = "2026-04-14T20:19:51.493Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/3b/68690a035ba7347860f1b8c0cde853230ba69ff41df5884ea7d89fe68cd3/Mako-1.2.4-py3-none-any.whl", hash = "sha256:c97c79c018b9165ac9922ae4f32da095ffd3c4e6872b45eded42926deea46818", size = 78672, upload-time = "2022-11-15T14:37:53.675Z" },
{ url = "https://files.pythonhosted.org/packages/68/a5/19d7aaa7e433713ffe881df33705925a196afb9532efc8475d26593921a6/mako-1.3.11-py3-none-any.whl", hash = "sha256:e372c6e333cf004aa736a15f425087ec977e1fcbd2966aae7f17c8dc1da27a77", size = 78503, upload-time = "2026-04-14T20:19:53.233Z" },
]
[[package]]
@@ -4204,7 +4429,7 @@ dependencies = [
{ name = "fastapi" },
{ name = "google-genai" },
{ name = "kubernetes" },
{ name = "litellm" },
{ name = "litellm", extra = ["google"] },
{ name = "openai" },
{ name = "prometheus-client" },
{ name = "prometheus-fastapi-instrumentator" },
@@ -4377,7 +4602,7 @@ requires-dist = [
{ name = "fastapi", specifier = "==0.133.1" },
{ name = "google-genai", specifier = "==1.52.0" },
{ name = "kubernetes", specifier = ">=31.0.0" },
{ name = "litellm", specifier = "==1.81.6" },
{ name = "litellm", extras = ["google"], specifier = "==1.81.6" },
{ name = "openai", specifier = "==2.14.0" },
{ name = "prometheus-client", specifier = ">=0.21.1" },
{ name = "prometheus-fastapi-instrumentator", specifier = "==7.1.0" },
@@ -4430,7 +4655,7 @@ backend = [
{ name = "langfuse", specifier = "==3.10.0" },
{ name = "lazy-imports", specifier = "==1.0.1" },
{ name = "lxml", specifier = "==5.3.0" },
{ name = "mako", specifier = "==1.2.4" },
{ name = "mako", specifier = "==1.3.11" },
{ name = "markitdown", extras = ["pdf", "docx", "pptx", "xlsx", "xls"], specifier = "==0.1.2" },
{ name = "mcp", extras = ["cli"], specifier = "==1.26.0" },
{ name = "mistune", specifier = "==3.2.0" },
@@ -4453,7 +4678,7 @@ backend = [
{ name = "pygithub", specifier = "==2.5.0" },
{ name = "pympler", specifier = "==1.1" },
{ name = "pypandoc-binary", specifier = "==1.16.2" },
{ name = "pypdf", specifier = "==6.10.0" },
{ name = "pypdf", specifier = "==6.10.2" },
{ name = "pytest-mock", specifier = "==3.12.0" },
{ name = "pytest-playwright", specifier = "==0.7.2" },
{ name = "python-dateutil", specifier = "==2.8.2" },
@@ -5703,11 +5928,11 @@ wheels = [
[[package]]
name = "pypdf"
version = "6.10.0"
version = "6.10.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/9f/ca96abf18683ca12602065e4ed2bec9050b672c87d317f1079abc7b6d993/pypdf-6.10.0.tar.gz", hash = "sha256:4c5a48ba258c37024ec2505f7e8fd858525f5502784a2e1c8d415604af29f6ef", size = 5314833, upload-time = "2026-04-10T09:34:57.102Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/3f/9f2167401c2e94833ca3b69535bad89e533b5de75fefe4197a2c224baec2/pypdf-6.10.2.tar.gz", hash = "sha256:7d09ce108eff6bf67465d461b6ef352dcb8d84f7a91befc02f904455c6eea11d", size = 5315679, upload-time = "2026-04-15T16:37:36.978Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/f2/7ebe366f633f30a6ad105f650f44f24f98cb1335c4157d21ae47138b3482/pypdf-6.10.0-py3-none-any.whl", hash = "sha256:90005e959e1596c6e6c84c8b0ad383285b3e17011751cedd17f2ce8fcdfc86de", size = 334459, upload-time = "2026-04-10T09:34:54.966Z" },
{ url = "https://files.pythonhosted.org/packages/0c/d6/1d5c60cc17bbdf37c1552d9c03862fc6d32c5836732a0415b2d637edc2d0/pypdf-6.10.2-py3-none-any.whl", hash = "sha256:aa53be9826655b51c96741e5d7983ca224d898ac0a77896e64636810517624aa", size = 336308, upload-time = "2026-04-15T16:37:34.851Z" },
]
[[package]]

View File

@@ -82,7 +82,10 @@ ARG NODE_OPTIONS
# SENTRY_AUTH_TOKEN is injected via BuildKit secret mount so it is never written
# to any image layer, build cache, or registry manifest.
# Use NODE_OPTIONS in the build command
RUN --mount=type=secret,id=sentry_auth_token,env=SENTRY_AUTH_TOKEN \
RUN --mount=type=secret,id=sentry_auth_token \
if [ -f /run/secrets/sentry_auth_token ]; then \
export SENTRY_AUTH_TOKEN="$(cat /run/secrets/sentry_auth_token)"; \
fi && \
NODE_OPTIONS="${NODE_OPTIONS}" npx next build
# Step 2. Production image, copy all the files and run next

View File

@@ -0,0 +1,112 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { LinkButton } from "@opal/components";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
const meta: Meta<typeof LinkButton> = {
title: "opal/components/LinkButton",
component: LinkButton,
tags: ["autodocs"],
decorators: [
(Story) => (
<TooltipPrimitive.Provider>
<Story />
</TooltipPrimitive.Provider>
),
],
};
export default meta;
type Story = StoryObj<typeof LinkButton>;
// ─── Anchor mode ────────────────────────────────────────────────────────────
export const Default: Story = {
render: () => <LinkButton href="/">Home</LinkButton>,
};
export const ExternalLink: Story = {
render: () => (
<LinkButton href="https://onyx.app" target="_blank">
Onyx
</LinkButton>
),
};
export const LongLabel: Story = {
render: () => (
<LinkButton href="https://docs.onyx.app" target="_blank">
Go read the full Onyx documentation site
</LinkButton>
),
};
// ─── Button mode ────────────────────────────────────────────────────────────
export const AsButton: Story = {
render: () => (
<LinkButton onClick={() => alert("clicked")}>Click me</LinkButton>
),
};
// ─── Disabled ───────────────────────────────────────────────────────────────
export const DisabledLink: Story = {
render: () => (
<LinkButton href="/" disabled>
Disabled link
</LinkButton>
),
};
export const DisabledButton: Story = {
render: () => (
<LinkButton onClick={() => alert("should not fire")} disabled>
Disabled button
</LinkButton>
),
};
// ─── Tooltip ────────────────────────────────────────────────────────────────
export const Tooltip: Story = {
render: () => (
<LinkButton href="/" tooltip="This is a tooltip">
Hover me
</LinkButton>
),
};
export const TooltipSides: Story = {
render: () => (
<div className="flex flex-col gap-8 p-16">
<LinkButton href="/" tooltip="Tooltip on top" tooltipSide="top">
top
</LinkButton>
<LinkButton href="/" tooltip="Tooltip on right" tooltipSide="right">
right
</LinkButton>
<LinkButton href="/" tooltip="Tooltip on bottom" tooltipSide="bottom">
bottom
</LinkButton>
<LinkButton href="/" tooltip="Tooltip on left" tooltipSide="left">
left
</LinkButton>
</div>
),
};
// ─── Inline in prose ────────────────────────────────────────────────────────
export const InlineInProse: Story = {
render: () => (
<p style={{ maxWidth: "36rem", lineHeight: 1.7 }}>
Modifying embedding settings requires a full re-index of all documents and
may take hours or days depending on corpus size.{" "}
<LinkButton href="https://docs.onyx.app" target="_blank">
Learn more
</LinkButton>
.
</p>
),
};

View File

@@ -0,0 +1,60 @@
# LinkButton
**Import:** `import { LinkButton, type LinkButtonProps } from "@opal/components";`
A compact, anchor-styled link with an underlined label and a trailing external-link glyph. Intended for **inline references** — "Pricing", "Docs", "Learn more" — not for interactive surfaces that need hover backgrounds or prominence tiers. Use [`Button`](../button/README.md) for those.
## Architecture
Deliberately **does not** use `Interactive.Stateless` / `Interactive.Container`. Those primitives come with height, rounding, padding, and a colour matrix designed for clickable surfaces — all wrong for an inline text link.
The component renders a plain `<a>` (when given `href`) or `<button>` (when given `onClick`) with:
- `inline-flex` so the label + icon track naturally next to surrounding prose
- `text-text-03` that shifts to `text-text-05` on hover
- `underline` on the label only (the icon stays non-underlined)
- `data-disabled` driven opacity + `cursor-not-allowed` for the disabled state
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `children` | `string` | — | Visible label (required) |
| `href` | `string` | — | Destination URL. Renders the component as `<a>`. |
| `target` | `string` | — | Anchor target (e.g. `"_blank"`). Adds `rel="noopener noreferrer"` automatically when `"_blank"`. |
| `onClick` | `() => void` | — | Click handler. Without `href`, renders the component as `<button>`. |
| `disabled` | `boolean` | `false` | Applies disabled styling + suppresses navigation / clicks |
| `tooltip` | `string \| RichStr` | — | Hover tooltip text. Pass `markdown(...)` for inline markdown. |
| `tooltipSide` | `TooltipSide` | `"top"` | Tooltip placement |
Exactly one of `href` / `onClick` is expected. Passing both is allowed but only `href` takes effect (renders as an anchor).
## Usage
```tsx
import { LinkButton } from "@opal/components";
// External link — automatic rel="noopener noreferrer"
<LinkButton href="https://docs.onyx.app" target="_blank">
Read the docs
</LinkButton>
// Internal link
<LinkButton href="/admin/settings">Settings</LinkButton>
// Button-mode (no href)
<LinkButton onClick={openModal}>Learn more</LinkButton>
// Disabled
<LinkButton href="/" disabled>
Not available
</LinkButton>
// With a tooltip
<LinkButton
href="/docs/pricing"
tooltip="See plan details"
tooltipSide="bottom"
>
Pricing
</LinkButton>
```

View File

@@ -0,0 +1,117 @@
import "@opal/components/buttons/link-button/styles.css";
import type { RichStr } from "@opal/types";
import type { TooltipSide } from "@opal/components/tooltip/components";
// Direct file imports to avoid circular resolution through the @opal/components
// and @opal/icons barrels, which break CJS-based test runners (jest).
import { Tooltip } from "@opal/components/tooltip/components";
import SvgExternalLink from "@opal/icons/external-link";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface LinkButtonProps {
/** Visible label. Always rendered as underlined link text. */
children: string;
/** Destination URL. When provided, the component renders as an `<a>`. */
href?: string;
/** Anchor `target` attribute (e.g. `"_blank"`). Only meaningful with `href`. */
target?: string;
/** Click handler. When provided without `href`, the component renders as a `<button>`. */
onClick?: () => void;
/** Applies disabled styling + suppresses navigation/clicks. */
disabled?: boolean;
/** Tooltip text shown on hover. Pass `markdown(...)` for inline markdown. */
tooltip?: string | RichStr;
/** Which side the tooltip appears on. @default "top" */
tooltipSide?: TooltipSide;
}
// ---------------------------------------------------------------------------
// LinkButton
// ---------------------------------------------------------------------------
/**
* A bare, anchor-styled link with a trailing external-link glyph. Renders
* as `<a>` when given `href`, or `<button>` when given `onClick`. Intended
* for inline references — "Pricing", "Docs", etc. — not for interactive
* surfaces that need hover backgrounds or prominence tiers (use `Button`
* for those).
*
* Deliberately does NOT use `Interactive.Stateless` / `Interactive.Container`
* — those come with height/rounding/padding and a colour matrix that are
* wrong for an inline text link. Styling is kept to: underlined label,
* small external-link icon, a subtle color shift on hover, and disabled
* opacity.
*/
function LinkButton({
children,
href,
target,
onClick,
disabled,
tooltip,
tooltipSide = "top",
}: LinkButtonProps) {
const inner = (
<>
<span className="opal-link-button-label font-secondary-body">
{children}
</span>
<SvgExternalLink size={12} />
</>
);
// Always stop propagation so clicks don't bubble to interactive ancestors
// (cards, list rows, etc. that commonly wrap a LinkButton). If disabled,
// also preventDefault on anchors so the browser doesn't navigate.
const handleAnchorClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.stopPropagation();
if (disabled) e.preventDefault();
};
const handleButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (disabled) return;
onClick?.();
};
const element = href ? (
<a
className="opal-link-button"
href={disabled ? undefined : href}
target={target}
rel={target === "_blank" ? "noopener noreferrer" : undefined}
aria-disabled={disabled || undefined}
data-disabled={disabled || undefined}
onClick={handleAnchorClick}
>
{inner}
</a>
) : (
<button
type="button"
className="opal-link-button"
onClick={handleButtonClick}
disabled={disabled}
data-disabled={disabled || undefined}
>
{inner}
</button>
);
return (
<Tooltip tooltip={tooltip} side={tooltipSide}>
{element}
</Tooltip>
);
}
export { LinkButton, type LinkButtonProps };

View File

@@ -0,0 +1,31 @@
/* ============================================================================
LinkButton — a bare anchor-style link with a trailing external-link icon.
Intentionally does NOT use `Interactive.Stateless` / `Interactive.Container`.
Styling is minimal: inline-flex, underlined label, subtle color shift on
hover, disabled opacity. The icon inherits the parent's text color via
`currentColor` so `text-text-03` on the root cascades through.
============================================================================ */
.opal-link-button {
@apply inline-flex flex-row items-center gap-0.5 text-text-03;
background: transparent;
border: 0;
padding: 0;
transition: color 150ms ease-out;
}
.opal-link-button:hover:not([data-disabled]) {
@apply text-text-05;
}
.opal-link-button[data-disabled] {
@apply opacity-50 cursor-not-allowed;
}
/* `font-secondary-body` is a plain CSS class defined in globals.css (not a
Tailwind utility), so `@apply` can't consume it — other Opal components
attach it via `className` on the JSX element, and we do the same here. */
.opal-link-button-label {
@apply underline;
}

View File

@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Card } from "@opal/components";
import { useState } from "react";
import { Button, Card } from "@opal/components";
const BACKGROUND_VARIANTS = ["none", "light", "heavy"] as const;
const BORDER_VARIANTS = ["none", "dashed", "solid"] as const;
@@ -100,3 +101,83 @@ export const AllCombinations: Story = {
</div>
),
};
// ─── Expandable mode ─────────────────────────────────────────────────────────
export const Expandable: Story = {
render: function ExpandableStory() {
const [open, setOpen] = useState(false);
return (
<div className="w-96">
<Card
expandable
expanded={open}
border="solid"
expandedContent={
<div className="flex flex-col gap-2">
<p>First model</p>
<p>Second model</p>
<p>Third model</p>
</div>
}
>
<Button
prominence="tertiary"
width="full"
onClick={() => setOpen((v) => !v)}
>
Toggle (expanded={String(open)})
</Button>
</Card>
</div>
);
},
};
export const ExpandableNoContent: Story = {
render: function ExpandableNoContentStory() {
const [open, setOpen] = useState(false);
return (
<div className="w-96">
<Card expandable expanded={open} border="solid">
<Button
prominence="tertiary"
width="full"
onClick={() => setOpen((v) => !v)}
>
Toggle (no content renders like a plain card)
</Button>
</Card>
</div>
);
},
};
export const ExpandableRoundingVariants: Story = {
render: function ExpandableRoundingStory() {
const [openKey, setOpenKey] =
useState<(typeof ROUNDING_VARIANTS)[number]>("md");
return (
<div className="flex flex-col gap-4 w-96">
{ROUNDING_VARIANTS.map((rounding) => (
<Card
key={rounding}
expandable
expanded={openKey === rounding}
rounding={rounding}
border="solid"
expandedContent={<p>content for rounding={rounding}</p>}
>
<Button
prominence="tertiary"
width="full"
onClick={() => setOpenKey(rounding)}
>
rounding={rounding} (click to expand)
</Button>
</Card>
))}
</div>
);
},
};

View File

@@ -2,11 +2,36 @@
**Import:** `import { Card, type CardProps } from "@opal/components";`
A plain container component with configurable background, border, padding, and rounding. Uses a simple `<div>` internally with `overflow-clip`.
A container component with configurable background, border, padding, and rounding. Has two mutually-exclusive modes:
## Architecture
- **Plain** (default) — renders children inside a single styled `<div>`.
- **Expandable** (`expandable: true`) — renders children as an always-visible header plus an `expandedContent` prop that animates open/closed.
Padding and rounding are controlled independently:
## Plain mode
Default behavior — a plain container.
```tsx
import { Card } from "@opal/components";
<Card padding="md" border="solid">
<p>Hello</p>
</Card>
```
### Plain mode props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `padding` | `PaddingVariants` | `"md"` | Padding preset |
| `rounding` | `RoundingVariants` | `"md"` | Border-radius preset |
| `background` | `"none" \| "light" \| "heavy"` | `"light"` | Background fill intensity |
| `border` | `"none" \| "dashed" \| "solid"` | `"none"` | Border style |
| `borderColor` | `StatusVariants` | `"default"` | Status-palette border color (needs `border``"none"`) |
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
| `children` | `React.ReactNode` | — | Card content |
### Padding scale
| `padding` | Class |
|-----------|---------|
@@ -17,6 +42,8 @@ Padding and rounding are controlled independently:
| `"2xs"` | `p-0.5` |
| `"fit"` | `p-0` |
### Rounding scale
| `rounding` | Class |
|------------|--------------|
| `"xs"` | `rounded-04` |
@@ -24,40 +51,92 @@ Padding and rounding are controlled independently:
| `"md"` | `rounded-12` |
| `"lg"` | `rounded-16` |
## Props
## Expandable mode
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `padding` | `PaddingVariants` | `"sm"` | Padding preset |
| `rounding` | `RoundingVariants` | `"md"` | Border-radius preset |
| `background` | `"none" \| "light" \| "heavy"` | `"light"` | Background fill intensity |
| `border` | `"none" \| "dashed" \| "solid"` | `"none"` | Border style |
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
| `children` | `React.ReactNode` | — | Card content |
## Usage
Enabled by passing `expandable: true`. The type is a discriminated union — `expanded` and `expandedContent` are only available (and type-checked) when `expandable: true`.
```tsx
import { Card } from "@opal/components";
import { useState } from "react";
// Default card (light background, no border, sm padding, md rounding)
<Card>
<h2>Card Title</h2>
<p>Card content</p>
</Card>
function ProviderCard() {
const [open, setOpen] = useState(false);
// Large padding + rounding with solid border
<Card padding="lg" rounding="lg" border="solid">
<p>Spacious card</p>
</Card>
// Compact card with solid border
<Card padding="xs" rounding="sm" border="solid">
<p>Compact card</p>
</Card>
// Empty state card
<Card background="none" border="dashed">
<p>No items yet</p>
</Card>
return (
<Card
expandable
expanded={open}
expandedContent={<ModelList />}
border="solid"
rounding="lg"
>
{/* always visible — the header region */}
<div
onClick={() => setOpen((v) => !v)}
className="flex items-center justify-between cursor-pointer"
>
<ProviderInfo />
<SvgChevronDown
className={cn("transition-transform", open && "rotate-180")}
/>
</div>
</Card>
);
}
```
### Expandable mode props
Everything from plain mode, **plus**:
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `expandable` | `true` | — | Required to enable the expandable variant |
| `expanded` | `boolean` | `false` | Controlled expanded state. Card never mutates this. |
| `expandedContent` | `React.ReactNode` | — | The body that animates open/closed below the header |
### Behavior
- **No trigger baked in.** Card does not attach any click handlers. Callers wire their own `onClick` / keyboard / button / etc. to toggle state. This keeps `padding` semantics consistent across modes and avoids surprises with interactive children.
- **Always controlled.** `expanded` is a pure one-way visual prop. There is no `defaultExpanded` or `onExpandChange` — the caller owns state entirely (`useState` at the call site).
- **No React context.** The component renders a flat tree; there are no compound sub-components (`Card.Header` / `Card.Content`) and no exported context hooks.
- **Rounding adapts automatically.** When `expanded && expandedContent !== undefined`, the header's bottom corners flatten and the content's top corners flatten so they meet seamlessly. When collapsed (or when `expandedContent` is undefined), the header is fully rounded.
- **Content background is always transparent.** The `background` prop applies to the header only; the content slot never fills its own background so the page shows through and keeps the two regions visually distinct.
- **Content has no intrinsic padding.** The `padding` prop applies to the header only. Callers own any padding inside whatever they pass to `expandedContent` — wrap it in a `<div className="p-4">` (or whatever) if you want spacing.
- **Animation.** Content uses a pure CSS grid `0fr ↔ 1fr` animation with an opacity fade (~200ms ease-out). No `@radix-ui/react-collapsible` dependency.
### Accessibility
Because Card doesn't own the trigger, it also doesn't generate IDs or ARIA attributes. Consumers are responsible for wiring `aria-expanded`, `aria-controls`, `aria-labelledby`, etc. on their trigger element.
## Complete prop reference
```ts
type CardBaseProps = {
padding?: PaddingVariants;
rounding?: RoundingVariants;
background?: "none" | "light" | "heavy";
border?: "none" | "dashed" | "solid";
borderColor?: StatusVariants;
ref?: React.Ref<HTMLDivElement>;
children?: React.ReactNode;
};
type CardPlainProps = CardBaseProps & { expandable?: false };
type CardExpandableProps = CardBaseProps & {
expandable: true;
expanded?: boolean;
expandedContent?: React.ReactNode;
};
type CardProps = CardPlainProps | CardExpandableProps;
```
The discriminated union enforces:
```tsx
<Card expanded></Card> // ❌ TS error — `expanded` not in plain mode
<Card expandable expandedContent={}></Card> // ✅ expandable mode
<Card border="solid"></Card> // ✅ plain mode
```

View File

@@ -1,6 +1,17 @@
import "@opal/components/cards/shared.css";
import "@opal/components/cards/card/styles.css";
import type { PaddingVariants, RoundingVariants } from "@opal/types";
import { paddingVariants, cardRoundingVariants } from "@opal/shared";
import type {
PaddingVariants,
RoundingVariants,
SizeVariants,
StatusVariants,
} from "@opal/types";
import {
paddingVariants,
cardRoundingVariants,
cardTopRoundingVariants,
cardBottomRoundingVariants,
} from "@opal/shared";
import { cn } from "@opal/utils";
// ---------------------------------------------------------------------------
@@ -10,7 +21,10 @@ import { cn } from "@opal/utils";
type BackgroundVariant = "none" | "light" | "heavy";
type BorderVariant = "none" | "dashed" | "solid";
type CardProps = {
/**
* Props shared by both plain and expandable Card modes.
*/
type CardBaseProps = {
/**
* Padding preset.
*
@@ -23,6 +37,10 @@ type CardProps = {
* | `"2xs"` | `p-0.5` |
* | `"fit"` | `p-0` |
*
* In expandable mode, applied **only** to the header region. The
* `expandedContent` slot has no intrinsic padding — callers own any padding
* inside the content they pass in.
*
* @default "md"
*/
padding?: PaddingVariants;
@@ -37,6 +55,10 @@ type CardProps = {
* | `"md"` | `rounded-12` |
* | `"lg"` | `rounded-16` |
*
* In expandable mode when expanded, rounding applies only to the header's
* top corners and the expandedContent's bottom corners so the two join seamlessly.
* When collapsed, rounding applies to all four corners of the header.
*
* @default "md"
*/
rounding?: RoundingVariants;
@@ -61,35 +83,177 @@ type CardProps = {
*/
border?: BorderVariant;
/**
* Border color, drawn from the same status palette as {@link MessageCard}.
* Has no visual effect when `border="none"`.
*
* @default "default"
*/
borderColor?: StatusVariants;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
/**
* In plain mode, the card body. In expandable mode, the always-visible
* header region (the part that stays put whether expanded or collapsed).
*/
children?: React.ReactNode;
};
type CardPlainProps = CardBaseProps & {
/**
* When `false` (or omitted), renders a plain card — same behavior as before
* this prop existed. No fold behavior, no `expandedContent` slot.
*
* @default false
*/
expandable?: false;
};
type CardExpandableProps = CardBaseProps & {
/**
* Enables the expandable variant. Renders `children` as the always-visible
* header and `expandedContent` as the body that animates open/closed based on
* `expanded`.
*/
expandable: true;
/**
* Controlled expanded state. The caller owns the state and any trigger
* (click-to-toggle) — Card is purely visual and never mutates this value.
*
* @default false
*/
expanded?: boolean;
/**
* The expandable body. Rendered below the header, animating open/closed
* when `expanded` changes. If `undefined`, the card behaves visually like
* a plain card (no divider, no bottom slot).
*/
expandedContent?: React.ReactNode;
/**
* Max-height constraint on the expandable content area.
* - `"md"` (default): caps at 20rem with vertical scroll.
* - `"fit"`: no max-height — content takes its natural height.
*
* @default "md"
*/
expandableContentHeight?: Extract<SizeVariants, "md" | "fit">;
};
type CardProps = CardPlainProps | CardExpandableProps;
// ---------------------------------------------------------------------------
// Card
// ---------------------------------------------------------------------------
function Card({
padding: paddingProp = "md",
rounding: roundingProp = "md",
background = "light",
border = "none",
ref,
children,
}: CardProps) {
/**
* A container with configurable background, border, padding, and rounding.
*
* Has two mutually-exclusive modes:
*
* - **Plain** (default): renders `children` inside a single styled `<div>`.
* Same shape as the original Card.
*
* - **Expandable** (`expandable: true`): renders `children` as the header
* region and the `expandedContent` prop as an animating body below. Fold state is
* fully controlled via the `expanded` prop — Card does not own state and
* does not wire a click trigger. Callers attach their own
* `onClick={() => setExpanded(v => !v)}` to whatever element they want to
* act as the toggle.
*
* @example Plain
* ```tsx
* <Card padding="md" border="solid">
* <p>Hello</p>
* </Card>
* ```
*
* @example Expandable, controlled
* ```tsx
* const [open, setOpen] = useState(false);
* <Card
* expandable
* expanded={open}
* expandedContent={<ModelList />}
* border="solid"
* >
* <button onClick={() => setOpen(v => !v)}>Toggle</button>
* </Card>
* ```
*/
function Card(props: CardProps) {
const {
padding: paddingProp = "md",
rounding: roundingProp = "md",
background = "light",
border = "none",
borderColor = "default",
ref,
children,
} = props;
const padding = paddingVariants[paddingProp];
const rounding = cardRoundingVariants[roundingProp];
// Plain mode — unchanged behavior
if (!props.expandable) {
return (
<div
ref={ref}
className={cn("opal-card", padding, cardRoundingVariants[roundingProp])}
data-background={background}
data-border={border}
data-opal-status-border={borderColor}
>
{children}
</div>
);
}
// Expandable mode
const {
expanded = false,
expandedContent,
expandableContentHeight = "md",
} = props;
const showContent = expanded && expandedContent !== undefined;
const headerRounding = showContent
? cardTopRoundingVariants[roundingProp]
: cardRoundingVariants[roundingProp];
return (
<div
ref={ref}
className={cn("opal-card", padding, rounding)}
data-background={background}
data-border={border}
>
{children}
<div ref={ref} className="opal-card-expandable">
<div
className={cn("opal-card-expandable-header", padding, headerRounding)}
data-background={background}
data-border={border}
data-opal-status-border={borderColor}
>
{children}
</div>
{expandedContent !== undefined && (
<div
className="opal-card-expandable-wrapper"
data-expanded={showContent ? "true" : "false"}
>
<div className="opal-card-expandable-inner">
<div
className={cn(
"opal-card-expandable-body",
cardBottomRoundingVariants[roundingProp]
)}
data-border={border}
data-opal-status-border={borderColor}
data-content-height={expandableContentHeight}
>
{expandedContent}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,3 +1,7 @@
/* ============================================================================
Plain Card
============================================================================ */
.opal-card {
@apply w-full overflow-clip;
}
@@ -15,7 +19,8 @@
@apply bg-background-tint-01;
}
/* Border variants */
/* Border variants. Border *color* lives in `cards/shared.css` and is keyed
off the `data-opal-status-border` attribute. */
.opal-card[data-border="none"] {
border: none;
}
@@ -27,3 +32,101 @@
.opal-card[data-border="solid"] {
@apply border;
}
/* ============================================================================
Expandable Card
Structure:
.opal-card-expandable (flex-col wrapper, no styling)
.opal-card-expandable-header (bg + border + padding + rounding)
.opal-card-expandable-wrapper (grid animation, overflow clip)
.opal-card-expandable-inner (min-h:0, overflow clip for grid)
.opal-card-expandable-body (bg + border-minus-top + padding)
Animation: pure CSS grid `0fr ↔ 1fr` with opacity fade on the wrapper.
No JS state machine, no Radix.
============================================================================ */
.opal-card-expandable {
@apply w-full flex flex-col;
}
/* ── Header ──────────────────────────────────────────────────────────── */
.opal-card-expandable-header {
@apply w-full overflow-clip transition-[border-radius] duration-200 ease-out;
}
.opal-card-expandable-header[data-background="none"] {
@apply bg-transparent;
}
.opal-card-expandable-header[data-background="light"] {
@apply bg-background-tint-00;
}
.opal-card-expandable-header[data-background="heavy"] {
@apply bg-background-tint-01;
}
.opal-card-expandable-header[data-border="none"] {
border: none;
}
.opal-card-expandable-header[data-border="dashed"] {
@apply border border-dashed;
}
.opal-card-expandable-header[data-border="solid"] {
@apply border;
}
/* ── Content wrapper: grid 0fr↔1fr animation ─────────────────────────── */
.opal-card-expandable-wrapper {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
overflow: hidden;
transition:
grid-template-rows 200ms ease-out,
opacity 200ms ease-out;
}
.opal-card-expandable-wrapper[data-expanded="true"] {
grid-template-rows: 1fr;
opacity: 1;
}
/* ── Content inner: clips the grid child so it collapses to 0 cleanly ── */
.opal-card-expandable-inner {
@apply overflow-hidden;
min-height: 0;
}
/* ── Content body: carries border + padding. Background is always
transparent so the page background shows through the content slot,
keeping it visually distinct from the header. ─ */
.opal-card-expandable-body {
@apply w-full bg-transparent;
}
.opal-card-expandable-body[data-content-height="md"] {
@apply max-h-[20rem] overflow-y-auto;
}
/* "fit" = no constraint; natural height, no scroll. */
.opal-card-expandable-body[data-border="none"] {
border: none;
}
.opal-card-expandable-body[data-border="dashed"] {
@apply border border-t-0 border-dashed;
}
.opal-card-expandable-body[data-border="solid"] {
@apply border border-t-0;
}

View File

@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react";
import { EmptyMessageCard } from "@opal/components";
import { SvgSparkle, SvgUsers } from "@opal/icons";
import { SvgActions, SvgServer, SvgSparkle, SvgUsers } from "@opal/icons";
const PADDING_VARIANTS = ["fit", "2xs", "xs", "sm", "md", "lg"] as const;
@@ -26,6 +26,22 @@ export const WithCustomIcon: Story = {
},
};
export const MainUi: Story = {
args: {
sizePreset: "main-ui",
title: "No Actions Found",
icon: SvgActions,
description: "Provide OpenAPI schema to preview actions here.",
},
};
export const MainUiNoDescription: Story = {
args: {
sizePreset: "main-ui",
title: "No Knowledge",
},
};
export const PaddingVariants: Story = {
render: () => (
<div className="flex flex-col gap-4 w-96">
@@ -46,6 +62,12 @@ export const Multiple: Story = {
<EmptyMessageCard title="No models available." />
<EmptyMessageCard icon={SvgSparkle} title="No agents selected." />
<EmptyMessageCard icon={SvgUsers} title="No groups added." />
<EmptyMessageCard
sizePreset="main-ui"
icon={SvgServer}
title="No Discord servers configured yet"
description="Create a server configuration to get started."
/>
</div>
),
};

View File

@@ -6,25 +6,44 @@ A pre-configured Card for empty states. Renders a transparent card with a dashed
## Props
| Prop | Type | Default | Description |
| --------- | --------------------------- | ---------- | -------------------------------- |
| `icon` | `IconFunctionComponent` | `SvgEmpty` | Icon displayed alongside the title |
| `title` | `string` | — | Primary message text (required) |
| `padding` | `PaddingVariants` | `"sm"` | Padding preset for the card |
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
### Base props (all presets)
| Prop | Type | Default | Description |
| ------------ | ----------------------------- | ------------- | ---------------------------------- |
| `sizePreset` | `"secondary" \| "main-ui"` | `"secondary"` | Controls layout and text sizing |
| `icon` | `IconFunctionComponent` | `SvgEmpty` | Icon displayed alongside the title |
| `title` | `string \| RichStr` | — | Primary message text (required) |
| `padding` | `PaddingVariants` | `"md"` | Padding preset for the card |
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
### `sizePreset="main-ui"` only
| Prop | Type | Default | Description |
| ------------- | ------------------- | ------- | ------------------------ |
| `description` | `string \| RichStr` | — | Optional description text |
> `description` is **not accepted** when `sizePreset` is `"secondary"` (the default).
## Usage
```tsx
import { EmptyMessageCard } from "@opal/components";
import { SvgSparkle, SvgFileText } from "@opal/icons";
import { SvgSparkle, SvgFileText, SvgActions } from "@opal/icons";
// Default empty state
// Default empty state (secondary)
<EmptyMessageCard title="No items yet." />
// With custom icon
<EmptyMessageCard icon={SvgSparkle} title="No agents selected." />
// With custom padding
// main-ui with description
<EmptyMessageCard
sizePreset="main-ui"
icon={SvgActions}
title="No Actions Found"
description="Provide OpenAPI schema to preview actions here."
/>
// Custom padding
<EmptyMessageCard padding="xs" icon={SvgFileText} title="No documents available." />
```

View File

@@ -1,5 +1,5 @@
import { Card } from "@opal/components/cards/card/components";
import { Content, SizePreset } from "@opal/layouts";
import { Content } from "@opal/layouts";
import { SvgEmpty } from "@opal/icons";
import type {
IconFunctionComponent,
@@ -32,7 +32,7 @@ type EmptyMessageCardProps =
})
| (EmptyMessageCardBaseProps & {
sizePreset: "main-ui";
/** Description text. Only supported when `sizePreset` is `"main-ui"`. */
/** Optional description text. */
description?: string | RichStr;
});

View File

@@ -1,6 +1,13 @@
import "@opal/components/cards/shared.css";
import "@opal/components/cards/message-card/styles.css";
import { cn } from "@opal/utils";
import type { RichStr, IconFunctionComponent } from "@opal/types";
import type {
IconFunctionComponent,
PaddingVariants,
RichStr,
StatusVariants,
} from "@opal/types";
import { paddingVariants } from "@opal/shared";
import { ContentAction } from "@opal/layouts";
import { Button, Divider } from "@opal/components";
import {
@@ -15,11 +22,9 @@ import {
// Types
// ---------------------------------------------------------------------------
type MessageCardVariant = "default" | "info" | "success" | "warning" | "error";
interface MessageCardBaseProps {
/** Visual variant controlling background, border, and icon. @default "default" */
variant?: MessageCardVariant;
variant?: StatusVariants;
/** Override the default variant icon. */
icon?: IconFunctionComponent;
@@ -30,6 +35,9 @@ interface MessageCardBaseProps {
/** Optional description below the title. */
description?: string | RichStr;
/** Padding preset. @default "sm" */
padding?: Extract<PaddingVariants, "sm" | "xs">;
/**
* Content rendered below a divider, under the main content area.
* When provided, a `Divider` is inserted between the `ContentAction` and this node.
@@ -59,7 +67,7 @@ type MessageCardProps = MessageCardBaseProps &
// ---------------------------------------------------------------------------
const VARIANT_CONFIG: Record<
MessageCardVariant,
StatusVariants,
{ icon: IconFunctionComponent; iconClass: string }
> = {
default: { icon: SvgAlertCircle, iconClass: "stroke-text-03" },
@@ -113,6 +121,7 @@ function MessageCard({
icon: iconOverride,
title,
description,
padding = "sm",
bottomChildren,
rightChildren,
onClose,
@@ -134,7 +143,11 @@ function MessageCard({
);
return (
<div className="opal-message-card" data-variant={variant} ref={ref}>
<div
className={cn("opal-message-card", paddingVariants[padding])}
data-variant={variant}
ref={ref}
>
<ContentAction
icon={(props) => (
<Icon {...props} className={cn(props.className, iconClass)} />
@@ -143,7 +156,7 @@ function MessageCard({
description={description}
sizePreset="main-ui"
variant="section"
paddingVariant="lg"
paddingVariant="md"
rightChildren={right}
/>
@@ -157,4 +170,4 @@ function MessageCard({
);
}
export { MessageCard, type MessageCardProps, type MessageCardVariant };
export { MessageCard, type MessageCardProps };

View File

@@ -1,5 +1,5 @@
.opal-message-card {
@apply flex flex-col self-stretch rounded-16 border p-2;
@apply flex flex-col w-full self-stretch rounded-16 border;
}
/* Variant colors */

View File

@@ -0,0 +1,32 @@
/* ============================================================================
Shared status-border palette for card components.
Sets the `border-color` per `StatusVariants` value. Components opt in by
setting the `data-opal-status-border` attribute on their root element
(and, separately, declaring an actual border via Tailwind's `border` /
`border-dashed` utility).
Used by:
- Card (`borderColor` prop)
- MessageCard (`variant` prop, in addition to its own background)
============================================================================ */
[data-opal-status-border="default"] {
@apply border-border-01;
}
[data-opal-status-border="info"] {
@apply border-status-info-02;
}
[data-opal-status-border="success"] {
@apply border-status-success-02;
}
[data-opal-status-border="warning"] {
@apply border-status-warning-02;
}
[data-opal-status-border="error"] {
@apply border-status-error-02;
}

View File

@@ -42,6 +42,12 @@ export {
type SidebarTabProps,
} from "@opal/components/buttons/sidebar-tab/components";
/* LinkButton */
export {
LinkButton,
type LinkButtonProps,
} from "@opal/components/buttons/link-button/components";
/* Text */
export {
Text,
@@ -87,7 +93,6 @@ export {
export {
MessageCard,
type MessageCardProps,
type MessageCardVariant,
} from "@opal/components/cards/message-card/components";
/* Pagination */

View File

@@ -1,48 +1,62 @@
import { Content, type ContentProps } from "@opal/layouts/content/components";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type CardHeaderProps = ContentProps & {
/** Content rendered to the right of the Content block. */
rightChildren?: React.ReactNode;
interface CardHeaderProps {
/** Content rendered in the top-left header slot — typically a {@link Content} block. */
headerChildren?: React.ReactNode;
/** Content rendered below `rightChildren` in the same column. */
/** Content rendered to the right of `headerChildren` (top of right column). */
topRightChildren?: React.ReactNode;
/** Content rendered below `topRightChildren`, in the same column. */
bottomRightChildren?: React.ReactNode;
/**
* Content rendered below the header row, full-width.
* Use for expandable sections, search bars, or any content
* that should appear beneath the icon/title/actions row.
* Content rendered below the entire header (left + right columns),
* spanning the full width. Use for expandable sections, search bars, or
* any content that should appear beneath the icon/title/actions row.
*/
children?: React.ReactNode;
};
bottomChildren?: React.ReactNode;
}
// ---------------------------------------------------------------------------
// Card.Header
// ---------------------------------------------------------------------------
/**
* A card header layout that pairs a {@link Content} block (with `p-2`)
* with a right-side column.
* A card header layout with three optional slots arranged in two independent
* columns, plus a full-width `bottomChildren` slot below.
*
* The right column contains two vertically stacked slots —
* `rightChildren` on top, `bottomRightChildren` below — with no
* padding or gap between them.
* ```
* +------------------+----------------+
* | headerChildren | topRight |
* + +----------------+
* | | bottomRight |
* +------------------+----------------+
* | bottomChildren (full width) |
* +-----------------------------------+
* ```
*
* The optional `children` slot renders below the full header row,
* spanning the entire width.
* The left column grows to fill available space; the right column shrinks
* to fit its content. The two columns are independent in height.
*
* For the typical icon/title/description pattern, pass a {@link Content}
* (or {@link ContentAction}) into `headerChildren`.
*
* @example
* ```tsx
* <Card.Header
* icon={SvgGlobe}
* title="Google"
* description="Search engine"
* sizePreset="main-ui"
* variant="section"
* rightChildren={<Button>Connect</Button>}
* headerChildren={
* <Content
* icon={SvgGlobe}
* title="Google"
* description="Search engine"
* sizePreset="main-ui"
* variant="section"
* />
* }
* topRightChildren={<Button>Connect</Button>}
* bottomRightChildren={
* <>
* <Button icon={SvgUnplug} size="sm" prominence="tertiary" />
@@ -53,29 +67,29 @@ type CardHeaderProps = ContentProps & {
* ```
*/
function Header({
rightChildren,
headerChildren,
topRightChildren,
bottomRightChildren,
children,
...contentProps
bottomChildren,
}: CardHeaderProps) {
const hasRight = rightChildren || bottomRightChildren;
const hasRight = topRightChildren != null || bottomRightChildren != null;
return (
<div className="flex flex-col w-full">
<div className="flex flex-row items-stretch w-full">
<div className="flex-1 min-w-0 self-start p-2">
<Content {...contentProps} />
</div>
<div className="flex flex-row items-start w-full">
{headerChildren != null && (
<div className="self-start p-2 grow min-w-0">{headerChildren}</div>
)}
{hasRight && (
<div className="flex flex-col items-end shrink-0">
{rightChildren && <div className="flex-1">{rightChildren}</div>}
{bottomRightChildren && (
{topRightChildren != null && <div>{topRightChildren}</div>}
{bottomRightChildren != null && (
<div className="flex flex-row">{bottomRightChildren}</div>
)}
</div>
)}
</div>
{children && <div className="w-full">{children}</div>}
{bottomChildren != null && <div className="w-full">{bottomChildren}</div>}
</div>
);
}

View File

@@ -134,6 +134,20 @@ const cardRoundingVariants: Record<RoundingVariants, string> = {
xs: "rounded-04",
};
const cardTopRoundingVariants: Record<RoundingVariants, string> = {
lg: "rounded-t-16",
md: "rounded-t-12",
sm: "rounded-t-08",
xs: "rounded-t-04",
};
const cardBottomRoundingVariants: Record<RoundingVariants, string> = {
lg: "rounded-b-16",
md: "rounded-b-12",
sm: "rounded-b-08",
xs: "rounded-b-04",
};
export {
type ExtremaSizeVariants,
type ContainerSizeVariants,
@@ -144,6 +158,8 @@ export {
paddingXVariants,
paddingYVariants,
cardRoundingVariants,
cardTopRoundingVariants,
cardBottomRoundingVariants,
widthVariants,
heightVariants,
};

View File

@@ -81,6 +81,22 @@ export type ExtremaSizeVariants = Extract<SizeVariants, "fit" | "full">;
*/
export type OverridableExtremaSizeVariants = ExtremaSizeVariants | number;
// ---------------------------------------------------------------------------
// Status Variants
// ---------------------------------------------------------------------------
/**
* Severity / status variants used by alert-style components (e.g. {@link
* MessageCard}, {@link Card}'s `borderColor`). Each variant maps to a
* dedicated background/border/icon palette in the design system.
*/
export type StatusVariants =
| "default"
| "info"
| "success"
| "warning"
| "error";
// ---------------------------------------------------------------------------
// Icon Props
// ---------------------------------------------------------------------------

View File

@@ -5,7 +5,6 @@ import { useSearchParams, useRouter } from "next/navigation";
import { mutate } from "swr";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { Section } from "@/layouts/general-layouts";
import Button from "@/refresh-components/buttons/Button";
import Text from "@/refresh-components/texts/Text";
import { SvgArrowUpCircle, SvgWallet } from "@opal/icons";
import type { IconProps } from "@opal/types";
@@ -19,7 +18,7 @@ import {
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
import { SWR_KEYS } from "@/lib/swr-keys";
import { useUser } from "@/providers/UserProvider";
import { MessageCard } from "@opal/components";
import { LinkButton, MessageCard } from "@opal/components";
import PlansView from "./PlansView";
import CheckoutView from "./CheckoutView";
@@ -72,25 +71,10 @@ function FooterLinks({
<Text secondaryBody text03>
Have a license key?
</Text>
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
<Button action tertiary onClick={onActivateLicense}>
<Text secondaryBody text05 className="underline">
{licenseText}
</Text>
</Button>
<LinkButton onClick={onActivateLicense}>{licenseText}</LinkButton>
</>
)}
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
<Button
action
tertiary
href={billingHelpHref}
className="billing-text-link"
>
<Text secondaryBody text03 className="underline">
Billing Help
</Text>
</Button>
<LinkButton href={billingHelpHref}>Billing Help</LinkButton>
</Section>
);
}

View File

@@ -15,7 +15,7 @@ import { DeleteButton } from "@/components/DeleteButton";
import { Button } from "@opal/components";
import Switch from "@/refresh-components/inputs/Switch";
import { SvgEdit, SvgServer } from "@opal/icons";
import EmptyMessage from "@/refresh-components/EmptyMessage";
import { EmptyMessageCard } from "@opal/components";
import { DiscordGuildConfig } from "@/app/admin/discord-bot/types";
import {
deleteGuildConfig,
@@ -81,7 +81,8 @@ export function DiscordGuildsTable({ guilds, onRefresh }: Props) {
if (guilds.length === 0) {
return (
<EmptyMessage
<EmptyMessageCard
sizePreset="main-ui"
icon={SvgServer}
title="No Discord servers configured yet"
description="Create a server configuration to get started."

View File

@@ -10,7 +10,7 @@ import {
} from "@/components/ui/table";
import Switch from "@/refresh-components/inputs/Switch";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import EmptyMessage from "@/refresh-components/EmptyMessage";
import { EmptyMessageCard } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
import { Section } from "@/layouts/general-layouts";
import {
@@ -61,7 +61,8 @@ export function DiscordChannelsTable({
}: Props) {
if (channels.length === 0) {
return (
<EmptyMessage
<EmptyMessageCard
sizePreset="main-ui"
title="No channels configured"
description="Run !sync-channels in Discord to add channels."
/>

View File

@@ -271,6 +271,22 @@ export default function UserLibraryModal({
/>
</Section>
{/* The exact cap is controlled by the backend env var
MAX_EMBEDDED_IMAGES_PER_FILE (default 500). This copy is
deliberately vague so it doesn't drift if the limit is
tuned per-deployment; the precise number is surfaced in
the rejection error the server returns. */}
<Section
flexDirection="row"
justifyContent="end"
padding={0.5}
height="fit"
>
<Text secondaryBody text03>
PDFs with many embedded images may be rejected.
</Text>
</Section>
{isLoading ? (
<Section padding={2} height="fit">
<Text secondaryBody text03>

View File

@@ -7,4 +7,6 @@
--container-md: 54.5rem;
--container-lg: 62rem;
--container-full: 100%;
--toast-width: 25rem;
}

View File

@@ -12,9 +12,9 @@ interface LLMOption {
value: string;
icon: ReturnType<typeof getModelIcon>;
modelName: string;
providerId: number;
providerName: string;
provider: string;
providerDisplayName: string;
supportsImageInput: boolean;
vendor: string | null;
}
@@ -64,7 +64,7 @@ export default function LLMSelector({
return;
}
const key = `${provider.provider}:${modelConfiguration.name}`;
const key = `${provider.id}:${modelConfiguration.name}`;
if (seenKeys.has(key)) {
return; // Skip exact duplicate
}
@@ -87,10 +87,9 @@ export default function LLMSelector({
),
icon: getModelIcon(provider.provider, modelConfiguration.name),
modelName: modelConfiguration.name,
providerId: provider.id,
providerName: provider.name,
provider: provider.provider,
providerDisplayName:
provider.provider_display_name || provider.provider,
supportsImageInput,
vendor: modelConfiguration.vendor || null,
};
@@ -108,33 +107,34 @@ export default function LLMSelector({
requiresImageGeneration,
]);
// Group options by provider using backend-provided display names
// Group options by configured provider instance so multiple instances of the
// same provider type (e.g., two Anthropic API keys) appear as separate groups
// labeled with their user-given names.
const groupedOptions = useMemo(() => {
const groups = new Map<
string,
number,
{ displayName: string; options: LLMOption[] }
>();
llmOptions.forEach((option) => {
const provider = option.provider.toLowerCase();
if (!groups.has(provider)) {
groups.set(provider, {
displayName: option.providerDisplayName,
if (!groups.has(option.providerId)) {
groups.set(option.providerId, {
displayName: option.providerName,
options: [],
});
}
groups.get(provider)!.options.push(option);
groups.get(option.providerId)!.options.push(option);
});
// Sort groups alphabetically by display name
const sortedProviders = Array.from(groups.keys()).sort((a, b) =>
const sortedProviderIds = Array.from(groups.keys()).sort((a, b) =>
groups.get(a)!.displayName.localeCompare(groups.get(b)!.displayName)
);
return sortedProviders.map((provider) => {
const group = groups.get(provider)!;
return sortedProviderIds.map((providerId) => {
const group = groups.get(providerId)!;
return {
provider,
providerId,
displayName: group.displayName,
options: group.options,
};
@@ -179,7 +179,7 @@ export default function LLMSelector({
)}
{showGrouped
? groupedOptions.map((group) => (
<InputSelect.Group key={group.provider}>
<InputSelect.Group key={group.providerId}>
<InputSelect.Label>{group.displayName}</InputSelect.Label>
{group.options.map((option) => (
<InputSelect.Item

View File

@@ -3,7 +3,7 @@
import { useState } from "react";
import { Formik, Form, useFormikContext } from "formik";
import * as Yup from "yup";
import { Button, Text } from "@opal/components";
import { Button, LinkButton, Text } from "@opal/components";
import {
SvgCheckCircle,
SvgShareWebhook,
@@ -286,16 +286,9 @@ export default function HookFormModal({
widthVariant="fit"
/>
{docsUrl && (
<a
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
className="underline leading-none"
>
<Text font="secondary-body" color="text-03">
Documentation
</Text>
</a>
<LinkButton href={docsUrl} target="_blank">
Documentation
</LinkButton>
)}
</div>
}

View File

@@ -15,7 +15,7 @@ import {
useModalClose,
} from "@/refresh-components/contexts/ModalContext";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { Button, SelectCard, Text } from "@opal/components";
import { Button, LinkButton, SelectCard, Text } from "@opal/components";
import { Disabled, Hoverable } from "@opal/core";
import { markdown } from "@opal/utils";
import { Content, IllustrationContent } from "@opal/layouts";
@@ -23,7 +23,6 @@ import Modal from "@/refresh-components/Modal";
import {
SvgArrowExchange,
SvgBubbleText,
SvgExternalLink,
SvgFileBroadcast,
SvgShareWebhook,
SvgPlug,
@@ -190,17 +189,11 @@ function UnconnectedHookCard({ spec, onConnect }: UnconnectedHookCardProps) {
/>
{spec.docs_url && (
<a
href={spec.docs_url}
target="_blank"
rel="noopener noreferrer"
className="ml-6 flex items-center gap-1 w-min"
>
<span className="underline font-secondary-body text-text-03">
<div className="ml-6">
<LinkButton href={spec.docs_url} target="_blank">
Documentation
</span>
<SvgExternalLink size={12} className="shrink-0" />
</a>
</LinkButton>
</div>
)}
</div>
@@ -369,17 +362,11 @@ function ConnectedHookCard({
/>
{spec?.docs_url && (
<a
href={spec.docs_url}
target="_blank"
rel="noopener noreferrer"
className="ml-6 flex items-center gap-1 w-min"
>
<span className="underline font-secondary-body text-text-03">
<div className="ml-6">
<LinkButton href={spec.docs_url} target="_blank">
Documentation
</span>
<SvgExternalLink size={12} className="shrink-0" />
</a>
</LinkButton>
</div>
)}
</div>

View File

@@ -8,7 +8,7 @@ import {
} from "@/lib/search/interfaces";
import SearchCard from "@/ee/sections/SearchCard";
import { Divider, Pagination } from "@opal/components";
import EmptyMessage from "@/refresh-components/EmptyMessage";
import { EmptyMessageCard } from "@opal/components";
import { IllustrationContent } from "@opal/layouts";
import SvgNoResult from "@opal/illustrations/no-result";
import { getSourceMetadata } from "@/lib/sources";
@@ -334,7 +334,11 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
)}
>
{error ? (
<EmptyMessage title="Search failed" description={error} />
<EmptyMessageCard
sizePreset="main-ui"
title="Search failed"
description={error}
/>
) : paginatedResults.length > 0 ? (
<>
{paginatedResults.map((doc) => (

View File

@@ -1,261 +0,0 @@
/**
* Actions Layout Components
*
* A namespaced collection of components for building consistent action cards
* (MCP servers, OpenAPI tools, etc.). These components provide a standardized
* layout that separates presentation from business logic, making it easier to
* build and maintain action-related UIs.
*
* Built on top of ExpandableCard layouts for the underlying card structure.
*
* @example
* ```tsx
* import * as ActionsLayouts from "@/layouts/actions-layouts";
* import * as ExpandableCard from "@/layouts/expandable-card-layouts";
* import { SvgServer } from "@opal/icons";
* import Switch from "@/components/ui/switch";
*
* function MyActionCard() {
* return (
* <ExpandableCard.Root>
* <ActionsLayouts.Header
* title="My MCP Server"
* description="A powerful MCP server for automation"
* icon={SvgServer}
* rightChildren={
* <Button onClick={handleDisconnect}>Disconnect</Button>
* }
* />
* <ActionsLayouts.Content>
* <ActionsLayouts.Tool
* title="File Reader"
* description="Read files from the filesystem"
* icon={SvgFile}
* rightChildren={
* <Switch checked={enabled} onCheckedChange={setEnabled} />
* }
* />
* <ActionsLayouts.Tool
* title="Web Search"
* description="Search the web"
* icon={SvgGlobe}
* disabled={true}
* rightChildren={
* <Switch checked={false} disabled />
* }
* />
* </ActionsLayouts.Content>
* </ExpandableCard.Root>
* );
* }
* ```
*/
"use client";
import React, { HtmlHTMLAttributes } from "react";
import type { IconProps } from "@opal/types";
import { WithoutStyles } from "@/types";
import { ContentAction } from "@opal/layouts";
import * as ExpandableCard from "@/layouts/expandable-card-layouts";
import { Card } from "@/refresh-components/cards";
import { Label } from "@opal/layouts";
/**
* Actions Header Component
*
* The header section of an action card. Displays icon, title, description,
* and optional right-aligned actions.
*
* Features:
* - Icon, title, and description display
* - Custom right-aligned actions via rightChildren
* - Responsive layout with truncated text
*
* @example
* ```tsx
* // Basic header
* <ActionsLayouts.Header
* title="File Server"
* description="Manage local files"
* icon={SvgFolder}
* />
*
* // With actions
* <ActionsLayouts.Header
* title="API Server"
* description="RESTful API integration"
* icon={SvgCloud}
* rightChildren={
* <div className="flex gap-2">
* <Button onClick={handleEdit}>Edit</Button>
* <Button onClick={handleDelete}>Delete</Button>
* </div>
* }
* />
* ```
*/
export interface ActionsHeaderProps
extends WithoutStyles<HtmlHTMLAttributes<HTMLDivElement>> {
// Core content
name?: string;
title: string;
description?: string;
icon: React.FunctionComponent<IconProps>;
// Custom content
rightChildren?: React.ReactNode;
}
function ActionsHeader({
name,
title,
description,
icon: Icon,
rightChildren,
...props
}: ActionsHeaderProps) {
return (
<ExpandableCard.Header>
<div className="flex flex-col gap-2 pt-4 pb-2">
<div className="px-4">
<Label label={name}>
<ContentAction
icon={Icon}
title={title}
description={description}
sizePreset="section"
variant="section"
rightChildren={rightChildren}
paddingVariant="fit"
/>
</Label>
</div>
<div {...props} className="px-2" />
</div>
</ExpandableCard.Header>
);
}
/**
* Actions Content Component
*
* A container for the content area of an action card.
* Use this to wrap tools, settings, or other expandable content.
* Features a maximum height with scrollable overflow.
*
* IMPORTANT: Only ONE ActionsContent should be used within a single ExpandableCard.Root.
* This component self-registers with the ActionsLayout context to inform
* ActionsHeader whether content exists (for border-radius styling). Using
* multiple ActionsContent components will cause incorrect unmount behavior -
* when any one unmounts, it will incorrectly signal that no content exists,
* even if other ActionsContent components remain mounted.
*
* @example
* ```tsx
* <ActionsLayouts.Content>
* <ActionsLayouts.Tool {...} />
* <ActionsLayouts.Tool {...} />
* </ActionsLayouts.Content>
* ```
*/
function ActionsContent({
children,
...props
}: WithoutStyles<React.HTMLAttributes<HTMLDivElement>>) {
return (
<ExpandableCard.Content {...props}>
<div className="flex flex-col gap-2 p-2">{children}</div>
</ExpandableCard.Content>
);
}
/**
* Actions Tool Component
*
* Represents a single tool within an actions content area. Displays the tool's
* title, description, and icon. The component provides a label wrapper for
* custom right-aligned controls (like toggle switches).
*
* Features:
* - Tool title and description
* - Custom icon
* - Disabled state (applies strikethrough to title)
* - Custom right-aligned content via rightChildren
* - Responsive layout with truncated text
*
* @example
* ```tsx
* // Basic tool with switch
* <ActionsLayouts.Tool
* title="File Reader"
* description="Read files from the filesystem"
* icon={SvgFile}
* rightChildren={
* <Switch checked={enabled} onCheckedChange={setEnabled} />
* }
* />
*
* // Disabled tool
* <ActionsLayouts.Tool
* title="Premium Feature"
* description="This feature requires a premium subscription"
* icon={SvgLock}
* disabled={true}
* rightChildren={
* <Switch checked={false} disabled />
* }
* />
*
* // Tool with custom action
* <ActionsLayouts.Tool
* name="config_tool"
* title="Configuration"
* description="Configure system settings"
* icon={SvgSettings}
* rightChildren={
* <Button onClick={openSettings}>Configure</Button>
* }
* />
* ```
*/
export type ActionsToolProps = WithoutStyles<{
// Core content
name?: string;
title: string;
description: string;
icon?: React.FunctionComponent<IconProps>;
// State
disabled?: boolean;
rightChildren?: React.ReactNode;
}>;
function ActionsTool({
name,
title,
description,
icon,
disabled,
rightChildren,
}: ActionsToolProps) {
return (
<Card padding={0.75} variant={disabled ? "disabled" : undefined}>
<Label label={name} disabled={disabled}>
<ContentAction
icon={icon}
title={title}
description={description}
sizePreset="main-ui"
variant="section"
rightChildren={rightChildren}
paddingVariant="fit"
/>
</Label>
</Card>
);
}
export {
ActionsHeader as Header,
ActionsContent as Content,
ActionsTool as Tool,
};

View File

@@ -1,291 +0,0 @@
/**
* Expandable Card Layout Components
*
* A namespaced collection of components for building expandable cards with
* collapsible content sections. These provide the structural foundation
* without opinionated content styling - just pure containers.
*
* Use these components when you need:
* - A card with a header that can have expandable content below it
* - Automatic border-radius handling based on whether content exists/is folded
* - Controlled or uncontrolled folding state
*
* @example
* ```tsx
* import * as ExpandableCard from "@/layouts/expandable-card-layouts";
*
* // Uncontrolled — Root manages its own state
* function MyCard() {
* return (
* <ExpandableCard.Root>
* <ExpandableCard.Header>
* <div className="p-4">
* <h3>My Header</h3>
* </div>
* </ExpandableCard.Header>
* <ExpandableCard.Content>
* <div className="p-4">
* <p>Expandable content goes here</p>
* </div>
* </ExpandableCard.Content>
* </ExpandableCard.Root>
* );
* }
*
* // Controlled — consumer owns the state
* function MyControlledCard() {
* const [isFolded, setIsFolded] = useState(false);
*
* return (
* <ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
* <ExpandableCard.Header>
* <button onClick={() => setIsFolded(!isFolded)}>Toggle</button>
* </ExpandableCard.Header>
* <ExpandableCard.Content>
* <p>Content here</p>
* </ExpandableCard.Content>
* </ExpandableCard.Root>
* );
* }
* ```
*/
"use client";
import React, {
createContext,
useContext,
useState,
useMemo,
useLayoutEffect,
Dispatch,
SetStateAction,
} from "react";
import { cn } from "@/lib/utils";
import { WithoutStyles } from "@/types";
import ShadowDiv from "@/refresh-components/ShadowDiv";
import { Section, SectionProps } from "@/layouts/general-layouts";
import {
Collapsible,
CollapsibleContent,
} from "@/refresh-components/Collapsible";
/**
* Expandable Card Context
*
* Provides folding state management for expandable cards without prop drilling.
* Also tracks whether content is present via self-registration.
*/
interface ExpandableCardContextValue {
isFolded: boolean;
setIsFolded: Dispatch<SetStateAction<boolean>>;
hasContent: boolean;
registerContent: () => () => void;
}
const ExpandableCardContext = createContext<
ExpandableCardContextValue | undefined
>(undefined);
function useExpandableCardContext() {
const context = useContext(ExpandableCardContext);
if (!context) {
throw new Error(
"ExpandableCard components must be used within an ExpandableCard.Root"
);
}
return context;
}
/**
* Expandable Card Root Component
*
* The root container and context provider for an expandable card. Provides a
* flex column layout with no gap or padding by default.
*
* Supports both controlled and uncontrolled folding state:
* - **Uncontrolled**: Manages its own state. Use `defaultFolded` to set the
* initial folding state (defaults to `false`, i.e. expanded).
* - **Controlled**: Pass `isFolded` and `onFoldedChange` to manage folding
* state externally.
*
* @example
* ```tsx
* // Uncontrolled
* <ExpandableCard.Root>
* <ExpandableCard.Header>...</ExpandableCard.Header>
* <ExpandableCard.Content>...</ExpandableCard.Content>
* </ExpandableCard.Root>
*
* // Uncontrolled, starts folded
* <ExpandableCard.Root defaultFolded>
* ...
* </ExpandableCard.Root>
*
* // Controlled
* const [isFolded, setIsFolded] = useState(false);
* <ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
* ...
* </ExpandableCard.Root>
* ```
*/
export interface ExpandableCardRootProps extends SectionProps {
/** Controlled folding state. When provided, the component is controlled. */
isFolded?: boolean;
/** Callback when folding state changes. Required for controlled usage. */
onFoldedChange?: Dispatch<SetStateAction<boolean>>;
/** Initial folding state for uncontrolled usage. Defaults to `false`. */
defaultFolded?: boolean;
}
function ExpandableCardRoot({
isFolded: controlledFolded,
onFoldedChange,
defaultFolded = false,
...props
}: ExpandableCardRootProps) {
const [uncontrolledFolded, setUncontrolledFolded] = useState(defaultFolded);
const isControlled = controlledFolded !== undefined;
const isFolded = isControlled ? controlledFolded : uncontrolledFolded;
const setIsFolded = isControlled
? onFoldedChange ?? (() => {})
: setUncontrolledFolded;
const [hasContent, setHasContent] = useState(false);
// Registration function for Content to announce its presence
const registerContent = useMemo(
() => () => {
setHasContent(true);
return () => setHasContent(false);
},
[]
);
const contextValue = useMemo(
() => ({ isFolded, setIsFolded, hasContent, registerContent }),
[isFolded, setIsFolded, hasContent, registerContent]
);
return (
<ExpandableCardContext.Provider value={contextValue}>
<Section gap={0} padding={0} {...props} />
</ExpandableCardContext.Provider>
);
}
/**
* Expandable Card Header Component
*
* The header section of an expandable card. This is a pure container that:
* - Has a border and neutral background
* - Automatically handles border-radius based on content state:
* - Fully rounded when no content exists or when content is folded
* - Only top-rounded when content is visible
*
* You are responsible for adding your own padding, layout, and content inside.
*
* @example
* ```tsx
* <ExpandableCard.Header>
* <div className="flex items-center justify-between p-4">
* <h3>My Title</h3>
* <button>Action</button>
* </div>
* </ExpandableCard.Header>
* ```
*/
export interface ExpandableCardHeaderProps
extends WithoutStyles<React.HTMLAttributes<HTMLDivElement>> {
children?: React.ReactNode;
}
function ExpandableCardHeader({
children,
...props
}: ExpandableCardHeaderProps) {
const { isFolded, hasContent } = useExpandableCardContext();
// Round all corners if there's no content, or if content exists but is folded
const shouldFullyRound = !hasContent || isFolded;
return (
<div
{...props}
className={cn(
"border bg-background-neutral-00 w-full transition-[border-radius] duration-200 ease-out",
shouldFullyRound ? "rounded-16" : "rounded-t-16"
)}
>
{children}
</div>
);
}
/**
* Expandable Card Content Component
*
* The expandable content section of the card. This is a pure container that:
* - Self-registers with context to inform Header about its presence
* - Animates open/closed using Radix Collapsible (slide down/up)
* - Has side and bottom borders that connect to the header
* - Has a max-height with scrollable overflow via ShadowDiv
*
* You are responsible for adding your own content inside.
*
* IMPORTANT: Only ONE Content component should be used within a single Root.
* This component self-registers with the context to inform Header whether
* content exists (for border-radius styling). Using multiple Content components
* will cause incorrect unmount behavior.
*
* @example
* ```tsx
* <ExpandableCard.Content>
* <div className="p-4">
* <p>Your expandable content here</p>
* </div>
* </ExpandableCard.Content>
* ```
*/
export interface ExpandableCardContentProps
extends WithoutStyles<React.HTMLAttributes<HTMLDivElement>> {
children?: React.ReactNode;
}
function ExpandableCardContent({
children,
...props
}: ExpandableCardContentProps) {
const { isFolded, registerContent } = useExpandableCardContext();
// Self-register with context to inform Header that content exists
useLayoutEffect(() => {
return registerContent();
}, [registerContent]);
return (
<Collapsible open={!isFolded} className="w-full">
<CollapsibleContent>
<div
className={cn(
"border-x border-b rounded-b-16 overflow-hidden w-full transition-opacity duration-200 ease-out",
isFolded ? "opacity-0" : "opacity-100"
)}
>
<ShadowDiv
className="flex flex-col rounded-b-16 max-h-[20rem]"
{...props}
>
{children}
</ShadowDiv>
</div>
</CollapsibleContent>
</Collapsible>
);
}
export {
ExpandableCardRoot as Root,
ExpandableCardHeader as Header,
ExpandableCardContent as Content,
};

View File

@@ -2,7 +2,8 @@
import { useCallback, useSyncExternalStore } from "react";
import { cn } from "@/lib/utils";
import { MessageCard, type MessageCardVariant } from "@opal/components";
import { MessageCard } from "@opal/components";
import type { StatusVariants } from "@opal/types";
import { NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK } from "@/lib/constants";
import { toast, toastStore, MAX_VISIBLE_TOASTS } from "@/hooks/useToast";
import type { Toast, ToastLevel } from "@/hooks/useToast";
@@ -10,7 +11,7 @@ import type { Toast, ToastLevel } from "@/hooks/useToast";
const ANIMATION_DURATION = 200; // matches tailwind fade-out-scale (0.2s)
const MAX_TOAST_MESSAGE_LENGTH = 150;
const LEVEL_TO_VARIANT: Record<ToastLevel, MessageCardVariant> = {
const LEVEL_TO_VARIANT: Record<ToastLevel, StatusVariants> = {
success: "success",
error: "error",
warning: "warning",
@@ -50,11 +51,7 @@ function ToastContainer() {
return (
<div
data-testid="toast-container"
className={cn(
"fixed bottom-4 right-4 z-[10000]",
"flex flex-col gap-2 items-end",
"max-w-[420px]"
)}
className="fixed bottom-4 right-4 z-[var(--z-toast)] flex flex-col gap-2 items-end max-w-[var(--toast-width)] w-full"
>
{visible.map((t) => {
const text =
@@ -65,7 +62,7 @@ function ToastContainer() {
<div
key={t.id}
className={cn(
"shadow-02 rounded-12",
"w-full",
t.leaving ? "animate-fade-out-scale" : "animate-fade-in-scale"
)}
>
@@ -73,6 +70,7 @@ function ToastContainer() {
variant={LEVEL_TO_VARIANT[t.level ?? "info"]}
title={text}
description={buildDescription(t)}
padding="xs"
onClose={t.dismissible ? () => handleClose(t.id) : undefined}
/>
</div>

View File

@@ -1,42 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import EmptyMessage from "./EmptyMessage";
import { SvgFileText, SvgUsers } from "@opal/icons";
const meta: Meta<typeof EmptyMessage> = {
title: "refresh-components/messages/EmptyMessage",
component: EmptyMessage,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof EmptyMessage>;
export const Default: Story = {
args: {
title: "No items found",
},
};
export const WithDescription: Story = {
args: {
title: "No connectors configured",
description:
"Set up a connector to start indexing documents from your data sources.",
},
};
export const WithCustomIcon: Story = {
args: {
icon: SvgFileText,
title: "No documents available",
description: "Upload documents or connect a data source to get started.",
},
};
export const UsersEmpty: Story = {
args: {
icon: SvgUsers,
title: "No users in this group",
description: "Add users to this group to grant them access.",
},
};

View File

@@ -1,64 +0,0 @@
/**
* EmptyMessage - A component for displaying empty state messages
*
* Displays a translucent card with an icon and message text to indicate
* when no data or content is available.
*
* Features:
* - Translucent card background with dashed border
* - Horizontal layout with icon on left, text on right
* - 0.5rem gap between icon and text
* - Accepts string children for the message text
* - Customizable icon
*
* @example
* ```tsx
* import EmptyMessage from "@/refresh-components/EmptyMessage";
* import { SvgActivity } from "@opal/icons";
*
* // Basic usage
* <EmptyMessage icon={SvgActivity}>
* No connectors set up for your organization.
* </EmptyMessage>
*
* // With different icon
* <EmptyMessage icon={SvgFileText}>
* No documents available.
* </EmptyMessage>
* ```
*/
import { SvgEmpty } from "@opal/icons";
import Card from "@/refresh-components/cards/Card";
import Text from "@/refresh-components/texts/Text";
import { Content } from "@opal/layouts";
import { IconProps } from "@opal/types";
export interface EmptyMessageProps {
icon?: React.FunctionComponent<IconProps>;
title: string;
description?: string;
}
export default function EmptyMessage({
icon: Icon = SvgEmpty,
title,
description,
}: EmptyMessageProps) {
return (
<Card variant="tertiary">
<Content
icon={Icon}
title={title}
sizePreset="main-ui"
variant="body"
prominence="muted"
/>
{description && (
<Text secondaryBody text03>
{description}
</Text>
)}
</Card>
);
}

View File

@@ -82,6 +82,7 @@ export interface LineItemProps
selected?: boolean;
icon?: React.FunctionComponent<IconProps>;
strokeIcon?: boolean;
description?: string;
rightChildren?: React.ReactNode;
href?: string;
@@ -154,6 +155,7 @@ export default function LineItem({
skeleton,
emphasized,
icon: Icon,
strokeIcon = true,
description,
children,
rightChildren,
@@ -245,7 +247,12 @@ export default function LineItem({
!!(children && description) && "mt-0.5"
)}
>
<Icon className={cn("h-[1rem] w-[1rem]", iconClassNames[variant])} />
<Icon
className={cn(
"h-[1rem] w-[1rem]",
strokeIcon && iconClassNames[variant]
)}
/>
</div>
)}
<Section alignItems="start" gap={0}>

View File

@@ -99,6 +99,7 @@ export default function SwitchList({
item.leading) as React.FunctionComponent<IconProps>)
: undefined
}
strokeIcon={false}
rightChildren={
<Switch
checked={item.isEnabled}

View File

@@ -172,6 +172,7 @@ export default function ModelListContent({
<LineItem
muted
icon={group.Icon}
strokeIcon={false}
rightChildren={
open ? (
<SvgChevronDown className="h-4 w-4 stroke-text-04 shrink-0" />

View File

@@ -4,8 +4,8 @@ import { useState, useRef, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import * as GeneralLayouts from "@/layouts/general-layouts";
import { Button, Divider, MessageCard } from "@opal/components";
import { Hoverable } from "@opal/core";
import { Button, Card, Divider, MessageCard } from "@opal/components";
import { Hoverable, Disabled } from "@opal/core";
import { FullPersona } from "@/app/admin/agents/interfaces";
import { buildImgUrl } from "@/app/app/components/files/images/utils";
import { Formik, Form, FieldArray } from "formik";
@@ -14,7 +14,12 @@ import InputTypeInField from "@/refresh-components/form/InputTypeInField";
import InputTextAreaField from "@/refresh-components/form/InputTextAreaField";
import InputTypeInElementField from "@/refresh-components/form/InputTypeInElementField";
import InputDatePickerField from "@/refresh-components/form/InputDatePickerField";
import { InputHorizontal, InputVertical } from "@opal/layouts";
import {
Card as CardLayout,
ContentAction,
InputHorizontal,
InputVertical,
} from "@opal/layouts";
import { useFormikContext } from "formik";
import LLMSelector from "@/components/llm/LLMSelector";
import { parseLlmDescriptor, structureValue } from "@/lib/llmConfig/utils";
@@ -32,7 +37,6 @@ import {
OPEN_URL_TOOL_ID,
} from "@/app/app/components/tools/constants";
import Text from "@/refresh-components/texts/Text";
import { Card } from "@/refresh-components/cards";
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
import SwitchField from "@/refresh-components/form/SwitchField";
import { Tooltip } from "@opal/components";
@@ -72,8 +76,6 @@ import {
import useMcpServersForAgentEditor from "@/hooks/useMcpServersForAgentEditor";
import useOpenApiTools from "@/hooks/useOpenApiTools";
import { useAvailableTools } from "@/hooks/useAvailableTools";
import * as ActionsLayouts from "@/layouts/actions-layouts";
import * as ExpandableCard from "@/layouts/expandable-card-layouts";
import { getActionIcon } from "@/lib/tools/mcpUtils";
import { MCPServer, MCPTool, ToolSnapshot } from "@/lib/tools/interfaces";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
@@ -221,7 +223,7 @@ function AgentIconEditor({ existingAgent }: AgentIconEditorProps) {
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 mb-2">
<Hoverable.Item group="inputAvatar" variant="opacity-on-hover">
<Button size="md" prominence="secondary">
<Button prominence="secondary" size="md">
Edit
</Button>
</Hoverable.Item>
@@ -277,14 +279,21 @@ function OpenApiToolCard({ tool }: OpenApiToolCardProps) {
const toolFieldName = `openapi_tool_${tool.id}`;
return (
<ExpandableCard.Root defaultFolded>
<ActionsLayouts.Header
title={tool.display_name || tool.name}
description={tool.description}
icon={SvgActions}
rightChildren={<SwitchField name={toolFieldName} />}
<Card border="solid" rounding="lg" padding="sm">
<CardLayout.Header
headerChildren={
<ContentAction
icon={SvgActions}
title={tool.display_name || tool.name}
description={tool.description}
sizePreset="main-ui"
variant="section"
paddingVariant="fit"
/>
}
topRightChildren={<SwitchField name={toolFieldName} />}
/>
</ExpandableCard.Root>
</Card>
);
}
@@ -315,87 +324,120 @@ function MCPServerCard({
return toolFieldValue === true;
}).length;
const hasTools = enabledTools.length > 0 && filteredTools.length > 0;
let cardContent: React.ReactNode | undefined;
if (isLoading) {
cardContent = (
<div className="flex flex-col gap-2 p-2">
<GeneralLayouts.Section padding={1}>
<SimpleLoader />
</GeneralLayouts.Section>
</div>
);
} else if (hasTools) {
cardContent = (
<div className="flex flex-col gap-2 p-2">
{filteredTools.map((tool) => {
const toolDisabled =
!tool.isAvailable ||
!getFieldMeta<boolean>(`${serverFieldName}.enabled`).value;
return (
<Disabled key={tool.id} disabled={toolDisabled}>
<Card border="solid" rounding="lg" padding="sm">
<CardLayout.Header
headerChildren={
<ContentAction
icon={tool.icon ?? SvgSliders}
title={tool.name}
description={tool.description}
sizePreset="main-ui"
variant="section"
paddingVariant="fit"
/>
}
topRightChildren={
<SwitchField
name={`${serverFieldName}.tool_${tool.id}`}
disabled={!isServerEnabled}
/>
}
/>
</Card>
</Disabled>
);
})}
</div>
);
}
return (
<ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
<ActionsLayouts.Header
title={server.name}
description={server.description}
icon={getActionIcon(server.server_url, server.name)}
rightChildren={
<GeneralLayouts.Section
flexDirection="row"
gap={0.5}
alignItems="start"
>
<EnabledCount
enabledCount={enabledCount}
totalCount={enabledTools.length}
/>
<SwitchField
name={`${serverFieldName}.enabled`}
onCheckedChange={(checked) => {
enabledTools.forEach((tool) => {
setFieldValue(`${serverFieldName}.tool_${tool.id}`, checked);
});
if (!checked) return;
setIsFolded(false);
}}
<Card
expandable
expanded={!isFolded}
border="solid"
rounding="lg"
padding="sm"
expandedContent={cardContent}
>
<CardLayout.Header
headerChildren={
<ContentAction
icon={getActionIcon(server.server_url, server.name)}
title={server.name}
description={server.description}
sizePreset="main-ui"
variant="section"
paddingVariant="fit"
rightChildren={
<GeneralLayouts.Section
flexDirection="row"
gap={0.5}
alignItems="start"
>
<EnabledCount
enabledCount={enabledCount}
totalCount={enabledTools.length}
/>
<SwitchField
name={`${serverFieldName}.enabled`}
onCheckedChange={(checked) => {
enabledTools.forEach((tool) => {
setFieldValue(
`${serverFieldName}.tool_${tool.id}`,
checked
);
});
if (!checked) return;
setIsFolded(false);
}}
/>
</GeneralLayouts.Section>
}
/>
}
bottomChildren={
<GeneralLayouts.Section flexDirection="row" gap={0.5}>
<InputTypeIn
placeholder="Search tools..."
variant="internal"
leftSearchIcon
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{enabledTools.length > 0 && (
<Button
prominence="internal"
rightIcon={isFolded ? SvgExpand : SvgFold}
onClick={() => setIsFolded((prev) => !prev)}
>
{isFolded ? "Expand" : "Fold"}
</Button>
)}
</GeneralLayouts.Section>
}
>
<GeneralLayouts.Section flexDirection="row" gap={0.5}>
<InputTypeIn
placeholder="Search tools..."
variant="internal"
leftSearchIcon
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{enabledTools.length > 0 && (
<Button
prominence="internal"
rightIcon={isFolded ? SvgExpand : SvgFold}
onClick={() => setIsFolded((prev) => !prev)}
>
{isFolded ? "Expand" : "Fold"}
</Button>
)}
</GeneralLayouts.Section>
</ActionsLayouts.Header>
{isLoading ? (
<ActionsLayouts.Content>
<GeneralLayouts.Section padding={1}>
<SimpleLoader />
</GeneralLayouts.Section>
</ActionsLayouts.Content>
) : (
enabledTools.length > 0 &&
filteredTools.length > 0 && (
<ActionsLayouts.Content>
{filteredTools.map((tool) => (
<ActionsLayouts.Tool
key={tool.id}
name={`${serverFieldName}.tool_${tool.id}`}
title={tool.name}
description={tool.description}
icon={tool.icon ?? SvgSliders}
disabled={
!tool.isAvailable ||
!getFieldMeta<boolean>(`${serverFieldName}.enabled`).value
}
rightChildren={
<SwitchField
name={`${serverFieldName}.tool_${tool.id}`}
disabled={!isServerEnabled}
/>
}
/>
))}
</ActionsLayouts.Content>
)
)}
</ExpandableCard.Root>
/>
</Card>
);
}
@@ -1376,17 +1418,11 @@ export default function AgentEditorPage({
/>
<SimpleCollapsible.Content>
<GeneralLayouts.Section gap={0.5}>
<Tooltip
<Disabled
disabled={!isImageGenerationAvailable}
tooltip={imageGenerationDisabledTooltip}
side="top"
>
<Card
variant={
isImageGenerationAvailable
? undefined
: "disabled"
}
>
<Card border="solid" rounding="lg">
<InputHorizontal
withLabel="image_generation"
title="Image Generation"
@@ -1399,57 +1435,55 @@ export default function AgentEditorPage({
/>
</InputHorizontal>
</Card>
</Tooltip>
</Disabled>
<Card
variant={!!webSearchTool ? undefined : "disabled"}
>
<InputHorizontal
withLabel="web_search"
title="Web Search"
description="Search the web for real-time information and up-to-date results."
disabled={!webSearchTool}
>
<SwitchField
name="web_search"
<Disabled disabled={!webSearchTool}>
<Card border="solid" rounding="lg">
<InputHorizontal
withLabel="web_search"
title="Web Search"
description="Search the web for real-time information and up-to-date results."
disabled={!webSearchTool}
/>
</InputHorizontal>
</Card>
>
<SwitchField
name="web_search"
disabled={!webSearchTool}
/>
</InputHorizontal>
</Card>
</Disabled>
<Card
variant={!!openURLTool ? undefined : "disabled"}
>
<InputHorizontal
withLabel="open_url"
title="Open URL"
description="Fetch and read content from web URLs."
disabled={!openURLTool}
>
<SwitchField
name="open_url"
<Disabled disabled={!openURLTool}>
<Card border="solid" rounding="lg">
<InputHorizontal
withLabel="open_url"
title="Open URL"
description="Fetch and read content from web URLs."
disabled={!openURLTool}
/>
</InputHorizontal>
</Card>
>
<SwitchField
name="open_url"
disabled={!openURLTool}
/>
</InputHorizontal>
</Card>
</Disabled>
<Card
variant={
!!codeInterpreterTool ? undefined : "disabled"
}
>
<InputHorizontal
withLabel="code_interpreter"
title="Code Interpreter"
description="Generate and run code."
disabled={!codeInterpreterTool}
>
<SwitchField
name="code_interpreter"
<Disabled disabled={!codeInterpreterTool}>
<Card border="solid" rounding="lg">
<InputHorizontal
withLabel="code_interpreter"
title="Code Interpreter"
description="Generate and run code."
disabled={!codeInterpreterTool}
/>
</InputHorizontal>
</Card>
>
<SwitchField
name="code_interpreter"
disabled={!codeInterpreterTool}
/>
</InputHorizontal>
</Card>
</Disabled>
{/* Tools */}
<>
@@ -1506,73 +1540,77 @@ export default function AgentEditorPage({
/>
<SimpleCollapsible.Content>
<GeneralLayouts.Section>
<Card>
<InputHorizontal
title="Share This Agent"
description="with other users, groups, or everyone in your organization."
center
>
<Button
prominence="secondary"
icon={isShared ? SvgUsers : SvgLock}
onClick={() => shareAgentModal.toggle(true)}
<Card border="solid" rounding="lg">
<GeneralLayouts.Section>
<InputHorizontal
title="Share This Agent"
description="with other users, groups, or everyone in your organization."
center
>
Share
</Button>
</InputHorizontal>
{canUpdateFeaturedStatus && (
<>
<InputHorizontal
withLabel="is_featured"
title="Feature This Agent"
description="Show this agent at the top of the explore agents list and automatically pin it to the sidebar for new users with access."
<Button
prominence="secondary"
icon={isShared ? SvgUsers : SvgLock}
onClick={() => shareAgentModal.toggle(true)}
>
<SwitchField name="is_featured" />
</InputHorizontal>
{values.is_featured && !isShared && (
<MessageCard title="This agent is private to you and will only be featured for yourself." />
)}
</>
)}
Share
</Button>
</InputHorizontal>
{canUpdateFeaturedStatus && (
<>
<InputHorizontal
withLabel="is_featured"
title="Feature This Agent"
description="Show this agent at the top of the explore agents list and automatically pin it to the sidebar for new users with access."
>
<SwitchField name="is_featured" />
</InputHorizontal>
{values.is_featured && !isShared && (
<MessageCard title="This agent is private to you and will only be featured for yourself." />
)}
</>
)}
</GeneralLayouts.Section>
</Card>
<Card>
<InputHorizontal
withLabel="llm_model"
title="Default Model"
description="This model will be used by Onyx by default in your chats."
>
<LLMSelector
name="llm_model"
llmProviders={llmProviders ?? []}
currentLlm={getCurrentLlm(
values,
llmProviders
)}
onSelect={(selected) =>
onLlmSelect(selected, setFieldValue)
}
/>
</InputHorizontal>
<InputHorizontal
withLabel="knowledge_cutoff_date"
title="Knowledge Cutoff Date"
suffix="optional"
description="Documents with a last-updated date prior to this will be ignored."
>
<InputDatePickerField
name="knowledge_cutoff_date"
maxDate={new Date()}
/>
</InputHorizontal>
<InputHorizontal
withLabel="replace_base_system_prompt"
title="Overwrite System Prompt"
suffix="(Not Recommended)"
description='Remove the base system prompt which includes useful instructions (e.g. "You can use Markdown tables"). This may affect response quality.'
>
<SwitchField name="replace_base_system_prompt" />
</InputHorizontal>
<Card border="solid" rounding="lg">
<GeneralLayouts.Section>
<InputHorizontal
withLabel="llm_model"
title="Default Model"
description="This model will be used by Onyx by default in your chats."
>
<LLMSelector
name="llm_model"
llmProviders={llmProviders ?? []}
currentLlm={getCurrentLlm(
values,
llmProviders
)}
onSelect={(selected) =>
onLlmSelect(selected, setFieldValue)
}
/>
</InputHorizontal>
<InputHorizontal
withLabel="knowledge_cutoff_date"
title="Knowledge Cutoff Date"
suffix="optional"
description="Documents with a last-updated date prior to this will be ignored."
>
<InputDatePickerField
name="knowledge_cutoff_date"
maxDate={new Date()}
/>
</InputHorizontal>
<InputHorizontal
withLabel="replace_base_system_prompt"
title="Overwrite System Prompt"
suffix="(Not Recommended)"
description='Remove the base system prompt which includes useful instructions (e.g. "You can use Markdown tables"). This may affect response quality.'
>
<SwitchField name="replace_base_system_prompt" />
</InputHorizontal>
</GeneralLayouts.Section>
</Card>
<GeneralLayouts.Section gap={0.25}>
@@ -1605,7 +1643,7 @@ export default function AgentEditorPage({
paddingPerpendicular="fit"
/>
<Card>
<Card border="solid" rounding="lg">
<InputHorizontal
title="Delete This Agent"
description="Anyone using this agent will no longer be able to access it."

View File

@@ -53,7 +53,7 @@ import CharacterCount from "@/refresh-components/CharacterCount";
import { InputPrompt } from "@/app/app/interfaces";
import usePromptShortcuts from "@/hooks/usePromptShortcuts";
import ColorSwatch from "@/refresh-components/ColorSwatch";
import EmptyMessage from "@/refresh-components/EmptyMessage";
import { EmptyMessageCard } from "@opal/components";
import Memories from "@/sections/settings/Memories";
import { FederatedConnectorOAuthStatus } from "@/components/chat/FederatedOAuthModal";
import {
@@ -1701,7 +1701,10 @@ function ConnectorsSettings() {
))}
</>
) : (
<EmptyMessage title="No connectors set up for your organization." />
<EmptyMessageCard
sizePreset="main-ui"
title="No connectors set up for your organization."
/>
)}
</Section>
</Section>

View File

@@ -9,7 +9,6 @@ import { SWR_KEYS } from "@/lib/swr-keys";
import { errorHandlingFetcher } from "@/lib/fetcher";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { Section } from "@/layouts/general-layouts";
import Card from "@/refresh-components/cards/Card";
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
import { Tooltip } from "@opal/components";
import InputTextAreaField from "@/refresh-components/form/InputTextAreaField";
@@ -26,14 +25,20 @@ import {
SvgRefreshCw,
} from "@opal/icons";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { Content, InputHorizontal, InputVertical } from "@opal/layouts";
import {
Card as CardLayout,
Content,
ContentAction,
InputHorizontal,
InputVertical,
} from "@opal/layouts";
import {
useSettingsContext,
useVectorDbEnabled,
} from "@/providers/SettingsProvider";
import useCCPairs from "@/hooks/useCCPairs";
import { getSourceMetadata } from "@/lib/sources";
import EmptyMessage from "@/refresh-components/EmptyMessage";
import { EmptyMessageCard } from "@opal/components";
import { Settings } from "@/interfaces/settings";
import { toast } from "@/hooks/useToast";
import { useAvailableTools } from "@/hooks/useAvailableTools";
@@ -44,13 +49,11 @@ import {
PYTHON_TOOL_ID,
OPEN_URL_TOOL_ID,
} from "@/app/app/components/tools/constants";
import { Button, Divider, Text, Card as OpalCard } from "@opal/components";
import { Button, Divider, Text, Card } from "@opal/components";
import Modal from "@/refresh-components/Modal";
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, Hoverable } from "@opal/core";
import IconButton from "@/refresh-components/buttons/IconButton";
@@ -103,68 +106,94 @@ function MCPServerCard({
? "Authenticate this MCP server before enabling its tools."
: undefined;
const expanded = !isFolded;
const hasContent = tools.length > 0 && filteredTools.length > 0;
return (
<ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
<ActionsLayouts.Header
title={server.name}
description={server.description}
icon={getActionIcon(server.server_url, server.name)}
rightChildren={
<Tooltip tooltip={authTooltip} side="top">
<Switch
checked={serverEnabled}
onCheckedChange={(checked) => onToggleTools(allToolIds, checked)}
disabled={needsAuth}
/>
</Tooltip>
}
>
{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">
<Card
expandable
expanded={expanded}
border="solid"
rounding="lg"
padding="sm"
expandedContent={
hasContent ? (
<div className="flex flex-col gap-2 p-2">
{filteredTools.map((tool) => (
<ActionsLayouts.Tool
key={tool.id}
title={tool.name}
description={tool.description}
icon={tool.icon}
rightChildren={
<Tooltip tooltip={authTooltip} side="top">
<Switch
checked={isToolEnabled(tool.id)}
onCheckedChange={(checked) =>
onToggleTool(tool.id, checked)
}
disabled={needsAuth}
<Card key={tool.id} border="solid" rounding="lg" padding="sm">
<CardLayout.Header
headerChildren={
<Content
icon={tool.icon}
title={tool.name}
description={tool.description}
sizePreset="main-ui"
variant="section"
/>
</Tooltip>
}
/>
}
topRightChildren={
<Tooltip tooltip={authTooltip} side="top">
<Switch
checked={isToolEnabled(tool.id)}
onCheckedChange={(checked) =>
onToggleTool(tool.id, checked)
}
disabled={needsAuth}
/>
</Tooltip>
}
/>
</Card>
))}
</div>
</ActionsLayouts.Content>
)}
</ExpandableCard.Root>
) : undefined
}
>
<CardLayout.Header
headerChildren={
<ContentAction
icon={getActionIcon(server.server_url, server.name)}
title={server.name}
description={server.description}
sizePreset="main-ui"
variant="section"
paddingVariant="fit"
rightChildren={
<Tooltip tooltip={authTooltip} side="top">
<Switch
checked={serverEnabled}
onCheckedChange={(checked) =>
onToggleTools(allToolIds, checked)
}
disabled={needsAuth}
/>
</Tooltip>
}
/>
}
bottomChildren={
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>
) : undefined
}
/>
</Card>
);
}
@@ -304,10 +333,11 @@ function FileSizeLimitFields({
maxAllowedUploadSizeMb,
}: FileSizeLimitFieldsProps) {
return (
<div className="flex gap-4 w-full items-start">
<div className="flex gap-4 w-full items-start pt-2">
<div className="flex-1">
<InputVertical
title="File Size Limit (MB)"
title="File Size Limit"
suffix="(MB)"
subDescription={
maxAllowedUploadSizeMb
? `Max: ${maxAllowedUploadSizeMb} MB`
@@ -325,7 +355,11 @@ function FileSizeLimitFields({
</InputVertical>
</div>
<div className="flex-1">
<InputVertical title="File Token Limit (thousand tokens)" withLabel>
<InputVertical
title="File Token Limit"
withLabel
suffix="(thousand tokens)"
>
<NumericLimitField
name="file_token_count_threshold_k"
initialValue={initialTokenThresholdK}
@@ -339,7 +373,7 @@ function FileSizeLimitFields({
);
}
function ChatPreferencesForm() {
export default function ChatPreferencesPage() {
const router = useRouter();
const settings = useSettingsContext();
const s = settings.settings;
@@ -523,72 +557,67 @@ function ChatPreferencesForm() {
<SettingsLayouts.Body>
{/* Features */}
<Card>
<Tooltip
tooltip={
uniqueSources.length === 0
? "Set up connectors to use Search Mode"
: undefined
}
side="top"
>
<Disabled disabled={uniqueSources.length === 0} allowClick>
<div className="w-full">
<InputHorizontal
title="Search Mode"
tag={{ title: "beta", color: "blue" }}
description="UI mode for quick document search across your organization."
<Card border="solid" rounding="lg">
<Section>
<Disabled
disabled={uniqueSources.length === 0}
allowClick
tooltip="Set up connectors to use Search Mode"
>
<InputHorizontal
title="Search Mode"
tag={{ title: "beta", color: "blue" }}
description="UI mode for quick document search across your organization."
disabled={uniqueSources.length === 0}
withLabel
>
<Switch
checked={s.search_ui_enabled ?? true}
onCheckedChange={(checked) => {
void saveSettings({ search_ui_enabled: checked });
}}
disabled={uniqueSources.length === 0}
withLabel
>
<Switch
checked={s.search_ui_enabled ?? true}
onCheckedChange={(checked) => {
void saveSettings({ search_ui_enabled: checked });
}}
disabled={uniqueSources.length === 0}
/>
</InputHorizontal>
</div>
/>
</InputHorizontal>
</Disabled>
</Tooltip>
<InputHorizontal
title="Multi-Model Generation"
tag={{ title: "beta", color: "blue" }}
description="Allow multiple models to generate responses in parallel in chat."
withLabel
>
<Switch
checked={s.multi_model_chat_enabled ?? true}
onCheckedChange={(checked) => {
void saveSettings({ multi_model_chat_enabled: checked });
}}
/>
</InputHorizontal>
<InputHorizontal
title="Deep Research"
description="Agentic research system that works across the web and connected sources. Uses significantly more tokens per query."
withLabel
>
<Switch
checked={s.deep_research_enabled ?? true}
onCheckedChange={(checked) => {
void saveSettings({ deep_research_enabled: checked });
}}
/>
</InputHorizontal>
<InputHorizontal
title="Chat Auto-Scroll"
description="Automatically scroll to new content as chat generates response. Users can override this in their personal settings."
withLabel
>
<Switch
checked={s.auto_scroll ?? false}
onCheckedChange={(checked) => {
void saveSettings({ auto_scroll: checked });
}}
/>
</InputHorizontal>
<InputHorizontal
title="Multi-Model Generation"
tag={{ title: "beta", color: "blue" }}
description="Allow multiple models to generate responses in parallel in chat."
withLabel
>
<Switch
checked={s.multi_model_chat_enabled ?? true}
onCheckedChange={(checked) => {
void saveSettings({ multi_model_chat_enabled: checked });
}}
/>
</InputHorizontal>
<InputHorizontal
title="Deep Research"
description="Agentic research system that works across the web and connected sources. Uses significantly more tokens per query."
withLabel
>
<Switch
checked={s.deep_research_enabled ?? true}
onCheckedChange={(checked) => {
void saveSettings({ deep_research_enabled: checked });
}}
/>
</InputHorizontal>
<InputHorizontal
title="Chat Auto-Scroll"
description="Automatically scroll to new content as chat generates response. Users can override this in their personal settings."
withLabel
>
<Switch
checked={s.auto_scroll ?? false}
onCheckedChange={(checked) => {
void saveSettings({ auto_scroll: checked });
}}
/>
</InputHorizontal>
</Section>
</Card>
<Divider paddingParallel="fit" paddingPerpendicular="fit" />
@@ -672,7 +701,10 @@ function ChatPreferencesForm() {
gap={0.25}
>
{uniqueSources.length === 0 ? (
<EmptyMessage title="No connectors set up" />
<EmptyMessageCard
sizePreset="main-ui"
title="No connectors set up"
/>
) : (
<>
<Section
@@ -684,17 +716,15 @@ function ChatPreferencesForm() {
{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>
<div key={source} className="w-[10rem]">
<Card padding="sm" border="solid">
<Content
icon={meta.icon}
title={meta.displayName}
sizePreset="main-ui"
/>
</Card>
</div>
);
})}
</Section>
@@ -720,7 +750,7 @@ function ChatPreferencesForm() {
<SimpleCollapsible.Content>
<Section gap={0.5}>
{vectorDbEnabled && searchTool && (
<Card>
<Card border="solid" rounding="lg">
<InputHorizontal
title="Internal Search"
description="Search through your organization's connected knowledge base and documents."
@@ -736,15 +766,11 @@ function ChatPreferencesForm() {
</Card>
)}
<Tooltip
tooltip={
imageGenTool
? undefined
: "Image generation requires a configured model. Set one up under Configuration > Image Generation, or ask an admin."
}
side="top"
<Disabled
disabled={!imageGenTool}
tooltip="Image generation requires a configured model. Set one up under Configuration > Image Generation, or ask an admin."
>
<Card variant={imageGenTool ? undefined : "disabled"}>
<Card border="solid" rounding="lg">
<InputHorizontal
title="Image Generation"
description="Generate and manipulate images using AI-powered tools."
@@ -765,75 +791,79 @@ function ChatPreferencesForm() {
/>
</InputHorizontal>
</Card>
</Tooltip>
</Disabled>
<Card variant={webSearchTool ? undefined : "disabled"}>
<InputHorizontal
title="Web Search"
description="Search the web for real-time information and up-to-date results."
disabled={!webSearchTool}
withLabel
>
<Switch
checked={
webSearchTool
? isToolEnabled(webSearchTool.id)
: false
}
onCheckedChange={(checked) =>
webSearchTool &&
void toggleTool(webSearchTool.id, checked)
}
<Disabled disabled={!webSearchTool}>
<Card border="solid" rounding="lg">
<InputHorizontal
title="Web Search"
description="Search the web for real-time information and up-to-date results."
disabled={!webSearchTool}
/>
</InputHorizontal>
</Card>
withLabel
>
<Switch
checked={
webSearchTool
? isToolEnabled(webSearchTool.id)
: false
}
onCheckedChange={(checked) =>
webSearchTool &&
void toggleTool(webSearchTool.id, checked)
}
disabled={!webSearchTool}
/>
</InputHorizontal>
</Card>
</Disabled>
<Card variant={openURLTool ? undefined : "disabled"}>
<InputHorizontal
title="Open URL"
description="Fetch and read content from web URLs."
disabled={!openURLTool}
withLabel
>
<Switch
checked={
openURLTool
? isToolEnabled(openURLTool.id)
: false
}
onCheckedChange={(checked) =>
openURLTool &&
void toggleTool(openURLTool.id, checked)
}
<Disabled disabled={!openURLTool}>
<Card border="solid" rounding="lg">
<InputHorizontal
title="Open URL"
description="Fetch and read content from web URLs."
disabled={!openURLTool}
/>
</InputHorizontal>
</Card>
withLabel
>
<Switch
checked={
openURLTool
? isToolEnabled(openURLTool.id)
: false
}
onCheckedChange={(checked) =>
openURLTool &&
void toggleTool(openURLTool.id, checked)
}
disabled={!openURLTool}
/>
</InputHorizontal>
</Card>
</Disabled>
<Card
variant={codeInterpreterTool ? undefined : "disabled"}
>
<InputHorizontal
title="Code Interpreter"
description="Generate and run code."
disabled={!codeInterpreterTool}
withLabel
>
<Switch
checked={
codeInterpreterTool
? isToolEnabled(codeInterpreterTool.id)
: false
}
onCheckedChange={(checked) =>
codeInterpreterTool &&
void toggleTool(codeInterpreterTool.id, checked)
}
<Disabled disabled={!codeInterpreterTool}>
<Card border="solid" rounding="lg">
<InputHorizontal
title="Code Interpreter"
description="Generate and run code."
disabled={!codeInterpreterTool}
/>
</InputHorizontal>
</Card>
withLabel
>
<Switch
checked={
codeInterpreterTool
? isToolEnabled(codeInterpreterTool.id)
: false
}
onCheckedChange={(checked) =>
codeInterpreterTool &&
void toggleTool(codeInterpreterTool.id, checked)
}
disabled={!codeInterpreterTool}
/>
</InputHorizontal>
</Card>
</Disabled>
</Section>
{/* Separator between built-in tools and MCP/OpenAPI tools */}
@@ -858,12 +888,23 @@ function ChatPreferencesForm() {
/>
))}
{openApiTools.map((tool) => (
<ExpandableCard.Root key={tool.id} defaultFolded>
<ActionsLayouts.Header
title={tool.display_name || tool.name}
description={tool.description}
icon={SvgActions}
rightChildren={
<Card
key={tool.id}
border="solid"
rounding="lg"
padding="sm"
>
<CardLayout.Header
headerChildren={
<Content
icon={SvgActions}
title={tool.display_name || tool.name}
description={tool.description}
sizePreset="main-ui"
variant="section"
/>
}
topRightChildren={
<Switch
checked={isToolEnabled(tool.id)}
onCheckedChange={(checked) =>
@@ -872,7 +913,7 @@ function ChatPreferencesForm() {
/>
}
/>
</ExpandableCard.Root>
</Card>
))}
</Section>
</SimpleCollapsible.Content>
@@ -888,7 +929,7 @@ function ChatPreferencesForm() {
<SimpleCollapsible.Header title="Advanced Options" />
<SimpleCollapsible.Content>
<Section gap={1}>
<Card>
<Card border="solid" rounding="lg">
<InputHorizontal
title="Keep Chat History"
description="Specify how long Onyx should retain chats in your organization."
@@ -921,7 +962,7 @@ function ChatPreferencesForm() {
</InputHorizontal>
</Card>
<Card>
<Card border="solid" rounding="lg">
<InputVertical
title="File Attachment Size Limit"
description="Files attached in chats and projects must fit within both limits to be accepted. Larger files increase latency, memory usage, and token costs."
@@ -956,35 +997,39 @@ function ChatPreferencesForm() {
</InputVertical>
</Card>
<Card>
<InputHorizontal
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."
withLabel
>
<Switch
checked={s.anonymous_user_enabled ?? false}
onCheckedChange={(checked) => {
void saveSettings({ anonymous_user_enabled: checked });
}}
/>
</InputHorizontal>
<Card border="solid" rounding="lg">
<Section>
<InputHorizontal
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."
withLabel
>
<Switch
checked={s.anonymous_user_enabled ?? false}
onCheckedChange={(checked) => {
void saveSettings({
anonymous_user_enabled: checked,
});
}}
/>
</InputHorizontal>
<InputHorizontal
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."
withLabel
>
<Switch
id="disable_default_assistant"
checked={s.disable_default_assistant ?? false}
onCheckedChange={(checked) => {
void saveSettings({
disable_default_assistant: checked,
});
}}
/>
</InputHorizontal>
<InputHorizontal
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."
withLabel
>
<Switch
id="disable_default_assistant"
checked={s.disable_default_assistant ?? false}
onCheckedChange={(checked) => {
void saveSettings({
disable_default_assistant: checked,
});
}}
/>
</InputHorizontal>
</Section>
</Card>
</Section>
</SimpleCollapsible.Content>
@@ -1046,14 +1091,14 @@ function ChatPreferencesForm() {
)}
</Text>
</Section>
<OpalCard background="none" border="solid" padding="sm">
<Card background="none" border="solid" padding="sm">
<Content
sizePreset="main-ui"
icon={SvgAlertCircle}
title="Modify with caution."
description="System prompt affects all chats, agents, and projects. Significant changes may degrade response quality."
/>
</OpalCard>
</Card>
</Modal.Body>
<Modal.Footer>
<Button
@@ -1078,7 +1123,3 @@ function ChatPreferencesForm() {
</>
);
}
export default function ChatPreferencesPage() {
return <ChatPreferencesForm />;
}

View File

@@ -13,7 +13,7 @@ import {
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { Section } from "@/layouts/general-layouts";
import { Button, SelectCard } from "@opal/components";
import { Card } from "@opal/layouts";
import { Card, Content } from "@opal/layouts";
import { Disabled, Hoverable } from "@opal/core";
import Text from "@/refresh-components/texts/Text";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
@@ -114,12 +114,16 @@ export default function CodeInterpreterPage() {
<Hoverable.Root group="code-interpreter/Card">
<SelectCard state="filled" padding="sm" rounding="lg">
<Card.Header
sizePreset="main-ui"
variant="section"
icon={SvgTerminal}
title="Code Interpreter"
description="Built-in Python runtime"
rightChildren={
headerChildren={
<Content
sizePreset="main-ui"
variant="section"
icon={SvgTerminal}
title="Code Interpreter"
description="Built-in Python runtime"
/>
}
topRightChildren={
<ConnectionStatus healthy={isHealthy} isLoading={isLoading} />
}
bottomRightChildren={
@@ -162,12 +166,16 @@ export default function CodeInterpreterPage() {
onClick={() => handleToggle(true)}
>
<Card.Header
sizePreset="main-ui"
variant="section"
icon={SvgTerminal}
title="Code Interpreter (Disconnected)"
description="Built-in Python runtime"
rightChildren={
headerChildren={
<Content
sizePreset="main-ui"
variant="section"
icon={SvgTerminal}
title="Code Interpreter (Disconnected)"
description="Built-in Python runtime"
/>
}
topRightChildren={
<Section flexDirection="row" alignItems="center" padding={0.5}>
{isReconnecting ? (
<CheckingStatus />

View File

@@ -146,6 +146,7 @@ function SharedGroupResources({
interactive={!dimmed}
muted={dimmed}
icon={getSourceMetadata(p.connector.source).icon}
strokeIcon={false}
rightChildren={
p.groups.length > 0 || dimmed ? <SharedBadge /> : undefined
}

View File

@@ -256,17 +256,21 @@ export default function ImageGenerationContent() {
}
>
<Card.Header
sizePreset="main-ui"
variant="section"
icon={() => (
<ModelIcon
provider={provider.provider_name}
size={16}
headerChildren={
<Content
sizePreset="main-ui"
variant="section"
icon={() => (
<ModelIcon
provider={provider.provider_name}
size={16}
/>
)}
title={provider.title}
description={provider.description}
/>
)}
title={provider.title}
description={provider.description}
rightChildren={
}
topRightChildren={
isDisconnected ? (
<Button
prominence="tertiary"

View File

@@ -141,13 +141,19 @@ function ExistingProviderCard({
onClick={() => setIsOpen(true)}
>
<CardLayout.Header
icon={icon}
title={provider.name}
description={companyName}
sizePreset="main-ui"
variant="section"
tag={isDefault ? { title: "Default", color: "blue" } : undefined}
rightChildren={
headerChildren={
<Content
icon={icon}
title={provider.name}
description={companyName}
sizePreset="main-ui"
variant="section"
tag={
isDefault ? { title: "Default", color: "blue" } : undefined
}
/>
}
topRightChildren={
<div className="flex flex-row">
<Hoverable.Item
group="ExistingProviderCard"
@@ -205,12 +211,16 @@ function NewProviderCard({
onClick={() => setIsOpen(true)}
>
<CardLayout.Header
icon={icon}
title={productName}
description={companyName}
sizePreset="main-ui"
variant="section"
rightChildren={
headerChildren={
<Content
icon={icon}
title={productName}
description={companyName}
sizePreset="main-ui"
variant="section"
/>
}
topRightChildren={
<Button
rightIcon={SvgArrowExchange}
prominence="tertiary"
@@ -252,12 +262,16 @@ function NewCustomProviderCard({
onClick={() => setIsOpen(true)}
>
<CardLayout.Header
icon={icon}
title={productName}
description={companyName}
sizePreset="main-ui"
variant="section"
rightChildren={
headerChildren={
<Content
icon={icon}
title={productName}
description={companyName}
sizePreset="main-ui"
variant="section"
/>
}
topRightChildren={
<Button
rightIcon={SvgArrowExchange}
prominence="tertiary"

View File

@@ -186,6 +186,7 @@ export default function UserFilters({
<LineItem
key={role}
icon={isSelected ? SvgCheck : roleIcon}
strokeIcon={isSelected || role !== UserRole.SLACK_USER}
selected={isSelected}
emphasized={isSelected}
onClick={() => toggleRole(role)}

View File

@@ -277,12 +277,16 @@ function ProviderCard({
}
>
<Card.Header
sizePreset="main-ui"
variant="section"
icon={icon}
title={title}
description={description}
rightChildren={
headerChildren={
<Content
sizePreset="main-ui"
variant="section"
icon={icon}
title={title}
description={description}
/>
}
topRightChildren={
isDisconnected && onConnect ? (
<Button
prominence="tertiary"

View File

@@ -33,7 +33,7 @@ import {
import InfoBlock from "@/refresh-components/messages/InfoBlock";
import { getActionIcon } from "@/lib/tools/mcpUtils";
import { Section } from "@/layouts/general-layouts";
import EmptyMessage from "@/refresh-components/EmptyMessage";
import { EmptyMessageCard } from "@opal/components";
interface AddOpenAPIActionModalProps {
skipOverlay?: boolean;
@@ -312,7 +312,8 @@ function FormContent({
</Section>
</>
) : (
<EmptyMessage
<EmptyMessageCard
sizePreset="main-ui"
title="No Actions Found"
icon={SvgActions}
description="Provide OpenAPI schema to preview actions here."

View File

@@ -93,12 +93,16 @@ export default function ProviderCard({
onClick={isDisconnected && onConnect ? onConnect : undefined}
>
<Card.Header
sizePreset="main-ui"
variant="section"
icon={icon}
title={title}
description={description}
rightChildren={
headerChildren={
<Content
sizePreset="main-ui"
variant="section"
icon={icon}
title={title}
description={description}
/>
}
topRightChildren={
isDisconnected && onConnect ? (
<Button
prominence="tertiary"

View File

@@ -129,6 +129,7 @@ function KnowledgeSidebar({
<LineItem
key={connectedSource.source}
icon={sourceMetadata.icon}
strokeIcon={false}
onClick={() => onNavigateToSource(connectedSource.source)}
selected={isActive}
emphasized={isActive || isSelected || selectionCount > 0}
@@ -718,6 +719,7 @@ const KnowledgeAddView = memo(function KnowledgeAddView({
<LineItem
key={connectedSource.source}
icon={sourceMetadata.icon}
strokeIcon={false}
onClick={() => onNavigateToSource(connectedSource.source)}
emphasized={isSelected || selectionCount > 0}
aria-label={`knowledge-add-source-${connectedSource.source}`}

View File

@@ -7,10 +7,15 @@ import { FullPersona } from "@/app/admin/agents/interfaces";
import { useModal } from "@/refresh-components/contexts/ModalContext";
import Modal from "@/refresh-components/Modal";
import { Section } from "@/layouts/general-layouts";
import { Content, ContentAction, InputHorizontal } from "@opal/layouts";
import {
Card as CardLayout,
Content,
ContentAction,
InputHorizontal,
} from "@opal/layouts";
import Text from "@/refresh-components/texts/Text";
import AgentAvatar from "@/refresh-components/avatars/AgentAvatar";
import { Divider } from "@opal/components";
import { Card, Divider } from "@opal/components";
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
import {
SvgActions,
@@ -21,12 +26,10 @@ import {
SvgStar,
SvgUser,
} from "@opal/icons";
import * as ExpandableCard from "@/layouts/expandable-card-layouts";
import * as ActionsLayouts from "@/layouts/actions-layouts";
import useMcpServersForAgentEditor from "@/hooks/useMcpServersForAgentEditor";
import { getActionIcon } from "@/lib/tools/mcpUtils";
import { MCPServer, ToolSnapshot } from "@/lib/tools/interfaces";
import EmptyMessage from "@/refresh-components/EmptyMessage";
import { EmptyMessageCard } from "@opal/components";
import Switch from "@/refresh-components/inputs/Switch";
import { Button } from "@opal/components";
import { SEARCH_PARAM_NAMES } from "@/app/app/services/searchParams";
@@ -50,46 +53,55 @@ interface ViewerMCPServerCardProps {
}
function ViewerMCPServerCard({ server, tools }: ViewerMCPServerCardProps) {
const [folded, setFolded] = useState(false);
const [expanded, setExpanded] = useState(true);
const serverIcon = getActionIcon(server.server_url, server.name);
return (
<ExpandableCard.Root isFolded={folded} onFoldedChange={setFolded}>
<ExpandableCard.Header>
<div className="p-2">
<Card
expandable
expanded={expanded}
border="solid"
rounding="lg"
padding="sm"
expandedContent={
tools.length > 0 ? (
<div className="flex flex-col gap-2 p-2">
{tools.map((tool) => (
<Section key={tool.id} padding={0.25}>
<Content
title={tool.display_name}
description={tool.description}
sizePreset="main-ui"
variant="section"
/>
</Section>
))}
</div>
) : undefined
}
>
<CardLayout.Header
headerChildren={
<ContentAction
icon={serverIcon}
title={server.name}
description={server.description}
sizePreset="main-ui"
variant="section"
rightChildren={
<Button
prominence="internal"
rightIcon={folded ? SvgExpand : SvgFold}
onClick={() => setFolded((prev) => !prev)}
>
{folded ? "Expand" : "Fold"}
</Button>
}
paddingVariant="fit"
/>
</div>
</ExpandableCard.Header>
{tools.length > 0 && (
<ActionsLayouts.Content>
{tools.map((tool) => (
<Section key={tool.id} padding={0.25}>
<Content
title={tool.display_name}
description={tool.description}
sizePreset="main-ui"
variant="section"
/>
</Section>
))}
</ActionsLayouts.Content>
)}
</ExpandableCard.Root>
}
topRightChildren={
<Button
prominence="internal"
rightIcon={expanded ? SvgFold : SvgExpand}
onClick={() => setExpanded((prev) => !prev)}
>
{expanded ? "Fold" : "Expand"}
</Button>
}
/>
</Card>
);
}
@@ -99,9 +111,9 @@ function ViewerMCPServerCard({ server, tools }: ViewerMCPServerCardProps) {
*/
function ViewerOpenApiToolCard({ tool }: { tool: ToolSnapshot }) {
return (
<ExpandableCard.Root>
<ExpandableCard.Header>
<div className="p-2">
<Card border="solid" rounding="lg" padding="sm">
<CardLayout.Header
headerChildren={
<Content
icon={SvgActions}
title={tool.display_name}
@@ -109,9 +121,9 @@ function ViewerOpenApiToolCard({ tool }: { tool: ToolSnapshot }) {
sizePreset="main-ui"
variant="section"
/>
</div>
</ExpandableCard.Header>
</ExpandableCard.Root>
}
/>
</Card>
);
}
@@ -307,7 +319,7 @@ export default function AgentViewerModal({ agent }: AgentViewerModalProps) {
})}
</Section>
) : (
<EmptyMessage title="No Knowledge" />
<EmptyMessageCard sizePreset="main-ui" title="No Knowledge" />
)}
</Section>
@@ -329,7 +341,7 @@ export default function AgentViewerModal({ agent }: AgentViewerModalProps) {
))}
</Section>
) : (
<EmptyMessage title="No Actions" />
<EmptyMessageCard sizePreset="main-ui" title="No Actions" />
)}
</SimpleCollapsible.Content>
</SimpleCollapsible>

View File

@@ -226,14 +226,14 @@ test("Tool OAuth Configuration: Creation, Selection, and Assistant Integration",
await expect(actionsHeading).toBeVisible({ timeout: 10000 });
await actionsHeading.scrollIntoViewIfNeeded();
// Look for our tool in the list
// The tool display_name is the tool name we created
const toolLabel = page.locator(`label:has-text("${toolName}")`);
await expect(toolLabel).toBeVisible({ timeout: 10000 });
await toolLabel.scrollIntoViewIfNeeded();
// Turn it on
await toolLabel.click();
// Look for our tool's card and toggle it on
const toolCard = page
.locator(".opal-card")
.filter({ hasText: toolName })
.first();
await expect(toolCard).toBeVisible({ timeout: 10000 });
await toolCard.scrollIntoViewIfNeeded();
await toolCard.getByRole("switch").click();
// Submit the assistant creation form
const createButton = page.locator('button[type="submit"]:has-text("Create")');

View File

@@ -342,22 +342,19 @@ test.describe("Default Agent MCP Integration", () => {
// Scroll to the Actions & Tools section (open by default)
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 });
// Find the MCP server card by name text (expandable card)
const serverCard = page
.locator(".opal-card-expandable")
.filter({ hasText: serverName })
.first();
await expect(serverCard).toBeVisible({ timeout: 10000 });
console.log(`[test] MCP server card found for server: ${serverName}`);
// Scroll server card into view
await serverLabel.first().scrollIntoViewIfNeeded();
await serverCard.scrollIntoViewIfNeeded();
// The server-level Switch in the header toggles ALL tools
const serverSwitch = serverLabel
.first()
.locator('button[role="switch"]')
.first();
const serverSwitch = serverCard.getByRole("switch").first();
await expect(serverSwitch).toBeVisible({ timeout: 5000 });
// Enable all tools by toggling the server switch ON
@@ -643,12 +640,13 @@ test.describe("Default Agent MCP Integration", () => {
// Scroll to Actions & Tools 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 card by name (expandable card)
const serverCard = page
.locator(".opal-card-expandable")
.filter({ hasText: serverName })
.first();
await expect(serverCard).toBeVisible({ timeout: 10000 });
await serverCard.scrollIntoViewIfNeeded();
// Click "Expand" to reveal individual tools
const expandButton = page.getByRole("button", { name: "Expand" }).first();
@@ -660,14 +658,11 @@ test.describe("Default Agent MCP Integration", () => {
}
// 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"]')
const toolCard = page
.locator(".opal-card")
.filter({ hasText: "tool_0" })
.first();
const firstToolSwitch = toolCard.getByRole("switch").first();
await expect(firstToolSwitch).toBeVisible({ timeout: 5000 });
await firstToolSwitch.scrollIntoViewIfNeeded();
@@ -688,12 +683,13 @@ test.describe("Default Agent MCP Integration", () => {
await page.waitForURL("**/admin/configuration/chat-preferences**");
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 server card (expandable card)
const serverCardAfter = page
.locator(".opal-card-expandable")
.filter({ hasText: serverName })
.first();
await expect(serverCardAfter).toBeVisible({ timeout: 10000 });
await serverCardAfter.scrollIntoViewIfNeeded();
// Re-expand the card
const expandButtonAfter = page
@@ -708,13 +704,11 @@ test.describe("Default Agent MCP Integration", () => {
}
// 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"]')
const toolCardAfter = page
.locator(".opal-card")
.filter({ hasText: "tool_0" })
.first();
const firstToolSwitchAfter = toolCardAfter.getByRole("switch").first();
await expect(firstToolSwitchAfter).toBeVisible({ timeout: 5000 });
const finalChecked =
await firstToolSwitchAfter.getAttribute("aria-checked");

View File

@@ -42,6 +42,7 @@ for (const theme of THEMES) {
page
.locator(".opal-content-md-header")
.filter({ hasText: expectedHeader })
.first()
).toBeVisible({ timeout: 10_000 });
} else {
await page.waitForLoadState("networkidle");