mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-18 16:06:45 +00:00
Compare commits
13 Commits
dane/infer
...
jamison/wo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eb945f060 | ||
|
|
e29f948f29 | ||
|
|
7a18b896aa | ||
|
|
53e00c7989 | ||
|
|
50df53727a | ||
|
|
e629574580 | ||
|
|
8d539cdf3f | ||
|
|
52524cbe57 | ||
|
|
c64def6a9e | ||
|
|
2628fe1b93 | ||
|
|
96bf344f9c | ||
|
|
b92d3a307d | ||
|
|
c55207eeba |
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -696,7 +696,6 @@ def upsert_documents(
|
||||
else {}
|
||||
),
|
||||
doc_metadata=doc.doc_metadata,
|
||||
file_id=doc.file_id,
|
||||
)
|
||||
)
|
||||
for doc in seen_documents.values()
|
||||
@@ -713,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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,82 +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 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,
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -443,7 +443,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
|
||||
@@ -679,7 +679,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
|
||||
|
||||
@@ -218,7 +218,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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
22
uv.lock
generated
22
uv.lock
generated
@@ -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]]
|
||||
@@ -3278,14 +3278,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]]
|
||||
@@ -4430,7 +4430,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 +4453,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 +5703,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]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
60
web/lib/opal/src/components/buttons/link-button/README.md
Normal file
60
web/lib/opal/src/components/buttons/link-button/README.md
Normal 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>
|
||||
```
|
||||
117
web/lib/opal/src/components/buttons/link-button/components.tsx
Normal file
117
web/lib/opal/src/components/buttons/link-button/components.tsx
Normal 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 };
|
||||
31
web/lib/opal/src/components/buttons/link-button/styles.css
Normal file
31
web/lib/opal/src/components/buttons/link-button/styles.css
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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." />
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
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,
|
||||
RichStr,
|
||||
StatusVariants,
|
||||
} from "@opal/types";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Button, Divider } from "@opal/components";
|
||||
import {
|
||||
@@ -15,11 +20,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;
|
||||
@@ -59,7 +62,7 @@ type MessageCardProps = MessageCardBaseProps &
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VARIANT_CONFIG: Record<
|
||||
MessageCardVariant,
|
||||
StatusVariants,
|
||||
{ icon: IconFunctionComponent; iconClass: string }
|
||||
> = {
|
||||
default: { icon: SvgAlertCircle, iconClass: "stroke-text-03" },
|
||||
@@ -157,4 +160,4 @@ function MessageCard({
|
||||
);
|
||||
}
|
||||
|
||||
export { MessageCard, type MessageCardProps, type MessageCardVariant };
|
||||
export { MessageCard, type MessageCardProps };
|
||||
|
||||
32
web/lib/opal/src/components/cards/shared.css
Normal file
32
web/lib/opal/src/components/cards/shared.css
Normal 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;
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -99,6 +99,7 @@ export default function SwitchList({
|
||||
item.leading) as React.FunctionComponent<IconProps>)
|
||||
: undefined
|
||||
}
|
||||
strokeIcon={false}
|
||||
rightChildren={
|
||||
<Switch
|
||||
checked={item.isEnabled}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,7 @@ 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 +77,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 +224,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 +280,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 +325,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 +1419,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 +1436,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 +1541,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 +1644,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."
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -26,14 +26,19 @@ import {
|
||||
SvgRefreshCw,
|
||||
} from "@opal/icons";
|
||||
import { ADMIN_ROUTES } from "@/lib/admin-routes";
|
||||
import { Content, InputHorizontal, InputVertical } from "@opal/layouts";
|
||||
import {
|
||||
Card as CardLayout,
|
||||
Content,
|
||||
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";
|
||||
@@ -49,8 +54,6 @@ 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,13 +106,60 @@ 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={
|
||||
<OpalCard
|
||||
expandable
|
||||
expanded={expanded}
|
||||
border="solid"
|
||||
rounding="lg"
|
||||
padding="sm"
|
||||
expandedContent={
|
||||
hasContent ? (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{filteredTools.map((tool) => (
|
||||
<OpalCard 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"
|
||||
/>
|
||||
}
|
||||
topRightChildren={
|
||||
<Tooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={isToolEnabled(tool.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleTool(tool.id, checked)
|
||||
}
|
||||
disabled={needsAuth}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</OpalCard>
|
||||
))}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<Content
|
||||
icon={getActionIcon(server.server_url, server.name)}
|
||||
title={server.name}
|
||||
description={server.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
}
|
||||
topRightChildren={
|
||||
<Tooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={serverEnabled}
|
||||
@@ -118,53 +168,29 @@ function MCPServerCard({
|
||||
/>
|
||||
</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">
|
||||
{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}
|
||||
/>
|
||||
</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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ActionsLayouts.Content>
|
||||
)}
|
||||
</ExpandableCard.Root>
|
||||
<Button
|
||||
rightIcon={isFolded ? SvgExpand : SvgFold}
|
||||
onClick={() => setIsFolded((prev) => !prev)}
|
||||
prominence="internal"
|
||||
size="lg"
|
||||
>
|
||||
{isFolded ? "Expand" : "Fold"}
|
||||
</Button>
|
||||
</Section>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</OpalCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -672,7 +698,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
|
||||
@@ -858,12 +887,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={
|
||||
<OpalCard
|
||||
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 +912,7 @@ function ChatPreferencesForm() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ExpandableCard.Root>
|
||||
</OpalCard>
|
||||
))}
|
||||
</Section>
|
||||
</SimpleCollapsible.Content>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")');
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user