mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-17 23:46:47 +00:00
Compare commits
18 Commits
dane/infer
...
jamison/ti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e69f66705 | ||
|
|
66c361bd37 | ||
|
|
01cbea8c4b | ||
|
|
2dc2b0da84 | ||
|
|
4b58c9cda6 | ||
|
|
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")
|
||||
@@ -15,7 +15,7 @@ from onyx.background.celery.tasks.shared.RetryDocumentIndex import RetryDocument
|
||||
from onyx.configs.constants import ONYX_CELERY_BEAT_HEARTBEAT_KEY
|
||||
from onyx.configs.constants import OnyxCeleryTask
|
||||
from onyx.db.document import delete_document_by_connector_credential_pair__no_commit
|
||||
from onyx.db.document import delete_documents_complete
|
||||
from onyx.db.document import delete_documents_complete__no_commit
|
||||
from onyx.db.document import fetch_chunk_count_for_document
|
||||
from onyx.db.document import get_document
|
||||
from onyx.db.document import get_document_connector_count
|
||||
@@ -129,10 +129,11 @@ def document_by_cc_pair_cleanup_task(
|
||||
document_id=document_id,
|
||||
)
|
||||
|
||||
delete_documents_complete(
|
||||
delete_documents_complete__no_commit(
|
||||
db_session=db_session,
|
||||
document_ids=[document_id],
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
completion_status = OnyxCeleryTaskCompletionStatus.SUCCEEDED
|
||||
elif count > 1:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -93,6 +93,7 @@ from onyx.llm.factory import get_llm_for_persona
|
||||
from onyx.llm.factory import get_llm_token_counter
|
||||
from onyx.llm.interfaces import LLM
|
||||
from onyx.llm.interfaces import LLMUserIdentity
|
||||
from onyx.llm.multi_llm import LLMTimeoutError
|
||||
from onyx.llm.override_models import LLMOverride
|
||||
from onyx.llm.request_context import reset_llm_mock_response
|
||||
from onyx.llm.request_context import set_llm_mock_response
|
||||
@@ -1166,6 +1167,32 @@ def _run_models(
|
||||
else:
|
||||
if item is _MODEL_DONE:
|
||||
models_remaining -= 1
|
||||
elif isinstance(item, LLMTimeoutError):
|
||||
model_llm = setup.llms[model_idx]
|
||||
error_msg = (
|
||||
"The LLM took too long to respond. "
|
||||
"If you're running a local model, try increasing the "
|
||||
"LLM_SOCKET_READ_TIMEOUT environment variable "
|
||||
"(current default: 120 seconds)."
|
||||
)
|
||||
stack_trace = "".join(
|
||||
traceback.format_exception(type(item), item, item.__traceback__)
|
||||
)
|
||||
if model_llm.config.api_key and len(model_llm.config.api_key) > 2:
|
||||
stack_trace = stack_trace.replace(
|
||||
model_llm.config.api_key, "[REDACTED_API_KEY]"
|
||||
)
|
||||
yield StreamingError(
|
||||
error=error_msg,
|
||||
stack_trace=stack_trace,
|
||||
error_code="CONNECTION_ERROR",
|
||||
is_retryable=True,
|
||||
details={
|
||||
"model": model_llm.config.model_name,
|
||||
"provider": model_llm.config.model_provider,
|
||||
"model_index": model_idx,
|
||||
},
|
||||
)
|
||||
elif isinstance(item, Exception):
|
||||
# Yield a tagged error for this model but keep the other models running.
|
||||
# Do NOT decrement models_remaining — _run_model's finally always posts
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -81,9 +81,7 @@ class ZulipConnector(LoadConnector, PollConnector):
|
||||
# zuliprc file. This reverts them back to newlines.
|
||||
contents_spaces_to_newlines = contents.replace(" ", "\n")
|
||||
# create a temporary zuliprc file
|
||||
tempdir = tempfile.tempdir
|
||||
if tempdir is None:
|
||||
raise Exception("Could not determine tempfile directory")
|
||||
tempdir = tempfile.gettempdir()
|
||||
config_file = os.path.join(tempdir, f"zuliprc-{self.realm_name}")
|
||||
with open(config_file, "w") as f:
|
||||
f.write(contents_spaces_to_newlines)
|
||||
|
||||
@@ -52,7 +52,6 @@ from onyx.db.utils import DocumentRow
|
||||
from onyx.db.utils import model_to_dict
|
||||
from onyx.db.utils import SortOrder
|
||||
from onyx.document_index.interfaces import DocumentMetadata
|
||||
from onyx.file_store.staging import delete_files_best_effort
|
||||
from onyx.kg.models import KGStage
|
||||
from onyx.server.documents.models import ConnectorCredentialPairIdentifier
|
||||
from onyx.utils.logger import setup_logger
|
||||
@@ -697,7 +696,6 @@ def upsert_documents(
|
||||
else {}
|
||||
),
|
||||
doc_metadata=doc.doc_metadata,
|
||||
file_id=doc.file_id,
|
||||
)
|
||||
)
|
||||
for doc in seen_documents.values()
|
||||
@@ -714,7 +712,6 @@ def upsert_documents(
|
||||
"secondary_owners": insert_stmt.excluded.secondary_owners,
|
||||
"doc_metadata": insert_stmt.excluded.doc_metadata,
|
||||
"parent_hierarchy_node_id": insert_stmt.excluded.parent_hierarchy_node_id,
|
||||
"file_id": insert_stmt.excluded.file_id,
|
||||
}
|
||||
if includes_permissions:
|
||||
# Use COALESCE to preserve existing permissions when new values are NULL.
|
||||
@@ -928,26 +925,6 @@ def delete_documents__no_commit(db_session: Session, document_ids: list[str]) ->
|
||||
db_session.execute(delete(DbDocument).where(DbDocument.id.in_(document_ids)))
|
||||
|
||||
|
||||
def get_file_ids_for_document_ids(
|
||||
db_session: Session,
|
||||
document_ids: list[str],
|
||||
) -> list[str]:
|
||||
"""Return the non-null `file_id` values attached to the given documents.
|
||||
|
||||
Used at deletion time to enumerate raw files that need to be reaped from
|
||||
the file store once their owning document rows are gone.
|
||||
"""
|
||||
if not document_ids:
|
||||
return []
|
||||
rows = (
|
||||
db_session.query(DbDocument.file_id)
|
||||
.filter(DbDocument.id.in_(document_ids))
|
||||
.filter(DbDocument.file_id.isnot(None))
|
||||
.all()
|
||||
)
|
||||
return [row.file_id for row in rows]
|
||||
|
||||
|
||||
def delete_documents_complete__no_commit(
|
||||
db_session: Session, document_ids: list[str]
|
||||
) -> None:
|
||||
@@ -991,32 +968,6 @@ def delete_documents_complete__no_commit(
|
||||
delete_documents__no_commit(db_session, document_ids)
|
||||
|
||||
|
||||
def delete_documents_complete(
|
||||
db_session: Session,
|
||||
document_ids: list[str],
|
||||
) -> None:
|
||||
"""Fully remove documents AND best-effort delete their attached files.
|
||||
|
||||
This is the canonical path for "I'm done with these docs" — it captures
|
||||
file_ids, removes the rows + every FK they hold, commits, then reaps
|
||||
files. The order matters: file deletion happens after commit so a DB
|
||||
rollback can never leave a `document` row pointing at a missing file.
|
||||
|
||||
Use this instead of `delete_documents_complete__no_commit` unless you
|
||||
specifically need to compose with other operations in one transaction.
|
||||
"""
|
||||
file_ids_to_delete = get_file_ids_for_document_ids(
|
||||
db_session=db_session,
|
||||
document_ids=document_ids,
|
||||
)
|
||||
delete_documents_complete__no_commit(
|
||||
db_session=db_session,
|
||||
document_ids=document_ids,
|
||||
)
|
||||
db_session.commit()
|
||||
delete_files_best_effort(file_ids_to_delete)
|
||||
|
||||
|
||||
def delete_all_documents_for_connector_credential_pair(
|
||||
db_session: Session,
|
||||
connector_id: int,
|
||||
@@ -1048,9 +999,10 @@ def delete_all_documents_for_connector_credential_pair(
|
||||
if not document_ids:
|
||||
break
|
||||
|
||||
delete_documents_complete(
|
||||
delete_documents_complete__no_commit(
|
||||
db_session=db_session, document_ids=list(document_ids)
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
if time.monotonic() - start_time > timeout:
|
||||
raise RuntimeError("Timeout reached while deleting documents")
|
||||
|
||||
@@ -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,103 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from typing import IO
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.configs.constants import FileOrigin
|
||||
from onyx.db.file_record import update_filerecord_origin
|
||||
from onyx.file_store.file_store import get_default_file_store
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
# (content, content_type) -> file_id
|
||||
RawFileCallback = Callable[[IO[bytes], str], str]
|
||||
|
||||
|
||||
def stage_raw_file(
|
||||
content: IO,
|
||||
content_type: str,
|
||||
*,
|
||||
metadata: dict[str, Any],
|
||||
) -> str:
|
||||
"""Persist raw bytes to the file store with FileOrigin.INDEXING_STAGING.
|
||||
|
||||
`metadata` is attached to the file_record so that downstream promotion
|
||||
(in docprocessing) and orphan reaping (TTL janitor) can locate the file
|
||||
by its originating context.
|
||||
"""
|
||||
file_store = get_default_file_store()
|
||||
file_id = file_store.save_file(
|
||||
content=content,
|
||||
display_name=None,
|
||||
file_origin=FileOrigin.INDEXING_STAGING,
|
||||
file_type=content_type,
|
||||
file_metadata=metadata,
|
||||
)
|
||||
return file_id
|
||||
|
||||
|
||||
def build_raw_file_callback(
|
||||
*,
|
||||
index_attempt_id: int,
|
||||
cc_pair_id: int,
|
||||
tenant_id: str,
|
||||
) -> RawFileCallback:
|
||||
"""Build a per-attempt callback that connectors can invoke to opt in to
|
||||
raw-file persistence. The closure binds the attempt-level context as the
|
||||
staging metadata so the connector only needs to pass per-call info
|
||||
(bytes, content_type) and gets back a file_id to attach to its Document.
|
||||
"""
|
||||
metadata: dict[str, Any] = {
|
||||
"index_attempt_id": index_attempt_id,
|
||||
"cc_pair_id": cc_pair_id,
|
||||
"tenant_id": tenant_id,
|
||||
}
|
||||
|
||||
def _callback(content: IO[bytes], content_type: str) -> str:
|
||||
return stage_raw_file(
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
return _callback
|
||||
|
||||
|
||||
def delete_files_best_effort(file_ids: list[str]) -> None:
|
||||
"""Delete a list of files from the file store, logging individual
|
||||
failures rather than raising.
|
||||
|
||||
Used at document-deletion time to reap raw files attached via
|
||||
`Document.file_id`. The corresponding document rows have already been
|
||||
deleted by the caller, so a failure here just leaves a recoverable
|
||||
orphan rather than a broken pointer.
|
||||
"""
|
||||
if not file_ids:
|
||||
return
|
||||
file_store = get_default_file_store()
|
||||
for file_id in file_ids:
|
||||
try:
|
||||
file_store.delete_file(file_id, error_on_missing=False)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Failed to delete file_id={file_id} during document cleanup"
|
||||
)
|
||||
|
||||
|
||||
def promote_staged_file(db_session: Session, file_id: str) -> None:
|
||||
"""Mark a previously-staged file as `FileOrigin.CONNECTOR`.
|
||||
|
||||
Idempotent — the underlying update filters on the STAGING origin so
|
||||
repeated calls no-op once the file has already been promoted or removed.
|
||||
Caller owns the commit so promotion stays transactional with whatever
|
||||
document-level bookkeeping the caller is doing.
|
||||
"""
|
||||
update_filerecord_origin(
|
||||
file_id=file_id,
|
||||
from_origin=FileOrigin.INDEXING_STAGING,
|
||||
to_origin=FileOrigin.CONNECTOR,
|
||||
db_session=db_session,
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -290,7 +290,11 @@ def litellm_exception_to_error_msg(
|
||||
error_code = "BUDGET_EXCEEDED"
|
||||
is_retryable = False
|
||||
elif isinstance(core_exception, Timeout):
|
||||
error_msg = "Request timed out: The operation took too long to complete. Please try again."
|
||||
error_msg = (
|
||||
"The LLM took too long to respond. "
|
||||
"If you're running a local model, try increasing the "
|
||||
"LLM_SOCKET_READ_TIMEOUT environment variable (current default: 120 seconds)."
|
||||
)
|
||||
error_code = "CONNECTION_ERROR"
|
||||
is_retryable = True
|
||||
elif isinstance(core_exception, APIError):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,7 +13,7 @@ from onyx.configs.constants import PUBLIC_API_TAGS
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import IndexAttemptMetadata
|
||||
from onyx.db.connector_credential_pair import get_connector_credential_pair_from_id
|
||||
from onyx.db.document import delete_documents_complete
|
||||
from onyx.db.document import delete_documents_complete__no_commit
|
||||
from onyx.db.document import get_document
|
||||
from onyx.db.document import get_documents_by_cc_pair
|
||||
from onyx.db.document import get_ingestion_documents
|
||||
@@ -210,4 +210,5 @@ def delete_ingestion_doc(
|
||||
)
|
||||
|
||||
# Delete from database
|
||||
delete_documents_complete(db_session, [document_id])
|
||||
delete_documents_complete__no_commit(db_session, [document_id])
|
||||
db_session.commit()
|
||||
|
||||
@@ -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
|
||||
@@ -214,7 +214,9 @@ distro==1.9.0
|
||||
dnspython==2.8.0
|
||||
# via email-validator
|
||||
docstring-parser==0.17.0
|
||||
# via cyclopts
|
||||
# via
|
||||
# cyclopts
|
||||
# google-cloud-aiplatform
|
||||
docutils==0.22.3
|
||||
# via rich-rst
|
||||
dropbox==12.0.2
|
||||
@@ -270,7 +272,13 @@ gitdb==4.0.12
|
||||
gitpython==3.1.45
|
||||
# via braintrust
|
||||
google-api-core==2.28.1
|
||||
# via google-api-python-client
|
||||
# via
|
||||
# google-api-python-client
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-core
|
||||
# google-cloud-resource-manager
|
||||
# google-cloud-storage
|
||||
google-api-python-client==2.86.0
|
||||
google-auth==2.48.0
|
||||
# via
|
||||
@@ -278,21 +286,61 @@ google-auth==2.48.0
|
||||
# google-api-python-client
|
||||
# google-auth-httplib2
|
||||
# google-auth-oauthlib
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-core
|
||||
# google-cloud-resource-manager
|
||||
# google-cloud-storage
|
||||
# google-genai
|
||||
# kubernetes
|
||||
google-auth-httplib2==0.1.0
|
||||
# via google-api-python-client
|
||||
google-auth-oauthlib==1.0.0
|
||||
google-cloud-aiplatform==1.133.0
|
||||
# via litellm
|
||||
google-cloud-bigquery==3.41.0
|
||||
# via google-cloud-aiplatform
|
||||
google-cloud-core==2.5.1
|
||||
# via
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
google-cloud-resource-manager==1.17.0
|
||||
# via google-cloud-aiplatform
|
||||
google-cloud-storage==3.10.1
|
||||
# via google-cloud-aiplatform
|
||||
google-crc32c==1.8.0
|
||||
# via
|
||||
# google-cloud-storage
|
||||
# google-resumable-media
|
||||
google-genai==1.52.0
|
||||
# via onyx
|
||||
# via
|
||||
# google-cloud-aiplatform
|
||||
# onyx
|
||||
google-resumable-media==2.8.2
|
||||
# via
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
googleapis-common-protos==1.72.0
|
||||
# via
|
||||
# google-api-core
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
# opentelemetry-exporter-otlp-proto-http
|
||||
greenlet==3.2.4
|
||||
# via
|
||||
# playwright
|
||||
# sqlalchemy
|
||||
grpc-google-iam-v1==0.14.4
|
||||
# via google-cloud-resource-manager
|
||||
grpcio==1.80.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-resource-manager
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
grpcio-status==1.80.0
|
||||
# via google-api-core
|
||||
h11==0.16.0
|
||||
# via
|
||||
# httpcore
|
||||
@@ -443,7 +491,7 @@ magika==0.6.3
|
||||
# via markitdown
|
||||
makefun==1.16.0
|
||||
# via fastapi-users
|
||||
mako==1.2.4
|
||||
mako==1.3.11
|
||||
# via alembic
|
||||
mammoth==1.11.0
|
||||
# via markitdown
|
||||
@@ -559,6 +607,8 @@ packaging==24.2
|
||||
# dask
|
||||
# distributed
|
||||
# fastmcp
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# huggingface-hub
|
||||
# jira
|
||||
# kombu
|
||||
@@ -605,12 +655,19 @@ propcache==0.4.1
|
||||
# aiohttp
|
||||
# yarl
|
||||
proto-plus==1.26.1
|
||||
# via google-api-core
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-resource-manager
|
||||
protobuf==6.33.5
|
||||
# via
|
||||
# ddtrace
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-resource-manager
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
# onnxruntime
|
||||
# opentelemetry-proto
|
||||
# proto-plus
|
||||
@@ -643,6 +700,7 @@ pydantic==2.11.7
|
||||
# exa-py
|
||||
# fastapi
|
||||
# fastmcp
|
||||
# google-cloud-aiplatform
|
||||
# google-genai
|
||||
# langchain-core
|
||||
# langfuse
|
||||
@@ -679,7 +737,7 @@ pynacl==1.6.2
|
||||
pypandoc-binary==1.16.2
|
||||
pyparsing==3.2.5
|
||||
# via httplib2
|
||||
pypdf==6.10.0
|
||||
pypdf==6.10.2
|
||||
# via unstructured-client
|
||||
pyperclip==1.11.0
|
||||
# via fastmcp
|
||||
@@ -701,6 +759,7 @@ python-dateutil==2.8.2
|
||||
# botocore
|
||||
# celery
|
||||
# dateparser
|
||||
# google-cloud-bigquery
|
||||
# htmldate
|
||||
# hubspot-api-client
|
||||
# kubernetes
|
||||
@@ -779,6 +838,8 @@ requests==2.33.0
|
||||
# dropbox
|
||||
# exa-py
|
||||
# google-api-core
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
# google-genai
|
||||
# hubspot-api-client
|
||||
# jira
|
||||
@@ -951,7 +1012,9 @@ typing-extensions==4.15.0
|
||||
# exa-py
|
||||
# exceptiongroup
|
||||
# fastapi
|
||||
# google-cloud-aiplatform
|
||||
# google-genai
|
||||
# grpcio
|
||||
# huggingface-hub
|
||||
# jira
|
||||
# langchain-core
|
||||
|
||||
@@ -114,6 +114,8 @@ distlib==0.4.0
|
||||
# via virtualenv
|
||||
distro==1.9.0
|
||||
# via openai
|
||||
docstring-parser==0.17.0
|
||||
# via google-cloud-aiplatform
|
||||
durationpy==0.10
|
||||
# via kubernetes
|
||||
execnet==2.1.2
|
||||
@@ -141,14 +143,65 @@ frozenlist==1.8.0
|
||||
# aiosignal
|
||||
fsspec==2025.10.0
|
||||
# via huggingface-hub
|
||||
google-api-core==2.28.1
|
||||
# via
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-core
|
||||
# google-cloud-resource-manager
|
||||
# google-cloud-storage
|
||||
google-auth==2.48.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-core
|
||||
# google-cloud-resource-manager
|
||||
# google-cloud-storage
|
||||
# google-genai
|
||||
# kubernetes
|
||||
google-cloud-aiplatform==1.133.0
|
||||
# via litellm
|
||||
google-cloud-bigquery==3.41.0
|
||||
# via google-cloud-aiplatform
|
||||
google-cloud-core==2.5.1
|
||||
# via
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
google-cloud-resource-manager==1.17.0
|
||||
# via google-cloud-aiplatform
|
||||
google-cloud-storage==3.10.1
|
||||
# via google-cloud-aiplatform
|
||||
google-crc32c==1.8.0
|
||||
# via
|
||||
# google-cloud-storage
|
||||
# google-resumable-media
|
||||
google-genai==1.52.0
|
||||
# via onyx
|
||||
# via
|
||||
# google-cloud-aiplatform
|
||||
# onyx
|
||||
google-resumable-media==2.8.2
|
||||
# via
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
googleapis-common-protos==1.72.0
|
||||
# via
|
||||
# google-api-core
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
greenlet==3.2.4 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'
|
||||
# via sqlalchemy
|
||||
grpc-google-iam-v1==0.14.4
|
||||
# via google-cloud-resource-manager
|
||||
grpcio==1.80.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-resource-manager
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
grpcio-status==1.80.0
|
||||
# via google-api-core
|
||||
h11==0.16.0
|
||||
# via
|
||||
# httpcore
|
||||
@@ -218,7 +271,7 @@ kubernetes==31.0.0
|
||||
# via onyx
|
||||
litellm==1.81.6
|
||||
# via onyx
|
||||
mako==1.2.4
|
||||
mako==1.3.11
|
||||
# via alembic
|
||||
manygo==0.2.0
|
||||
markdown-it-py==4.0.0
|
||||
@@ -267,6 +320,8 @@ openapi-generator-cli==7.17.0
|
||||
packaging==24.2
|
||||
# via
|
||||
# black
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# hatchling
|
||||
# huggingface-hub
|
||||
# ipykernel
|
||||
@@ -307,6 +362,20 @@ propcache==0.4.1
|
||||
# via
|
||||
# aiohttp
|
||||
# yarl
|
||||
proto-plus==1.26.1
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-resource-manager
|
||||
protobuf==6.33.5
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-resource-manager
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
# proto-plus
|
||||
psutil==7.1.3
|
||||
# via ipykernel
|
||||
ptyprocess==0.7.0 ; sys_platform != 'emscripten' and sys_platform != 'win32'
|
||||
@@ -328,6 +397,7 @@ pydantic==2.11.7
|
||||
# agent-client-protocol
|
||||
# cohere
|
||||
# fastapi
|
||||
# google-cloud-aiplatform
|
||||
# google-genai
|
||||
# litellm
|
||||
# mcp
|
||||
@@ -364,6 +434,7 @@ python-dateutil==2.8.2
|
||||
# via
|
||||
# aiobotocore
|
||||
# botocore
|
||||
# google-cloud-bigquery
|
||||
# jupyter-client
|
||||
# kubernetes
|
||||
# matplotlib
|
||||
@@ -398,6 +469,9 @@ reorder-python-imports-black==3.14.0
|
||||
requests==2.33.0
|
||||
# via
|
||||
# cohere
|
||||
# google-api-core
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
# google-genai
|
||||
# kubernetes
|
||||
# requests-oauthlib
|
||||
@@ -498,7 +572,9 @@ typing-extensions==4.15.0
|
||||
# celery-types
|
||||
# cohere
|
||||
# fastapi
|
||||
# google-cloud-aiplatform
|
||||
# google-genai
|
||||
# grpcio
|
||||
# huggingface-hub
|
||||
# ipython
|
||||
# mcp
|
||||
|
||||
@@ -87,6 +87,8 @@ discord-py==2.4.0
|
||||
# via onyx
|
||||
distro==1.9.0
|
||||
# via openai
|
||||
docstring-parser==0.17.0
|
||||
# via google-cloud-aiplatform
|
||||
durationpy==0.10
|
||||
# via kubernetes
|
||||
fastapi==0.133.1
|
||||
@@ -103,12 +105,63 @@ frozenlist==1.8.0
|
||||
# aiosignal
|
||||
fsspec==2025.10.0
|
||||
# via huggingface-hub
|
||||
google-api-core==2.28.1
|
||||
# via
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-core
|
||||
# google-cloud-resource-manager
|
||||
# google-cloud-storage
|
||||
google-auth==2.48.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-core
|
||||
# google-cloud-resource-manager
|
||||
# google-cloud-storage
|
||||
# google-genai
|
||||
# kubernetes
|
||||
google-cloud-aiplatform==1.133.0
|
||||
# via litellm
|
||||
google-cloud-bigquery==3.41.0
|
||||
# via google-cloud-aiplatform
|
||||
google-cloud-core==2.5.1
|
||||
# via
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
google-cloud-resource-manager==1.17.0
|
||||
# via google-cloud-aiplatform
|
||||
google-cloud-storage==3.10.1
|
||||
# via google-cloud-aiplatform
|
||||
google-crc32c==1.8.0
|
||||
# via
|
||||
# google-cloud-storage
|
||||
# google-resumable-media
|
||||
google-genai==1.52.0
|
||||
# via onyx
|
||||
# via
|
||||
# google-cloud-aiplatform
|
||||
# onyx
|
||||
google-resumable-media==2.8.2
|
||||
# via
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
googleapis-common-protos==1.72.0
|
||||
# via
|
||||
# google-api-core
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
grpc-google-iam-v1==0.14.4
|
||||
# via google-cloud-resource-manager
|
||||
grpcio==1.80.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-resource-manager
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
grpcio-status==1.80.0
|
||||
# via google-api-core
|
||||
h11==0.16.0
|
||||
# via
|
||||
# httpcore
|
||||
@@ -184,7 +237,10 @@ openai==2.14.0
|
||||
# litellm
|
||||
# onyx
|
||||
packaging==24.2
|
||||
# via huggingface-hub
|
||||
# via
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# huggingface-hub
|
||||
parameterized==0.9.0
|
||||
# via cohere
|
||||
posthog==3.7.4
|
||||
@@ -198,6 +254,20 @@ propcache==0.4.1
|
||||
# via
|
||||
# aiohttp
|
||||
# yarl
|
||||
proto-plus==1.26.1
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-resource-manager
|
||||
protobuf==6.33.5
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-resource-manager
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
# proto-plus
|
||||
py==1.11.0
|
||||
# via retry
|
||||
pyasn1==0.6.3
|
||||
@@ -213,6 +283,7 @@ pydantic==2.11.7
|
||||
# agent-client-protocol
|
||||
# cohere
|
||||
# fastapi
|
||||
# google-cloud-aiplatform
|
||||
# google-genai
|
||||
# litellm
|
||||
# mcp
|
||||
@@ -231,6 +302,7 @@ python-dateutil==2.8.2
|
||||
# via
|
||||
# aiobotocore
|
||||
# botocore
|
||||
# google-cloud-bigquery
|
||||
# kubernetes
|
||||
# posthog
|
||||
python-dotenv==1.1.1
|
||||
@@ -254,6 +326,9 @@ regex==2025.11.3
|
||||
requests==2.33.0
|
||||
# via
|
||||
# cohere
|
||||
# google-api-core
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
# google-genai
|
||||
# kubernetes
|
||||
# posthog
|
||||
@@ -318,7 +393,9 @@ typing-extensions==4.15.0
|
||||
# anyio
|
||||
# cohere
|
||||
# fastapi
|
||||
# google-cloud-aiplatform
|
||||
# google-genai
|
||||
# grpcio
|
||||
# huggingface-hub
|
||||
# mcp
|
||||
# openai
|
||||
|
||||
@@ -102,6 +102,8 @@ discord-py==2.4.0
|
||||
# via onyx
|
||||
distro==1.9.0
|
||||
# via openai
|
||||
docstring-parser==0.17.0
|
||||
# via google-cloud-aiplatform
|
||||
durationpy==0.10
|
||||
# via kubernetes
|
||||
einops==0.8.1
|
||||
@@ -125,12 +127,63 @@ fsspec==2025.10.0
|
||||
# via
|
||||
# huggingface-hub
|
||||
# torch
|
||||
google-api-core==2.28.1
|
||||
# via
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-core
|
||||
# google-cloud-resource-manager
|
||||
# google-cloud-storage
|
||||
google-auth==2.48.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-core
|
||||
# google-cloud-resource-manager
|
||||
# google-cloud-storage
|
||||
# google-genai
|
||||
# kubernetes
|
||||
google-cloud-aiplatform==1.133.0
|
||||
# via litellm
|
||||
google-cloud-bigquery==3.41.0
|
||||
# via google-cloud-aiplatform
|
||||
google-cloud-core==2.5.1
|
||||
# via
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
google-cloud-resource-manager==1.17.0
|
||||
# via google-cloud-aiplatform
|
||||
google-cloud-storage==3.10.1
|
||||
# via google-cloud-aiplatform
|
||||
google-crc32c==1.8.0
|
||||
# via
|
||||
# google-cloud-storage
|
||||
# google-resumable-media
|
||||
google-genai==1.52.0
|
||||
# via onyx
|
||||
# via
|
||||
# google-cloud-aiplatform
|
||||
# onyx
|
||||
google-resumable-media==2.8.2
|
||||
# via
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
googleapis-common-protos==1.72.0
|
||||
# via
|
||||
# google-api-core
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
grpc-google-iam-v1==0.14.4
|
||||
# via google-cloud-resource-manager
|
||||
grpcio==1.80.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-resource-manager
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
grpcio-status==1.80.0
|
||||
# via google-api-core
|
||||
h11==0.16.0
|
||||
# via
|
||||
# httpcore
|
||||
@@ -265,6 +318,8 @@ openai==2.14.0
|
||||
packaging==24.2
|
||||
# via
|
||||
# accelerate
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# huggingface-hub
|
||||
# kombu
|
||||
# transformers
|
||||
@@ -282,6 +337,20 @@ propcache==0.4.1
|
||||
# via
|
||||
# aiohttp
|
||||
# yarl
|
||||
proto-plus==1.26.1
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-resource-manager
|
||||
protobuf==6.33.5
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-resource-manager
|
||||
# googleapis-common-protos
|
||||
# grpc-google-iam-v1
|
||||
# grpcio-status
|
||||
# proto-plus
|
||||
psutil==7.1.3
|
||||
# via accelerate
|
||||
py==1.11.0
|
||||
@@ -299,6 +368,7 @@ pydantic==2.11.7
|
||||
# agent-client-protocol
|
||||
# cohere
|
||||
# fastapi
|
||||
# google-cloud-aiplatform
|
||||
# google-genai
|
||||
# litellm
|
||||
# mcp
|
||||
@@ -318,6 +388,7 @@ python-dateutil==2.8.2
|
||||
# aiobotocore
|
||||
# botocore
|
||||
# celery
|
||||
# google-cloud-bigquery
|
||||
# kubernetes
|
||||
python-dotenv==1.1.1
|
||||
# via
|
||||
@@ -344,6 +415,9 @@ regex==2025.11.3
|
||||
requests==2.33.0
|
||||
# via
|
||||
# cohere
|
||||
# google-api-core
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-storage
|
||||
# google-genai
|
||||
# kubernetes
|
||||
# requests-oauthlib
|
||||
@@ -437,7 +511,9 @@ typing-extensions==4.15.0
|
||||
# anyio
|
||||
# cohere
|
||||
# fastapi
|
||||
# google-cloud-aiplatform
|
||||
# google-genai
|
||||
# grpcio
|
||||
# huggingface-hub
|
||||
# mcp
|
||||
# openai
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
"""External dependency unit tests for the file_id cleanup that runs alongside
|
||||
document deletion across the three deletion paths:
|
||||
|
||||
1. `document_by_cc_pair_cleanup_task` (pruning + connector deletion)
|
||||
2. `delete_ingestion_doc` (public ingestion API DELETE)
|
||||
3. `delete_all_documents_for_connector_credential_pair` (index swap)
|
||||
|
||||
Each path captures attached `Document.file_id`s before the row is removed and
|
||||
best-effort deletes the underlying files after the DB commit.
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
from io import BytesIO
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.background.celery.tasks.shared.tasks import (
|
||||
document_by_cc_pair_cleanup_task,
|
||||
)
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.configs.constants import FileOrigin
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import IndexAttemptMetadata
|
||||
from onyx.connectors.models import InputType
|
||||
from onyx.connectors.models import TextSection
|
||||
from onyx.db.document import delete_all_documents_for_connector_credential_pair
|
||||
from onyx.db.document import upsert_document_by_connector_credential_pair
|
||||
from onyx.db.enums import AccessType
|
||||
from onyx.db.enums import ConnectorCredentialPairStatus
|
||||
from onyx.db.file_record import get_filerecord_by_file_id_optional
|
||||
from onyx.db.models import Connector
|
||||
from onyx.db.models import ConnectorCredentialPair
|
||||
from onyx.db.models import Credential
|
||||
from onyx.db.models import Document as DBDocument
|
||||
from onyx.db.models import FileRecord
|
||||
from onyx.file_store.file_store import get_default_file_store
|
||||
from onyx.indexing.indexing_pipeline import index_doc_batch_prepare
|
||||
from onyx.server.onyx_api.ingestion import delete_ingestion_doc
|
||||
from tests.external_dependency_unit.constants import TEST_TENANT_ID
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_doc(
|
||||
doc_id: str,
|
||||
file_id: str | None = None,
|
||||
from_ingestion_api: bool = False,
|
||||
) -> Document:
|
||||
return Document(
|
||||
id=doc_id,
|
||||
source=DocumentSource.MOCK_CONNECTOR,
|
||||
semantic_identifier=f"semantic-{doc_id}",
|
||||
sections=[TextSection(text="content", link=None)],
|
||||
metadata={},
|
||||
file_id=file_id,
|
||||
from_ingestion_api=from_ingestion_api,
|
||||
)
|
||||
|
||||
|
||||
def _stage_file(content: bytes = b"raw bytes") -> str:
|
||||
return get_default_file_store().save_file(
|
||||
content=BytesIO(content),
|
||||
display_name=None,
|
||||
file_origin=FileOrigin.INDEXING_STAGING,
|
||||
file_type="application/octet-stream",
|
||||
file_metadata={"test": True},
|
||||
)
|
||||
|
||||
|
||||
def _get_doc_row(db_session: Session, doc_id: str) -> DBDocument | None:
|
||||
db_session.expire_all()
|
||||
return db_session.query(DBDocument).filter(DBDocument.id == doc_id).one_or_none()
|
||||
|
||||
|
||||
def _get_filerecord(db_session: Session, file_id: str) -> FileRecord | None:
|
||||
db_session.expire_all()
|
||||
return get_filerecord_by_file_id_optional(file_id=file_id, db_session=db_session)
|
||||
|
||||
|
||||
def _index_doc(
|
||||
db_session: Session,
|
||||
doc: Document,
|
||||
attempt_metadata: IndexAttemptMetadata,
|
||||
) -> None:
|
||||
"""Run the doc through the upsert pipeline so the row + cc_pair mapping
|
||||
exist (so deletion paths have something to find)."""
|
||||
index_doc_batch_prepare(
|
||||
documents=[doc],
|
||||
index_attempt_metadata=attempt_metadata,
|
||||
db_session=db_session,
|
||||
ignore_time_skip=True,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_cc_pair(db_session: Session) -> ConnectorCredentialPair:
|
||||
connector = Connector(
|
||||
name=f"test-connector-{uuid4().hex[:8]}",
|
||||
source=DocumentSource.MOCK_CONNECTOR,
|
||||
input_type=InputType.LOAD_STATE,
|
||||
connector_specific_config={},
|
||||
refresh_freq=None,
|
||||
prune_freq=None,
|
||||
indexing_start=None,
|
||||
)
|
||||
db_session.add(connector)
|
||||
db_session.flush()
|
||||
|
||||
credential = Credential(
|
||||
source=DocumentSource.MOCK_CONNECTOR,
|
||||
credential_json={},
|
||||
)
|
||||
db_session.add(credential)
|
||||
db_session.flush()
|
||||
|
||||
pair = ConnectorCredentialPair(
|
||||
connector_id=connector.id,
|
||||
credential_id=credential.id,
|
||||
name=f"test-cc-pair-{uuid4().hex[:8]}",
|
||||
status=ConnectorCredentialPairStatus.ACTIVE,
|
||||
access_type=AccessType.PUBLIC,
|
||||
auto_sync_options=None,
|
||||
)
|
||||
db_session.add(pair)
|
||||
db_session.commit()
|
||||
db_session.refresh(pair)
|
||||
return pair
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cc_pair(
|
||||
db_session: Session,
|
||||
tenant_context: None, # noqa: ARG001
|
||||
initialize_file_store: None, # noqa: ARG001
|
||||
) -> Generator[ConnectorCredentialPair, None, None]:
|
||||
yield _make_cc_pair(db_session)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def second_cc_pair(
|
||||
db_session: Session,
|
||||
tenant_context: None, # noqa: ARG001
|
||||
initialize_file_store: None, # noqa: ARG001
|
||||
) -> Generator[ConnectorCredentialPair, None, None]:
|
||||
"""A second cc_pair, used to test the count > 1 branch."""
|
||||
yield _make_cc_pair(db_session)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def attempt_metadata(cc_pair: ConnectorCredentialPair) -> IndexAttemptMetadata:
|
||||
return IndexAttemptMetadata(
|
||||
connector_id=cc_pair.connector_id,
|
||||
credential_id=cc_pair.credential_id,
|
||||
attempt_id=None,
|
||||
request_id="test-request",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteAllDocumentsForCcPair:
|
||||
"""Path 3: bulk delete during index swap (`INSTANT` switchover)."""
|
||||
|
||||
def test_cleans_up_files_for_all_docs(
|
||||
self,
|
||||
db_session: Session,
|
||||
cc_pair: ConnectorCredentialPair,
|
||||
attempt_metadata: IndexAttemptMetadata,
|
||||
) -> None:
|
||||
file_id_a = _stage_file(content=b"a")
|
||||
file_id_b = _stage_file(content=b"b")
|
||||
doc_a = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=file_id_a)
|
||||
doc_b = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=file_id_b)
|
||||
|
||||
_index_doc(db_session, doc_a, attempt_metadata)
|
||||
_index_doc(db_session, doc_b, attempt_metadata)
|
||||
|
||||
assert _get_filerecord(db_session, file_id_a) is not None
|
||||
assert _get_filerecord(db_session, file_id_b) is not None
|
||||
|
||||
delete_all_documents_for_connector_credential_pair(
|
||||
db_session=db_session,
|
||||
connector_id=cc_pair.connector_id,
|
||||
credential_id=cc_pair.credential_id,
|
||||
)
|
||||
|
||||
assert _get_doc_row(db_session, doc_a.id) is None
|
||||
assert _get_doc_row(db_session, doc_b.id) is None
|
||||
assert _get_filerecord(db_session, file_id_a) is None
|
||||
assert _get_filerecord(db_session, file_id_b) is None
|
||||
|
||||
def test_handles_mixed_docs_with_and_without_file_ids(
|
||||
self,
|
||||
db_session: Session,
|
||||
cc_pair: ConnectorCredentialPair,
|
||||
attempt_metadata: IndexAttemptMetadata,
|
||||
) -> None:
|
||||
"""Docs without file_id should be cleanly removed — no errors,
|
||||
no spurious file_store calls."""
|
||||
file_id = _stage_file()
|
||||
doc_with = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=file_id)
|
||||
doc_without = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=None)
|
||||
|
||||
_index_doc(db_session, doc_with, attempt_metadata)
|
||||
_index_doc(db_session, doc_without, attempt_metadata)
|
||||
|
||||
delete_all_documents_for_connector_credential_pair(
|
||||
db_session=db_session,
|
||||
connector_id=cc_pair.connector_id,
|
||||
credential_id=cc_pair.credential_id,
|
||||
)
|
||||
|
||||
assert _get_doc_row(db_session, doc_with.id) is None
|
||||
assert _get_doc_row(db_session, doc_without.id) is None
|
||||
assert _get_filerecord(db_session, file_id) is None
|
||||
|
||||
|
||||
class TestDeleteIngestionDoc:
|
||||
"""Path 2: public ingestion API DELETE endpoint."""
|
||||
|
||||
def test_cleans_up_file_for_ingestion_api_doc(
|
||||
self,
|
||||
db_session: Session,
|
||||
attempt_metadata: IndexAttemptMetadata,
|
||||
tenant_context: None, # noqa: ARG002
|
||||
initialize_file_store: None, # noqa: ARG002
|
||||
) -> None:
|
||||
file_id = _stage_file()
|
||||
doc = _make_doc(
|
||||
f"doc-{uuid4().hex[:8]}",
|
||||
file_id=file_id,
|
||||
from_ingestion_api=True,
|
||||
)
|
||||
|
||||
_index_doc(db_session, doc, attempt_metadata)
|
||||
assert _get_filerecord(db_session, file_id) is not None
|
||||
|
||||
# Patch out Vespa — we're testing the file cleanup, not the document
|
||||
# index integration.
|
||||
with patch(
|
||||
"onyx.server.onyx_api.ingestion.get_all_document_indices",
|
||||
return_value=[],
|
||||
):
|
||||
delete_ingestion_doc(
|
||||
document_id=doc.id,
|
||||
_=MagicMock(), # auth dep — not used by the function body
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
assert _get_doc_row(db_session, doc.id) is None
|
||||
assert _get_filerecord(db_session, file_id) is None
|
||||
|
||||
|
||||
class TestDocumentByCcPairCleanupTask:
|
||||
"""Path 1: per-doc cleanup task fired by pruning / connector deletion."""
|
||||
|
||||
def test_count_1_branch_cleans_up_file(
|
||||
self,
|
||||
db_session: Session,
|
||||
cc_pair: ConnectorCredentialPair,
|
||||
attempt_metadata: IndexAttemptMetadata,
|
||||
full_deployment_setup: None, # noqa: ARG002
|
||||
) -> None:
|
||||
"""When the doc has exactly one cc_pair reference, the full delete
|
||||
path runs and the attached file is reaped."""
|
||||
file_id = _stage_file()
|
||||
doc = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=file_id)
|
||||
_index_doc(db_session, doc, attempt_metadata)
|
||||
|
||||
assert _get_filerecord(db_session, file_id) is not None
|
||||
|
||||
# Patch out Vespa interaction — no chunks were ever written, and we're
|
||||
# not testing the document index here.
|
||||
with patch(
|
||||
"onyx.background.celery.tasks.shared.tasks.get_all_document_indices",
|
||||
return_value=[],
|
||||
):
|
||||
result = document_by_cc_pair_cleanup_task.apply(
|
||||
args=(
|
||||
doc.id,
|
||||
cc_pair.connector_id,
|
||||
cc_pair.credential_id,
|
||||
TEST_TENANT_ID,
|
||||
),
|
||||
)
|
||||
|
||||
assert result.successful(), result.traceback
|
||||
assert _get_doc_row(db_session, doc.id) is None
|
||||
assert _get_filerecord(db_session, file_id) is None
|
||||
|
||||
def test_count_gt_1_branch_preserves_file(
|
||||
self,
|
||||
db_session: Session,
|
||||
cc_pair: ConnectorCredentialPair,
|
||||
second_cc_pair: ConnectorCredentialPair,
|
||||
attempt_metadata: IndexAttemptMetadata,
|
||||
full_deployment_setup: None, # noqa: ARG002
|
||||
) -> None:
|
||||
"""When the doc is referenced by another cc_pair, only the mapping
|
||||
for the detaching cc_pair is removed. The file MUST stay because
|
||||
the doc and its file are still owned by the remaining cc_pair."""
|
||||
file_id = _stage_file()
|
||||
doc = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=file_id)
|
||||
_index_doc(db_session, doc, attempt_metadata)
|
||||
|
||||
# Attach the same doc to a second cc_pair so refcount becomes 2.
|
||||
upsert_document_by_connector_credential_pair(
|
||||
db_session,
|
||||
second_cc_pair.connector_id,
|
||||
second_cc_pair.credential_id,
|
||||
[doc.id],
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
with patch(
|
||||
"onyx.background.celery.tasks.shared.tasks.get_all_document_indices",
|
||||
return_value=[],
|
||||
):
|
||||
result = document_by_cc_pair_cleanup_task.apply(
|
||||
args=(
|
||||
doc.id,
|
||||
cc_pair.connector_id,
|
||||
cc_pair.credential_id,
|
||||
TEST_TENANT_ID,
|
||||
),
|
||||
)
|
||||
|
||||
assert result.successful(), result.traceback
|
||||
# Document row still exists (other cc_pair owns it).
|
||||
assert _get_doc_row(db_session, doc.id) is not None
|
||||
# File MUST still exist.
|
||||
record = _get_filerecord(db_session, file_id)
|
||||
assert record is not None
|
||||
@@ -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
|
||||
@@ -1,262 +0,0 @@
|
||||
"""Workflow-level test for the INSTANT index swap.
|
||||
|
||||
When `check_and_perform_index_swap` runs against an `INSTANT` switchover, it
|
||||
calls `delete_all_documents_for_connector_credential_pair` for each cc_pair.
|
||||
This test exercises that full workflow end-to-end and asserts that the
|
||||
attached `Document.file_id`s are also reaped — not just the document rows.
|
||||
|
||||
Mocks Vespa (`get_all_document_indices`) since this is testing the postgres +
|
||||
file_store side effects of the swap, not the document index integration.
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.configs.constants import FileOrigin
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import IndexAttemptMetadata
|
||||
from onyx.connectors.models import InputType
|
||||
from onyx.connectors.models import TextSection
|
||||
from onyx.context.search.models import SavedSearchSettings
|
||||
from onyx.db.enums import AccessType
|
||||
from onyx.db.enums import ConnectorCredentialPairStatus
|
||||
from onyx.db.enums import EmbeddingPrecision
|
||||
from onyx.db.enums import SwitchoverType
|
||||
from onyx.db.file_record import get_filerecord_by_file_id_optional
|
||||
from onyx.db.models import Connector
|
||||
from onyx.db.models import ConnectorCredentialPair
|
||||
from onyx.db.models import Credential
|
||||
from onyx.db.models import Document as DBDocument
|
||||
from onyx.db.models import FileRecord
|
||||
from onyx.db.models import IndexModelStatus
|
||||
from onyx.db.search_settings import create_search_settings
|
||||
from onyx.db.swap_index import check_and_perform_index_swap
|
||||
from onyx.file_store.file_store import get_default_file_store
|
||||
from onyx.indexing.indexing_pipeline import index_doc_batch_prepare
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers (kept inline; extract to a shared conftest if a 4th test file shows up)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_doc(doc_id: str, file_id: str | None = None) -> Document:
|
||||
return Document(
|
||||
id=doc_id,
|
||||
source=DocumentSource.MOCK_CONNECTOR,
|
||||
semantic_identifier=f"semantic-{doc_id}",
|
||||
sections=[TextSection(text="content", link=None)],
|
||||
metadata={},
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
|
||||
def _stage_file(content: bytes = b"raw bytes") -> str:
|
||||
return get_default_file_store().save_file(
|
||||
content=BytesIO(content),
|
||||
display_name=None,
|
||||
file_origin=FileOrigin.INDEXING_STAGING,
|
||||
file_type="application/octet-stream",
|
||||
file_metadata={"test": True},
|
||||
)
|
||||
|
||||
|
||||
def _get_doc_row(db_session: Session, doc_id: str) -> DBDocument | None:
|
||||
db_session.expire_all()
|
||||
return db_session.query(DBDocument).filter(DBDocument.id == doc_id).one_or_none()
|
||||
|
||||
|
||||
def _get_filerecord(db_session: Session, file_id: str) -> FileRecord | None:
|
||||
db_session.expire_all()
|
||||
return get_filerecord_by_file_id_optional(file_id=file_id, db_session=db_session)
|
||||
|
||||
|
||||
def _make_cc_pair(db_session: Session) -> ConnectorCredentialPair:
|
||||
connector = Connector(
|
||||
name=f"test-connector-{uuid4().hex[:8]}",
|
||||
source=DocumentSource.MOCK_CONNECTOR,
|
||||
input_type=InputType.LOAD_STATE,
|
||||
connector_specific_config={},
|
||||
refresh_freq=None,
|
||||
prune_freq=None,
|
||||
indexing_start=None,
|
||||
)
|
||||
db_session.add(connector)
|
||||
db_session.flush()
|
||||
|
||||
credential = Credential(
|
||||
source=DocumentSource.MOCK_CONNECTOR,
|
||||
credential_json={},
|
||||
)
|
||||
db_session.add(credential)
|
||||
db_session.flush()
|
||||
|
||||
pair = ConnectorCredentialPair(
|
||||
connector_id=connector.id,
|
||||
credential_id=credential.id,
|
||||
name=f"test-cc-pair-{uuid4().hex[:8]}",
|
||||
status=ConnectorCredentialPairStatus.ACTIVE,
|
||||
access_type=AccessType.PUBLIC,
|
||||
auto_sync_options=None,
|
||||
)
|
||||
db_session.add(pair)
|
||||
db_session.commit()
|
||||
db_session.refresh(pair)
|
||||
return pair
|
||||
|
||||
|
||||
def _make_saved_search_settings(
|
||||
*,
|
||||
switchover_type: SwitchoverType = SwitchoverType.REINDEX,
|
||||
) -> SavedSearchSettings:
|
||||
return SavedSearchSettings(
|
||||
model_name=f"test-embedding-model-{uuid4().hex[:8]}",
|
||||
model_dim=768,
|
||||
normalize=True,
|
||||
query_prefix="",
|
||||
passage_prefix="",
|
||||
provider_type=None,
|
||||
index_name=f"test_index_{uuid4().hex[:8]}",
|
||||
multipass_indexing=False,
|
||||
embedding_precision=EmbeddingPrecision.FLOAT,
|
||||
reduced_dimension=None,
|
||||
enable_contextual_rag=False,
|
||||
contextual_rag_llm_name=None,
|
||||
contextual_rag_llm_provider=None,
|
||||
switchover_type=switchover_type,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cc_pair(
|
||||
db_session: Session,
|
||||
tenant_context: None, # noqa: ARG001
|
||||
initialize_file_store: None, # noqa: ARG001
|
||||
full_deployment_setup: None, # noqa: ARG001
|
||||
) -> Generator[ConnectorCredentialPair, None, None]:
|
||||
yield _make_cc_pair(db_session)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def attempt_metadata(cc_pair: ConnectorCredentialPair) -> IndexAttemptMetadata:
|
||||
return IndexAttemptMetadata(
|
||||
connector_id=cc_pair.connector_id,
|
||||
credential_id=cc_pair.credential_id,
|
||||
attempt_id=None,
|
||||
request_id="test-request",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInstantIndexSwap:
|
||||
"""`SwitchoverType.INSTANT` wipes all docs for every cc_pair as part of
|
||||
the swap. The associated raw files must be reaped too."""
|
||||
|
||||
def test_instant_swap_deletes_docs_and_files(
|
||||
self,
|
||||
db_session: Session,
|
||||
attempt_metadata: IndexAttemptMetadata,
|
||||
) -> None:
|
||||
# Index two docs with attached files via the normal pipeline.
|
||||
file_id_a = _stage_file(content=b"alpha")
|
||||
file_id_b = _stage_file(content=b"beta")
|
||||
doc_a = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=file_id_a)
|
||||
doc_b = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=file_id_b)
|
||||
|
||||
index_doc_batch_prepare(
|
||||
documents=[doc_a, doc_b],
|
||||
index_attempt_metadata=attempt_metadata,
|
||||
db_session=db_session,
|
||||
ignore_time_skip=True,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
# Sanity: docs and files exist before the swap.
|
||||
assert _get_doc_row(db_session, doc_a.id) is not None
|
||||
assert _get_doc_row(db_session, doc_b.id) is not None
|
||||
assert _get_filerecord(db_session, file_id_a) is not None
|
||||
assert _get_filerecord(db_session, file_id_b) is not None
|
||||
|
||||
# Stage a FUTURE search settings with INSTANT switchover. The next
|
||||
# `check_and_perform_index_swap` call will see this and trigger the
|
||||
# bulk-delete path on every cc_pair.
|
||||
create_search_settings(
|
||||
search_settings=_make_saved_search_settings(
|
||||
switchover_type=SwitchoverType.INSTANT
|
||||
),
|
||||
db_session=db_session,
|
||||
status=IndexModelStatus.FUTURE,
|
||||
)
|
||||
|
||||
# Vespa is patched out — we're testing the postgres + file_store
|
||||
# side effects, not the document-index integration.
|
||||
with patch(
|
||||
"onyx.db.swap_index.get_all_document_indices",
|
||||
return_value=[],
|
||||
):
|
||||
old_settings = check_and_perform_index_swap(db_session)
|
||||
|
||||
assert old_settings is not None, "INSTANT swap should have executed"
|
||||
|
||||
# Documents are gone.
|
||||
assert _get_doc_row(db_session, doc_a.id) is None
|
||||
assert _get_doc_row(db_session, doc_b.id) is None
|
||||
|
||||
# Files are gone — the workflow's bulk-delete path correctly
|
||||
# propagated through to file cleanup.
|
||||
assert _get_filerecord(db_session, file_id_a) is None
|
||||
assert _get_filerecord(db_session, file_id_b) is None
|
||||
|
||||
def test_instant_swap_with_mixed_docs_does_not_break(
|
||||
self,
|
||||
db_session: Session,
|
||||
attempt_metadata: IndexAttemptMetadata,
|
||||
) -> None:
|
||||
"""A mix of docs with and without file_ids must all be swept up
|
||||
without errors during the swap."""
|
||||
file_id = _stage_file()
|
||||
doc_with = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=file_id)
|
||||
doc_without = _make_doc(f"doc-{uuid4().hex[:8]}", file_id=None)
|
||||
|
||||
index_doc_batch_prepare(
|
||||
documents=[doc_with, doc_without],
|
||||
index_attempt_metadata=attempt_metadata,
|
||||
db_session=db_session,
|
||||
ignore_time_skip=True,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
create_search_settings(
|
||||
search_settings=_make_saved_search_settings(
|
||||
switchover_type=SwitchoverType.INSTANT
|
||||
),
|
||||
db_session=db_session,
|
||||
status=IndexModelStatus.FUTURE,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"onyx.db.swap_index.get_all_document_indices",
|
||||
return_value=[],
|
||||
):
|
||||
old_settings = check_and_perform_index_swap(db_session)
|
||||
|
||||
assert old_settings is not None
|
||||
|
||||
assert _get_doc_row(db_session, doc_with.id) is None
|
||||
assert _get_doc_row(db_session, doc_without.id) is None
|
||||
assert _get_filerecord(db_session, file_id) is None
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ LOG_ONYX_MODEL_INTERACTIONS=False
|
||||
|
||||
## Gen AI Settings
|
||||
# GEN_AI_MAX_TOKENS=
|
||||
# LLM_SOCKET_READ_TIMEOUT=
|
||||
LLM_SOCKET_READ_TIMEOUT=120
|
||||
# MAX_CHUNKS_FED_TO_CHAT=
|
||||
# DISABLE_LITELLM_STREAMING=
|
||||
# LITELLM_EXTRA_HEADERS=
|
||||
|
||||
@@ -1262,7 +1262,7 @@ configMap:
|
||||
S3_FILE_STORE_BUCKET_NAME: ""
|
||||
# Gen AI Settings
|
||||
GEN_AI_MAX_TOKENS: ""
|
||||
LLM_SOCKET_READ_TIMEOUT: "60"
|
||||
LLM_SOCKET_READ_TIMEOUT: "120"
|
||||
MAX_CHUNKS_FED_TO_CHAT: ""
|
||||
# Query Options
|
||||
DOC_TIME_DECAY: ""
|
||||
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"cohere==5.6.1",
|
||||
"fastapi==0.133.1",
|
||||
"google-genai==1.52.0",
|
||||
"litellm==1.81.6",
|
||||
"litellm[google]==1.81.6",
|
||||
"openai==2.14.0",
|
||||
"pydantic==2.11.7",
|
||||
"prometheus_client>=0.21.1",
|
||||
@@ -69,7 +69,7 @@ backend = [
|
||||
"langchain-core==1.2.28",
|
||||
"lazy_imports==1.0.1",
|
||||
"lxml==5.3.0",
|
||||
"Mako==1.2.4",
|
||||
"Mako==1.3.11",
|
||||
# NOTE: Do not update without understanding the patching behavior in
|
||||
# get_markitdown_converter in
|
||||
# backend/onyx/file_processing/extract_file_text.py and what impacts
|
||||
@@ -96,7 +96,7 @@ backend = [
|
||||
"python-gitlab==5.6.0",
|
||||
"python-pptx==0.6.23",
|
||||
"pypandoc_binary==1.16.2",
|
||||
"pypdf==6.10.0",
|
||||
"pypdf==6.10.2",
|
||||
"pytest-mock==3.12.0",
|
||||
"pytest-playwright==0.7.2",
|
||||
"python-docx==1.1.2",
|
||||
|
||||
251
uv.lock
generated
251
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]]
|
||||
@@ -2124,6 +2124,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/d4/90197b416cb61cefd316964fd9e7bd8324bcbafabf40eef14a9f20b81974/google_api_core-2.28.1-py3-none-any.whl", hash = "sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c", size = 173706, upload-time = "2025-10-28T21:34:50.151Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
grpc = [
|
||||
{ name = "grpcio" },
|
||||
{ name = "grpcio-status" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-api-python-client"
|
||||
version = "2.86.0"
|
||||
@@ -2181,6 +2187,124 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/07/8d9a8186e6768b55dfffeb57c719bc03770cf8a970a074616ae6f9e26a57/google_auth_oauthlib-1.0.0-py2.py3-none-any.whl", hash = "sha256:95880ca704928c300f48194d1770cf5b1462835b6e49db61445a520f793fd5fb", size = 18926, upload-time = "2023-02-07T20:53:18.837Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-aiplatform"
|
||||
version = "1.133.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "docstring-parser" },
|
||||
{ name = "google-api-core", extra = ["grpc"] },
|
||||
{ name = "google-auth" },
|
||||
{ name = "google-cloud-bigquery" },
|
||||
{ name = "google-cloud-resource-manager" },
|
||||
{ name = "google-cloud-storage" },
|
||||
{ name = "google-genai" },
|
||||
{ name = "packaging" },
|
||||
{ name = "proto-plus" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/be/31ce7fd658ddebafbe5583977ddee536b2bacc491ad10b5a067388aec66f/google_cloud_aiplatform-1.133.0.tar.gz", hash = "sha256:3a6540711956dd178daaab3c2c05db476e46d94ac25912b8cf4f59b00b058ae0", size = 9921309, upload-time = "2026-01-08T22:11:25.079Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/01/5b/ef74ff65aebb74eaba51078e33ddd897247ba0d1197fd5a7953126205519/google_cloud_aiplatform-1.133.0-py2.py3-none-any.whl", hash = "sha256:dfc81228e987ca10d1c32c7204e2131b3c8d6b7c8e0b4e23bf7c56816bc4c566", size = 8184595, upload-time = "2026-01-08T22:11:22.067Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-bigquery"
|
||||
version = "3.41.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core", extra = ["grpc"] },
|
||||
{ name = "google-auth" },
|
||||
{ name = "google-cloud-core" },
|
||||
{ name = "google-resumable-media" },
|
||||
{ name = "packaging" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/13/6515c7aab55a4a0cf708ffd309fb9af5bab54c13e32dc22c5acd6497193c/google_cloud_bigquery-3.41.0.tar.gz", hash = "sha256:2217e488b47ed576360c9b2cc07d59d883a54b83167c0ef37f915c26b01a06fe", size = 513434, upload-time = "2026-03-30T22:50:55.347Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/40/33/1d3902efadef9194566d499d61507e1f038454e0b55499d2d7f8ab2a4fee/google_cloud_bigquery-3.41.0-py3-none-any.whl", hash = "sha256:2a5b5a737b401cbd824a6e5eac7554100b878668d908e6548836b5d8aaa4dcaa", size = 262343, upload-time = "2026-03-30T22:48:45.444Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-core"
|
||||
version = "2.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core" },
|
||||
{ name = "google-auth" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/24/6ca08b0a03c7b0c620427503ab00353a4ae806b848b93bcea18b6b76fde6/google_cloud_core-2.5.1.tar.gz", hash = "sha256:3dc94bdec9d05a31d9f355045ed0f369fbc0d8c665076c734f065d729800f811", size = 36078, upload-time = "2026-03-30T22:50:08.057Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/73/d9/5bb050cb32826466aa9b25f79e2ca2879fe66cb76782d4ed798dd7506151/google_cloud_core-2.5.1-py3-none-any.whl", hash = "sha256:ea62cdf502c20e3e14be8a32c05ed02113d7bef454e40ff3fab6fe1ec9f1f4e7", size = 29452, upload-time = "2026-03-30T22:48:31.567Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-resource-manager"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core", extra = ["grpc"] },
|
||||
{ name = "google-auth" },
|
||||
{ name = "grpc-google-iam-v1" },
|
||||
{ name = "grpcio" },
|
||||
{ name = "proto-plus" },
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/1a/13060cabf553d52d151d2afc26b39561e82853380d499dd525a0d422d9f0/google_cloud_resource_manager-1.17.0.tar.gz", hash = "sha256:0f486b62e2c58ff992a3a50fa0f4a96eef7750aa6c971bb373398ccb91828660", size = 464971, upload-time = "2026-03-26T22:17:29.204Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/f7/661d7a9023e877a226b5683429c3662f75a29ef45cb1464cf39adb689218/google_cloud_resource_manager-1.17.0-py3-none-any.whl", hash = "sha256:e479baf4b014a57f298e01b8279e3290b032e3476d69c8e5e1427af8f82739a5", size = 404403, upload-time = "2026-03-26T22:15:26.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-storage"
|
||||
version = "3.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core" },
|
||||
{ name = "google-auth" },
|
||||
{ name = "google-cloud-core" },
|
||||
{ name = "google-crc32c" },
|
||||
{ name = "google-resumable-media" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/47/205eb8e9a1739b5345843e5a425775cbdc472cc38e7eda082ba5b8d02450/google_cloud_storage-3.10.1.tar.gz", hash = "sha256:97db9aa4460727982040edd2bd13ff3d5e2260b5331ad22895802da1fc2a5286", size = 17309950, upload-time = "2026-03-23T09:35:23.409Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/ff/ca9ab2417fa913d75aae38bf40bf856bb2749a604b2e0f701b37cfcd23cc/google_cloud_storage-3.10.1-py3-none-any.whl", hash = "sha256:a72f656759b7b99bda700f901adcb3425a828d4a29f911bc26b3ea79c5b1217f", size = 324453, upload-time = "2026-03-23T09:35:21.368Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-crc32c"
|
||||
version = "1.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-genai"
|
||||
version = "1.52.0"
|
||||
@@ -2200,6 +2324,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/66/03f663e7bca7abe9ccfebe6cb3fe7da9a118fd723a5abb278d6117e7990e/google_genai-1.52.0-py3-none-any.whl", hash = "sha256:c8352b9f065ae14b9322b949c7debab8562982f03bf71d44130cd2b798c20743", size = 261219, upload-time = "2025-11-21T02:18:54.515Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-resumable-media"
|
||||
version = "2.8.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-crc32c" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/d1/b1ea14b93b6b78f57fc580125de44e9f593ab88dd2460f1a8a8d18f74754/google_resumable_media-2.8.2.tar.gz", hash = "sha256:f3354a182ebd193ae3f42e3ef95e6c9b10f128320de23ac7637236713b1acd70", size = 2164510, upload-time = "2026-03-30T23:34:25.369Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/f8/50bfaf4658431ff9de45c5c3935af7ab01157a4903c603cd0eee6e78e087/google_resumable_media-2.8.2-py3-none-any.whl", hash = "sha256:82b6d8ccd11765268cdd2a2123f417ec806b8eef3000a9a38dfe3033da5fb220", size = 81511, upload-time = "2026-03-30T23:34:09.671Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "googleapis-common-protos"
|
||||
version = "1.72.0"
|
||||
@@ -2212,6 +2348,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
grpc = [
|
||||
{ name = "grpcio" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.2.4"
|
||||
@@ -2262,6 +2403,85 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grpc-google-iam-v1"
|
||||
version = "0.14.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "googleapis-common-protos", extra = ["grpc"] },
|
||||
{ name = "grpcio" },
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/4f/d098419ad0bfc06c9ce440575f05aa22d8973b6c276e86ac7890093d3c37/grpc_google_iam_v1-0.14.4.tar.gz", hash = "sha256:392b3796947ed6334e61171d9ab06bf7eb357f554e5fc7556ad7aab6d0e17038", size = 23706, upload-time = "2026-04-01T01:57:49.813Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/22/c2dd50c09bf679bd38173656cd4402d2511e563b33bc88f90009cf50613c/grpc_google_iam_v1-0.14.4-py3-none-any.whl", hash = "sha256:412facc320fcbd94034b4df3d557662051d4d8adfa86e0ddb4dca70a3f739964", size = 32675, upload-time = "2026-04-01T01:57:47.69Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grpcio"
|
||||
version = "1.80.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grpcio-status"
|
||||
version = "1.80.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "googleapis-common-protos" },
|
||||
{ name = "grpcio" },
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/ed/105f619bdd00cb47a49aa2feea6232ea2bbb04199d52a22cc6a7d603b5cb/grpcio_status-1.80.0.tar.gz", hash = "sha256:df73802a4c89a3ea88aa2aff971e886fccce162bc2e6511408b3d67a144381cd", size = 13901, upload-time = "2026-03-30T08:54:34.784Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/80/58cd2dfc19a07d022abe44bde7c365627f6c7cb6f692ada6c65ca437d09a/grpcio_status-1.80.0-py3-none-any.whl", hash = "sha256:4b56990363af50dbf2c2ebb80f1967185c07d87aa25aa2bea45ddb75fc181dbe", size = 14638, upload-time = "2026-03-30T08:54:01.569Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -3164,6 +3384,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/05/3516cc7386b220d388aa0bd833308c677e94eceb82b2756dd95e06f6a13f/litellm-1.81.6-py3-none-any.whl", hash = "sha256:573206ba194d49a1691370ba33f781671609ac77c35347f8a0411d852cf6341a", size = 12224343, upload-time = "2026-02-01T04:02:23.704Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
google = [
|
||||
{ name = "google-cloud-aiplatform" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "locket"
|
||||
version = "1.0.0"
|
||||
@@ -3278,14 +3503,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.2.4"
|
||||
version = "1.3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/5f/2ba6e026d33a0e6ddc1dddf9958677f76f5f80c236bd65309d280b166d3e/Mako-1.2.4.tar.gz", hash = "sha256:d60a3903dc3bb01a18ad6a89cdbe2e4eadc69c0bc8ef1e3773ba53d44c3f7a34", size = 497021, upload-time = "2022-11-15T14:37:51.327Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/8a/805404d0c0b9f3d7a326475ca008db57aea9c5c9f2e1e39ed0faa335571c/mako-1.3.11.tar.gz", hash = "sha256:071eb4ab4c5010443152255d77db7faa6ce5916f35226eb02dc34479b6858069", size = 399811, upload-time = "2026-04-14T20:19:51.493Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/3b/68690a035ba7347860f1b8c0cde853230ba69ff41df5884ea7d89fe68cd3/Mako-1.2.4-py3-none-any.whl", hash = "sha256:c97c79c018b9165ac9922ae4f32da095ffd3c4e6872b45eded42926deea46818", size = 78672, upload-time = "2022-11-15T14:37:53.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/a5/19d7aaa7e433713ffe881df33705925a196afb9532efc8475d26593921a6/mako-1.3.11-py3-none-any.whl", hash = "sha256:e372c6e333cf004aa736a15f425087ec977e1fcbd2966aae7f17c8dc1da27a77", size = 78503, upload-time = "2026-04-14T20:19:53.233Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4204,7 +4429,7 @@ dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "google-genai" },
|
||||
{ name = "kubernetes" },
|
||||
{ name = "litellm" },
|
||||
{ name = "litellm", extra = ["google"] },
|
||||
{ name = "openai" },
|
||||
{ name = "prometheus-client" },
|
||||
{ name = "prometheus-fastapi-instrumentator" },
|
||||
@@ -4377,7 +4602,7 @@ requires-dist = [
|
||||
{ name = "fastapi", specifier = "==0.133.1" },
|
||||
{ name = "google-genai", specifier = "==1.52.0" },
|
||||
{ name = "kubernetes", specifier = ">=31.0.0" },
|
||||
{ name = "litellm", specifier = "==1.81.6" },
|
||||
{ name = "litellm", extras = ["google"], specifier = "==1.81.6" },
|
||||
{ name = "openai", specifier = "==2.14.0" },
|
||||
{ name = "prometheus-client", specifier = ">=0.21.1" },
|
||||
{ name = "prometheus-fastapi-instrumentator", specifier = "==7.1.0" },
|
||||
@@ -4430,7 +4655,7 @@ backend = [
|
||||
{ name = "langfuse", specifier = "==3.10.0" },
|
||||
{ name = "lazy-imports", specifier = "==1.0.1" },
|
||||
{ name = "lxml", specifier = "==5.3.0" },
|
||||
{ name = "mako", specifier = "==1.2.4" },
|
||||
{ name = "mako", specifier = "==1.3.11" },
|
||||
{ name = "markitdown", extras = ["pdf", "docx", "pptx", "xlsx", "xls"], specifier = "==0.1.2" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = "==1.26.0" },
|
||||
{ name = "mistune", specifier = "==3.2.0" },
|
||||
@@ -4453,7 +4678,7 @@ backend = [
|
||||
{ name = "pygithub", specifier = "==2.5.0" },
|
||||
{ name = "pympler", specifier = "==1.1" },
|
||||
{ name = "pypandoc-binary", specifier = "==1.16.2" },
|
||||
{ name = "pypdf", specifier = "==6.10.0" },
|
||||
{ name = "pypdf", specifier = "==6.10.2" },
|
||||
{ name = "pytest-mock", specifier = "==3.12.0" },
|
||||
{ name = "pytest-playwright", specifier = "==0.7.2" },
|
||||
{ name = "python-dateutil", specifier = "==2.8.2" },
|
||||
@@ -5703,11 +5928,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.10.0"
|
||||
version = "6.10.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/9f/ca96abf18683ca12602065e4ed2bec9050b672c87d317f1079abc7b6d993/pypdf-6.10.0.tar.gz", hash = "sha256:4c5a48ba258c37024ec2505f7e8fd858525f5502784a2e1c8d415604af29f6ef", size = 5314833, upload-time = "2026-04-10T09:34:57.102Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/3f/9f2167401c2e94833ca3b69535bad89e533b5de75fefe4197a2c224baec2/pypdf-6.10.2.tar.gz", hash = "sha256:7d09ce108eff6bf67465d461b6ef352dcb8d84f7a91befc02f904455c6eea11d", size = 5315679, upload-time = "2026-04-15T16:37:36.978Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/55/f2/7ebe366f633f30a6ad105f650f44f24f98cb1335c4157d21ae47138b3482/pypdf-6.10.0-py3-none-any.whl", hash = "sha256:90005e959e1596c6e6c84c8b0ad383285b3e17011751cedd17f2ce8fcdfc86de", size = 334459, upload-time = "2026-04-10T09:34:54.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/d6/1d5c60cc17bbdf37c1552d9c03862fc6d32c5836732a0415b2d637edc2d0/pypdf-6.10.2-py3-none-any.whl", hash = "sha256:aa53be9826655b51c96741e5d7983ca224d898ac0a77896e64636810517624aa", size = 336308, upload-time = "2026-04-15T16:37:34.851Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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,13 @@
|
||||
import "@opal/components/cards/shared.css";
|
||||
import "@opal/components/cards/message-card/styles.css";
|
||||
import { cn } from "@opal/utils";
|
||||
import type { RichStr, IconFunctionComponent } from "@opal/types";
|
||||
import type {
|
||||
IconFunctionComponent,
|
||||
PaddingVariants,
|
||||
RichStr,
|
||||
StatusVariants,
|
||||
} from "@opal/types";
|
||||
import { paddingVariants } from "@opal/shared";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Button, Divider } from "@opal/components";
|
||||
import {
|
||||
@@ -15,11 +22,9 @@ import {
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MessageCardVariant = "default" | "info" | "success" | "warning" | "error";
|
||||
|
||||
interface MessageCardBaseProps {
|
||||
/** Visual variant controlling background, border, and icon. @default "default" */
|
||||
variant?: MessageCardVariant;
|
||||
variant?: StatusVariants;
|
||||
|
||||
/** Override the default variant icon. */
|
||||
icon?: IconFunctionComponent;
|
||||
@@ -30,6 +35,9 @@ interface MessageCardBaseProps {
|
||||
/** Optional description below the title. */
|
||||
description?: string | RichStr;
|
||||
|
||||
/** Padding preset. @default "sm" */
|
||||
padding?: Extract<PaddingVariants, "sm" | "xs">;
|
||||
|
||||
/**
|
||||
* Content rendered below a divider, under the main content area.
|
||||
* When provided, a `Divider` is inserted between the `ContentAction` and this node.
|
||||
@@ -59,7 +67,7 @@ type MessageCardProps = MessageCardBaseProps &
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VARIANT_CONFIG: Record<
|
||||
MessageCardVariant,
|
||||
StatusVariants,
|
||||
{ icon: IconFunctionComponent; iconClass: string }
|
||||
> = {
|
||||
default: { icon: SvgAlertCircle, iconClass: "stroke-text-03" },
|
||||
@@ -113,6 +121,7 @@ function MessageCard({
|
||||
icon: iconOverride,
|
||||
title,
|
||||
description,
|
||||
padding = "sm",
|
||||
bottomChildren,
|
||||
rightChildren,
|
||||
onClose,
|
||||
@@ -134,7 +143,11 @@ function MessageCard({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="opal-message-card" data-variant={variant} ref={ref}>
|
||||
<div
|
||||
className={cn("opal-message-card", paddingVariants[padding])}
|
||||
data-variant={variant}
|
||||
ref={ref}
|
||||
>
|
||||
<ContentAction
|
||||
icon={(props) => (
|
||||
<Icon {...props} className={cn(props.className, iconClass)} />
|
||||
@@ -143,7 +156,7 @@ function MessageCard({
|
||||
description={description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="lg"
|
||||
paddingVariant="md"
|
||||
rightChildren={right}
|
||||
/>
|
||||
|
||||
@@ -157,4 +170,4 @@ function MessageCard({
|
||||
);
|
||||
}
|
||||
|
||||
export { MessageCard, type MessageCardProps, type MessageCardVariant };
|
||||
export { MessageCard, type MessageCardProps };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.opal-message-card {
|
||||
@apply flex flex-col self-stretch rounded-16 border p-2;
|
||||
@apply flex flex-col w-full self-stretch rounded-16 border;
|
||||
}
|
||||
|
||||
/* Variant colors */
|
||||
|
||||
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>
|
||||
|
||||
@@ -7,4 +7,6 @@
|
||||
--container-md: 54.5rem;
|
||||
--container-lg: 62rem;
|
||||
--container-full: 100%;
|
||||
|
||||
--toast-width: 25rem;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -50,11 +51,7 @@ function ToastContainer() {
|
||||
return (
|
||||
<div
|
||||
data-testid="toast-container"
|
||||
className={cn(
|
||||
"fixed bottom-4 right-4 z-[10000]",
|
||||
"flex flex-col gap-2 items-end",
|
||||
"max-w-[420px]"
|
||||
)}
|
||||
className="fixed bottom-4 right-4 z-[var(--z-toast)] flex flex-col gap-2 items-end max-w-[var(--toast-width)] w-full"
|
||||
>
|
||||
{visible.map((t) => {
|
||||
const text =
|
||||
@@ -65,7 +62,7 @@ function ToastContainer() {
|
||||
<div
|
||||
key={t.id}
|
||||
className={cn(
|
||||
"shadow-02 rounded-12",
|
||||
"w-full",
|
||||
t.leaving ? "animate-fade-out-scale" : "animate-fade-in-scale"
|
||||
)}
|
||||
>
|
||||
@@ -73,6 +70,7 @@ function ToastContainer() {
|
||||
variant={LEVEL_TO_VARIANT[t.level ?? "info"]}
|
||||
title={text}
|
||||
description={buildDescription(t)}
|
||||
padding="xs"
|
||||
onClose={t.dismissible ? () => handleClose(t.id) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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,6 @@ import {
|
||||
OPEN_URL_TOOL_ID,
|
||||
} from "@/app/app/components/tools/constants";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Card } from "@/refresh-components/cards";
|
||||
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
|
||||
import SwitchField from "@/refresh-components/form/SwitchField";
|
||||
import { Tooltip } from "@opal/components";
|
||||
@@ -72,8 +76,6 @@ import {
|
||||
import useMcpServersForAgentEditor from "@/hooks/useMcpServersForAgentEditor";
|
||||
import useOpenApiTools from "@/hooks/useOpenApiTools";
|
||||
import { useAvailableTools } from "@/hooks/useAvailableTools";
|
||||
import * as ActionsLayouts from "@/layouts/actions-layouts";
|
||||
import * as ExpandableCard from "@/layouts/expandable-card-layouts";
|
||||
import { getActionIcon } from "@/lib/tools/mcpUtils";
|
||||
import { MCPServer, MCPTool, ToolSnapshot } from "@/lib/tools/interfaces";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
@@ -221,7 +223,7 @@ function AgentIconEditor({ existingAgent }: AgentIconEditorProps) {
|
||||
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 mb-2">
|
||||
<Hoverable.Item group="inputAvatar" variant="opacity-on-hover">
|
||||
<Button size="md" prominence="secondary">
|
||||
<Button prominence="secondary" size="md">
|
||||
Edit
|
||||
</Button>
|
||||
</Hoverable.Item>
|
||||
@@ -277,14 +279,21 @@ function OpenApiToolCard({ tool }: OpenApiToolCardProps) {
|
||||
const toolFieldName = `openapi_tool_${tool.id}`;
|
||||
|
||||
return (
|
||||
<ExpandableCard.Root defaultFolded>
|
||||
<ActionsLayouts.Header
|
||||
title={tool.display_name || tool.name}
|
||||
description={tool.description}
|
||||
icon={SvgActions}
|
||||
rightChildren={<SwitchField name={toolFieldName} />}
|
||||
<Card border="solid" rounding="lg" padding="sm">
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<ContentAction
|
||||
icon={SvgActions}
|
||||
title={tool.display_name || tool.name}
|
||||
description={tool.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
/>
|
||||
}
|
||||
topRightChildren={<SwitchField name={toolFieldName} />}
|
||||
/>
|
||||
</ExpandableCard.Root>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -315,87 +324,120 @@ function MCPServerCard({
|
||||
return toolFieldValue === true;
|
||||
}).length;
|
||||
|
||||
const hasTools = enabledTools.length > 0 && filteredTools.length > 0;
|
||||
|
||||
let cardContent: React.ReactNode | undefined;
|
||||
if (isLoading) {
|
||||
cardContent = (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<GeneralLayouts.Section padding={1}>
|
||||
<SimpleLoader />
|
||||
</GeneralLayouts.Section>
|
||||
</div>
|
||||
);
|
||||
} else if (hasTools) {
|
||||
cardContent = (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{filteredTools.map((tool) => {
|
||||
const toolDisabled =
|
||||
!tool.isAvailable ||
|
||||
!getFieldMeta<boolean>(`${serverFieldName}.enabled`).value;
|
||||
return (
|
||||
<Disabled key={tool.id} disabled={toolDisabled}>
|
||||
<Card border="solid" rounding="lg" padding="sm">
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<ContentAction
|
||||
icon={tool.icon ?? SvgSliders}
|
||||
title={tool.name}
|
||||
description={tool.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
/>
|
||||
}
|
||||
topRightChildren={
|
||||
<SwitchField
|
||||
name={`${serverFieldName}.tool_${tool.id}`}
|
||||
disabled={!isServerEnabled}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Disabled>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
|
||||
<ActionsLayouts.Header
|
||||
title={server.name}
|
||||
description={server.description}
|
||||
icon={getActionIcon(server.server_url, server.name)}
|
||||
rightChildren={
|
||||
<GeneralLayouts.Section
|
||||
flexDirection="row"
|
||||
gap={0.5}
|
||||
alignItems="start"
|
||||
>
|
||||
<EnabledCount
|
||||
enabledCount={enabledCount}
|
||||
totalCount={enabledTools.length}
|
||||
/>
|
||||
<SwitchField
|
||||
name={`${serverFieldName}.enabled`}
|
||||
onCheckedChange={(checked) => {
|
||||
enabledTools.forEach((tool) => {
|
||||
setFieldValue(`${serverFieldName}.tool_${tool.id}`, checked);
|
||||
});
|
||||
if (!checked) return;
|
||||
setIsFolded(false);
|
||||
}}
|
||||
<Card
|
||||
expandable
|
||||
expanded={!isFolded}
|
||||
border="solid"
|
||||
rounding="lg"
|
||||
padding="sm"
|
||||
expandedContent={cardContent}
|
||||
>
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<ContentAction
|
||||
icon={getActionIcon(server.server_url, server.name)}
|
||||
title={server.name}
|
||||
description={server.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
rightChildren={
|
||||
<GeneralLayouts.Section
|
||||
flexDirection="row"
|
||||
gap={0.5}
|
||||
alignItems="start"
|
||||
>
|
||||
<EnabledCount
|
||||
enabledCount={enabledCount}
|
||||
totalCount={enabledTools.length}
|
||||
/>
|
||||
<SwitchField
|
||||
name={`${serverFieldName}.enabled`}
|
||||
onCheckedChange={(checked) => {
|
||||
enabledTools.forEach((tool) => {
|
||||
setFieldValue(
|
||||
`${serverFieldName}.tool_${tool.id}`,
|
||||
checked
|
||||
);
|
||||
});
|
||||
if (!checked) return;
|
||||
setIsFolded(false);
|
||||
}}
|
||||
/>
|
||||
</GeneralLayouts.Section>
|
||||
}
|
||||
/>
|
||||
}
|
||||
bottomChildren={
|
||||
<GeneralLayouts.Section flexDirection="row" gap={0.5}>
|
||||
<InputTypeIn
|
||||
placeholder="Search tools..."
|
||||
variant="internal"
|
||||
leftSearchIcon
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{enabledTools.length > 0 && (
|
||||
<Button
|
||||
prominence="internal"
|
||||
rightIcon={isFolded ? SvgExpand : SvgFold}
|
||||
onClick={() => setIsFolded((prev) => !prev)}
|
||||
>
|
||||
{isFolded ? "Expand" : "Fold"}
|
||||
</Button>
|
||||
)}
|
||||
</GeneralLayouts.Section>
|
||||
}
|
||||
>
|
||||
<GeneralLayouts.Section flexDirection="row" gap={0.5}>
|
||||
<InputTypeIn
|
||||
placeholder="Search tools..."
|
||||
variant="internal"
|
||||
leftSearchIcon
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{enabledTools.length > 0 && (
|
||||
<Button
|
||||
prominence="internal"
|
||||
rightIcon={isFolded ? SvgExpand : SvgFold}
|
||||
onClick={() => setIsFolded((prev) => !prev)}
|
||||
>
|
||||
{isFolded ? "Expand" : "Fold"}
|
||||
</Button>
|
||||
)}
|
||||
</GeneralLayouts.Section>
|
||||
</ActionsLayouts.Header>
|
||||
{isLoading ? (
|
||||
<ActionsLayouts.Content>
|
||||
<GeneralLayouts.Section padding={1}>
|
||||
<SimpleLoader />
|
||||
</GeneralLayouts.Section>
|
||||
</ActionsLayouts.Content>
|
||||
) : (
|
||||
enabledTools.length > 0 &&
|
||||
filteredTools.length > 0 && (
|
||||
<ActionsLayouts.Content>
|
||||
{filteredTools.map((tool) => (
|
||||
<ActionsLayouts.Tool
|
||||
key={tool.id}
|
||||
name={`${serverFieldName}.tool_${tool.id}`}
|
||||
title={tool.name}
|
||||
description={tool.description}
|
||||
icon={tool.icon ?? SvgSliders}
|
||||
disabled={
|
||||
!tool.isAvailable ||
|
||||
!getFieldMeta<boolean>(`${serverFieldName}.enabled`).value
|
||||
}
|
||||
rightChildren={
|
||||
<SwitchField
|
||||
name={`${serverFieldName}.tool_${tool.id}`}
|
||||
disabled={!isServerEnabled}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ActionsLayouts.Content>
|
||||
)
|
||||
)}
|
||||
</ExpandableCard.Root>
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1376,17 +1418,11 @@ export default function AgentEditorPage({
|
||||
/>
|
||||
<SimpleCollapsible.Content>
|
||||
<GeneralLayouts.Section gap={0.5}>
|
||||
<Tooltip
|
||||
<Disabled
|
||||
disabled={!isImageGenerationAvailable}
|
||||
tooltip={imageGenerationDisabledTooltip}
|
||||
side="top"
|
||||
>
|
||||
<Card
|
||||
variant={
|
||||
isImageGenerationAvailable
|
||||
? undefined
|
||||
: "disabled"
|
||||
}
|
||||
>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
withLabel="image_generation"
|
||||
title="Image Generation"
|
||||
@@ -1399,57 +1435,55 @@ export default function AgentEditorPage({
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
</Disabled>
|
||||
|
||||
<Card
|
||||
variant={!!webSearchTool ? undefined : "disabled"}
|
||||
>
|
||||
<InputHorizontal
|
||||
withLabel="web_search"
|
||||
title="Web Search"
|
||||
description="Search the web for real-time information and up-to-date results."
|
||||
disabled={!webSearchTool}
|
||||
>
|
||||
<SwitchField
|
||||
name="web_search"
|
||||
<Disabled disabled={!webSearchTool}>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
withLabel="web_search"
|
||||
title="Web Search"
|
||||
description="Search the web for real-time information and up-to-date results."
|
||||
disabled={!webSearchTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
>
|
||||
<SwitchField
|
||||
name="web_search"
|
||||
disabled={!webSearchTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Disabled>
|
||||
|
||||
<Card
|
||||
variant={!!openURLTool ? undefined : "disabled"}
|
||||
>
|
||||
<InputHorizontal
|
||||
withLabel="open_url"
|
||||
title="Open URL"
|
||||
description="Fetch and read content from web URLs."
|
||||
disabled={!openURLTool}
|
||||
>
|
||||
<SwitchField
|
||||
name="open_url"
|
||||
<Disabled disabled={!openURLTool}>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
withLabel="open_url"
|
||||
title="Open URL"
|
||||
description="Fetch and read content from web URLs."
|
||||
disabled={!openURLTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
>
|
||||
<SwitchField
|
||||
name="open_url"
|
||||
disabled={!openURLTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Disabled>
|
||||
|
||||
<Card
|
||||
variant={
|
||||
!!codeInterpreterTool ? undefined : "disabled"
|
||||
}
|
||||
>
|
||||
<InputHorizontal
|
||||
withLabel="code_interpreter"
|
||||
title="Code Interpreter"
|
||||
description="Generate and run code."
|
||||
disabled={!codeInterpreterTool}
|
||||
>
|
||||
<SwitchField
|
||||
name="code_interpreter"
|
||||
<Disabled disabled={!codeInterpreterTool}>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
withLabel="code_interpreter"
|
||||
title="Code Interpreter"
|
||||
description="Generate and run code."
|
||||
disabled={!codeInterpreterTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
>
|
||||
<SwitchField
|
||||
name="code_interpreter"
|
||||
disabled={!codeInterpreterTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Disabled>
|
||||
|
||||
{/* Tools */}
|
||||
<>
|
||||
@@ -1506,73 +1540,77 @@ export default function AgentEditorPage({
|
||||
/>
|
||||
<SimpleCollapsible.Content>
|
||||
<GeneralLayouts.Section>
|
||||
<Card>
|
||||
<InputHorizontal
|
||||
title="Share This Agent"
|
||||
description="with other users, groups, or everyone in your organization."
|
||||
center
|
||||
>
|
||||
<Button
|
||||
prominence="secondary"
|
||||
icon={isShared ? SvgUsers : SvgLock}
|
||||
onClick={() => shareAgentModal.toggle(true)}
|
||||
<Card border="solid" rounding="lg">
|
||||
<GeneralLayouts.Section>
|
||||
<InputHorizontal
|
||||
title="Share This Agent"
|
||||
description="with other users, groups, or everyone in your organization."
|
||||
center
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</InputHorizontal>
|
||||
{canUpdateFeaturedStatus && (
|
||||
<>
|
||||
<InputHorizontal
|
||||
withLabel="is_featured"
|
||||
title="Feature This Agent"
|
||||
description="Show this agent at the top of the explore agents list and automatically pin it to the sidebar for new users with access."
|
||||
<Button
|
||||
prominence="secondary"
|
||||
icon={isShared ? SvgUsers : SvgLock}
|
||||
onClick={() => shareAgentModal.toggle(true)}
|
||||
>
|
||||
<SwitchField name="is_featured" />
|
||||
</InputHorizontal>
|
||||
{values.is_featured && !isShared && (
|
||||
<MessageCard title="This agent is private to you and will only be featured for yourself." />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
Share
|
||||
</Button>
|
||||
</InputHorizontal>
|
||||
{canUpdateFeaturedStatus && (
|
||||
<>
|
||||
<InputHorizontal
|
||||
withLabel="is_featured"
|
||||
title="Feature This Agent"
|
||||
description="Show this agent at the top of the explore agents list and automatically pin it to the sidebar for new users with access."
|
||||
>
|
||||
<SwitchField name="is_featured" />
|
||||
</InputHorizontal>
|
||||
{values.is_featured && !isShared && (
|
||||
<MessageCard title="This agent is private to you and will only be featured for yourself." />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</GeneralLayouts.Section>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<InputHorizontal
|
||||
withLabel="llm_model"
|
||||
title="Default Model"
|
||||
description="This model will be used by Onyx by default in your chats."
|
||||
>
|
||||
<LLMSelector
|
||||
name="llm_model"
|
||||
llmProviders={llmProviders ?? []}
|
||||
currentLlm={getCurrentLlm(
|
||||
values,
|
||||
llmProviders
|
||||
)}
|
||||
onSelect={(selected) =>
|
||||
onLlmSelect(selected, setFieldValue)
|
||||
}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
withLabel="knowledge_cutoff_date"
|
||||
title="Knowledge Cutoff Date"
|
||||
suffix="optional"
|
||||
description="Documents with a last-updated date prior to this will be ignored."
|
||||
>
|
||||
<InputDatePickerField
|
||||
name="knowledge_cutoff_date"
|
||||
maxDate={new Date()}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
withLabel="replace_base_system_prompt"
|
||||
title="Overwrite System Prompt"
|
||||
suffix="(Not Recommended)"
|
||||
description='Remove the base system prompt which includes useful instructions (e.g. "You can use Markdown tables"). This may affect response quality.'
|
||||
>
|
||||
<SwitchField name="replace_base_system_prompt" />
|
||||
</InputHorizontal>
|
||||
<Card border="solid" rounding="lg">
|
||||
<GeneralLayouts.Section>
|
||||
<InputHorizontal
|
||||
withLabel="llm_model"
|
||||
title="Default Model"
|
||||
description="This model will be used by Onyx by default in your chats."
|
||||
>
|
||||
<LLMSelector
|
||||
name="llm_model"
|
||||
llmProviders={llmProviders ?? []}
|
||||
currentLlm={getCurrentLlm(
|
||||
values,
|
||||
llmProviders
|
||||
)}
|
||||
onSelect={(selected) =>
|
||||
onLlmSelect(selected, setFieldValue)
|
||||
}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
withLabel="knowledge_cutoff_date"
|
||||
title="Knowledge Cutoff Date"
|
||||
suffix="optional"
|
||||
description="Documents with a last-updated date prior to this will be ignored."
|
||||
>
|
||||
<InputDatePickerField
|
||||
name="knowledge_cutoff_date"
|
||||
maxDate={new Date()}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
withLabel="replace_base_system_prompt"
|
||||
title="Overwrite System Prompt"
|
||||
suffix="(Not Recommended)"
|
||||
description='Remove the base system prompt which includes useful instructions (e.g. "You can use Markdown tables"). This may affect response quality.'
|
||||
>
|
||||
<SwitchField name="replace_base_system_prompt" />
|
||||
</InputHorizontal>
|
||||
</GeneralLayouts.Section>
|
||||
</Card>
|
||||
|
||||
<GeneralLayouts.Section gap={0.25}>
|
||||
@@ -1605,7 +1643,7 @@ export default function AgentEditorPage({
|
||||
paddingPerpendicular="fit"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Delete This Agent"
|
||||
description="Anyone using this agent will no longer be able to access it."
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { SWR_KEYS } from "@/lib/swr-keys";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
|
||||
import { Tooltip } from "@opal/components";
|
||||
import InputTextAreaField from "@/refresh-components/form/InputTextAreaField";
|
||||
@@ -26,14 +25,20 @@ import {
|
||||
SvgRefreshCw,
|
||||
} from "@opal/icons";
|
||||
import { ADMIN_ROUTES } from "@/lib/admin-routes";
|
||||
import { Content, InputHorizontal, InputVertical } from "@opal/layouts";
|
||||
import {
|
||||
Card as CardLayout,
|
||||
Content,
|
||||
ContentAction,
|
||||
InputHorizontal,
|
||||
InputVertical,
|
||||
} from "@opal/layouts";
|
||||
import {
|
||||
useSettingsContext,
|
||||
useVectorDbEnabled,
|
||||
} from "@/providers/SettingsProvider";
|
||||
import useCCPairs from "@/hooks/useCCPairs";
|
||||
import { getSourceMetadata } from "@/lib/sources";
|
||||
import EmptyMessage from "@/refresh-components/EmptyMessage";
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import { Settings } from "@/interfaces/settings";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { useAvailableTools } from "@/hooks/useAvailableTools";
|
||||
@@ -44,13 +49,11 @@ import {
|
||||
PYTHON_TOOL_ID,
|
||||
OPEN_URL_TOOL_ID,
|
||||
} from "@/app/app/components/tools/constants";
|
||||
import { Button, Divider, Text, Card as OpalCard } from "@opal/components";
|
||||
import { Button, Divider, Text, Card } from "@opal/components";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import Switch from "@/refresh-components/inputs/Switch";
|
||||
import useMcpServersForAgentEditor from "@/hooks/useMcpServersForAgentEditor";
|
||||
import useOpenApiTools from "@/hooks/useOpenApiTools";
|
||||
import * as ExpandableCard from "@/layouts/expandable-card-layouts";
|
||||
import * as ActionsLayouts from "@/layouts/actions-layouts";
|
||||
import { getActionIcon } from "@/lib/tools/mcpUtils";
|
||||
import { Disabled, Hoverable } from "@opal/core";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
@@ -103,68 +106,94 @@ function MCPServerCard({
|
||||
? "Authenticate this MCP server before enabling its tools."
|
||||
: undefined;
|
||||
|
||||
const expanded = !isFolded;
|
||||
const hasContent = tools.length > 0 && filteredTools.length > 0;
|
||||
|
||||
return (
|
||||
<ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
|
||||
<ActionsLayouts.Header
|
||||
title={server.name}
|
||||
description={server.description}
|
||||
icon={getActionIcon(server.server_url, server.name)}
|
||||
rightChildren={
|
||||
<Tooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={serverEnabled}
|
||||
onCheckedChange={(checked) => onToggleTools(allToolIds, checked)}
|
||||
disabled={needsAuth}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{tools.length > 0 && (
|
||||
<Section flexDirection="row" gap={0.5}>
|
||||
<InputTypeIn
|
||||
placeholder="Search tools..."
|
||||
variant="internal"
|
||||
leftSearchIcon
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
rightIcon={isFolded ? SvgExpand : SvgFold}
|
||||
onClick={() => setIsFolded((prev) => !prev)}
|
||||
prominence="internal"
|
||||
size="lg"
|
||||
>
|
||||
{isFolded ? "Expand" : "Fold"}
|
||||
</Button>
|
||||
</Section>
|
||||
)}
|
||||
</ActionsLayouts.Header>
|
||||
{tools.length > 0 && filteredTools.length > 0 && (
|
||||
<ActionsLayouts.Content>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Card
|
||||
expandable
|
||||
expanded={expanded}
|
||||
border="solid"
|
||||
rounding="lg"
|
||||
padding="sm"
|
||||
expandedContent={
|
||||
hasContent ? (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{filteredTools.map((tool) => (
|
||||
<ActionsLayouts.Tool
|
||||
key={tool.id}
|
||||
title={tool.name}
|
||||
description={tool.description}
|
||||
icon={tool.icon}
|
||||
rightChildren={
|
||||
<Tooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={isToolEnabled(tool.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleTool(tool.id, checked)
|
||||
}
|
||||
disabled={needsAuth}
|
||||
<Card key={tool.id} border="solid" rounding="lg" padding="sm">
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<Content
|
||||
icon={tool.icon}
|
||||
title={tool.name}
|
||||
description={tool.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
}
|
||||
topRightChildren={
|
||||
<Tooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={isToolEnabled(tool.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleTool(tool.id, checked)
|
||||
}
|
||||
disabled={needsAuth}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ActionsLayouts.Content>
|
||||
)}
|
||||
</ExpandableCard.Root>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<ContentAction
|
||||
icon={getActionIcon(server.server_url, server.name)}
|
||||
title={server.name}
|
||||
description={server.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
rightChildren={
|
||||
<Tooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={serverEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleTools(allToolIds, checked)
|
||||
}
|
||||
disabled={needsAuth}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
}
|
||||
bottomChildren={
|
||||
tools.length > 0 ? (
|
||||
<Section flexDirection="row" gap={0.5}>
|
||||
<InputTypeIn
|
||||
placeholder="Search tools..."
|
||||
variant="internal"
|
||||
leftSearchIcon
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
rightIcon={isFolded ? SvgExpand : SvgFold}
|
||||
onClick={() => setIsFolded((prev) => !prev)}
|
||||
prominence="internal"
|
||||
size="lg"
|
||||
>
|
||||
{isFolded ? "Expand" : "Fold"}
|
||||
</Button>
|
||||
</Section>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -304,10 +333,11 @@ function FileSizeLimitFields({
|
||||
maxAllowedUploadSizeMb,
|
||||
}: FileSizeLimitFieldsProps) {
|
||||
return (
|
||||
<div className="flex gap-4 w-full items-start">
|
||||
<div className="flex gap-4 w-full items-start pt-2">
|
||||
<div className="flex-1">
|
||||
<InputVertical
|
||||
title="File Size Limit (MB)"
|
||||
title="File Size Limit"
|
||||
suffix="(MB)"
|
||||
subDescription={
|
||||
maxAllowedUploadSizeMb
|
||||
? `Max: ${maxAllowedUploadSizeMb} MB`
|
||||
@@ -325,7 +355,11 @@ function FileSizeLimitFields({
|
||||
</InputVertical>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<InputVertical title="File Token Limit (thousand tokens)" withLabel>
|
||||
<InputVertical
|
||||
title="File Token Limit"
|
||||
withLabel
|
||||
suffix="(thousand tokens)"
|
||||
>
|
||||
<NumericLimitField
|
||||
name="file_token_count_threshold_k"
|
||||
initialValue={initialTokenThresholdK}
|
||||
@@ -339,7 +373,7 @@ function FileSizeLimitFields({
|
||||
);
|
||||
}
|
||||
|
||||
function ChatPreferencesForm() {
|
||||
export default function ChatPreferencesPage() {
|
||||
const router = useRouter();
|
||||
const settings = useSettingsContext();
|
||||
const s = settings.settings;
|
||||
@@ -523,72 +557,67 @@ function ChatPreferencesForm() {
|
||||
|
||||
<SettingsLayouts.Body>
|
||||
{/* Features */}
|
||||
<Card>
|
||||
<Tooltip
|
||||
tooltip={
|
||||
uniqueSources.length === 0
|
||||
? "Set up connectors to use Search Mode"
|
||||
: undefined
|
||||
}
|
||||
side="top"
|
||||
>
|
||||
<Disabled disabled={uniqueSources.length === 0} allowClick>
|
||||
<div className="w-full">
|
||||
<InputHorizontal
|
||||
title="Search Mode"
|
||||
tag={{ title: "beta", color: "blue" }}
|
||||
description="UI mode for quick document search across your organization."
|
||||
<Card border="solid" rounding="lg">
|
||||
<Section>
|
||||
<Disabled
|
||||
disabled={uniqueSources.length === 0}
|
||||
allowClick
|
||||
tooltip="Set up connectors to use Search Mode"
|
||||
>
|
||||
<InputHorizontal
|
||||
title="Search Mode"
|
||||
tag={{ title: "beta", color: "blue" }}
|
||||
description="UI mode for quick document search across your organization."
|
||||
disabled={uniqueSources.length === 0}
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.search_ui_enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ search_ui_enabled: checked });
|
||||
}}
|
||||
disabled={uniqueSources.length === 0}
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.search_ui_enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ search_ui_enabled: checked });
|
||||
}}
|
||||
disabled={uniqueSources.length === 0}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</div>
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Disabled>
|
||||
</Tooltip>
|
||||
<InputHorizontal
|
||||
title="Multi-Model Generation"
|
||||
tag={{ title: "beta", color: "blue" }}
|
||||
description="Allow multiple models to generate responses in parallel in chat."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.multi_model_chat_enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ multi_model_chat_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
title="Deep Research"
|
||||
description="Agentic research system that works across the web and connected sources. Uses significantly more tokens per query."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.deep_research_enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ deep_research_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
title="Chat Auto-Scroll"
|
||||
description="Automatically scroll to new content as chat generates response. Users can override this in their personal settings."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.auto_scroll ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ auto_scroll: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
title="Multi-Model Generation"
|
||||
tag={{ title: "beta", color: "blue" }}
|
||||
description="Allow multiple models to generate responses in parallel in chat."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.multi_model_chat_enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ multi_model_chat_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
title="Deep Research"
|
||||
description="Agentic research system that works across the web and connected sources. Uses significantly more tokens per query."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.deep_research_enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ deep_research_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
title="Chat Auto-Scroll"
|
||||
description="Automatically scroll to new content as chat generates response. Users can override this in their personal settings."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.auto_scroll ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ auto_scroll: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Section>
|
||||
</Card>
|
||||
|
||||
<Divider paddingParallel="fit" paddingPerpendicular="fit" />
|
||||
@@ -672,7 +701,10 @@ function ChatPreferencesForm() {
|
||||
gap={0.25}
|
||||
>
|
||||
{uniqueSources.length === 0 ? (
|
||||
<EmptyMessage title="No connectors set up" />
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
title="No connectors set up"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Section
|
||||
@@ -684,17 +716,15 @@ function ChatPreferencesForm() {
|
||||
{uniqueSources.slice(0, 3).map((source) => {
|
||||
const meta = getSourceMetadata(source);
|
||||
return (
|
||||
<Card
|
||||
key={source}
|
||||
padding={0.75}
|
||||
className="w-[10rem]"
|
||||
>
|
||||
<Content
|
||||
icon={meta.icon}
|
||||
title={meta.displayName}
|
||||
sizePreset="main-ui"
|
||||
/>
|
||||
</Card>
|
||||
<div key={source} className="w-[10rem]">
|
||||
<Card padding="sm" border="solid">
|
||||
<Content
|
||||
icon={meta.icon}
|
||||
title={meta.displayName}
|
||||
sizePreset="main-ui"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
@@ -720,7 +750,7 @@ function ChatPreferencesForm() {
|
||||
<SimpleCollapsible.Content>
|
||||
<Section gap={0.5}>
|
||||
{vectorDbEnabled && searchTool && (
|
||||
<Card>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Internal Search"
|
||||
description="Search through your organization's connected knowledge base and documents."
|
||||
@@ -736,15 +766,11 @@ function ChatPreferencesForm() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
tooltip={
|
||||
imageGenTool
|
||||
? undefined
|
||||
: "Image generation requires a configured model. Set one up under Configuration > Image Generation, or ask an admin."
|
||||
}
|
||||
side="top"
|
||||
<Disabled
|
||||
disabled={!imageGenTool}
|
||||
tooltip="Image generation requires a configured model. Set one up under Configuration > Image Generation, or ask an admin."
|
||||
>
|
||||
<Card variant={imageGenTool ? undefined : "disabled"}>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Image Generation"
|
||||
description="Generate and manipulate images using AI-powered tools."
|
||||
@@ -765,75 +791,79 @@ function ChatPreferencesForm() {
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
</Disabled>
|
||||
|
||||
<Card variant={webSearchTool ? undefined : "disabled"}>
|
||||
<InputHorizontal
|
||||
title="Web Search"
|
||||
description="Search the web for real-time information and up-to-date results."
|
||||
disabled={!webSearchTool}
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
webSearchTool
|
||||
? isToolEnabled(webSearchTool.id)
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
webSearchTool &&
|
||||
void toggleTool(webSearchTool.id, checked)
|
||||
}
|
||||
<Disabled disabled={!webSearchTool}>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Web Search"
|
||||
description="Search the web for real-time information and up-to-date results."
|
||||
disabled={!webSearchTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
webSearchTool
|
||||
? isToolEnabled(webSearchTool.id)
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
webSearchTool &&
|
||||
void toggleTool(webSearchTool.id, checked)
|
||||
}
|
||||
disabled={!webSearchTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Disabled>
|
||||
|
||||
<Card variant={openURLTool ? undefined : "disabled"}>
|
||||
<InputHorizontal
|
||||
title="Open URL"
|
||||
description="Fetch and read content from web URLs."
|
||||
disabled={!openURLTool}
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
openURLTool
|
||||
? isToolEnabled(openURLTool.id)
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
openURLTool &&
|
||||
void toggleTool(openURLTool.id, checked)
|
||||
}
|
||||
<Disabled disabled={!openURLTool}>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Open URL"
|
||||
description="Fetch and read content from web URLs."
|
||||
disabled={!openURLTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
openURLTool
|
||||
? isToolEnabled(openURLTool.id)
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
openURLTool &&
|
||||
void toggleTool(openURLTool.id, checked)
|
||||
}
|
||||
disabled={!openURLTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Disabled>
|
||||
|
||||
<Card
|
||||
variant={codeInterpreterTool ? undefined : "disabled"}
|
||||
>
|
||||
<InputHorizontal
|
||||
title="Code Interpreter"
|
||||
description="Generate and run code."
|
||||
disabled={!codeInterpreterTool}
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
codeInterpreterTool
|
||||
? isToolEnabled(codeInterpreterTool.id)
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
codeInterpreterTool &&
|
||||
void toggleTool(codeInterpreterTool.id, checked)
|
||||
}
|
||||
<Disabled disabled={!codeInterpreterTool}>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Code Interpreter"
|
||||
description="Generate and run code."
|
||||
disabled={!codeInterpreterTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
codeInterpreterTool
|
||||
? isToolEnabled(codeInterpreterTool.id)
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
codeInterpreterTool &&
|
||||
void toggleTool(codeInterpreterTool.id, checked)
|
||||
}
|
||||
disabled={!codeInterpreterTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Disabled>
|
||||
</Section>
|
||||
|
||||
{/* Separator between built-in tools and MCP/OpenAPI tools */}
|
||||
@@ -858,12 +888,23 @@ function ChatPreferencesForm() {
|
||||
/>
|
||||
))}
|
||||
{openApiTools.map((tool) => (
|
||||
<ExpandableCard.Root key={tool.id} defaultFolded>
|
||||
<ActionsLayouts.Header
|
||||
title={tool.display_name || tool.name}
|
||||
description={tool.description}
|
||||
icon={SvgActions}
|
||||
rightChildren={
|
||||
<Card
|
||||
key={tool.id}
|
||||
border="solid"
|
||||
rounding="lg"
|
||||
padding="sm"
|
||||
>
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<Content
|
||||
icon={SvgActions}
|
||||
title={tool.display_name || tool.name}
|
||||
description={tool.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
}
|
||||
topRightChildren={
|
||||
<Switch
|
||||
checked={isToolEnabled(tool.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
@@ -872,7 +913,7 @@ function ChatPreferencesForm() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ExpandableCard.Root>
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
</SimpleCollapsible.Content>
|
||||
@@ -888,7 +929,7 @@ function ChatPreferencesForm() {
|
||||
<SimpleCollapsible.Header title="Advanced Options" />
|
||||
<SimpleCollapsible.Content>
|
||||
<Section gap={1}>
|
||||
<Card>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Keep Chat History"
|
||||
description="Specify how long Onyx should retain chats in your organization."
|
||||
@@ -921,7 +962,7 @@ function ChatPreferencesForm() {
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputVertical
|
||||
title="File Attachment Size Limit"
|
||||
description="Files attached in chats and projects must fit within both limits to be accepted. Larger files increase latency, memory usage, and token costs."
|
||||
@@ -956,35 +997,39 @@ function ChatPreferencesForm() {
|
||||
</InputVertical>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<InputHorizontal
|
||||
title="Allow Anonymous Users"
|
||||
description="Allow anyone to start chats without logging in. They do not see any other chats and cannot create agents or update settings."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.anonymous_user_enabled ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ anonymous_user_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<Card border="solid" rounding="lg">
|
||||
<Section>
|
||||
<InputHorizontal
|
||||
title="Allow Anonymous Users"
|
||||
description="Allow anyone to start chats without logging in. They do not see any other chats and cannot create agents or update settings."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.anonymous_user_enabled ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({
|
||||
anonymous_user_enabled: checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
|
||||
<InputHorizontal
|
||||
title="Always Start with an Agent"
|
||||
description="This removes the default chat. Users will always start in an agent, and new chats will be created in their last active agent. Set featured agents to help new users get started."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
id="disable_default_assistant"
|
||||
checked={s.disable_default_assistant ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({
|
||||
disable_default_assistant: checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
title="Always Start with an Agent"
|
||||
description="This removes the default chat. Users will always start in an agent, and new chats will be created in their last active agent. Set featured agents to help new users get started."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
id="disable_default_assistant"
|
||||
checked={s.disable_default_assistant ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({
|
||||
disable_default_assistant: checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Section>
|
||||
</Card>
|
||||
</Section>
|
||||
</SimpleCollapsible.Content>
|
||||
@@ -1046,14 +1091,14 @@ function ChatPreferencesForm() {
|
||||
)}
|
||||
</Text>
|
||||
</Section>
|
||||
<OpalCard background="none" border="solid" padding="sm">
|
||||
<Card background="none" border="solid" padding="sm">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
icon={SvgAlertCircle}
|
||||
title="Modify with caution."
|
||||
description="System prompt affects all chats, agents, and projects. Significant changes may degrade response quality."
|
||||
/>
|
||||
</OpalCard>
|
||||
</Card>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
@@ -1078,7 +1123,3 @@ function ChatPreferencesForm() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatPreferencesPage() {
|
||||
return <ChatPreferencesForm />;
|
||||
}
|
||||
|
||||
@@ -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