Compare commits

..

45 Commits

Author SHA1 Message Date
Nik
deb6c65846 fix(slackbot): add safety net to never break code fences in SectionBlocks
If code extraction misses some fences (regex doesn't match edge case
formatting), the cleaned text could still contain code fences. Splitting
this text at word boundaries would break the fences across SectionBlocks.

Now after extraction, if the cleaned text still contains ``` and exceeds
the 3000 char SectionBlock limit, we force-extract ALL remaining code
blocks (limit=0) so only prose ends up in the blocks.

Also adds logging for extraction diagnostics and extracts
_SECTION_BLOCK_LIMIT constant.
2026-03-12 14:25:23 -07:00
Nik
1683e8f667 fix(slackbot): address greptile review feedback
- Fix over-extraction: guard that text is still over limit before
  extracting a block (prevents unnecessary extraction after previous
  blocks already brought text under limit)
- Fix snippet fallback visibility: pass receiver_ids and
  send_as_ephemeral through to fallback respond_in_thread_or_channel
- Fix snippets dropped for slash commands: use message_ts_to_respond_to
  as fallback thread anchor when target_thread_ts is None
- Tighten test assertion to verify exact snippet count
2026-03-12 13:34:02 -07:00
Nik
6ac4cacfad fix(slackbot): two-pass extraction + normalize snippet_type aliases
- _extract_code_snippets now uses a two-pass approach so each extraction
  decision accounts for previously removed blocks
- Add _SNIPPET_TYPE_MAP to normalize common LLM language aliases (py,
  js, ts, sh, etc.) to Slack's expected snippet_type values
- Add tests for cumulative removal logic and type normalization
2026-03-12 13:13:05 -07:00
Nik
71415c1a49 feat(slackbot): upload large code blocks as Slack file snippets
Instead of splitting code blocks across multiple SectionBlocks (which
breaks fence rendering), extract code blocks that would push the text
over the 3000 char SectionBlock limit and upload them as Slack file
snippets via files_upload_v2. Snippets render as collapsible,
syntax-highlighted blocks in the thread.

- Add _extract_code_snippets() to detect and extract large code blocks
- Upload extracted snippets in handle_regular_answer after posting blocks
- Fallback to inline code fence if snippet upload fails
- Remove fence-aware splitting logic (_find_unclosed_fence, tier 1/2)
  since code blocks are now extracted before splitting
2026-03-12 13:07:22 -07:00
Nik
3fb8dc1db0 fix(slackbot): only treat column-0 backticks as fences, add no-space test
- _find_unclosed_fence now uses line.startswith("```") instead of
  line.lstrip().startswith("```") — Slack only renders fences at column 0,
  so indented backticks inside code blocks are correctly treated as content
- Add clarifying comment on else branch lstrip() explaining why it's safe
- Add test for Tier 2 forced-split with no spaces in code content
- Add test for indented backticks not being counted as fences
- Clarify test_code_block_not_split_when_fits exercises early-return path
2026-03-12 11:58:50 -07:00
Nik
fda1528174 fix(slackbot): use _find_unclosed_fence in tests, strip only leading newline in Tier 1
- Replace count("```") % 2 assertions in tests with _find_unclosed_fence
  for consistency with production code
- Tier 1 now strips only the leading newline instead of lstrip() to
  preserve blank lines and formatting before code fences
2026-03-12 11:43:48 -07:00
Nik
5f34c83f0a fix(slackbot): address review — robust fence detection and lang preservation
- Replace naive `count("```")` with line-by-line `_find_unclosed_fence()`
  that only considers fences at the start of a line, fixing false positives
  from inline backticks inside code blocks
- Preserve language specifier (e.g. ```python) when reopening fences in
  Tier 2 fallback
- Guard against whitespace-only chunks in Tier 1 backup
- Strip only the boundary character in Tier 2 instead of lstrip() to
  preserve meaningful code indentation
- Add tests for language specifier preservation, inline backtick handling,
  and _find_unclosed_fence helper
2026-03-12 11:12:28 -07:00
Nik
3509e9c48c fix(slackbot): close code fences when splitting long messages
When a Slack bot response exceeds 3000 chars, _split_text splits it
into multiple SectionBlocks. If the split lands inside a code fence,
the opening ``` ends up in one block and the closing ``` in the next,
causing Slack to render everything after the cut as raw code.

Now detects unclosed fences at the split point, closes them in the
current chunk and reopens in the next so both render correctly.
2026-03-12 09:25:03 -07:00
Nikolas Garza
c57ea65d42 fix(db): avoid SQLAlchemy sentinel mismatch in batch user insert (#9300) 2026-03-12 06:56:45 +00:00
Nikolas Garza
c1ce180b72 feat(admin): add role, group, and status filters to Users table - 4/9 (#9179) 2026-03-11 21:56:19 -07:00
Jamison Lahman
b5474dc127 chore(devtools): upgrade ods: 0.6.3->0.7.0 (#9297) 2026-03-11 20:30:55 -07:00
Nikolas Garza
e1df3f533a feat(admin): add Users table with DataTable and server-side pagination - 3/9 (#9178) 2026-03-11 20:26:07 -07:00
Jamison Lahman
df5252db05 chore(devtools): ods backend api (#9295)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 20:07:23 -07:00
Nikolas Garza
f01f210af8 fix(slackbot): resolve channel references and filter search by channel tags (#9256) 2026-03-11 19:37:03 -07:00
Jamison Lahman
781219cf18 chore(models): rm claude-3-5-sonnet-v2 metadata (#9285) 2026-03-12 02:17:09 +00:00
Nikolas Garza
ca39da7de9 feat(admin): add user timestamps and enrich FullUserSnapshot - 2/9 (#9183) 2026-03-11 19:07:45 -07:00
dependabot[bot]
abf76cd747 chore(deps): bump tornado from 6.5.2 to 6.5.5 (#9290)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-12 01:41:01 +00:00
Jamison Lahman
a78607f1b5 fix(fe): InputComboBox resets filter value on open (#9287) 2026-03-12 01:06:02 +00:00
roshan
e213853f63 fix(craft): rename webapp download endpoint to avoid route conflict (#9283)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Wenxi <wenxi@onyx.app>
2026-03-11 23:19:38 +00:00
Wenxi
8dc379c6fd feat(ods): use release-tag to print highest stable semver that should receive the latest tag (#9278)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 22:18:13 +00:00
dependabot[bot]
787f117e17 chore(deps): bump pypdf from 6.7.5 to 6.8.0 (#9260)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-11 21:59:35 +00:00
Jamison Lahman
665640fac8 chore(opensearch): unset container ulimits in dev (#9277)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 21:58:43 +00:00
Danelegend
d2d44c1e68 fix(indexing): Stop deep-copy during indexing (#9275) 2026-03-11 21:24:15 +00:00
Nikolas Garza
ffe04ab91f fix(tests): remove deprecated o1-preview and o1-mini model tests (#9280) 2026-03-11 20:32:51 +00:00
Raunak Bhagat
6499b21235 feat(opal): add Card and EmptyMessageCard components (#9271) 2026-03-11 13:14:17 -07:00
Nikolas Garza
c5bfd5a152 feat(admin): add Users page shell with stats bar and SCIM card - 1/9 (#9079) 2026-03-11 16:28:47 +00:00
Justin Tahara
a0329161b0 feat(litellm): Adding FE Provider workflow (#9264) 2026-03-11 03:45:08 +00:00
Raunak Bhagat
334b7a6d2f feat(opal): add foldable support to OpenButton + fix MessageToolbar (#9265) 2026-03-11 03:00:51 +00:00
dependabot[bot]
36196373a8 chore(deps): bump hono from 4.12.5 to 4.12.7 in /backend/onyx/server/features/build/sandbox/kubernetes/docker/templates/outputs/web (#9263)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-10 18:54:17 -07:00
Jamison Lahman
533aa8eff8 chore(release): upgrade release-tag (#9257) 2026-03-11 00:50:55 +00:00
Raunak Bhagat
ecbb267f80 fix: Consolidate search state-machine (#9234) 2026-03-11 00:42:39 +00:00
Danelegend
66023dbb6d feat(llm-provider): fetch litellm models (#8418) 2026-03-10 23:48:56 +00:00
Wenxi
f97466e4de chore: redeclare cache_okay for EncryptedBase children (#9253) 2026-03-10 23:44:51 +00:00
Evan Lohn
2cc8303e5f chore: sharepoint dedupe (#9254) 2026-03-10 23:41:51 +00:00
Wenxi
a92ff61f64 chore: add cache_okay to EncryptedJson (#9252) 2026-03-10 22:18:39 +00:00
acaprau
17551a907e fix(opensearch): Update should clear projects and personas when they are empty (#8845) 2026-03-10 21:49:55 +00:00
Jamison Lahman
9e42951fa4 fix(fe): increase responsive breakpoint for centering modals (#9250) 2026-03-10 21:45:23 +00:00
acaprau
dcb18c2411 chore(opensearch): Followup for #9243 (#9247) 2026-03-10 14:31:44 -07:00
Jamison Lahman
2f628e39d3 fix(fe): correctly parse comma literals in CSVs (#9245) 2026-03-10 21:03:47 +00:00
Nikolas Garza
fd200d46f8 fix(storybook): case-sensitivity, icon rename, and story fixes (#9244) 2026-03-10 20:05:32 +00:00
Evan Lohn
ec7482619b fix: update jira group sync endpoint (#9241) 2026-03-10 19:57:01 +00:00
Jamison Lahman
9d1a357533 fix(fe): make CSV inline display responsive (#9242) 2026-03-10 19:42:23 +00:00
acaprau
fbe823b551 chore(opensearch): Allow configuring num hits from hybrid subquery from env var (#9243) 2026-03-10 19:27:36 +00:00
acaprau
1608e2f274 fix(opensearch): Allow configuring the page size of chunks we get from Vespa during migration (#9239) 2026-03-10 17:51:52 +00:00
Jamison Lahman
4dbb1fa606 chore(tests): fix nightly model-server tests (#9236) 2026-03-10 17:49:08 +00:00
132 changed files with 5798 additions and 2130 deletions

View File

@@ -48,7 +48,7 @@ jobs:
- name: Deploy to Vercel (Production)
working-directory: web
run: npx --yes "$VERCEL_CLI" deploy storybook-static/ --prod --yes
run: npx --yes "$VERCEL_CLI" deploy storybook-static/ --prod --yes --token="$VERCEL_TOKEN"
notify-slack-on-failure:
needs: Deploy-Storybook

View File

@@ -0,0 +1,43 @@
"""add timestamps to user table
Revision ID: 27fb147a843f
Revises: b5c4d7e8f9a1
Create Date: 2026-03-08 17:18:40.828644
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "27fb147a843f"
down_revision = "b5c4d7e8f9a1"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"user",
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.add_column(
"user",
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
def downgrade() -> None:
op.drop_column("user", "updated_at")
op.drop_column("user", "created_at")

View File

@@ -1,6 +1,8 @@
from collections.abc import Generator
from typing import Any
from jira import JIRA
from jira.exceptions import JIRAError
from ee.onyx.db.external_perm import ExternalUserGroup
from onyx.connectors.jira.utils import build_jira_client
@@ -9,107 +11,102 @@ from onyx.utils.logger import setup_logger
logger = setup_logger()
_ATLASSIAN_ACCOUNT_TYPE = "atlassian"
_GROUP_MEMBER_PAGE_SIZE = 50
def _get_jira_group_members_email(
# The GET /group/member endpoint was introduced in Jira 6.0.
# Jira versions older than 6.0 do not have group management REST APIs at all.
_MIN_JIRA_VERSION_FOR_GROUP_MEMBER = "6.0"
def _fetch_group_member_page(
jira_client: JIRA,
group_name: str,
) -> list[str]:
"""Get all member emails for a Jira group.
start_at: int,
) -> dict[str, Any]:
"""Fetch a single page from the non-deprecated GET /group/member endpoint.
Filters out app accounts (bots, integrations) and only returns real user emails.
The old GET /group endpoint (used by jira_client.group_members()) is deprecated
and decommissioned in Jira Server 10.3+. This uses the replacement endpoint
directly via the library's internal _get_json helper, following the same pattern
as enhanced_search_ids / bulk_fetch_issues in connector.py.
There is an open PR to the library to switch to this endpoint since last year:
https://github.com/pycontribs/jira/pull/2356
so once it is merged and released, we can switch to using the library function.
"""
emails: list[str] = []
try:
# group_members returns an OrderedDict of account_id -> member_info
members = jira_client.group_members(group=group_name)
if not members:
logger.warning(f"No members found for group {group_name}")
return emails
for account_id, member_info in members.items():
# member_info is a dict with keys like 'fullname', 'email', 'active'
email = member_info.get("email")
# Skip "hidden" emails - these are typically app accounts
if email and email != "hidden":
emails.append(email)
else:
# For cloud, we might need to fetch user details separately
try:
user = jira_client.user(id=account_id)
# Skip app accounts (bots, integrations, etc.)
if hasattr(user, "accountType") and user.accountType == "app":
logger.info(
f"Skipping app account {account_id} for group {group_name}"
)
continue
if hasattr(user, "emailAddress") and user.emailAddress:
emails.append(user.emailAddress)
else:
logger.warning(f"User {account_id} has no email address")
except Exception as e:
logger.warning(
f"Could not fetch email for user {account_id} in group {group_name}: {e}"
)
except Exception as e:
logger.error(f"Error fetching members for group {group_name}: {e}")
return emails
return jira_client._get_json(
"group/member",
params={
"groupname": group_name,
"includeInactiveUsers": "false",
"startAt": start_at,
"maxResults": _GROUP_MEMBER_PAGE_SIZE,
},
)
except JIRAError as e:
if e.status_code == 404:
raise RuntimeError(
f"GET /group/member returned 404 for group '{group_name}'. "
f"This endpoint requires Jira {_MIN_JIRA_VERSION_FOR_GROUP_MEMBER}+. "
f"If you are running a self-hosted Jira instance, please upgrade "
f"to at least Jira {_MIN_JIRA_VERSION_FOR_GROUP_MEMBER}."
) from e
raise
def _build_group_member_email_map(
def _get_group_member_emails(
jira_client: JIRA,
) -> dict[str, set[str]]:
"""Build a map of group names to member emails."""
group_member_emails: dict[str, set[str]] = {}
group_name: str,
) -> set[str]:
"""Get all member emails for a single Jira group.
try:
# Get all groups from Jira - returns a list of group name strings
group_names = jira_client.groups()
Uses the non-deprecated GET /group/member endpoint which returns full user
objects including accountType, so we can filter out app/customer accounts
without making separate user() calls.
"""
emails: set[str] = set()
start_at = 0
if not group_names:
logger.warning("No groups found in Jira")
return group_member_emails
while True:
try:
page = _fetch_group_member_page(jira_client, group_name, start_at)
except Exception as e:
logger.error(f"Error fetching members for group {group_name}: {e}")
raise
logger.info(f"Found {len(group_names)} groups in Jira")
for group_name in group_names:
if not group_name:
members: list[dict[str, Any]] = page.get("values", [])
for member in members:
account_type = member.get("accountType")
# On Jira DC < 9.0, accountType is absent; include those users.
# On Cloud / DC 9.0+, filter to real user accounts only.
if account_type is not None and account_type != _ATLASSIAN_ACCOUNT_TYPE:
continue
member_emails = _get_jira_group_members_email(
jira_client=jira_client,
group_name=group_name,
)
if member_emails:
group_member_emails[group_name] = set(member_emails)
logger.debug(
f"Found {len(member_emails)} members for group {group_name}"
)
email = member.get("emailAddress")
if email:
emails.add(email)
else:
logger.debug(f"No members found for group {group_name}")
logger.warning(
f"Atlassian user {member.get('accountId', 'unknown')} "
f"in group {group_name} has no visible email address"
)
except Exception as e:
logger.error(f"Error building group member email map: {e}")
if page.get("isLast", True) or not members:
break
start_at += len(members)
return group_member_emails
return emails
def jira_group_sync(
tenant_id: str, # noqa: ARG001
cc_pair: ConnectorCredentialPair,
) -> Generator[ExternalUserGroup, None, None]:
"""
Sync Jira groups and their members.
"""Sync Jira groups and their members, yielding one group at a time.
This function fetches all groups from Jira and yields ExternalUserGroup
objects containing the group ID and member emails.
Streams group-by-group rather than accumulating all groups in memory.
"""
jira_base_url = cc_pair.connector.connector_specific_config.get("jira_base_url", "")
scoped_token = cc_pair.connector.connector_specific_config.get(
@@ -130,12 +127,26 @@ def jira_group_sync(
scoped_token=scoped_token,
)
group_member_email_map = _build_group_member_email_map(jira_client=jira_client)
if not group_member_email_map:
raise ValueError(f"No groups with members found for cc_pair_id={cc_pair.id}")
group_names = jira_client.groups()
if not group_names:
raise ValueError(f"No groups found for cc_pair_id={cc_pair.id}")
for group_id, group_member_emails in group_member_email_map.items():
yield ExternalUserGroup(
id=group_id,
user_emails=list(group_member_emails),
logger.info(f"Found {len(group_names)} groups in Jira")
for group_name in group_names:
if not group_name:
continue
member_emails = _get_group_member_emails(
jira_client=jira_client,
group_name=group_name,
)
if not member_emails:
logger.debug(f"No members found for group {group_name}")
continue
logger.debug(f"Found {len(member_emails)} members for group {group_name}")
yield ExternalUserGroup(
id=group_name,
user_emails=list(member_emails),
)

View File

@@ -11,6 +11,9 @@
# lock after its cleanup which happens at most after its soft timeout.
# Constants corresponding to migrate_documents_from_vespa_to_opensearch_task.
from onyx.configs.app_configs import OPENSEARCH_MIGRATION_GET_VESPA_CHUNKS_PAGE_SIZE
MIGRATION_TASK_SOFT_TIME_LIMIT_S = 60 * 5 # 5 minutes.
MIGRATION_TASK_TIME_LIMIT_S = 60 * 6 # 6 minutes.
# The maximum time the lock can be held for. Will automatically be released
@@ -44,7 +47,7 @@ TOTAL_ALLOWABLE_DOC_MIGRATION_ATTEMPTS_BEFORE_PERMANENT_FAILURE = 15
# WARNING: Do not change these values without knowing what changes also need to
# be made to OpenSearchTenantMigrationRecord.
GET_VESPA_CHUNKS_PAGE_SIZE = 500
GET_VESPA_CHUNKS_PAGE_SIZE = OPENSEARCH_MIGRATION_GET_VESPA_CHUNKS_PAGE_SIZE
GET_VESPA_CHUNKS_SLICE_COUNT = 4
# String used to indicate in the vespa_visit_continuation_token mapping that the

View File

@@ -311,6 +311,12 @@ VERIFY_CREATE_OPENSEARCH_INDEX_ON_INIT_MT = (
os.environ.get("VERIFY_CREATE_OPENSEARCH_INDEX_ON_INIT_MT", "true").lower()
== "true"
)
OPENSEARCH_MIGRATION_GET_VESPA_CHUNKS_PAGE_SIZE = int(
os.environ.get("OPENSEARCH_MIGRATION_GET_VESPA_CHUNKS_PAGE_SIZE") or 500
)
OPENSEARCH_OVERRIDE_DEFAULT_NUM_HYBRID_SEARCH_CANDIDATES = int(
os.environ.get("OPENSEARCH_DEFAULT_NUM_HYBRID_SEARCH_CANDIDATES") or 0
)
VESPA_HOST = os.environ.get("VESPA_HOST") or "localhost"
# NOTE: this is used if and only if the vespa config server is accessible via a

View File

@@ -258,6 +258,10 @@ class SharepointConnectorCheckpoint(ConnectorCheckpoint):
# Track yielded hierarchy nodes by their raw_node_id (URLs) to avoid duplicates
seen_hierarchy_node_raw_ids: set[str] = Field(default_factory=set)
# Track yielded document IDs to avoid processing the same document twice.
# The Microsoft Graph delta API can return the same item on multiple pages.
seen_document_ids: set[str] = Field(default_factory=set)
class SharepointAuthMethod(Enum):
CLIENT_SECRET = "client_secret"
@@ -1557,6 +1561,7 @@ class SharepointConnector(
checkpoint.current_drive_id = None
checkpoint.current_drive_web_url = None
checkpoint.current_drive_delta_next_link = None
checkpoint.seen_document_ids.clear()
def _fetch_slim_documents_from_sharepoint(self) -> GenerateSlimDocumentOutput:
site_descriptors = self.site_descriptors or self.fetch_sites()
@@ -2137,6 +2142,14 @@ class SharepointConnector(
item_count = 0
for driveitem in driveitems:
item_count += 1
if driveitem.id and driveitem.id in checkpoint.seen_document_ids:
logger.debug(
f"Skipping duplicate document {driveitem.id} "
f"({driveitem.name})"
)
continue
driveitem_extension = get_file_ext(driveitem.name)
if driveitem_extension not in OnyxFileExtensions.ALL_ALLOWED_EXTENSIONS:
logger.warning(
@@ -2189,11 +2202,13 @@ class SharepointConnector(
if isinstance(doc_or_failure, Document):
if doc_or_failure.sections:
checkpoint.seen_document_ids.add(doc_or_failure.id)
yield doc_or_failure
elif should_yield_if_empty:
doc_or_failure.sections = [
TextSection(link=driveitem.web_url, text="")
]
checkpoint.seen_document_ids.add(doc_or_failure.id)
yield doc_or_failure
else:
logger.warning(

View File

@@ -25,6 +25,7 @@ from onyx.server.manage.embedding.models import CloudEmbeddingProvider
from onyx.server.manage.embedding.models import CloudEmbeddingProviderCreationRequest
from onyx.server.manage.llm.models import LLMProviderUpsertRequest
from onyx.server.manage.llm.models import LLMProviderView
from onyx.server.manage.llm.models import SyncModelEntry
from onyx.utils.logger import setup_logger
from shared_configs.enums import EmbeddingProvider
@@ -369,9 +370,9 @@ def upsert_llm_provider(
def sync_model_configurations(
db_session: Session,
provider_name: str,
models: list[dict],
models: list[SyncModelEntry],
) -> int:
"""Sync model configurations for a dynamic provider (OpenRouter, Bedrock, Ollama).
"""Sync model configurations for a dynamic provider (OpenRouter, Bedrock, Ollama, etc.).
This inserts NEW models from the source API without overwriting existing ones.
User preferences (is_visible, max_input_tokens) are preserved for existing models.
@@ -379,7 +380,7 @@ def sync_model_configurations(
Args:
db_session: Database session
provider_name: Name of the LLM provider
models: List of model dicts with keys: name, display_name, max_input_tokens, supports_image_input
models: List of SyncModelEntry objects describing the fetched models
Returns:
Number of new models added
@@ -393,21 +394,20 @@ def sync_model_configurations(
new_count = 0
for model in models:
model_name = model["name"]
if model_name not in existing_names:
if model.name not in existing_names:
# Insert new model with is_visible=False (user must explicitly enable)
supported_flows = [LLMModelFlowType.CHAT]
if model.get("supports_image_input", False):
if model.supports_image_input:
supported_flows.append(LLMModelFlowType.VISION)
insert_new_model_configuration__no_commit(
db_session=db_session,
llm_provider_id=provider.id,
model_name=model_name,
model_name=model.name,
supported_flows=supported_flows,
is_visible=False,
max_input_tokens=model.get("max_input_tokens"),
display_name=model.get("display_name"),
max_input_tokens=model.max_input_tokens,
display_name=model.display_name,
)
new_count += 1

View File

@@ -163,6 +163,8 @@ class _EncryptedBase(TypeDecorator):
class EncryptedString(_EncryptedBase):
# Must redeclare cache_ok in this child class since we explicitly redeclare _is_json
cache_ok = True
_is_json: bool = False
def process_bind_param(
@@ -189,6 +191,7 @@ class EncryptedString(_EncryptedBase):
class EncryptedJson(_EncryptedBase):
cache_ok = True
_is_json: bool = True
def process_bind_param(
@@ -336,6 +339,16 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
TIMESTAMPAware(timezone=True), nullable=True
)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
default_model: Mapped[str] = mapped_column(Text, nullable=True)
# organized in typical structured fashion
# formatted as `displayName__provider__modelName`

View File

@@ -4,6 +4,7 @@ from uuid import UUID
from fastapi import HTTPException
from fastapi_users.password import PasswordHelper
from sqlalchemy import case
from sqlalchemy import func
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
@@ -11,6 +12,7 @@ from sqlalchemy.orm import Session
from sqlalchemy.sql import expression
from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.sql.elements import KeyedColumnElement
from sqlalchemy.sql.expression import or_
from onyx.auth.invited_users import remove_user_from_invited_users
from onyx.auth.schemas import UserRole
@@ -24,6 +26,7 @@ from onyx.db.models import Persona__User
from onyx.db.models import SamlAccount
from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.db.models import UserGroup
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
@@ -162,7 +165,13 @@ def _get_accepted_user_where_clause(
where_clause.append(User.role != UserRole.EXT_PERM_USER)
if email_filter_string is not None:
where_clause.append(email_col.ilike(f"%{email_filter_string}%"))
personal_name_col: KeyedColumnElement[Any] = User.__table__.c.personal_name
where_clause.append(
or_(
email_col.ilike(f"%{email_filter_string}%"),
personal_name_col.ilike(f"%{email_filter_string}%"),
)
)
if roles_filter:
where_clause.append(User.role.in_(roles_filter))
@@ -173,6 +182,21 @@ def _get_accepted_user_where_clause(
return where_clause
def get_all_accepted_users(
db_session: Session,
include_external: bool = False,
) -> Sequence[User]:
"""Returns all accepted users without pagination.
Uses the same filtering as the paginated endpoint but without
search, role, or active filters."""
stmt = select(User)
where_clause = _get_accepted_user_where_clause(
include_external=include_external,
)
stmt = stmt.where(*where_clause).order_by(User.email)
return db_session.scalars(stmt).unique().all()
def get_page_of_filtered_users(
db_session: Session,
page_size: int,
@@ -218,6 +242,41 @@ def get_total_filtered_users_count(
return db_session.scalar(total_count_stmt) or 0
def get_user_counts_by_role_and_status(
db_session: Session,
) -> dict[str, dict[str, int]]:
"""Returns user counts grouped by role and by active/inactive status.
Excludes API key users, anonymous users, and no-auth placeholder users.
Uses a single query with conditional aggregation.
"""
base_where = _get_accepted_user_where_clause()
role_col = User.__table__.c.role
is_active_col = User.__table__.c.is_active
stmt = (
select(
role_col,
func.count().label("total"),
func.sum(case((is_active_col.is_(True), 1), else_=0)).label("active"),
func.sum(case((is_active_col.is_(False), 1), else_=0)).label("inactive"),
)
.where(*base_where)
.group_by(role_col)
)
role_counts: dict[str, int] = {}
status_counts: dict[str, int] = {"active": 0, "inactive": 0}
for role_val, total, active, inactive in db_session.execute(stmt).all():
key = role_val.value if hasattr(role_val, "value") else str(role_val)
role_counts[key] = total
status_counts["active"] += active or 0
status_counts["inactive"] += inactive or 0
return {"role_counts": role_counts, "status_counts": status_counts}
def get_user_by_email(email: str, db_session: Session) -> User | None:
user = (
db_session.query(User)
@@ -294,24 +353,23 @@ def batch_add_ext_perm_user_if_not_exists(
lower_emails = [email.lower() for email in emails]
found_users, missing_lower_emails = _get_users_by_emails(db_session, lower_emails)
new_users: list[User] = []
# Use savepoints (begin_nested) so that a failed insert only rolls back
# that single user, not the entire transaction. A plain rollback() would
# discard all previously flushed users in the same transaction.
# We also avoid add_all() because SQLAlchemy 2.0's insertmanyvalues
# batch path hits a UUID sentinel mismatch with server_default columns.
for email in missing_lower_emails:
new_users.append(_generate_ext_permissioned_user(email=email))
user = _generate_ext_permissioned_user(email=email)
savepoint = db_session.begin_nested()
try:
db_session.add(user)
savepoint.commit()
except IntegrityError:
savepoint.rollback()
if not continue_on_error:
raise
try:
db_session.add_all(new_users)
db_session.commit()
except IntegrityError:
db_session.rollback()
if not continue_on_error:
raise
for user in new_users:
try:
db_session.add(user)
db_session.commit()
except IntegrityError:
db_session.rollback()
continue
db_session.commit()
# Fetch all users again to ensure we have the most up-to-date list
all_users, _ = _get_users_by_emails(db_session, lower_emails)
return all_users
@@ -358,3 +416,28 @@ def delete_user_from_db(
# NOTE: edge case may exist with race conditions
# with this `invited user` scheme generally.
remove_user_from_invited_users(user_to_delete.email)
def batch_get_user_groups(
db_session: Session,
user_ids: list[UUID],
) -> dict[UUID, list[tuple[int, str]]]:
"""Fetch group memberships for a batch of users in a single query.
Returns a mapping of user_id -> list of (group_id, group_name) tuples."""
if not user_ids:
return {}
rows = db_session.execute(
select(
User__UserGroup.user_id,
UserGroup.id,
UserGroup.name,
)
.join(UserGroup, UserGroup.id == User__UserGroup.user_group_id)
.where(User__UserGroup.user_id.in_(user_ids))
).all()
result: dict[UUID, list[tuple[int, str]]] = {uid: [] for uid in user_ids}
for user_id, group_id, group_name in rows:
result[user_id].append((group_id, group_name))
return result

View File

@@ -1,5 +1,10 @@
# Default value for the maximum number of tokens a chunk can hold, if none is
# specified when creating an index.
from onyx.configs.app_configs import (
OPENSEARCH_OVERRIDE_DEFAULT_NUM_HYBRID_SEARCH_CANDIDATES,
)
DEFAULT_MAX_CHUNK_SIZE = 512
# Size of the dynamic list used to consider elements during kNN graph creation.
@@ -10,27 +15,43 @@ EF_CONSTRUCTION = 256
# quality but increase memory footprint. Values typically range between 12 - 48.
M = 32 # Set relatively high for better accuracy.
# When performing hybrid search, we need to consider more candidates than the number of results to be returned.
# This is because the scoring is hybrid and the results are reordered due to the hybrid scoring.
# Higher = more candidates for hybrid fusion = better retrieval accuracy, but results in more computation per query.
# Imagine a simple case with a single keyword query and a single vector query and we want 10 final docs.
# If we only fetch 10 candidates from each of keyword and vector, they would have to have perfect overlap to get a good hybrid
# ranking for the 10 results. If we fetch 1000 candidates from each, we have a much higher chance of all 10 of the final desired
# docs showing up and getting scored. In worse situations, the final 10 docs don't even show up as the final 10 (worse than just
# a miss at the reranking step).
DEFAULT_NUM_HYBRID_SEARCH_CANDIDATES = 750
# When performing hybrid search, we need to consider more candidates than the
# number of results to be returned. This is because the scoring is hybrid and
# the results are reordered due to the hybrid scoring. Higher = more candidates
# for hybrid fusion = better retrieval accuracy, but results in more computation
# per query. Imagine a simple case with a single keyword query and a single
# vector query and we want 10 final docs. If we only fetch 10 candidates from
# each of keyword and vector, they would have to have perfect overlap to get a
# good hybrid ranking for the 10 results. If we fetch 1000 candidates from each,
# we have a much higher chance of all 10 of the final desired docs showing up
# and getting scored. In worse situations, the final 10 docs don't even show up
# as the final 10 (worse than just a miss at the reranking step).
DEFAULT_NUM_HYBRID_SEARCH_CANDIDATES = (
OPENSEARCH_OVERRIDE_DEFAULT_NUM_HYBRID_SEARCH_CANDIDATES
if OPENSEARCH_OVERRIDE_DEFAULT_NUM_HYBRID_SEARCH_CANDIDATES > 0
else 750
)
# Number of vectors to examine for top k neighbors for the HNSW method.
# Number of vectors to examine to decide the top k neighbors for the HNSW
# method.
# NOTE: "When creating a search query, you must specify k. If you provide both k
# and ef_search, then the larger value is passed to the engine. If ef_search is
# larger than k, you can provide the size parameter to limit the final number of
# results to k." from
# https://docs.opensearch.org/latest/query-dsl/specialized/k-nn/index/#ef_search
EF_SEARCH = DEFAULT_NUM_HYBRID_SEARCH_CANDIDATES
# Since the titles are included in the contents, they are heavily downweighted as they act as a boost
# rather than an independent scoring component.
# Since the titles are included in the contents, the embedding matches are
# heavily downweighted as they act as a boost rather than an independent scoring
# component.
SEARCH_TITLE_VECTOR_WEIGHT = 0.1
SEARCH_CONTENT_VECTOR_WEIGHT = 0.45
# Single keyword weight for both title and content (merged from former title keyword + content keyword).
# Single keyword weight for both title and content (merged from former title
# keyword + content keyword).
SEARCH_KEYWORD_WEIGHT = 0.45
# NOTE: it is critical that the order of these weights matches the order of the sub-queries in the hybrid search.
# NOTE: It is critical that the order of these weights matches the order of the
# sub-queries in the hybrid search.
HYBRID_SEARCH_NORMALIZATION_WEIGHTS = [
SEARCH_TITLE_VECTOR_WEIGHT,
SEARCH_CONTENT_VECTOR_WEIGHT,

View File

@@ -433,12 +433,16 @@ class OpenSearchOldDocumentIndex(OldDocumentIndex):
hidden=fields.hidden if fields else None,
project_ids=(
set(user_fields.user_projects)
if user_fields and user_fields.user_projects
# NOTE: Empty user_projects is semantically different from None
# user_projects.
if user_fields and user_fields.user_projects is not None
else None
),
persona_ids=(
set(user_fields.personas)
if user_fields and user_fields.personas
# NOTE: Empty personas is semantically different from None
# personas.
if user_fields and user_fields.personas is not None
else None
),
)

View File

@@ -255,8 +255,12 @@ class DocumentQuery:
f"result window ({DEFAULT_OPENSEARCH_MAX_RESULT_WINDOW})."
)
# TODO(andrei, yuhong): We can tune this more dynamically based on
# num_hits.
max_results_per_subquery = DEFAULT_NUM_HYBRID_SEARCH_CANDIDATES
hybrid_search_subqueries = DocumentQuery._get_hybrid_search_subqueries(
query_text, query_vector
query_text, query_vector, vector_candidates=max_results_per_subquery
)
hybrid_search_filters = DocumentQuery._get_search_filters(
tenant_state=tenant_state,
@@ -285,13 +289,16 @@ class DocumentQuery:
hybrid_search_query: dict[str, Any] = {
"hybrid": {
"queries": hybrid_search_subqueries,
# Max results per subquery per shard before aggregation. Ensures keyword and vector
# subqueries contribute equally to the candidate pool for hybrid fusion.
# Max results per subquery per shard before aggregation. Ensures
# keyword and vector subqueries contribute equally to the
# candidate pool for hybrid fusion.
# Sources:
# https://docs.opensearch.org/latest/vector-search/ai-search/hybrid-search/pagination/
# https://opensearch.org/blog/navigating-pagination-in-hybrid-queries-with-the-pagination_depth-parameter/
"pagination_depth": DEFAULT_NUM_HYBRID_SEARCH_CANDIDATES,
# Applied to all the sub-queries independently (this avoids having subqueries having a lot of results thrown out).
"pagination_depth": max_results_per_subquery,
# Applied to all the sub-queries independently (this avoids
# subqueries having a lot of results thrown out during
# aggregation).
# Sources:
# https://docs.opensearch.org/latest/query-dsl/compound/hybrid/
# https://opensearch.org/blog/introducing-common-filter-support-for-hybrid-search-queries
@@ -374,9 +381,10 @@ class DocumentQuery:
def _get_hybrid_search_subqueries(
query_text: str,
query_vector: list[float],
# The default number of neighbors to consider for knn vector similarity search.
# This is higher than the number of results because the scoring is hybrid.
# for a detailed breakdown, see where the default value is set.
# The default number of neighbors to consider for knn vector similarity
# search. This is higher than the number of results because the scoring
# is hybrid. For a detailed breakdown, see where the default value is
# set.
vector_candidates: int = DEFAULT_NUM_HYBRID_SEARCH_CANDIDATES,
) -> list[dict[str, Any]]:
"""Returns subqueries for hybrid search.
@@ -400,20 +408,27 @@ class DocumentQuery:
in a single hybrid query. Source:
https://docs.opensearch.org/latest/query-dsl/compound/hybrid/
NOTE: Each query is independent during the search phase, there is no backfilling of scores for missing query components.
What this means is that if a document was a good vector match but did not show up for keyword, it gets a score of 0 for
the keyword component of the hybrid scoring. This is not as bad as just disregarding a score though as there is
normalization applied after. So really it is "increasing" the missing score compared to if it was included and the range
was renormalized. This does however mean that between docs that have high scores for say the vector field, the keyword
scores between them are completely ignored unless they also showed up in the keyword query as a reasonably high match.
TLDR, this is a bit of unique funky behavior but it seems ok.
NOTE: Each query is independent during the search phase, there is no
backfilling of scores for missing query components. What this means is
that if a document was a good vector match but did not show up for
keyword, it gets a score of 0 for the keyword component of the hybrid
scoring. This is not as bad as just disregarding a score though as there
is normalization applied after. So really it is "increasing" the missing
score compared to if it was included and the range was renormalized.
This does however mean that between docs that have high scores for say
the vector field, the keyword scores between them are completely ignored
unless they also showed up in the keyword query as a reasonably high
match. TLDR, this is a bit of unique funky behavior but it seems ok.
NOTE: Options considered and rejected:
- minimum_should_match: Since it's hybrid search and users often provide semantic queries, there is often a lot of terms,
and very low number of meaningful keywords (and a low ratio of keywords).
- fuzziness AUTO: typo tolerance (0/1/2 edit distance by term length). It's mostly for typos as the analyzer ("english by
default") already does some stemming and tokenization. In testing datasets, this makes recall slightly worse. It also is
less performant so not really any reason to do it.
- minimum_should_match: Since it's hybrid search and users often provide
semantic queries, there is often a lot of terms, and very low number
of meaningful keywords (and a low ratio of keywords).
- fuzziness AUTO: Typo tolerance (0/1/2 edit distance by term length).
It's mostly for typos as the analyzer ("english" by default) already
does some stemming and tokenization. In testing datasets, this makes
recall slightly worse. It also is less performant so not really any
reason to do it.
Args:
query_text: The text of the query to search for.
@@ -723,14 +738,13 @@ class DocumentQuery:
# document's metadata list.
filter_clauses.append(_get_tag_filter(tags))
# Knowledge scope: explicit knowledge attachments restrict what
# an assistant can see. When none are set the assistant
# searches everything.
# Knowledge scope: explicit knowledge attachments restrict what an
# assistant can see. When none are set the assistant searches
# everything.
#
# project_id / persona_id are additive: they make overflowing
# user files findable but must NOT trigger the restriction on
# their own (an agent with no explicit knowledge should search
# everything).
# project_id / persona_id are additive: they make overflowing user files
# findable but must NOT trigger the restriction on their own (an agent
# with no explicit knowledge should search everything).
has_knowledge_scope = (
attached_document_ids
or hierarchy_node_ids
@@ -758,9 +772,8 @@ class DocumentQuery:
knowledge_filter["bool"]["should"].append(
_get_document_set_filter(document_sets)
)
# Additive: widen scope to also cover overflowing user
# files, but only when an explicit restriction is already
# in effect.
# Additive: widen scope to also cover overflowing user files, but
# only when an explicit restriction is already in effect.
if project_id is not None:
knowledge_filter["bool"]["should"].append(
_get_user_project_filter(project_id)

View File

@@ -690,9 +690,12 @@ class VespaIndex(DocumentIndex):
)
project_ids: set[int] | None = None
# NOTE: Empty user_projects is semantically different from None
# user_projects.
if user_fields is not None and user_fields.user_projects is not None:
project_ids = set(user_fields.user_projects)
persona_ids: set[int] | None = None
# NOTE: Empty personas is semantically different from None personas.
if user_fields is not None and user_fields.personas is not None:
persona_ids = set(user_fields.personas)
update_request = MetadataUpdateRequest(

View File

@@ -1,4 +1,3 @@
import csv
import gc
import io
import json
@@ -20,7 +19,6 @@ from zipfile import BadZipFile
import chardet
import openpyxl
from openpyxl.worksheet.worksheet import Worksheet
from PIL import Image
from onyx.configs.constants import ONYX_METADATA_FILENAME
@@ -354,65 +352,6 @@ def pptx_to_text(file: IO[Any], file_name: str = "") -> str:
return presentation.markdown
def _worksheet_to_matrix(
worksheet: Worksheet,
) -> list[list[str]]:
"""
Converts a singular worksheet to a matrix of values
"""
rows: list[list[str]] = []
for worksheet_row in worksheet.iter_rows(min_row=1, values_only=True):
row = ["" if cell is None else str(cell) for cell in worksheet_row]
rows.append(row)
return rows
def _clean_worksheet_matrix(matrix: list[list[str]]) -> list[list[str]]:
"""
Cleans a worksheet matrix by removing rows if there are N consecutive empty
rows and removing cols if there are M consecutive empty columns
"""
MAX_EMPTY_ROWS = 2 # Runs longer than this are capped to max_empty; shorter runs are preserved as-is
MAX_EMPTY_COLS = 2
# Row cleanup
matrix = _remove_empty_runs(matrix, max_empty=MAX_EMPTY_ROWS)
# Column cleanup (transpose, clean, transpose back)
transposed = list(map(list, zip(*matrix))) if matrix else []
transposed = _remove_empty_runs(transposed, max_empty=MAX_EMPTY_COLS)
matrix = list(map(list, zip(*transposed))) if transposed else []
return matrix
def _remove_empty_runs(
rows: list[list[str]],
max_empty: int,
) -> list[list[str]]:
"""Removes entire runs of empty rows when the run length exceeds max_empty.
Leading and trailing empty rows are always dropped regardless of run length,
since there is no adjacent non-empty row to bound the run.
"""
result: list[list[str]] = []
empty_buffer: list[list[str]] = []
for row in rows:
# Check if empty
if not any(row):
empty_buffer.append(row)
else:
# Add upto max empty rows onto the result - that's what we allow
result.extend(empty_buffer[:max_empty])
# Add the new non-empty row
result.append(row)
empty_buffer = []
return result
def xlsx_to_text(file: IO[Any], file_name: str = "") -> str:
# TODO: switch back to this approach in a few months when markitdown
# fixes their handling of excel files
@@ -451,15 +390,30 @@ def xlsx_to_text(file: IO[Any], file_name: str = "") -> str:
f"Failed to extract text from {file_name or 'xlsx file'}. This happens due to a bug in openpyxl. {e}"
)
return ""
raise
raise e
text_content = []
for sheet in workbook.worksheets:
sheet_matrix = _clean_worksheet_matrix(_worksheet_to_matrix(sheet))
buf = io.StringIO()
writer = csv.writer(buf, lineterminator="\n")
writer.writerows(sheet_matrix)
text_content.append(buf.getvalue().rstrip("\n"))
rows = []
num_empty_consecutive_rows = 0
for row in sheet.iter_rows(min_row=1, values_only=True):
row_str = ",".join(str(cell or "") for cell in row)
# Only add the row if there are any values in the cells
if len(row_str) >= len(row):
rows.append(row_str)
num_empty_consecutive_rows = 0
else:
num_empty_consecutive_rows += 1
if num_empty_consecutive_rows > 100:
# handle massive excel sheets with mostly empty cells
logger.warning(
f"Found {num_empty_consecutive_rows} empty rows in {file_name}, skipping rest of file"
)
break
sheet_str = "\n".join(rows)
text_content.append(sheet_str)
return TEXT_SECTION_SEPARATOR.join(text_content)

View File

@@ -123,15 +123,11 @@ class DocumentIndexingBatchAdapter:
}
doc_id_to_new_chunk_cnt: dict[str, int] = {
document_id: len(
[
chunk
for chunk in chunks_with_embeddings
if chunk.source_document.id == document_id
]
)
for document_id in updatable_ids
doc_id: 0 for doc_id in updatable_ids
}
for chunk in chunks_with_embeddings:
if chunk.source_document.id in doc_id_to_new_chunk_cnt:
doc_id_to_new_chunk_cnt[chunk.source_document.id] += 1
# Get ancestor hierarchy node IDs for each document
doc_id_to_ancestor_ids = self._get_ancestor_ids_for_documents(

View File

@@ -16,6 +16,7 @@ from onyx.indexing.models import DocAwareChunk
from onyx.indexing.models import IndexChunk
from onyx.natural_language_processing.search_nlp_models import EmbeddingModel
from onyx.utils.logger import setup_logger
from onyx.utils.pydantic_util import shallow_model_dump
from onyx.utils.timing import log_function_time
from shared_configs.configs import INDEXING_MODEL_SERVER_HOST
from shared_configs.configs import INDEXING_MODEL_SERVER_PORT
@@ -210,8 +211,8 @@ class DefaultIndexingEmbedder(IndexingEmbedder):
)[0]
title_embed_dict[title] = title_embedding
new_embedded_chunk = IndexChunk(
**chunk.model_dump(),
new_embedded_chunk = IndexChunk.model_construct(
**shallow_model_dump(chunk),
embeddings=ChunkEmbedding(
full_embedding=chunk_embeddings[0],
mini_chunk_embeddings=chunk_embeddings[1:],

View File

@@ -12,6 +12,7 @@ from onyx.connectors.models import Document
from onyx.db.enums import EmbeddingPrecision
from onyx.db.enums import SwitchoverType
from onyx.utils.logger import setup_logger
from onyx.utils.pydantic_util import shallow_model_dump
from shared_configs.enums import EmbeddingProvider
from shared_configs.model_server_models import Embedding
@@ -133,9 +134,8 @@ class DocMetadataAwareIndexChunk(IndexChunk):
tenant_id: str,
ancestor_hierarchy_node_ids: list[int] | None = None,
) -> "DocMetadataAwareIndexChunk":
index_chunk_data = index_chunk.model_dump()
return cls(
**index_chunk_data,
return cls.model_construct(
**shallow_model_dump(index_chunk),
access=access,
document_sets=document_sets,
user_project=user_project,

View File

@@ -43,6 +43,7 @@ WELL_KNOWN_PROVIDER_NAMES = [
LlmProviderNames.AZURE,
LlmProviderNames.OLLAMA_CHAT,
LlmProviderNames.LM_STUDIO,
LlmProviderNames.LITELLM_PROXY,
]
@@ -59,6 +60,7 @@ PROVIDER_DISPLAY_NAMES: dict[str, str] = {
"ollama": "Ollama",
LlmProviderNames.OLLAMA_CHAT: "Ollama",
LlmProviderNames.LM_STUDIO: "LM Studio",
LlmProviderNames.LITELLM_PROXY: "LiteLLM Proxy",
"groq": "Groq",
"anyscale": "Anyscale",
"deepseek": "DeepSeek",
@@ -109,6 +111,7 @@ AGGREGATOR_PROVIDERS: set[str] = {
LlmProviderNames.LM_STUDIO,
LlmProviderNames.VERTEX_AI,
LlmProviderNames.AZURE,
LlmProviderNames.LITELLM_PROXY,
}
# Model family name mappings for display name generation

View File

@@ -3782,16 +3782,6 @@
"display_name": "Claude Sonnet 3.5",
"model_vendor": "anthropic"
},
"vertex_ai/claude-3-5-sonnet-v2": {
"display_name": "Claude Sonnet 3.5",
"model_vendor": "anthropic",
"model_version": "v2"
},
"vertex_ai/claude-3-5-sonnet-v2@20241022": {
"display_name": "Claude Sonnet 3.5 v2",
"model_vendor": "anthropic",
"model_version": "20241022"
},
"vertex_ai/claude-3-5-sonnet@20240620": {
"display_name": "Claude Sonnet 3.5",
"model_vendor": "anthropic",

View File

@@ -11,6 +11,8 @@ OLLAMA_API_KEY_CONFIG_KEY = "OLLAMA_API_KEY"
LM_STUDIO_PROVIDER_NAME = "lm_studio"
LM_STUDIO_API_KEY_CONFIG_KEY = "LM_STUDIO_API_KEY"
LITELLM_PROXY_PROVIDER_NAME = "litellm_proxy"
# Providers that use optional Bearer auth from custom_config
PROVIDERS_WITH_SPECIAL_API_KEY_HANDLING: dict[str, str] = {
LlmProviderNames.OLLAMA_CHAT: OLLAMA_API_KEY_CONFIG_KEY,

View File

@@ -15,6 +15,7 @@ from onyx.llm.well_known_providers.auto_update_service import (
from onyx.llm.well_known_providers.constants import ANTHROPIC_PROVIDER_NAME
from onyx.llm.well_known_providers.constants import AZURE_PROVIDER_NAME
from onyx.llm.well_known_providers.constants import BEDROCK_PROVIDER_NAME
from onyx.llm.well_known_providers.constants import LITELLM_PROXY_PROVIDER_NAME
from onyx.llm.well_known_providers.constants import LM_STUDIO_PROVIDER_NAME
from onyx.llm.well_known_providers.constants import OLLAMA_PROVIDER_NAME
from onyx.llm.well_known_providers.constants import OPENAI_PROVIDER_NAME
@@ -47,6 +48,7 @@ def _get_provider_to_models_map() -> dict[str, list[str]]:
OLLAMA_PROVIDER_NAME: [], # Dynamic - fetched from Ollama API
LM_STUDIO_PROVIDER_NAME: [], # Dynamic - fetched from LM Studio API
OPENROUTER_PROVIDER_NAME: [], # Dynamic - fetched from OpenRouter API
LITELLM_PROXY_PROVIDER_NAME: [], # Dynamic - fetched from LiteLLM proxy API
}
@@ -331,6 +333,7 @@ def get_provider_display_name(provider_name: str) -> str:
BEDROCK_PROVIDER_NAME: "Amazon Bedrock",
VERTEXAI_PROVIDER_NAME: "Google Vertex AI",
OPENROUTER_PROVIDER_NAME: "OpenRouter",
LITELLM_PROXY_PROVIDER_NAME: "LiteLLM Proxy",
}
if provider_name in _ONYX_PROVIDER_DISPLAY_NAMES:

View File

@@ -1,3 +1,6 @@
import logging
import re
from dataclasses import dataclass
from datetime import datetime
from typing import cast
@@ -46,6 +49,8 @@ from onyx.onyxbot.slack.utils import remove_slack_text_interactions
from onyx.onyxbot.slack.utils import translate_vespa_highlight_to_slack
from onyx.utils.text_processing import decode_escapes
logger = logging.getLogger(__name__)
_MAX_BLURB_LEN = 45
@@ -76,6 +81,99 @@ def get_feedback_reminder_blocks(thread_link: str, include_followup: bool) -> Bl
return SectionBlock(text=text)
@dataclass
class CodeSnippet:
"""A code block extracted from the answer to be uploaded as a Slack file."""
code: str
language: str
filename: str
_SECTION_BLOCK_LIMIT = 3000
# Matches fenced code blocks: ```lang\n...\n```
# The opening fence must start at the beginning of a line (^), may have an
# optional language specifier (\w*), followed by a newline. The closing fence
# is ``` at the beginning of a line. We also handle an optional trailing
# newline on the closing fence line to be robust against different formatting.
_CODE_FENCE_RE = re.compile(
r"^```(\w*)\n(.*?)^```\s*$",
re.MULTILINE | re.DOTALL,
)
def _extract_code_snippets(
text: str, limit: int = _SECTION_BLOCK_LIMIT
) -> tuple[str, list[CodeSnippet]]:
"""Extract code blocks that would push the text over *limit*.
Returns (cleaned_text, snippets) where *cleaned_text* has large code
blocks replaced with a placeholder and *snippets* contains the extracted
code to be uploaded as Slack file snippets.
Uses a two-pass approach: first collect all matches, then decide which
to extract based on cumulative removal so each decision accounts for
previously extracted blocks.
Pass *limit=0* to force-extract ALL code blocks unconditionally.
"""
if limit > 0 and len(text) <= limit:
return text, []
# Pass 1: collect all code-fence matches
matches = list(_CODE_FENCE_RE.finditer(text))
if not matches:
return text, []
# Pass 2: decide which blocks to extract, accounting for cumulative removal.
# Only extract if the text is still over the limit OR the block is very large.
# With limit=0, extract everything unconditionally.
extract_indices: set[int] = set()
removed_chars = 0
for i, match in enumerate(matches):
full_block = match.group(0)
if limit == 0:
extract_indices.add(i)
removed_chars += len(full_block)
else:
current_len = len(text) - removed_chars
if current_len > limit and current_len - len(full_block) <= limit:
extract_indices.add(i)
removed_chars += len(full_block)
elif len(full_block) > limit // 2:
extract_indices.add(i)
removed_chars += len(full_block)
if not extract_indices:
return text, []
# Build cleaned text and snippets by processing matches in reverse
# so character offsets remain valid.
snippets: list[CodeSnippet] = []
cleaned = text
for i in sorted(extract_indices, reverse=True):
match = matches[i]
lang = match.group(1) or ""
code = match.group(2)
ext = lang if lang else "txt"
snippets.append(
CodeSnippet(
code=code.strip(),
language=lang or "text",
filename=f"code_{len(extract_indices) - len(snippets)}.{ext}",
)
)
cleaned = cleaned[: match.start()] + cleaned[match.end() :]
# Snippets were appended in reverse order — flip to match document order
snippets.reverse()
# Clean up any triple+ blank lines left by extraction
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
return cleaned, snippets
def _split_text(text: str, limit: int = 3000) -> list[str]:
if len(text) <= limit:
return [text]
@@ -417,7 +515,7 @@ def _build_citations_blocks(
def _build_main_response_blocks(
answer: ChatBasicResponse,
) -> list[Block]:
) -> tuple[list[Block], list[CodeSnippet]]:
# TODO: add back in later when auto-filtering is implemented
# if (
# retrieval_info.applied_time_cutoff
@@ -448,9 +546,45 @@ def _build_main_response_blocks(
# replaces markdown links with slack format links
formatted_answer = format_slack_message(answer.answer)
answer_processed = decode_escapes(remove_slack_text_interactions(formatted_answer))
answer_blocks = [SectionBlock(text=text) for text in _split_text(answer_processed)]
return cast(list[Block], answer_blocks)
# Extract large code blocks as snippets to upload separately,
# avoiding broken code fences when splitting across SectionBlocks.
cleaned_text, code_snippets = _extract_code_snippets(answer_processed)
logger.info(
"Code extraction: input=%d chars, cleaned=%d chars, snippets=%d",
len(answer_processed),
len(cleaned_text),
len(code_snippets),
)
if len(cleaned_text) <= _SECTION_BLOCK_LIMIT:
answer_blocks = [SectionBlock(text=cleaned_text)]
elif "```" not in cleaned_text:
# No code fences — safe to split at word boundaries.
answer_blocks = [
SectionBlock(text=text)
for text in _split_text(cleaned_text, limit=_SECTION_BLOCK_LIMIT)
]
else:
# Text still has code fences after extraction and exceeds the
# SectionBlock limit. Splitting would break the fences, so fall
# back to uploading the entire remaining code as another snippet
# and keeping only the prose in the blocks.
logger.warning(
"Cleaned text still has code fences (%d chars); "
"force-extracting remaining code blocks",
len(cleaned_text),
)
remaining_cleaned, remaining_snippets = _extract_code_snippets(
cleaned_text, limit=0
)
code_snippets.extend(remaining_snippets)
answer_blocks = [
SectionBlock(text=text)
for text in _split_text(remaining_cleaned, limit=_SECTION_BLOCK_LIMIT)
]
return cast(list[Block], answer_blocks), code_snippets
def _build_continue_in_web_ui_block(
@@ -531,10 +665,13 @@ def build_slack_response_blocks(
skip_ai_feedback: bool = False,
offer_ephemeral_publication: bool = False,
skip_restated_question: bool = False,
) -> list[Block]:
) -> tuple[list[Block], list[CodeSnippet]]:
"""
This function is a top level function that builds all the blocks for the Slack response.
It also handles combining all the blocks together.
Returns (blocks, code_snippets) where code_snippets should be uploaded
as Slack file snippets in the same thread.
"""
# If called with the OnyxBot slash command, the question is lost so we have to reshow it
if not skip_restated_question:
@@ -544,7 +681,7 @@ def build_slack_response_blocks(
else:
restate_question_block = []
answer_blocks = _build_main_response_blocks(answer)
answer_blocks, code_snippets = _build_main_response_blocks(answer)
web_follow_up_block = []
if channel_conf and channel_conf.get("show_continue_in_web_ui"):
@@ -610,4 +747,4 @@ def build_slack_response_blocks(
+ follow_up_block
)
return all_blocks
return all_blocks, code_snippets

View File

@@ -1,5 +1,9 @@
import re
from enum import Enum
# Matches Slack channel references like <#C097NBWMY8Y> or <#C097NBWMY8Y|channel-name>
SLACK_CHANNEL_REF_PATTERN = re.compile(r"<#([A-Z0-9]+)(?:\|([^>]+))?>")
LIKE_BLOCK_ACTION_ID = "feedback-like"
DISLIKE_BLOCK_ACTION_ID = "feedback-dislike"
SHOW_EVERYONE_ACTION_ID = "show-everyone"

View File

@@ -282,7 +282,7 @@ def handle_publish_ephemeral_message_button(
logger.error(f"Failed to send webhook: {e}")
# remove handling of empheremal block and add AI feedback.
all_blocks = build_slack_response_blocks(
all_blocks, _ = build_slack_response_blocks(
answer=onyx_bot_answer,
message_info=slack_message_info,
channel_conf=channel_conf,
@@ -311,7 +311,7 @@ def handle_publish_ephemeral_message_button(
elif action_id == KEEP_TO_YOURSELF_ACTION_ID:
# Keep as ephemeral message in channel or thread, but remove the publish button and add feedback button
changed_blocks = build_slack_response_blocks(
changed_blocks, _ = build_slack_response_blocks(
answer=onyx_bot_answer,
message_info=slack_message_info,
channel_conf=channel_conf,

View File

@@ -18,15 +18,19 @@ from onyx.configs.onyxbot_configs import ONYX_BOT_DISPLAY_ERROR_MSGS
from onyx.configs.onyxbot_configs import ONYX_BOT_NUM_RETRIES
from onyx.configs.onyxbot_configs import ONYX_BOT_REACT_EMOJI
from onyx.context.search.models import BaseFilters
from onyx.context.search.models import Tag
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.models import SlackChannelConfig
from onyx.db.models import User
from onyx.db.persona import get_persona_by_id
from onyx.db.users import get_user_by_email
from onyx.onyxbot.slack.blocks import build_slack_response_blocks
from onyx.onyxbot.slack.blocks import CodeSnippet
from onyx.onyxbot.slack.constants import SLACK_CHANNEL_REF_PATTERN
from onyx.onyxbot.slack.handlers.utils import send_team_member_message
from onyx.onyxbot.slack.models import SlackMessageInfo
from onyx.onyxbot.slack.models import ThreadMessage
from onyx.onyxbot.slack.utils import get_channel_from_id
from onyx.onyxbot.slack.utils import get_channel_name_from_id
from onyx.onyxbot.slack.utils import respond_in_thread_or_channel
from onyx.onyxbot.slack.utils import SlackRateLimiter
@@ -41,6 +45,51 @@ srl = SlackRateLimiter()
RT = TypeVar("RT") # return type
def resolve_channel_references(
message: str,
client: WebClient,
logger: OnyxLoggingAdapter,
) -> tuple[str, list[Tag]]:
"""Parse Slack channel references from a message, resolve IDs to names,
replace the raw markup with readable #channel-name, and return channel tags
for search filtering."""
tags: list[Tag] = []
channel_matches = SLACK_CHANNEL_REF_PATTERN.findall(message)
seen_channel_ids: set[str] = set()
for channel_id, channel_name_from_markup in channel_matches:
if channel_id in seen_channel_ids:
continue
seen_channel_ids.add(channel_id)
channel_name = channel_name_from_markup or None
if not channel_name:
try:
channel_info = get_channel_from_id(client=client, channel_id=channel_id)
channel_name = channel_info.get("name") or None
except Exception:
logger.warning(f"Failed to resolve channel name for ID: {channel_id}")
if not channel_name:
continue
# Replace raw Slack markup with readable channel name
if channel_name_from_markup:
message = message.replace(
f"<#{channel_id}|{channel_name_from_markup}>",
f"#{channel_name}",
)
else:
message = message.replace(
f"<#{channel_id}>",
f"#{channel_name}",
)
tags.append(Tag(tag_key="Channel", tag_value=channel_name))
return message, tags
def rate_limits(
client: WebClient, channel: str, thread_ts: Optional[str]
) -> Callable[[Callable[..., RT]], Callable[..., RT]]:
@@ -86,6 +135,65 @@ def build_slack_context_str(
return slack_context_str + "\n\n".join(message_strs)
# Normalize common LLM language aliases to Slack's expected snippet_type values.
# Slack silently falls back to plain text for unrecognized types, so this map
# only needs to cover the most common mismatches.
_SNIPPET_TYPE_MAP: dict[str, str] = {
"py": "python",
"js": "javascript",
"ts": "typescript",
"tsx": "typescript",
"jsx": "javascript",
"sh": "shell",
"bash": "shell",
"zsh": "shell",
"yml": "yaml",
"rb": "ruby",
"rs": "rust",
"cs": "csharp",
"md": "markdown",
"txt": "text",
"text": "plain_text",
}
def _upload_code_snippets(
client: WebClient,
channel: str,
thread_ts: str,
snippets: list[CodeSnippet],
logger: OnyxLoggingAdapter,
receiver_ids: list[str] | None = None,
send_as_ephemeral: bool | None = None,
) -> None:
"""Upload extracted code blocks as Slack file snippets in the thread."""
for snippet in snippets:
try:
snippet_type = _SNIPPET_TYPE_MAP.get(snippet.language, snippet.language)
client.files_upload_v2(
channel=channel,
thread_ts=thread_ts,
content=snippet.code,
filename=snippet.filename,
snippet_type=snippet_type,
)
except Exception:
logger.warning(
f"Failed to upload code snippet {snippet.filename}, "
"falling back to inline code block"
)
# Fall back to posting as a regular message with code fences,
# preserving the same visibility as the primary response.
respond_in_thread_or_channel(
client=client,
channel=channel,
receiver_ids=receiver_ids,
text=f"```{snippet.language}\n{snippet.code}\n```",
thread_ts=thread_ts,
send_as_ephemeral=send_as_ephemeral,
)
def handle_regular_answer(
message_info: SlackMessageInfo,
slack_channel_config: SlackChannelConfig,
@@ -157,6 +265,20 @@ def handle_regular_answer(
user_message = messages[-1]
history_messages = messages[:-1]
# Resolve any <#CHANNEL_ID> references in the user message to readable
# channel names and extract channel tags for search filtering
resolved_message, channel_tags = resolve_channel_references(
message=user_message.message,
client=client,
logger=logger,
)
user_message = ThreadMessage(
message=resolved_message,
sender=user_message.sender,
role=user_message.role,
)
channel_name, _ = get_channel_name_from_id(
client=client,
channel_id=channel,
@@ -207,6 +329,7 @@ def handle_regular_answer(
source_type=None,
document_set=document_set_names,
time_cutoff=None,
tags=channel_tags if channel_tags else None,
)
new_message_request = SendMessageRequest(
@@ -231,6 +354,16 @@ def handle_regular_answer(
slack_context_str=slack_context_str,
)
# If a channel filter was applied but no results were found, override
# the LLM response to avoid hallucinated answers about unindexed channels
if channel_tags and not answer.citation_info and not answer.top_documents:
channel_names = ", ".join(f"#{tag.tag_value}" for tag in channel_tags)
answer.answer = (
f"No indexed data found for {channel_names}. "
"This channel may not be indexed, or there may be no messages "
"matching your query within it."
)
except Exception as e:
logger.exception(
f"Unable to process message - did not successfully answer "
@@ -285,6 +418,7 @@ def handle_regular_answer(
only_respond_if_citations
and not answer.citation_info
and not message_info.bypass_filters
and not channel_tags
):
logger.error(
f"Unable to find citations to answer: '{answer.answer}' - not answering!"
@@ -313,7 +447,7 @@ def handle_regular_answer(
offer_ephemeral_publication = False
skip_ai_feedback = False
all_blocks = build_slack_response_blocks(
all_blocks, code_snippets = build_slack_response_blocks(
message_info=message_info,
answer=answer,
channel_conf=channel_conf,
@@ -340,6 +474,20 @@ def handle_regular_answer(
send_as_ephemeral=send_as_ephemeral,
)
# Upload extracted code blocks as Slack file snippets so they
# render as collapsible, syntax-highlighted blocks in the thread.
snippet_thread_ts = target_thread_ts or message_ts_to_respond_to
if code_snippets and snippet_thread_ts:
_upload_code_snippets(
client=client,
channel=channel,
thread_ts=snippet_thread_ts,
snippets=code_snippets,
logger=logger,
receiver_ids=target_receiver_ids,
send_as_ephemeral=send_as_ephemeral,
)
# For DM (ephemeral message), we need to create a thread via a normal message so the user can see
# the ephemeral message. This also will give the user a notification which ephemeral message does not.
# if there is no message_ts_to_respond_to, and we have made it this far, then this is a /onyx message

View File

@@ -732,7 +732,7 @@ def get_webapp_info(
return WebappInfo(**webapp_info)
@router.get("/{session_id}/webapp/download")
@router.get("/{session_id}/webapp-download")
def download_webapp(
session_id: UUID,
user: User = Depends(current_user),

View File

@@ -7424,9 +7424,9 @@
}
},
"node_modules/hono": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
"integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==",
"version": "4.12.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"

View File

@@ -58,6 +58,9 @@ from onyx.llm.well_known_providers.llm_provider_options import (
from onyx.server.manage.llm.models import BedrockFinalModelResponse
from onyx.server.manage.llm.models import BedrockModelsRequest
from onyx.server.manage.llm.models import DefaultModel
from onyx.server.manage.llm.models import LitellmFinalModelResponse
from onyx.server.manage.llm.models import LitellmModelDetails
from onyx.server.manage.llm.models import LitellmModelsRequest
from onyx.server.manage.llm.models import LLMCost
from onyx.server.manage.llm.models import LLMProviderDescriptor
from onyx.server.manage.llm.models import LLMProviderResponse
@@ -72,6 +75,7 @@ from onyx.server.manage.llm.models import OllamaModelsRequest
from onyx.server.manage.llm.models import OpenRouterFinalModelResponse
from onyx.server.manage.llm.models import OpenRouterModelDetails
from onyx.server.manage.llm.models import OpenRouterModelsRequest
from onyx.server.manage.llm.models import SyncModelEntry
from onyx.server.manage.llm.models import TestLLMRequest
from onyx.server.manage.llm.models import VisionProviderResponse
from onyx.server.manage.llm.utils import generate_bedrock_display_name
@@ -98,6 +102,34 @@ def _mask_string(value: str) -> str:
return value[:4] + "****" + value[-4:]
def _sync_fetched_models(
db_session: Session,
provider_name: str,
models: list[SyncModelEntry],
source_label: str,
) -> None:
"""Sync fetched models to DB for the given provider.
Args:
db_session: Database session
provider_name: Name of the LLM provider
models: List of SyncModelEntry objects describing the fetched models
source_label: Human-readable label for log messages (e.g. "Bedrock", "LiteLLM")
"""
try:
new_count = sync_model_configurations(
db_session=db_session,
provider_name=provider_name,
models=models,
)
if new_count > 0:
logger.info(
f"Added {new_count} new {source_label} models to provider '{provider_name}'"
)
except ValueError as e:
logger.warning(f"Failed to sync {source_label} models to DB: {e}")
# Keys in custom_config that contain sensitive credentials
_SENSITIVE_CONFIG_KEYS = {
"vertex_credentials",
@@ -963,27 +995,20 @@ def get_bedrock_available_models(
# Sync new models to DB if provider_name is specified
if request.provider_name:
try:
models_to_sync = [
{
"name": r.name,
"display_name": r.display_name,
"max_input_tokens": r.max_input_tokens,
"supports_image_input": r.supports_image_input,
}
for r in results
]
new_count = sync_model_configurations(
db_session=db_session,
provider_name=request.provider_name,
models=models_to_sync,
)
if new_count > 0:
logger.info(
f"Added {new_count} new Bedrock models to provider '{request.provider_name}'"
_sync_fetched_models(
db_session=db_session,
provider_name=request.provider_name,
models=[
SyncModelEntry(
name=r.name,
display_name=r.display_name,
max_input_tokens=r.max_input_tokens,
supports_image_input=r.supports_image_input,
)
except ValueError as e:
logger.warning(f"Failed to sync Bedrock models to DB: {e}")
for r in results
],
source_label="Bedrock",
)
return results
@@ -1101,27 +1126,20 @@ def get_ollama_available_models(
# Sync new models to DB if provider_name is specified
if request.provider_name:
try:
models_to_sync = [
{
"name": r.name,
"display_name": r.display_name,
"max_input_tokens": r.max_input_tokens,
"supports_image_input": r.supports_image_input,
}
for r in sorted_results
]
new_count = sync_model_configurations(
db_session=db_session,
provider_name=request.provider_name,
models=models_to_sync,
)
if new_count > 0:
logger.info(
f"Added {new_count} new Ollama models to provider '{request.provider_name}'"
_sync_fetched_models(
db_session=db_session,
provider_name=request.provider_name,
models=[
SyncModelEntry(
name=r.name,
display_name=r.display_name,
max_input_tokens=r.max_input_tokens,
supports_image_input=r.supports_image_input,
)
except ValueError as e:
logger.warning(f"Failed to sync Ollama models to DB: {e}")
for r in sorted_results
],
source_label="Ollama",
)
return sorted_results
@@ -1210,27 +1228,20 @@ def get_openrouter_available_models(
# Sync new models to DB if provider_name is specified
if request.provider_name:
try:
models_to_sync = [
{
"name": r.name,
"display_name": r.display_name,
"max_input_tokens": r.max_input_tokens,
"supports_image_input": r.supports_image_input,
}
for r in sorted_results
]
new_count = sync_model_configurations(
db_session=db_session,
provider_name=request.provider_name,
models=models_to_sync,
)
if new_count > 0:
logger.info(
f"Added {new_count} new OpenRouter models to provider '{request.provider_name}'"
_sync_fetched_models(
db_session=db_session,
provider_name=request.provider_name,
models=[
SyncModelEntry(
name=r.name,
display_name=r.display_name,
max_input_tokens=r.max_input_tokens,
supports_image_input=r.supports_image_input,
)
except ValueError as e:
logger.warning(f"Failed to sync OpenRouter models to DB: {e}")
for r in sorted_results
],
source_label="OpenRouter",
)
return sorted_results
@@ -1324,26 +1335,119 @@ def get_lm_studio_available_models(
# Sync new models to DB if provider_name is specified
if request.provider_name:
try:
models_to_sync = [
{
"name": r.name,
"display_name": r.display_name,
"max_input_tokens": r.max_input_tokens,
"supports_image_input": r.supports_image_input,
}
for r in sorted_results
]
new_count = sync_model_configurations(
db_session=db_session,
provider_name=request.provider_name,
models=models_to_sync,
)
if new_count > 0:
logger.info(
f"Added {new_count} new LM Studio models to provider '{request.provider_name}'"
_sync_fetched_models(
db_session=db_session,
provider_name=request.provider_name,
models=[
SyncModelEntry(
name=r.name,
display_name=r.display_name,
max_input_tokens=r.max_input_tokens,
supports_image_input=r.supports_image_input,
)
except ValueError as e:
logger.warning(f"Failed to sync LM Studio models to DB: {e}")
for r in sorted_results
],
source_label="LM Studio",
)
return sorted_results
@admin_router.post("/litellm/available-models")
def get_litellm_available_models(
request: LitellmModelsRequest,
_: User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> list[LitellmFinalModelResponse]:
"""Fetch available models from Litellm proxy /v1/models endpoint."""
response_json = _get_litellm_models_response(
api_key=request.api_key, api_base=request.api_base
)
models = response_json.get("data", [])
if not isinstance(models, list) or len(models) == 0:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"No models found from your Litellm endpoint",
)
results: list[LitellmFinalModelResponse] = []
for model in models:
try:
model_details = LitellmModelDetails.model_validate(model)
results.append(
LitellmFinalModelResponse(
provider_name=model_details.owned_by,
model_name=model_details.id,
)
)
except Exception as e:
logger.warning(
"Failed to parse Litellm model entry",
extra={"error": str(e), "item": str(model)[:1000]},
)
if not results:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"No compatible models found from Litellm",
)
sorted_results = sorted(results, key=lambda m: m.model_name.lower())
# Sync new models to DB if provider_name is specified
if request.provider_name:
_sync_fetched_models(
db_session=db_session,
provider_name=request.provider_name,
models=[
SyncModelEntry(
name=r.model_name,
display_name=r.model_name,
)
for r in sorted_results
],
source_label="LiteLLM",
)
return sorted_results
def _get_litellm_models_response(api_key: str, api_base: str) -> dict:
"""Perform GET to Litellm proxy /api/v1/models and return parsed JSON."""
cleaned_api_base = api_base.strip().rstrip("/")
url = f"{cleaned_api_base}/v1/models"
headers = {
"Authorization": f"Bearer {api_key}",
"HTTP-Referer": "https://onyx.app",
"X-Title": "Onyx",
}
try:
response = httpx.get(url, headers=headers, timeout=10.0)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
if e.response.status_code == 401:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Authentication failed: invalid or missing API key for LiteLLM proxy.",
)
elif e.response.status_code == 404:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
f"LiteLLM models endpoint not found at {url}. "
"Please verify the API base URL.",
)
else:
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
f"Failed to fetch LiteLLM models: {e}",
)
except Exception as e:
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
f"Failed to fetch LiteLLM models: {e}",
)

View File

@@ -420,3 +420,32 @@ class LLMProviderResponse(BaseModel, Generic[T]):
default_text=default_text,
default_vision=default_vision,
)
class SyncModelEntry(BaseModel):
"""Typed model for syncing fetched models to the DB."""
name: str
display_name: str
max_input_tokens: int | None = None
supports_image_input: bool = False
class LitellmModelsRequest(BaseModel):
api_key: str
api_base: str
provider_name: str | None = None # Optional: to save models to existing provider
class LitellmModelDetails(BaseModel):
"""Response model for Litellm proxy /api/v1/models endpoint"""
id: str # Model ID (e.g. "gpt-4o")
object: str # "model"
created: int # Unix timestamp in seconds
owned_by: str # Provider name (e.g. "openai")
class LitellmFinalModelResponse(BaseModel):
provider_name: str # Provider name (e.g. "openai")
model_name: str # Model ID (e.g. "gpt-4o")

View File

@@ -5,6 +5,7 @@ from datetime import datetime
from datetime import timedelta
from datetime import timezone
from typing import cast
from uuid import UUID
import jwt
from email_validator import EmailNotValidError
@@ -18,6 +19,7 @@ from fastapi import Query
from fastapi import Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from onyx.auth.anonymous_user import fetch_anonymous_user_info
@@ -67,11 +69,14 @@ from onyx.db.user_preferences import update_user_role
from onyx.db.user_preferences import update_user_shortcut_enabled
from onyx.db.user_preferences import update_user_temperature_override_enabled
from onyx.db.user_preferences import update_user_theme_preference
from onyx.db.users import batch_get_user_groups
from onyx.db.users import delete_user_from_db
from onyx.db.users import get_all_accepted_users
from onyx.db.users import get_all_users
from onyx.db.users import get_page_of_filtered_users
from onyx.db.users import get_total_filtered_users_count
from onyx.db.users import get_user_by_email
from onyx.db.users import get_user_counts_by_role_and_status
from onyx.db.users import validate_user_role_update
from onyx.key_value_store.factory import get_kv_store
from onyx.redis.redis_pool import get_raw_redis_client
@@ -98,6 +103,7 @@ from onyx.server.manage.models import UserSpecificAssistantPreferences
from onyx.server.models import FullUserSnapshot
from onyx.server.models import InvitedUserSnapshot
from onyx.server.models import MinimalUserSnapshot
from onyx.server.models import UserGroupInfo
from onyx.server.usage_limits import is_tenant_on_trial_fn
from onyx.server.utils import BasicAuthenticationError
from onyx.utils.logger import setup_logger
@@ -203,14 +209,91 @@ def list_accepted_users(
total_items=0,
)
user_ids = [user.id for user in filtered_accepted_users]
groups_by_user = batch_get_user_groups(db_session, user_ids)
# Batch-fetch SCIM mappings to mark synced users
scim_synced_ids: set[UUID] = set()
try:
from onyx.db.models import ScimUserMapping
scim_mappings = db_session.scalars(
select(ScimUserMapping.user_id).where(ScimUserMapping.user_id.in_(user_ids))
).all()
scim_synced_ids = set(scim_mappings)
except Exception:
logger.warning(
"Failed to fetch SCIM mappings; marking all users as non-synced",
exc_info=True,
)
return PaginatedReturn(
items=[
FullUserSnapshot.from_user_model(user) for user in filtered_accepted_users
FullUserSnapshot.from_user_model(
user,
groups=[
UserGroupInfo(id=gid, name=gname)
for gid, gname in groups_by_user.get(user.id, [])
],
is_scim_synced=user.id in scim_synced_ids,
)
for user in filtered_accepted_users
],
total_items=total_accepted_users_count,
)
@router.get("/manage/users/accepted/all", tags=PUBLIC_API_TAGS)
def list_all_accepted_users(
_: User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> list[FullUserSnapshot]:
"""Returns all accepted users without pagination.
Used by the admin Users page for client-side filtering/sorting."""
users = get_all_accepted_users(db_session=db_session)
if not users:
return []
user_ids = [user.id for user in users]
groups_by_user = batch_get_user_groups(db_session, user_ids)
# Batch-fetch SCIM mappings to mark synced users
scim_synced_ids: set[UUID] = set()
try:
from onyx.db.models import ScimUserMapping
scim_mappings = db_session.scalars(
select(ScimUserMapping.user_id).where(ScimUserMapping.user_id.in_(user_ids))
).all()
scim_synced_ids = set(scim_mappings)
except Exception:
logger.warning(
"Failed to fetch SCIM mappings; marking all users as non-synced",
exc_info=True,
)
return [
FullUserSnapshot.from_user_model(
user,
groups=[
UserGroupInfo(id=gid, name=gname)
for gid, gname in groups_by_user.get(user.id, [])
],
is_scim_synced=user.id in scim_synced_ids,
)
for user in users
]
@router.get("/manage/users/counts")
def get_user_counts(
_: User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> dict[str, dict[str, int]]:
return get_user_counts_by_role_and_status(db_session)
@router.get("/manage/users/invited", tags=PUBLIC_API_TAGS)
def list_invited_users(
_: User = Depends(current_admin_user),
@@ -269,24 +352,10 @@ def list_all_users(
if accepted_page is None or invited_page is None or slack_users_page is None:
return AllUsersResponse(
accepted=[
FullUserSnapshot(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
)
for user in accepted_users
FullUserSnapshot.from_user_model(user) for user in accepted_users
],
slack_users=[
FullUserSnapshot(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
)
for user in slack_users
FullUserSnapshot.from_user_model(user) for user in slack_users
],
invited=[InvitedUserSnapshot(email=email) for email in invited_emails],
accepted_pages=1,
@@ -296,26 +365,10 @@ def list_all_users(
# Otherwise, return paginated results
return AllUsersResponse(
accepted=[
FullUserSnapshot(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
)
for user in accepted_users
][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE],
slack_users=[
FullUserSnapshot(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
)
for user in slack_users
][
accepted=[FullUserSnapshot.from_user_model(user) for user in accepted_users][
accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE
],
slack_users=[FullUserSnapshot.from_user_model(user) for user in slack_users][
slack_users_page
* USERS_PAGE_SIZE : (slack_users_page + 1)
* USERS_PAGE_SIZE

View File

@@ -1,3 +1,4 @@
import datetime
from typing import Generic
from typing import Optional
from typing import TypeVar
@@ -31,21 +32,41 @@ class MinimalUserSnapshot(BaseModel):
email: str
class UserGroupInfo(BaseModel):
id: int
name: str
class FullUserSnapshot(BaseModel):
id: UUID
email: str
role: UserRole
is_active: bool
password_configured: bool
personal_name: str | None
created_at: datetime.datetime
updated_at: datetime.datetime
groups: list[UserGroupInfo]
is_scim_synced: bool
@classmethod
def from_user_model(cls, user: User) -> "FullUserSnapshot":
def from_user_model(
cls,
user: User,
groups: list[UserGroupInfo] | None = None,
is_scim_synced: bool = False,
) -> "FullUserSnapshot":
return cls(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
personal_name=user.personal_name,
created_at=user.created_at,
updated_at=user.updated_at,
groups=groups or [],
is_scim_synced=is_scim_synced,
)

View File

@@ -0,0 +1,13 @@
from typing import Any
from pydantic import BaseModel
def shallow_model_dump(model_instance: BaseModel) -> dict[str, Any]:
"""Like model_dump(), but returns references to field values instead of
deep copies. Use with model_construct() to avoid unnecessary memory
duplication when building subclass instances."""
return {
field_name: getattr(model_instance, field_name)
for field_name in model_instance.__class__.model_fields
}

View File

@@ -750,7 +750,7 @@ pypandoc-binary==1.16.2
# via onyx
pyparsing==3.2.5
# via httplib2
pypdf==6.7.5
pypdf==6.8.0
# via
# onyx
# unstructured-client
@@ -1020,7 +1020,7 @@ toolz==1.1.0
# dask
# distributed
# partd
tornado==6.5.2
tornado==6.5.5
# via distributed
tqdm==4.67.1
# via

View File

@@ -263,7 +263,7 @@ oauthlib==3.2.2
# via
# kubernetes
# requests-oauthlib
onyx-devtools==0.6.3
onyx-devtools==0.7.0
# via onyx
openai==2.14.0
# via
@@ -406,7 +406,7 @@ referencing==0.36.2
# jsonschema-specifications
regex==2025.11.3
# via tiktoken
release-tag==0.4.3
release-tag==0.5.2
# via onyx
reorder-python-imports-black==3.14.0
# via onyx
@@ -466,7 +466,7 @@ tokenizers==0.21.4
# via
# cohere
# litellm
tornado==6.5.2
tornado==6.5.5
# via
# ipykernel
# jupyter-client

View File

@@ -19,7 +19,7 @@ from fastapi.testclient import TestClient
from onyx.auth.users import current_admin_user
from onyx.db.engine.sql_engine import get_session
from onyx.db.models import UserRole
from onyx.main import fetch_versioned_implementation
from onyx.main import get_application
from onyx.utils.logger import setup_logger
logger = setup_logger()
@@ -51,11 +51,8 @@ def client() -> Generator[TestClient, None, None]:
# Patch out prometheus metrics setup to avoid "Duplicated timeseries in
# CollectorRegistry" errors when multiple tests each create a new app
# (prometheus registers metrics globally and rejects duplicate names).
get_app = fetch_versioned_implementation(
module="onyx.main", attribute="get_application"
)
with patch("onyx.main.setup_prometheus_metrics"):
app: FastAPI = get_app(lifespan_override=test_lifespan)
app: FastAPI = get_application(lifespan_override=test_lifespan)
# Override the database session dependency with a mock
# (these tests don't actually need DB access)

View File

@@ -0,0 +1,398 @@
"""External dependency tests for the old DocumentIndex interface.
These tests assume Vespa and OpenSearch are running.
TODO(ENG-3764)(andrei): Consolidate some of these test fixtures.
"""
import os
import time
import uuid
from collections.abc import Generator
from unittest.mock import patch
import httpx
import pytest
from onyx.access.models import DocumentAccess
from onyx.configs.constants import DocumentSource
from onyx.connectors.models import Document
from onyx.context.search.models import IndexFilters
from onyx.db.enums import EmbeddingPrecision
from onyx.document_index.interfaces import DocumentIndex
from onyx.document_index.interfaces import IndexBatchParams
from onyx.document_index.interfaces import VespaChunkRequest
from onyx.document_index.interfaces import VespaDocumentUserFields
from onyx.document_index.opensearch.client import wait_for_opensearch_with_timeout
from onyx.document_index.opensearch.opensearch_document_index import (
OpenSearchOldDocumentIndex,
)
from onyx.document_index.vespa.index import VespaIndex
from onyx.document_index.vespa.shared_utils.utils import get_vespa_http_client
from onyx.document_index.vespa.shared_utils.utils import wait_for_vespa_with_timeout
from onyx.indexing.models import ChunkEmbedding
from onyx.indexing.models import DocMetadataAwareIndexChunk
from shared_configs.configs import MULTI_TENANT
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
from shared_configs.contextvars import get_current_tenant_id
from tests.external_dependency_unit.constants import TEST_TENANT_ID
@pytest.fixture(scope="module")
def opensearch_available() -> Generator[None, None, None]:
"""Verifies OpenSearch is running, fails the test if not."""
if not wait_for_opensearch_with_timeout():
pytest.fail("OpenSearch is not available.")
yield # Test runs here.
@pytest.fixture(scope="module")
def test_index_name() -> Generator[str, None, None]:
yield f"test_index_{uuid.uuid4().hex[:8]}" # Test runs here.
@pytest.fixture(scope="module")
def tenant_context() -> Generator[None, None, None]:
"""Sets up tenant context for testing."""
token = CURRENT_TENANT_ID_CONTEXTVAR.set(TEST_TENANT_ID)
try:
yield # Test runs here.
finally:
# Reset the tenant context after the test
CURRENT_TENANT_ID_CONTEXTVAR.reset(token)
@pytest.fixture(scope="module")
def httpx_client() -> Generator[httpx.Client, None, None]:
client = get_vespa_http_client()
try:
yield client
finally:
client.close()
@pytest.fixture(scope="module")
def vespa_document_index(
httpx_client: httpx.Client,
tenant_context: None, # noqa: ARG001
test_index_name: str,
) -> Generator[VespaIndex, None, None]:
vespa_index = VespaIndex(
index_name=test_index_name,
secondary_index_name=None,
large_chunks_enabled=False,
secondary_large_chunks_enabled=None,
multitenant=MULTI_TENANT,
httpx_client=httpx_client,
)
backend_dir = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "..")
)
with patch("os.getcwd", return_value=backend_dir):
vespa_index.ensure_indices_exist(
primary_embedding_dim=128,
primary_embedding_precision=EmbeddingPrecision.FLOAT,
secondary_index_embedding_dim=None,
secondary_index_embedding_precision=None,
)
# Verify Vespa is running, fails the test if not. Try 90 seconds for testing
# in CI. We have to do this here because this endpoint only becomes live
# once we create an index.
if not wait_for_vespa_with_timeout(wait_limit=90):
pytest.fail("Vespa is not available.")
# Wait until the schema is actually ready for writes on content nodes. We
# probe by attempting a PUT; 200 means the schema is live, 400 means not
# yet. This is so scuffed but running the test is really flakey otherwise;
# this is only temporary until we entirely move off of Vespa.
probe_doc = {
"fields": {
"document_id": "__probe__",
"chunk_id": 0,
"blurb": "",
"title": "",
"skip_title": True,
"content": "",
"content_summary": "",
"source_type": "file",
"source_links": "null",
"semantic_identifier": "",
"section_continuation": False,
"large_chunk_reference_ids": [],
"metadata": "{}",
"metadata_list": [],
"metadata_suffix": "",
"chunk_context": "",
"doc_summary": "",
"embeddings": {"full_chunk": [1.0] + [0.0] * 127},
"access_control_list": {},
"document_sets": {},
"image_file_name": None,
"user_project": [],
"personas": [],
"boost": 0.0,
"aggregated_chunk_boost_factor": 0.0,
"primary_owners": [],
"secondary_owners": [],
}
}
schema_ready = False
probe_url = (
f"http://localhost:8081/document/v1/default/{test_index_name}/docid/__probe__"
)
for _ in range(60):
resp = httpx_client.post(probe_url, json=probe_doc)
if resp.status_code == 200:
schema_ready = True
# Clean up the probe document.
httpx_client.delete(probe_url)
break
time.sleep(1)
if not schema_ready:
pytest.fail(f"Vespa schema '{test_index_name}' did not become ready in time.")
yield vespa_index # Test runs here.
# TODO(ENG-3765)(andrei): Explicitly cleanup index. Not immediately
# pressing; in CI we should be using fresh instances of dependencies each
# time anyway.
@pytest.fixture(scope="module")
def opensearch_document_index(
opensearch_available: None, # noqa: ARG001
tenant_context: None, # noqa: ARG001
test_index_name: str,
) -> Generator[OpenSearchOldDocumentIndex, None, None]:
opensearch_index = OpenSearchOldDocumentIndex(
index_name=test_index_name,
embedding_dim=128,
embedding_precision=EmbeddingPrecision.FLOAT,
secondary_index_name=None,
secondary_embedding_dim=None,
secondary_embedding_precision=None,
large_chunks_enabled=False,
secondary_large_chunks_enabled=None,
multitenant=MULTI_TENANT,
)
opensearch_index.ensure_indices_exist(
primary_embedding_dim=128,
primary_embedding_precision=EmbeddingPrecision.FLOAT,
secondary_index_embedding_dim=None,
secondary_index_embedding_precision=None,
)
yield opensearch_index # Test runs here.
# TODO(ENG-3765)(andrei): Explicitly cleanup index. Not immediately
# pressing; in CI we should be using fresh instances of dependencies each
# time anyway.
@pytest.fixture(scope="module")
def document_indices(
vespa_document_index: VespaIndex,
opensearch_document_index: OpenSearchOldDocumentIndex,
) -> Generator[list[DocumentIndex], None, None]:
# Ideally these are parametrized; doing so with pytest fixtures is tricky.
yield [opensearch_document_index, vespa_document_index] # Test runs here.
@pytest.fixture(scope="function")
def chunks(
tenant_context: None, # noqa: ARG001
) -> Generator[list[DocMetadataAwareIndexChunk], None, None]:
result = []
chunk_count = 5
doc_id = "test_doc"
tenant_id = get_current_tenant_id()
access = DocumentAccess.build(
user_emails=[],
user_groups=[],
external_user_emails=[],
external_user_group_ids=[],
is_public=True,
)
document_sets: set[str] = set()
user_project: list[int] = list()
personas: list[int] = list()
boost = 0
blurb = "blurb"
content = "content"
title_prefix = ""
doc_summary = ""
chunk_context = ""
title_embedding = [1.0] + [0] * 127
# Full 0 vectors are not supported for cos similarity.
embeddings = ChunkEmbedding(
full_embedding=[1.0] + [0] * 127, mini_chunk_embeddings=[]
)
source_document = Document(
id=doc_id,
semantic_identifier="semantic identifier",
source=DocumentSource.FILE,
sections=[],
metadata={},
title="title",
)
metadata_suffix_keyword = ""
image_file_id = None
source_links: dict[int, str] = {0: ""}
ancestor_hierarchy_node_ids: list[int] = []
for i in range(chunk_count):
result.append(
DocMetadataAwareIndexChunk(
tenant_id=tenant_id,
access=access,
document_sets=document_sets,
user_project=user_project,
personas=personas,
boost=boost,
aggregated_chunk_boost_factor=0,
ancestor_hierarchy_node_ids=ancestor_hierarchy_node_ids,
embeddings=embeddings,
title_embedding=title_embedding,
source_document=source_document,
title_prefix=title_prefix,
metadata_suffix_keyword=metadata_suffix_keyword,
metadata_suffix_semantic="",
contextual_rag_reserved_tokens=0,
doc_summary=doc_summary,
chunk_context=chunk_context,
mini_chunk_texts=None,
large_chunk_id=None,
chunk_id=i,
blurb=blurb,
content=content,
source_links=source_links,
image_file_id=image_file_id,
section_continuation=False,
)
)
yield result # Test runs here.
@pytest.fixture(scope="function")
def index_batch_params(
tenant_context: None, # noqa: ARG001
) -> Generator[IndexBatchParams, None, None]:
# WARNING: doc_id_to_previous_chunk_cnt={"test_doc": 0} is hardcoded to 0,
# which is only correct on the very first index call. The document_indices
# fixture is scope="module", meaning the same OpenSearch and Vespa backends
# persist across all test functions in this module. When a second test
# function uses this fixture and calls document_index.index(...), the
# backend already has 5 chunks for "test_doc" from the previous test run,
# but the batch params still claim 0 prior chunks exist. This can lead to
# orphaned/duplicate chunks that make subsequent assertions incorrect.
# TODO: Whenever adding a second test, either change this or cleanup the
# index between test cases.
yield IndexBatchParams(
doc_id_to_previous_chunk_cnt={"test_doc": 0},
doc_id_to_new_chunk_cnt={"test_doc": 5},
tenant_id=get_current_tenant_id(),
large_chunks_enabled=False,
)
class TestDocumentIndexOld:
"""Tests the old DocumentIndex interface."""
def test_update_single_can_clear_user_projects_and_personas(
self,
document_indices: list[DocumentIndex],
# This test case assumes all these chunks correspond to one document.
chunks: list[DocMetadataAwareIndexChunk],
index_batch_params: IndexBatchParams,
) -> None:
"""
Tests that update_single can clear user_projects and personas.
"""
for document_index in document_indices:
# Precondition.
# Ensure there is some non-empty value for user project and
# personas.
for chunk in chunks:
chunk.user_project = [1]
chunk.personas = [2]
document_index.index(chunks, index_batch_params)
# Ensure that we can get chunks as expected with filters.
doc_id = chunks[0].source_document.id
chunk_count = len(chunks)
tenant_id = get_current_tenant_id()
# We need to specify the chunk index range and specify
# batch_retrieval=True below to trigger the codepath for Vespa's
# search API, which uses the expected additive filtering for
# project_id and persona_id. Otherwise we would use the codepath for
# the visit API, which does not have this kind of filtering
# implemented.
chunk_request = VespaChunkRequest(
document_id=doc_id, min_chunk_ind=0, max_chunk_ind=chunk_count - 1
)
project_persona_filters = IndexFilters(
access_control_list=None,
tenant_id=tenant_id,
project_id=1,
persona_id=2,
# We need this even though none of the chunks belong to a
# document set because project_id and persona_id are only
# additive filters in the event the agent has knowledge scope;
# if the agent does not, it is implied that it can see
# everything it is allowed to.
document_set=["1"],
)
# Not best practice here but the API for refreshing the index to
# ensure that the latest data is present is not exposed in this
# class and is not the same for Vespa and OpenSearch, so we just
# tolerate a sleep for now. As a consequence the number of tests in
# this suite should be small. We only need to tolerate this for as
# long as we continue to use Vespa, we can consider exposing
# something for OpenSearch later.
time.sleep(1)
inference_chunks = document_index.id_based_retrieval(
chunk_requests=[chunk_request],
filters=project_persona_filters,
batch_retrieval=True,
)
assert len(inference_chunks) == chunk_count
# Sort by chunk id to easily test if we have all chunks.
for i, inference_chunk in enumerate(
sorted(inference_chunks, key=lambda x: x.chunk_id)
):
assert inference_chunk.chunk_id == i
assert inference_chunk.document_id == doc_id
# Under test.
# Explicitly set empty fields here.
user_fields = VespaDocumentUserFields(user_projects=[], personas=[])
document_index.update_single(
doc_id=doc_id,
chunk_count=chunk_count,
tenant_id=tenant_id,
fields=None,
user_fields=user_fields,
)
# Postcondition.
filters = IndexFilters(access_control_list=None, tenant_id=tenant_id)
# We should expect to get back all expected chunks with no filters.
# Again, not best practice here.
time.sleep(1)
inference_chunks = document_index.id_based_retrieval(
chunk_requests=[chunk_request], filters=filters, batch_retrieval=True
)
assert len(inference_chunks) == chunk_count
# Sort by chunk id to easily test if we have all chunks.
for i, inference_chunk in enumerate(
sorted(inference_chunks, key=lambda x: x.chunk_id)
):
assert inference_chunk.chunk_id == i
assert inference_chunk.document_id == doc_id
# Now, we should expect to not get any chunks if we specify the user
# project and personas filters.
inference_chunks = document_index.id_based_retrieval(
chunk_requests=[chunk_request],
filters=project_persona_filters,
batch_retrieval=True,
)
assert len(inference_chunks) == 0

View File

@@ -17,6 +17,9 @@ from unittest.mock import patch
import pytest
from sqlalchemy.orm import Session
from onyx.background.celery.tasks.opensearch_migration.constants import (
GET_VESPA_CHUNKS_SLICE_COUNT,
)
from onyx.background.celery.tasks.opensearch_migration.tasks import (
is_continuation_token_done_for_all_slices,
)
@@ -236,6 +239,8 @@ def full_deployment_setup() -> Generator[None, None, None]:
NOTE: We deliberately duplicate this logic from
backend/tests/external_dependency_unit/conftest.py because we need to set
opensearch_available just for this module, not the entire test session.
TODO(ENG-3764)(andrei): Consolidate some of these test fixtures.
"""
# Patch ENABLE_OPENSEARCH_INDEXING_FOR_ONYX just for this test because we
# don't yet want that enabled for all tests.
@@ -320,9 +325,15 @@ def test_embedding_dimension(db_session: Session) -> Generator[int, None, None]:
@pytest.fixture(scope="function")
def patch_get_vespa_chunks_page_size() -> Generator[int, None, None]:
test_page_size = 5
with patch(
"onyx.background.celery.tasks.opensearch_migration.tasks.GET_VESPA_CHUNKS_PAGE_SIZE",
test_page_size,
with (
patch(
"onyx.background.celery.tasks.opensearch_migration.tasks.GET_VESPA_CHUNKS_PAGE_SIZE",
test_page_size,
),
patch(
"onyx.background.celery.tasks.opensearch_migration.constants.GET_VESPA_CHUNKS_PAGE_SIZE",
test_page_size,
),
):
yield test_page_size # Test runs here.
@@ -582,6 +593,175 @@ class TestMigrateChunksFromVespaToOpenSearchTask:
document_chunks[document.id][opensearch_chunk.chunk_index],
)
def test_chunk_migration_visits_all_chunks_even_when_batch_size_varies(
self,
db_session: Session,
test_documents: list[Document],
vespa_document_index: VespaDocumentIndex,
opensearch_client: OpenSearchIndexClient,
test_embedding_dimension: int,
clean_migration_tables: None, # noqa: ARG002
enable_opensearch_indexing_for_onyx: None, # noqa: ARG002
) -> None:
"""
Tests that chunk migration works correctly even when the batch size
changes halfway through a migration.
Simulates task time running out my mocking the locking behavior.
"""
# Precondition.
# Index chunks into Vespa.
document_chunks: dict[str, list[dict[str, Any]]] = {
document.id: [
_create_raw_document_chunk(
document_id=document.id,
chunk_index=i,
content=f"Test content {i} for {document.id}",
embedding=_generate_test_vector(test_embedding_dimension),
now=datetime.now(),
title=f"Test title {document.id}",
title_embedding=_generate_test_vector(test_embedding_dimension),
)
for i in range(CHUNK_COUNT)
]
for document in test_documents
}
all_chunks: list[dict[str, Any]] = []
for chunks in document_chunks.values():
all_chunks.extend(chunks)
vespa_document_index.index_raw_chunks(all_chunks)
# Run the initial batch. To simulate partial progress we will mock the
# redis lock to return True for the first invocation of .owned() and
# False subsequently.
# NOTE: The batch size is currently set to 5 in
# patch_get_vespa_chunks_page_size.
mock_redis_client = Mock()
mock_lock = Mock()
mock_lock.owned.side_effect = [True, False, False]
mock_lock.acquire.return_value = True
mock_redis_client.lock.return_value = mock_lock
with patch(
"onyx.background.celery.tasks.opensearch_migration.tasks.get_redis_client",
return_value=mock_redis_client,
):
result_1 = migrate_chunks_from_vespa_to_opensearch_task(
tenant_id=get_current_tenant_id()
)
assert result_1 is True
# Expire the session cache to see the committed changes from the task.
db_session.expire_all()
# Verify partial progress was saved.
tenant_record = db_session.query(OpenSearchTenantMigrationRecord).first()
assert tenant_record is not None
partial_chunks_migrated = tenant_record.total_chunks_migrated
assert partial_chunks_migrated > 0
# page_size applies per slice, so one iteration can fetch up to
# page_size * GET_VESPA_CHUNKS_SLICE_COUNT chunks total.
assert partial_chunks_migrated <= 5 * GET_VESPA_CHUNKS_SLICE_COUNT
assert tenant_record.vespa_visit_continuation_token is not None
# Slices are not necessarily evenly distributed across all document
# chunks so we can't test that every token is non-None, but certainly at
# least one must be.
assert any(json.loads(tenant_record.vespa_visit_continuation_token).values())
assert tenant_record.migration_completed_at is None
assert tenant_record.approx_chunk_count_in_vespa is not None
# Under test.
# Now patch the batch size to be some other number, like 2.
mock_redis_client = Mock()
mock_lock = Mock()
mock_lock.owned.side_effect = [True, False, False]
mock_lock.acquire.return_value = True
mock_redis_client.lock.return_value = mock_lock
with (
patch(
"onyx.background.celery.tasks.opensearch_migration.tasks.GET_VESPA_CHUNKS_PAGE_SIZE",
2,
),
patch(
"onyx.background.celery.tasks.opensearch_migration.constants.GET_VESPA_CHUNKS_PAGE_SIZE",
2,
),
patch(
"onyx.background.celery.tasks.opensearch_migration.tasks.get_redis_client",
return_value=mock_redis_client,
),
):
result_2 = migrate_chunks_from_vespa_to_opensearch_task(
tenant_id=get_current_tenant_id()
)
# Postcondition.
assert result_2 is True
# Expire the session cache to see the committed changes from the task.
db_session.expire_all()
# Verify next partial progress was saved.
tenant_record = db_session.query(OpenSearchTenantMigrationRecord).first()
assert tenant_record is not None
new_partial_chunks_migrated = tenant_record.total_chunks_migrated
assert new_partial_chunks_migrated > partial_chunks_migrated
# page_size applies per slice, so one iteration can fetch up to
# page_size * GET_VESPA_CHUNKS_SLICE_COUNT chunks total.
assert new_partial_chunks_migrated <= (5 + 2) * GET_VESPA_CHUNKS_SLICE_COUNT
assert tenant_record.vespa_visit_continuation_token is not None
# Slices are not necessarily evenly distributed across all document
# chunks so we can't test that every token is non-None, but certainly at
# least one must be.
assert any(json.loads(tenant_record.vespa_visit_continuation_token).values())
assert tenant_record.migration_completed_at is None
assert tenant_record.approx_chunk_count_in_vespa is not None
# Under test.
# Run the remainder of the migration.
with (
patch(
"onyx.background.celery.tasks.opensearch_migration.tasks.GET_VESPA_CHUNKS_PAGE_SIZE",
2,
),
patch(
"onyx.background.celery.tasks.opensearch_migration.constants.GET_VESPA_CHUNKS_PAGE_SIZE",
2,
),
):
result_3 = migrate_chunks_from_vespa_to_opensearch_task(
tenant_id=get_current_tenant_id()
)
# Postcondition.
assert result_3 is True
# Expire the session cache to see the committed changes from the task.
db_session.expire_all()
# Verify completion.
tenant_record = db_session.query(OpenSearchTenantMigrationRecord).first()
assert tenant_record is not None
assert tenant_record.total_chunks_migrated > new_partial_chunks_migrated
assert tenant_record.total_chunks_migrated == len(all_chunks)
# Visit is complete so continuation token should be None.
assert tenant_record.vespa_visit_continuation_token is not None
assert is_continuation_token_done_for_all_slices(
json.loads(tenant_record.vespa_visit_continuation_token)
)
assert tenant_record.migration_completed_at is not None
assert tenant_record.approx_chunk_count_in_vespa == len(all_chunks)
# Verify chunks were indexed in OpenSearch.
for document in test_documents:
opensearch_chunks = _get_document_chunks_from_opensearch(
opensearch_client, document.id, get_current_tenant_id()
)
assert len(opensearch_chunks) == CHUNK_COUNT
opensearch_chunks.sort(key=lambda x: x.chunk_index)
for opensearch_chunk in opensearch_chunks:
_assert_chunk_matches_vespa_chunk(
opensearch_chunk,
document_chunks[document.id][opensearch_chunk.chunk_index],
)
def test_chunk_migration_empty_vespa(
self,
db_session: Session,

View File

@@ -6,6 +6,7 @@ Validates that:
- Crash + resume skips already-processed pages
- BFS (folder-scoped) drives process all items in one call
- 410 Gone triggers a full-resync URL in the checkpoint
- Duplicate document IDs across delta pages are deduplicated
"""
from __future__ import annotations
@@ -457,3 +458,228 @@ class TestDeltaPageFetchFailure:
assert final_cp.current_drive_name is None
assert final_cp.current_drive_id is None
assert final_cp.current_drive_delta_next_link is None
class TestDeltaDuplicateDocumentDedup:
"""The Microsoft Graph delta API can return the same item on multiple
pages. Documents already yielded should be skipped via
checkpoint.seen_document_ids."""
def test_duplicate_across_pages_is_skipped(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Item 'dup' appears on both page 1 and page 2. It should only be
yielded once."""
connector = _setup_connector(monkeypatch)
_mock_convert(monkeypatch)
call_count = 0
def fake_fetch_page(
self: SharepointConnector, # noqa: ARG001
page_url: str, # noqa: ARG001
drive_id: str, # noqa: ARG001
start: datetime | None = None, # noqa: ARG001
end: datetime | None = None, # noqa: ARG001
page_size: int = 200, # noqa: ARG001
) -> tuple[list[DriveItemData], str | None]:
nonlocal call_count
call_count += 1
if call_count == 1:
return [_make_item("a"), _make_item("dup")], "https://next2"
return [_make_item("dup"), _make_item("b")], None
monkeypatch.setattr(
SharepointConnector, "_fetch_one_delta_page", fake_fetch_page
)
checkpoint = _build_ready_checkpoint()
# Page 1: yields a, dup
gen = connector._load_from_checkpoint(
_START_TS, _END_TS, checkpoint, include_permissions=False
)
yielded, checkpoint = _consume_generator(gen)
docs = _docs_from(yielded)
assert [d.id for d in docs] == ["a", "dup"]
assert "dup" in checkpoint.seen_document_ids
# Page 2: dup should be skipped, only b yielded
gen = connector._load_from_checkpoint(
_START_TS, _END_TS, checkpoint, include_permissions=False
)
yielded, checkpoint = _consume_generator(gen)
docs = _docs_from(yielded)
assert [d.id for d in docs] == ["b"]
def test_duplicate_within_same_page_is_skipped(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""If the same item appears twice on a single delta page, only the
first occurrence should be yielded."""
connector = _setup_connector(monkeypatch)
_mock_convert(monkeypatch)
def fake_fetch_page(
self: SharepointConnector, # noqa: ARG001
page_url: str, # noqa: ARG001
drive_id: str, # noqa: ARG001
start: datetime | None = None, # noqa: ARG001
end: datetime | None = None, # noqa: ARG001
page_size: int = 200, # noqa: ARG001
) -> tuple[list[DriveItemData], str | None]:
return [_make_item("x"), _make_item("x"), _make_item("y")], None
monkeypatch.setattr(
SharepointConnector, "_fetch_one_delta_page", fake_fetch_page
)
checkpoint = _build_ready_checkpoint()
gen = connector._load_from_checkpoint(
_START_TS, _END_TS, checkpoint, include_permissions=False
)
yielded, checkpoint = _consume_generator(gen)
docs = _docs_from(yielded)
assert [d.id for d in docs] == ["x", "y"]
def test_seen_ids_survive_checkpoint_serialization(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""seen_document_ids must survive JSON serialization so that
dedup works across crash + resume."""
connector = _setup_connector(monkeypatch)
_mock_convert(monkeypatch)
call_count = 0
def fake_fetch_page(
self: SharepointConnector, # noqa: ARG001
page_url: str, # noqa: ARG001
drive_id: str, # noqa: ARG001
start: datetime | None = None, # noqa: ARG001
end: datetime | None = None, # noqa: ARG001
page_size: int = 200, # noqa: ARG001
) -> tuple[list[DriveItemData], str | None]:
nonlocal call_count
call_count += 1
if call_count == 1:
return [_make_item("a")], "https://next2"
return [_make_item("a"), _make_item("b")], None
monkeypatch.setattr(
SharepointConnector, "_fetch_one_delta_page", fake_fetch_page
)
checkpoint = _build_ready_checkpoint()
# Page 1
gen = connector._load_from_checkpoint(
_START_TS, _END_TS, checkpoint, include_permissions=False
)
_, checkpoint = _consume_generator(gen)
assert "a" in checkpoint.seen_document_ids
# Simulate crash: round-trip through JSON
restored = SharepointConnectorCheckpoint.model_validate_json(
checkpoint.model_dump_json()
)
assert "a" in restored.seen_document_ids
# Page 2 with restored checkpoint: 'a' should be skipped
connector2 = _setup_connector(monkeypatch)
_mock_convert(monkeypatch)
monkeypatch.setattr(
SharepointConnector, "_fetch_one_delta_page", fake_fetch_page
)
gen = connector2._load_from_checkpoint(
_START_TS, _END_TS, restored, include_permissions=False
)
yielded, final_cp = _consume_generator(gen)
docs = _docs_from(yielded)
assert [d.id for d in docs] == ["b"]
def test_no_dedup_across_separate_indexing_runs(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A fresh checkpoint (new indexing run) should have an empty
seen_document_ids, so previously-indexed docs are re-processed."""
connector = _setup_connector(monkeypatch)
_mock_convert(monkeypatch)
def fake_fetch_page(
self: SharepointConnector, # noqa: ARG001
page_url: str, # noqa: ARG001
drive_id: str, # noqa: ARG001
start: datetime | None = None, # noqa: ARG001
end: datetime | None = None, # noqa: ARG001
page_size: int = 200, # noqa: ARG001
) -> tuple[list[DriveItemData], str | None]:
return [_make_item("a")], None
monkeypatch.setattr(
SharepointConnector, "_fetch_one_delta_page", fake_fetch_page
)
# First run
cp1 = _build_ready_checkpoint()
gen = connector._load_from_checkpoint(
_START_TS, _END_TS, cp1, include_permissions=False
)
yielded, _ = _consume_generator(gen)
assert len(_docs_from(yielded)) == 1
# Second run with a fresh checkpoint — same doc should appear again
cp2 = _build_ready_checkpoint()
assert len(cp2.seen_document_ids) == 0
gen = connector._load_from_checkpoint(
_START_TS, _END_TS, cp2, include_permissions=False
)
yielded, _ = _consume_generator(gen)
assert len(_docs_from(yielded)) == 1
def test_same_id_across_drives_not_skipped(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Graph item IDs are only unique within a drive. An item in drive B
that happens to share an ID with an item already seen in drive A must
NOT be skipped."""
connector = _setup_connector(monkeypatch)
_mock_convert(monkeypatch)
def fake_fetch_page(
self: SharepointConnector, # noqa: ARG001
page_url: str, # noqa: ARG001
drive_id: str, # noqa: ARG001
start: datetime | None = None, # noqa: ARG001
end: datetime | None = None, # noqa: ARG001
page_size: int = 200, # noqa: ARG001
) -> tuple[list[DriveItemData], str | None]:
return [_make_item("shared-id")], None
monkeypatch.setattr(
SharepointConnector, "_fetch_one_delta_page", fake_fetch_page
)
checkpoint = _build_ready_checkpoint(drive_names=["DriveA", "DriveB"])
# Drive A: yields the item
gen = connector._load_from_checkpoint(
_START_TS, _END_TS, checkpoint, include_permissions=False
)
yielded, checkpoint = _consume_generator(gen)
docs = _docs_from(yielded)
assert len(docs) == 1
assert docs[0].id == "shared-id"
# seen_document_ids should have been cleared when drive A finished
assert len(checkpoint.seen_document_ids) == 0
# Drive B: same ID must be yielded again (different drive)
gen = connector._load_from_checkpoint(
_START_TS, _END_TS, checkpoint, include_permissions=False
)
yielded, checkpoint = _consume_generator(gen)
docs = _docs_from(yielded)
assert len(docs) == 1
assert docs[0].id == "shared-id"

View File

@@ -7,6 +7,7 @@ import pytest
from onyx.db.llm import sync_model_configurations
from onyx.llm.constants import LlmProviderNames
from onyx.server.manage.llm.models import SyncModelEntry
class TestSyncModelConfigurations:
@@ -25,18 +26,18 @@ class TestSyncModelConfigurations:
"onyx.db.llm.fetch_existing_llm_provider", return_value=mock_provider
):
models = [
{
"name": "gpt-4",
"display_name": "GPT-4",
"max_input_tokens": 128000,
"supports_image_input": True,
},
{
"name": "gpt-4o",
"display_name": "GPT-4o",
"max_input_tokens": 128000,
"supports_image_input": True,
},
SyncModelEntry(
name="gpt-4",
display_name="GPT-4",
max_input_tokens=128000,
supports_image_input=True,
),
SyncModelEntry(
name="gpt-4o",
display_name="GPT-4o",
max_input_tokens=128000,
supports_image_input=True,
),
]
result = sync_model_configurations(
@@ -67,18 +68,18 @@ class TestSyncModelConfigurations:
"onyx.db.llm.fetch_existing_llm_provider", return_value=mock_provider
):
models = [
{
"name": "gpt-4", # Existing - should be skipped
"display_name": "GPT-4",
"max_input_tokens": 128000,
"supports_image_input": True,
},
{
"name": "gpt-4o", # New - should be inserted
"display_name": "GPT-4o",
"max_input_tokens": 128000,
"supports_image_input": True,
},
SyncModelEntry(
name="gpt-4", # Existing - should be skipped
display_name="GPT-4",
max_input_tokens=128000,
supports_image_input=True,
),
SyncModelEntry(
name="gpt-4o", # New - should be inserted
display_name="GPT-4o",
max_input_tokens=128000,
supports_image_input=True,
),
]
result = sync_model_configurations(
@@ -105,12 +106,12 @@ class TestSyncModelConfigurations:
"onyx.db.llm.fetch_existing_llm_provider", return_value=mock_provider
):
models = [
{
"name": "gpt-4", # Already exists
"display_name": "GPT-4",
"max_input_tokens": 128000,
"supports_image_input": True,
},
SyncModelEntry(
name="gpt-4", # Already exists
display_name="GPT-4",
max_input_tokens=128000,
supports_image_input=True,
),
]
result = sync_model_configurations(
@@ -131,7 +132,7 @@ class TestSyncModelConfigurations:
sync_model_configurations(
db_session=mock_session,
provider_name="nonexistent",
models=[{"name": "model", "display_name": "Model"}],
models=[SyncModelEntry(name="model", display_name="Model")],
)
def test_handles_missing_optional_fields(self) -> None:
@@ -145,12 +146,12 @@ class TestSyncModelConfigurations:
with patch(
"onyx.db.llm.fetch_existing_llm_provider", return_value=mock_provider
):
# Model with only required fields
# Model with only required fields (max_input_tokens and supports_image_input default)
models = [
{
"name": "model-1",
# No display_name, max_input_tokens, or supports_image_input
},
SyncModelEntry(
name="model-1",
display_name="Model 1",
),
]
result = sync_model_configurations(

View File

@@ -1,196 +0,0 @@
import io
import openpyxl
from onyx.file_processing.extract_file_text import xlsx_to_text
def _make_xlsx(sheets: dict[str, list[list[str]]]) -> io.BytesIO:
"""Create an in-memory xlsx file from a dict of sheet_name -> matrix of strings."""
wb = openpyxl.Workbook()
if wb.active is not None:
wb.remove(wb.active)
for sheet_name, rows in sheets.items():
ws = wb.create_sheet(title=sheet_name)
for row in rows:
ws.append(row)
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
return buf
class TestXlsxToText:
def test_single_sheet_basic(self) -> None:
xlsx = _make_xlsx(
{
"Sheet1": [
["Name", "Age"],
["Alice", "30"],
["Bob", "25"],
]
}
)
result = xlsx_to_text(xlsx)
lines = [line for line in result.strip().split("\n") if line.strip()]
assert len(lines) == 3
assert "Name" in lines[0]
assert "Age" in lines[0]
assert "Alice" in lines[1]
assert "30" in lines[1]
assert "Bob" in lines[2]
def test_multiple_sheets_separated(self) -> None:
xlsx = _make_xlsx(
{
"Sheet1": [["a", "b"]],
"Sheet2": [["c", "d"]],
}
)
result = xlsx_to_text(xlsx)
# TEXT_SECTION_SEPARATOR is "\n\n"
assert "\n\n" in result
parts = result.split("\n\n")
assert any("a" in p for p in parts)
assert any("c" in p for p in parts)
def test_empty_cells(self) -> None:
xlsx = _make_xlsx(
{
"Sheet1": [
["a", "", "b"],
["", "c", ""],
]
}
)
result = xlsx_to_text(xlsx)
lines = [line for line in result.strip().split("\n") if line.strip()]
assert len(lines) == 2
def test_commas_in_cells_are_quoted(self) -> None:
"""Cells containing commas should be quoted in CSV output."""
xlsx = _make_xlsx(
{
"Sheet1": [
["hello, world", "normal"],
]
}
)
result = xlsx_to_text(xlsx)
assert '"hello, world"' in result
def test_empty_workbook(self) -> None:
xlsx = _make_xlsx({"Sheet1": []})
result = xlsx_to_text(xlsx)
assert result.strip() == ""
def test_long_empty_row_run_capped(self) -> None:
"""Runs of >2 empty rows should be capped to 2."""
xlsx = _make_xlsx(
{
"Sheet1": [
["header"],
[""],
[""],
[""],
[""],
["data"],
]
}
)
result = xlsx_to_text(xlsx)
lines = [line for line in result.strip().split("\n") if line.strip()]
# 4 empty rows capped to 2, so: header + 2 empty + data = 4 lines
assert len(lines) == 4
assert "header" in lines[0]
assert "data" in lines[-1]
def test_long_empty_col_run_capped(self) -> None:
"""Runs of >2 empty columns should be capped to 2."""
xlsx = _make_xlsx(
{
"Sheet1": [
["a", "", "", "", "b"],
["c", "", "", "", "d"],
]
}
)
result = xlsx_to_text(xlsx)
lines = [line for line in result.strip().split("\n") if line.strip()]
assert len(lines) == 2
# Each row should have 4 fields (a + 2 empty + b), not 5
# csv format: a,,,b (3 commas = 4 fields)
first_line = lines[0].strip()
# Count commas to verify column reduction
assert first_line.count(",") == 3
def test_short_empty_runs_kept(self) -> None:
"""Runs of <=2 empty rows/cols should be preserved."""
xlsx = _make_xlsx(
{
"Sheet1": [
["a", "b"],
["", ""],
["", ""],
["c", "d"],
]
}
)
result = xlsx_to_text(xlsx)
lines = [line for line in result.strip().split("\n") if line.strip()]
# All 4 rows preserved (2 empty rows <= threshold)
assert len(lines) == 4
def test_bad_zip_file_returns_empty(self) -> None:
bad_file = io.BytesIO(b"not a zip file")
result = xlsx_to_text(bad_file, file_name="test.xlsx")
assert result == ""
def test_bad_zip_tilde_file_returns_empty(self) -> None:
bad_file = io.BytesIO(b"not a zip file")
result = xlsx_to_text(bad_file, file_name="~$temp.xlsx")
assert result == ""
def test_large_sparse_sheet(self) -> None:
"""A sheet with data, a big empty gap, and more data — gap is capped to 2."""
rows: list[list[str]] = [["row1_data"]]
rows.extend([[""] for _ in range(10)])
rows.append(["row2_data"])
xlsx = _make_xlsx({"Sheet1": rows})
result = xlsx_to_text(xlsx)
lines = [line for line in result.strip().split("\n") if line.strip()]
# 10 empty rows capped to 2: row1_data + 2 empty + row2_data = 4
assert len(lines) == 4
assert "row1_data" in lines[0]
assert "row2_data" in lines[-1]
def test_quotes_in_cells(self) -> None:
"""Cells containing quotes should be properly escaped."""
xlsx = _make_xlsx(
{
"Sheet1": [
['say "hello"', "normal"],
]
}
)
result = xlsx_to_text(xlsx)
# csv.writer escapes quotes by doubling them
assert '""hello""' in result
def test_each_row_is_separate_line(self) -> None:
"""Each row should produce its own line (regression for writerow vs writerows)."""
xlsx = _make_xlsx(
{
"Sheet1": [
["r1c1", "r1c2"],
["r2c1", "r2c2"],
["r3c1", "r3c2"],
]
}
)
result = xlsx_to_text(xlsx)
lines = [line for line in result.strip().split("\n") if line.strip()]
assert len(lines) == 3
assert "r1c1" in lines[0] and "r1c2" in lines[0]
assert "r2c1" in lines[1] and "r2c2" in lines[1]
assert "r3c1" in lines[2] and "r3c2" in lines[2]

View File

@@ -26,14 +26,6 @@ class TestIsTrueOpenAIModel:
"""Test that real OpenAI GPT-4o-mini model is correctly identified."""
assert is_true_openai_model(LlmProviderNames.OPENAI, "gpt-4o-mini") is True
def test_real_openai_o1_preview(self) -> None:
"""Test that real OpenAI o1-preview reasoning model is correctly identified."""
assert is_true_openai_model(LlmProviderNames.OPENAI, "o1-preview") is True
def test_real_openai_o1_mini(self) -> None:
"""Test that real OpenAI o1-mini reasoning model is correctly identified."""
assert is_true_openai_model(LlmProviderNames.OPENAI, "o1-mini") is True
def test_openai_with_provider_prefix(self) -> None:
"""Test that OpenAI model with provider prefix is correctly identified."""
assert is_true_openai_model(LlmProviderNames.OPENAI, "openai/gpt-4") is False

View File

@@ -0,0 +1,204 @@
"""Tests for Slack channel reference resolution and tag filtering
in handle_regular_answer.py."""
from unittest.mock import MagicMock
from slack_sdk.errors import SlackApiError
from onyx.context.search.models import Tag
from onyx.onyxbot.slack.constants import SLACK_CHANNEL_REF_PATTERN
from onyx.onyxbot.slack.handlers.handle_regular_answer import resolve_channel_references
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mock_client_with_channels(
channel_map: dict[str, str],
) -> MagicMock:
"""Return a mock WebClient where conversations_info resolves IDs to names."""
client = MagicMock()
def _conversations_info(channel: str) -> MagicMock:
if channel in channel_map:
resp = MagicMock()
resp.validate = MagicMock()
resp.__getitem__ = lambda _self, key: {
"channel": {
"name": channel_map[channel],
"is_im": False,
"is_mpim": False,
}
}[key]
return resp
raise SlackApiError("channel_not_found", response=MagicMock())
client.conversations_info = _conversations_info
return client
def _mock_logger() -> MagicMock:
return MagicMock()
# ---------------------------------------------------------------------------
# SLACK_CHANNEL_REF_PATTERN regex tests
# ---------------------------------------------------------------------------
class TestSlackChannelRefPattern:
def test_matches_bare_channel_id(self) -> None:
matches = SLACK_CHANNEL_REF_PATTERN.findall("<#C097NBWMY8Y>")
assert matches == [("C097NBWMY8Y", "")]
def test_matches_channel_id_with_name(self) -> None:
matches = SLACK_CHANNEL_REF_PATTERN.findall("<#C097NBWMY8Y|eng-infra>")
assert matches == [("C097NBWMY8Y", "eng-infra")]
def test_matches_multiple_channels(self) -> None:
msg = "compare <#C111AAA> and <#C222BBB|general>"
matches = SLACK_CHANNEL_REF_PATTERN.findall(msg)
assert len(matches) == 2
assert ("C111AAA", "") in matches
assert ("C222BBB", "general") in matches
def test_no_match_on_plain_text(self) -> None:
matches = SLACK_CHANNEL_REF_PATTERN.findall("no channels here")
assert matches == []
def test_no_match_on_user_mention(self) -> None:
matches = SLACK_CHANNEL_REF_PATTERN.findall("<@U12345>")
assert matches == []
# ---------------------------------------------------------------------------
# resolve_channel_references tests
# ---------------------------------------------------------------------------
class TestResolveChannelReferences:
def test_resolves_bare_channel_id_via_api(self) -> None:
client = _mock_client_with_channels({"C097NBWMY8Y": "eng-infra"})
logger = _mock_logger()
message, tags = resolve_channel_references(
message="summary of <#C097NBWMY8Y> this week",
client=client,
logger=logger,
)
assert message == "summary of #eng-infra this week"
assert len(tags) == 1
assert tags[0] == Tag(tag_key="Channel", tag_value="eng-infra")
def test_uses_name_from_pipe_format_without_api_call(self) -> None:
client = MagicMock()
logger = _mock_logger()
message, tags = resolve_channel_references(
message="check <#C097NBWMY8Y|eng-infra> for updates",
client=client,
logger=logger,
)
assert message == "check #eng-infra for updates"
assert tags == [Tag(tag_key="Channel", tag_value="eng-infra")]
# Should NOT have called the API since name was in the markup
client.conversations_info.assert_not_called()
def test_multiple_channels(self) -> None:
client = _mock_client_with_channels(
{
"C111AAA": "eng-infra",
"C222BBB": "eng-general",
}
)
logger = _mock_logger()
message, tags = resolve_channel_references(
message="compare <#C111AAA> and <#C222BBB>",
client=client,
logger=logger,
)
assert "#eng-infra" in message
assert "#eng-general" in message
assert "<#" not in message
assert len(tags) == 2
tag_values = {t.tag_value for t in tags}
assert tag_values == {"eng-infra", "eng-general"}
def test_no_channel_references_returns_unchanged(self) -> None:
client = MagicMock()
logger = _mock_logger()
message, tags = resolve_channel_references(
message="just a normal message with no channels",
client=client,
logger=logger,
)
assert message == "just a normal message with no channels"
assert tags == []
def test_api_failure_skips_channel_gracefully(self) -> None:
# Client that fails for all channel lookups
client = _mock_client_with_channels({})
logger = _mock_logger()
message, tags = resolve_channel_references(
message="check <#CBADID123>",
client=client,
logger=logger,
)
# Message should remain unchanged for the failed channel
assert "<#CBADID123>" in message
assert tags == []
logger.warning.assert_called_once()
def test_partial_failure_resolves_what_it_can(self) -> None:
# Only one of two channels resolves
client = _mock_client_with_channels({"C111AAA": "eng-infra"})
logger = _mock_logger()
message, tags = resolve_channel_references(
message="compare <#C111AAA> and <#CBADID123>",
client=client,
logger=logger,
)
assert "#eng-infra" in message
assert "<#CBADID123>" in message # failed one stays raw
assert len(tags) == 1
assert tags[0].tag_value == "eng-infra"
def test_duplicate_channel_produces_single_tag(self) -> None:
client = _mock_client_with_channels({"C111AAA": "eng-infra"})
logger = _mock_logger()
message, tags = resolve_channel_references(
message="summarize <#C111AAA> and compare with <#C111AAA>",
client=client,
logger=logger,
)
assert message == "summarize #eng-infra and compare with #eng-infra"
assert len(tags) == 1
assert tags[0].tag_value == "eng-infra"
def test_mixed_pipe_and_bare_formats(self) -> None:
client = _mock_client_with_channels({"C222BBB": "random"})
logger = _mock_logger()
message, tags = resolve_channel_references(
message="see <#C111AAA|eng-infra> and <#C222BBB>",
client=client,
logger=logger,
)
assert "#eng-infra" in message
assert "#random" in message
assert len(tags) == 2

View File

@@ -7,6 +7,9 @@ import timeago # type: ignore
from onyx.configs.constants import DocumentSource
from onyx.context.search.models import SavedSearchDoc
from onyx.onyxbot.slack.blocks import _build_documents_blocks
from onyx.onyxbot.slack.blocks import _extract_code_snippets
from onyx.onyxbot.slack.blocks import _split_text
from onyx.onyxbot.slack.handlers.handle_regular_answer import _SNIPPET_TYPE_MAP
def _make_saved_doc(updated_at: datetime | None) -> SavedSearchDoc:
@@ -69,3 +72,148 @@ def test_build_documents_blocks_formats_naive_timestamp(
formatted_timestamp: datetime = captured["doc"]
expected_timestamp: datetime = naive_timestamp.replace(tzinfo=pytz.utc)
assert formatted_timestamp == expected_timestamp
# ---------------------------------------------------------------------------
# _split_text tests
# ---------------------------------------------------------------------------
class TestSplitText:
def test_short_text_returns_single_chunk(self) -> None:
result = _split_text("hello world", limit=100)
assert result == ["hello world"]
def test_splits_at_space_boundary(self) -> None:
text = "aaa bbb ccc ddd"
result = _split_text(text, limit=8)
assert len(result) >= 2
def test_no_code_fences_splits_normally(self) -> None:
text = "word " * 100 # 500 chars
result = _split_text(text, limit=100)
assert len(result) >= 5
for chunk in result:
assert "```" not in chunk
# ---------------------------------------------------------------------------
# _extract_code_snippets tests
# ---------------------------------------------------------------------------
class TestExtractCodeSnippets:
def test_short_text_no_extraction(self) -> None:
text = "short answer with ```python\nprint('hi')\n``` inline"
cleaned, snippets = _extract_code_snippets(text, limit=3000)
assert cleaned == text
assert snippets == []
def test_large_code_block_extracted(self) -> None:
code = "x = 1\n" * 200 # ~1200 chars of code
text = f"Here is the solution:\n```python\n{code}```\nHope that helps!"
cleaned, snippets = _extract_code_snippets(text, limit=200)
assert len(snippets) == 1
assert snippets[0].language == "python"
assert snippets[0].filename == "code_1.python"
assert "x = 1" in snippets[0].code
# Code block should be removed from cleaned text
assert "```" not in cleaned
assert "Here is the solution" in cleaned
assert "Hope that helps!" in cleaned
def test_multiple_code_blocks_only_large_ones_extracted(self) -> None:
small_code = "print('hi')"
large_code = "x = 1\n" * 300
text = (
f"First:\n```python\n{small_code}\n```\n"
f"Second:\n```javascript\n{large_code}\n```\n"
"Done!"
)
cleaned, snippets = _extract_code_snippets(text, limit=500)
# The large block should be extracted
assert len(snippets) >= 1
langs = [s.language for s in snippets]
assert "javascript" in langs
def test_language_specifier_captured(self) -> None:
code = "fn main() {}\n" * 100
text = f"```rust\n{code}```"
_, snippets = _extract_code_snippets(text, limit=100)
assert len(snippets) == 1
assert snippets[0].language == "rust"
assert snippets[0].filename == "code_1.rust"
def test_no_language_defaults_to_text(self) -> None:
code = "some output\n" * 100
text = f"```\n{code}```"
_, snippets = _extract_code_snippets(text, limit=100)
assert len(snippets) == 1
assert snippets[0].language == "text"
assert snippets[0].filename == "code_1.txt"
def test_cleaned_text_has_no_triple_blank_lines(self) -> None:
code = "x = 1\n" * 200
text = f"Before\n\n```python\n{code}```\n\nAfter"
cleaned, _ = _extract_code_snippets(text, limit=100)
assert "\n\n\n" not in cleaned
def test_multiple_blocks_cumulative_removal(self) -> None:
"""When multiple code blocks exist, extraction decisions should
account for previously extracted blocks (two-pass logic).
Blocks must be smaller than limit//2 so the 'very large block'
override doesn't trigger — we're testing the cumulative logic only."""
# Each fenced block is ~103 chars (```python\n + 15*6 + ```)
block_a = "a = 1\n" * 15 # 90 chars of code
block_b = "b = 2\n" * 15 # 90 chars of code
prose = "x" * 200
# Total: ~200 + 103 + 103 + overhead ≈ 420 chars
# limit=300, limit//2=150. Each block (~103) < 150, so only
# the cumulative check applies. Removing block_a (~103 chars)
# brings us to ~317 > 300, so block_b should also be extracted.
# But with limit=350: removing block_a → ~317 ≤ 350, stop.
text = f"{prose}\n```python\n{block_a}```\n```python\n{block_b}```\nEnd"
cleaned, snippets = _extract_code_snippets(text, limit=350)
# After extracting block_a the text is ≤ 350, so block_b stays.
assert len(snippets) == 1
assert snippets[0].filename == "code_1.python"
# block_b should still be in the cleaned text
assert "b = 2" in cleaned
# ---------------------------------------------------------------------------
# _SNIPPET_TYPE_MAP tests
# ---------------------------------------------------------------------------
class TestSnippetTypeMap:
@pytest.mark.parametrize(
"alias,expected",
[
("py", "python"),
("js", "javascript"),
("ts", "typescript"),
("tsx", "typescript"),
("jsx", "javascript"),
("sh", "shell"),
("bash", "shell"),
("yml", "yaml"),
("rb", "ruby"),
("rs", "rust"),
("cs", "csharp"),
("md", "markdown"),
("text", "plain_text"),
],
)
def test_common_aliases_normalized(self, alias: str, expected: str) -> None:
assert _SNIPPET_TYPE_MAP[alias] == expected
def test_unknown_language_passes_through(self) -> None:
unknown = "haskell"
assert _SNIPPET_TYPE_MAP.get(unknown, unknown) == "haskell"

View File

@@ -1,15 +1,19 @@
"""Tests for LLM model fetch endpoints.
These tests verify the full request/response flow for fetching models
from dynamic providers (Ollama, OpenRouter), including the
from dynamic providers (Ollama, OpenRouter, Litellm), including the
sync-to-DB behavior when provider_name is specified.
"""
from unittest.mock import MagicMock
from unittest.mock import patch
import httpx
import pytest
from onyx.error_handling.exceptions import OnyxError
from onyx.server.manage.llm.models import LitellmFinalModelResponse
from onyx.server.manage.llm.models import LitellmModelsRequest
from onyx.server.manage.llm.models import LMStudioFinalModelResponse
from onyx.server.manage.llm.models import LMStudioModelsRequest
from onyx.server.manage.llm.models import OllamaFinalModelResponse
@@ -614,3 +618,283 @@ class TestGetLMStudioAvailableModels:
request = LMStudioModelsRequest(api_base="http://localhost:1234")
with pytest.raises(OnyxError):
get_lm_studio_available_models(request, MagicMock(), mock_session)
class TestGetLitellmAvailableModels:
"""Tests for the Litellm proxy model fetch endpoint."""
@pytest.fixture
def mock_litellm_response(self) -> dict:
"""Mock response from Litellm /v1/models endpoint."""
return {
"data": [
{
"id": "gpt-4o",
"object": "model",
"created": 1700000000,
"owned_by": "openai",
},
{
"id": "claude-3-5-sonnet",
"object": "model",
"created": 1700000001,
"owned_by": "anthropic",
},
{
"id": "gemini-pro",
"object": "model",
"created": 1700000002,
"owned_by": "google",
},
]
}
def test_returns_model_list(self, mock_litellm_response: dict) -> None:
"""Test that endpoint returns properly formatted model list."""
from onyx.server.manage.llm.api import get_litellm_available_models
mock_session = MagicMock()
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = mock_litellm_response
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
request = LitellmModelsRequest(
api_base="http://localhost:4000",
api_key="test-key",
)
results = get_litellm_available_models(request, MagicMock(), mock_session)
assert len(results) == 3
assert all(isinstance(r, LitellmFinalModelResponse) for r in results)
def test_model_fields_parsed_correctly(self, mock_litellm_response: dict) -> None:
"""Test that provider_name and model_name are correctly extracted."""
from onyx.server.manage.llm.api import get_litellm_available_models
mock_session = MagicMock()
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = mock_litellm_response
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
request = LitellmModelsRequest(
api_base="http://localhost:4000",
api_key="test-key",
)
results = get_litellm_available_models(request, MagicMock(), mock_session)
gpt = next(r for r in results if r.model_name == "gpt-4o")
assert gpt.provider_name == "openai"
claude = next(r for r in results if r.model_name == "claude-3-5-sonnet")
assert claude.provider_name == "anthropic"
def test_results_sorted_by_model_name(self, mock_litellm_response: dict) -> None:
"""Test that results are alphabetically sorted by model_name."""
from onyx.server.manage.llm.api import get_litellm_available_models
mock_session = MagicMock()
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = mock_litellm_response
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
request = LitellmModelsRequest(
api_base="http://localhost:4000",
api_key="test-key",
)
results = get_litellm_available_models(request, MagicMock(), mock_session)
model_names = [r.model_name for r in results]
assert model_names == sorted(model_names, key=str.lower)
def test_empty_data_raises_onyx_error(self) -> None:
"""Test that empty model list raises OnyxError."""
from onyx.server.manage.llm.api import get_litellm_available_models
mock_session = MagicMock()
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {"data": []}
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
request = LitellmModelsRequest(
api_base="http://localhost:4000",
api_key="test-key",
)
with pytest.raises(OnyxError, match="No models found"):
get_litellm_available_models(request, MagicMock(), mock_session)
def test_missing_data_key_raises_onyx_error(self) -> None:
"""Test that response without 'data' key raises OnyxError."""
from onyx.server.manage.llm.api import get_litellm_available_models
mock_session = MagicMock()
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {}
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
request = LitellmModelsRequest(
api_base="http://localhost:4000",
api_key="test-key",
)
with pytest.raises(OnyxError):
get_litellm_available_models(request, MagicMock(), mock_session)
def test_skips_unparseable_entries(self) -> None:
"""Test that malformed model entries are skipped without failing."""
from onyx.server.manage.llm.api import get_litellm_available_models
mock_session = MagicMock()
response_with_bad_entry = {
"data": [
{
"id": "gpt-4o",
"object": "model",
"created": 1700000000,
"owned_by": "openai",
},
# Missing required fields
{"bad_field": "bad_value"},
]
}
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = response_with_bad_entry
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
request = LitellmModelsRequest(
api_base="http://localhost:4000",
api_key="test-key",
)
results = get_litellm_available_models(request, MagicMock(), mock_session)
assert len(results) == 1
assert results[0].model_name == "gpt-4o"
def test_all_entries_unparseable_raises_onyx_error(self) -> None:
"""Test that OnyxError is raised when all entries fail to parse."""
from onyx.server.manage.llm.api import get_litellm_available_models
mock_session = MagicMock()
response_all_bad = {
"data": [
{"bad_field": "bad_value"},
{"another_bad": 123},
]
}
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = response_all_bad
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
request = LitellmModelsRequest(
api_base="http://localhost:4000",
api_key="test-key",
)
with pytest.raises(OnyxError, match="No compatible models"):
get_litellm_available_models(request, MagicMock(), mock_session)
def test_api_base_trailing_slash_handled(self) -> None:
"""Test that trailing slashes in api_base are handled correctly."""
from onyx.server.manage.llm.api import get_litellm_available_models
mock_session = MagicMock()
mock_litellm_response = {
"data": [
{
"id": "gpt-4o",
"object": "model",
"created": 1700000000,
"owned_by": "openai",
},
]
}
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = mock_litellm_response
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
request = LitellmModelsRequest(
api_base="http://localhost:4000/",
api_key="test-key",
)
get_litellm_available_models(request, MagicMock(), mock_session)
# Should call /v1/models without double slashes
call_args = mock_get.call_args
assert call_args[0][0] == "http://localhost:4000/v1/models"
def test_connection_failure_raises_onyx_error(self) -> None:
"""Test that connection failures are wrapped in OnyxError."""
from onyx.server.manage.llm.api import get_litellm_available_models
mock_session = MagicMock()
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
mock_get.side_effect = Exception("Connection refused")
request = LitellmModelsRequest(
api_base="http://localhost:4000",
api_key="test-key",
)
with pytest.raises(OnyxError, match="Failed to fetch LiteLLM models"):
get_litellm_available_models(request, MagicMock(), mock_session)
def test_401_raises_authentication_error(self) -> None:
"""Test that a 401 response raises OnyxError with authentication message."""
from onyx.server.manage.llm.api import get_litellm_available_models
mock_session = MagicMock()
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 401
mock_get.side_effect = httpx.HTTPStatusError(
"Unauthorized", request=MagicMock(), response=mock_response
)
request = LitellmModelsRequest(
api_base="http://localhost:4000",
api_key="bad-key",
)
with pytest.raises(OnyxError, match="Authentication failed"):
get_litellm_available_models(request, MagicMock(), mock_session)
def test_404_raises_not_found_error(self) -> None:
"""Test that a 404 response raises OnyxError with endpoint not found message."""
from onyx.server.manage.llm.api import get_litellm_available_models
mock_session = MagicMock()
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 404
mock_get.side_effect = httpx.HTTPStatusError(
"Not Found", request=MagicMock(), response=mock_response
)
request = LitellmModelsRequest(
api_base="http://localhost:4000",
api_key="test-key",
)
with pytest.raises(OnyxError, match="endpoint not found"):
get_litellm_available_models(request, MagicMock(), mock_session)

View File

@@ -0,0 +1,54 @@
import datetime
from unittest.mock import MagicMock
from uuid import uuid4
from onyx.auth.schemas import UserRole
from onyx.server.models import FullUserSnapshot
from onyx.server.models import UserGroupInfo
def _mock_user(
personal_name: str | None = "Test User",
created_at: datetime.datetime | None = None,
updated_at: datetime.datetime | None = None,
) -> MagicMock:
user = MagicMock()
user.id = uuid4()
user.email = "test@example.com"
user.role = UserRole.BASIC
user.is_active = True
user.password_configured = True
user.personal_name = personal_name
user.created_at = created_at or datetime.datetime(
2025, 1, 1, tzinfo=datetime.timezone.utc
)
user.updated_at = updated_at or datetime.datetime(
2025, 6, 15, tzinfo=datetime.timezone.utc
)
return user
def test_from_user_model_includes_new_fields() -> None:
user = _mock_user(personal_name="Alice")
groups = [UserGroupInfo(id=1, name="Engineering")]
snapshot = FullUserSnapshot.from_user_model(user, groups=groups)
assert snapshot.personal_name == "Alice"
assert snapshot.created_at == user.created_at
assert snapshot.updated_at == user.updated_at
assert snapshot.groups == groups
def test_from_user_model_defaults_groups_to_empty() -> None:
user = _mock_user()
snapshot = FullUserSnapshot.from_user_model(user)
assert snapshot.groups == []
def test_from_user_model_personal_name_none() -> None:
user = _mock_user(personal_name=None)
snapshot = FullUserSnapshot.from_user_model(user)
assert snapshot.personal_name is None

View File

@@ -38,6 +38,11 @@ services:
opensearch:
ports:
- "9200:9200"
# Rootless Docker can reject the base OpenSearch ulimit settings, so clear
# the inherited block entirely in the dev override.
ulimits: !reset null
environment:
- bootstrap.memory_lock=false
inference_model_server:
ports:

View File

@@ -91,7 +91,7 @@ backend = [
"python-gitlab==5.6.0",
"python-pptx==0.6.23",
"pypandoc_binary==1.16.2",
"pypdf==6.7.5",
"pypdf==6.8.0",
"pytest-mock==3.12.0",
"pytest-playwright==0.7.0",
"python-docx==1.1.2",
@@ -143,7 +143,7 @@ dev = [
"matplotlib==3.10.8",
"mypy-extensions==1.0.0",
"mypy==1.13.0",
"onyx-devtools==0.6.3",
"onyx-devtools==0.7.0",
"openapi-generator-cli==7.17.0",
"pandas-stubs~=2.3.3",
"pre-commit==3.2.2",
@@ -153,7 +153,7 @@ dev = [
"pytest-repeat==0.9.4",
"pytest-xdist==3.8.0",
"pytest==8.3.5",
"release-tag==0.4.3",
"release-tag==0.5.2",
"reorder-python-imports-black==3.14.0",
"ruff==0.12.0",
"types-beautifulsoup4==4.12.0.3",

View File

@@ -25,6 +25,9 @@ Some commands require external tools to be installed and configured:
- **Docker** - Required for `compose`, `logs`, and `pull` commands
- Install from [docker.com](https://docs.docker.com/get-docker/)
- **uv** - Required for `backend` commands
- Install from [docs.astral.sh/uv](https://docs.astral.sh/uv/)
- **GitHub CLI** (`gh`) - Required for `run-ci` and `cherry-pick` commands
- Install from [cli.github.com](https://cli.github.com/)
- Authenticate with `gh auth login`
@@ -170,6 +173,53 @@ ods pull
ods pull --tag edge
```
### `backend` - Run Backend Services
Run backend services (API server, model server) with environment loaded from
`.vscode/.env`. On first run, copies `.vscode/env_template.txt` to `.vscode/.env`
if the `.env` file does not already exist.
Enterprise Edition features are enabled by default with license enforcement
disabled, matching the `compose` command behavior.
```shell
ods backend <subcommand>
```
**Subcommands:**
- `api` - Start the FastAPI backend server (`uvicorn onyx.main:app --reload`)
- `model_server` - Start the model server (`uvicorn model_server.main:app --reload`)
**Flags:**
| Flag | Default | Description |
|------|---------|-------------|
| `--no-ee` | `false` | Disable Enterprise Edition features (enabled by default) |
| `--port` | `8080` (api) / `9000` (model_server) | Port to listen on |
Shell environment takes precedence over `.env` file values, so inline overrides
work as expected (e.g. `S3_ENDPOINT_URL=foo ods backend api`).
**Examples:**
```shell
# Start the API server
ods backend api
# Start the API server on a custom port
ods backend api --port 9090
# Start without Enterprise Edition
ods backend api --no-ee
# Start the model server
ods backend model_server
# Start the model server on a custom port
ods backend model_server --port 9001
```
### `web` - Run Frontend Scripts
Run npm scripts from `web/package.json` without manually changing directories.

242
tools/ods/cmd/backend.go Normal file
View File

@@ -0,0 +1,242 @@
package cmd
import (
"bufio"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/onyx-dot-app/onyx/tools/ods/internal/paths"
)
// NewBackendCommand creates the parent "backend" command with subcommands for
// running backend services.
// BackendOptions holds options shared across backend subcommands.
type BackendOptions struct {
NoEE bool
}
func NewBackendCommand() *cobra.Command {
opts := &BackendOptions{}
cmd := &cobra.Command{
Use: "backend",
Short: "Run backend services (api, model_server)",
Long: `Run backend services with environment from .vscode/.env.
On first run, copies .vscode/env_template.txt to .vscode/.env if the
.env file does not already exist.
Enterprise Edition features are enabled by default for development,
with license enforcement disabled.
Available subcommands:
api Start the FastAPI backend server
model_server Start the model server`,
}
cmd.PersistentFlags().BoolVar(&opts.NoEE, "no-ee", false, "Disable Enterprise Edition features (enabled by default)")
cmd.AddCommand(newBackendAPICommand(opts))
cmd.AddCommand(newBackendModelServerCommand(opts))
return cmd
}
func newBackendAPICommand(opts *BackendOptions) *cobra.Command {
var port string
cmd := &cobra.Command{
Use: "api",
Short: "Start the backend API server (uvicorn with hot-reload)",
Long: `Start the backend API server using uvicorn with hot-reload.
Examples:
ods backend api
ods backend api --port 9090
ods backend api --no-ee`,
Run: func(cmd *cobra.Command, args []string) {
runBackendService("api", "onyx.main:app", port, opts)
},
}
cmd.Flags().StringVar(&port, "port", "8080", "Port to listen on")
return cmd
}
func newBackendModelServerCommand(opts *BackendOptions) *cobra.Command {
var port string
cmd := &cobra.Command{
Use: "model_server",
Short: "Start the model server (uvicorn with hot-reload)",
Long: `Start the model server using uvicorn with hot-reload.
Examples:
ods backend model_server
ods backend model_server --port 9001`,
Run: func(cmd *cobra.Command, args []string) {
runBackendService("model_server", "model_server.main:app", port, opts)
},
}
cmd.Flags().StringVar(&port, "port", "9000", "Port to listen on")
return cmd
}
func runBackendService(name, module, port string, opts *BackendOptions) {
root, err := paths.GitRoot()
if err != nil {
log.Fatalf("Failed to find git root: %v", err)
}
envFile := ensureBackendEnvFile(root)
fileVars := loadBackendEnvFile(envFile)
eeDefaults := eeEnvDefaults(opts.NoEE)
fileVars = append(fileVars, eeDefaults...)
backendDir := filepath.Join(root, "backend")
uvicornArgs := []string{
"run", "uvicorn", module,
"--reload",
"--port", port,
}
log.Infof("Starting %s on port %s...", name, port)
if !opts.NoEE {
log.Info("Enterprise Edition enabled (use --no-ee to disable)")
}
log.Debugf("Running in %s: uv %v", backendDir, uvicornArgs)
mergedEnv := mergeEnv(os.Environ(), fileVars)
log.Debugf("Applied %d env vars from %s (shell takes precedence)", len(fileVars), envFile)
svcCmd := exec.Command("uv", uvicornArgs...)
svcCmd.Dir = backendDir
svcCmd.Stdout = os.Stdout
svcCmd.Stderr = os.Stderr
svcCmd.Stdin = os.Stdin
svcCmd.Env = mergedEnv
if err := svcCmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if code := exitErr.ExitCode(); code != -1 {
os.Exit(code)
}
}
log.Fatalf("Failed to run %s: %v", name, err)
}
}
// eeEnvDefaults returns env entries for EE and license enforcement settings.
// These are appended to the file vars so they act as defaults — shell env
// and .env file values still take precedence via mergeEnv.
func eeEnvDefaults(noEE bool) []string {
if noEE {
return []string{
"ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=false",
}
}
return []string{
"ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true",
"LICENSE_ENFORCEMENT_ENABLED=false",
}
}
// ensureBackendEnvFile copies env_template.txt to .env if .env doesn't exist.
func ensureBackendEnvFile(root string) string {
vscodeDir := filepath.Join(root, ".vscode")
envFile := filepath.Join(vscodeDir, ".env")
templateFile := filepath.Join(vscodeDir, "env_template.txt")
if _, err := os.Stat(envFile); err != nil {
if !errors.Is(err, os.ErrNotExist) {
log.Fatalf("Failed to stat env file %s: %v", envFile, err)
}
} else {
log.Debugf("Using existing env file: %s", envFile)
return envFile
}
templateData, err := os.ReadFile(templateFile)
if err != nil {
log.Fatalf("Failed to read env template %s: %v", templateFile, err)
}
if err := os.MkdirAll(vscodeDir, 0755); err != nil {
log.Fatalf("Failed to create .vscode directory: %v", err)
}
if err := os.WriteFile(envFile, templateData, 0644); err != nil {
log.Fatalf("Failed to write env file %s: %v", envFile, err)
}
log.Infof("Created %s from template (review and fill in <REPLACE THIS> values)", envFile)
return envFile
}
// mergeEnv combines shell environment with file-based defaults. Shell values
// take precedence — file entries are only added for keys not already present.
func mergeEnv(shellEnv, fileVars []string) []string {
existing := make(map[string]bool, len(shellEnv))
for _, entry := range shellEnv {
if idx := strings.Index(entry, "="); idx > 0 {
existing[entry[:idx]] = true
}
}
merged := make([]string, len(shellEnv))
copy(merged, shellEnv)
for _, entry := range fileVars {
if idx := strings.Index(entry, "="); idx > 0 {
key := entry[:idx]
if !existing[key] {
merged = append(merged, entry)
} else {
log.Debugf("Env var %s already set in shell, skipping .env value", key)
}
}
}
return merged
}
// loadBackendEnvFile parses a .env file into KEY=VALUE entries suitable for
// appending to os.Environ(). Blank lines and comments are skipped.
func loadBackendEnvFile(path string) []string {
f, err := os.Open(path)
if err != nil {
log.Fatalf("Failed to open env file %s: %v", path, err)
}
defer func() { _ = f.Close() }()
var envVars []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if idx := strings.Index(line, "="); idx > 0 {
key := strings.TrimSpace(line[:idx])
value := strings.TrimSpace(line[idx+1:])
value = strings.Trim(value, `"'`)
envVars = append(envVars, fmt.Sprintf("%s=%s", key, value))
}
}
if err := scanner.Err(); err != nil {
log.Fatalf("Failed to read env file %s: %v", path, err)
}
return envVars
}

View File

@@ -0,0 +1,36 @@
package cmd
import (
"fmt"
"github.com/jmelahman/tag/git"
"github.com/spf13/cobra"
)
// NewLatestStableTagCommand creates the latest-stable-tag command.
func NewLatestStableTagCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "latest-stable-tag",
Short: "Print the git tag that should receive the 'latest' Docker tag",
Long: `Print the highest stable (non-pre-release) semver tag in the repository.
This is used during deployment to decide whether a given tag should
receive the "latest" tag on Docker Hub. Only the highest vX.Y.Z tag
qualifies. Tags with pre-release suffixes (e.g. v1.2.3-beta,
v1.2.3-cloud.1) are excluded.`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
tag, err := git.GetLatestStableSemverTag("")
if err != nil {
return fmt.Errorf("get latest stable semver tag: %w", err)
}
if tag == "" {
return fmt.Errorf("no stable semver tag found in repository")
}
fmt.Println(tag)
return nil
},
}
return cmd
}

View File

@@ -41,6 +41,7 @@ func NewRootCommand() *cobra.Command {
cmd.PersistentFlags().BoolVar(&opts.Debug, "debug", false, "run in debug mode")
// Add subcommands
cmd.AddCommand(NewBackendCommand())
cmd.AddCommand(NewCheckLazyImportsCommand())
cmd.AddCommand(NewCherryPickCommand())
cmd.AddCommand(NewDBCommand())
@@ -52,6 +53,7 @@ func NewRootCommand() *cobra.Command {
cmd.AddCommand(NewScreenshotDiffCommand())
cmd.AddCommand(NewDesktopCommand())
cmd.AddCommand(NewWebCommand())
cmd.AddCommand(NewLatestStableTagCommand())
cmd.AddCommand(NewWhoisCommand())
return cmd

View File

@@ -3,12 +3,13 @@ module github.com/onyx-dot-app/onyx/tools/ods
go 1.26.0
require (
github.com/jmelahman/tag v0.5.2
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.9
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
golang.org/x/sys v0.39.0 // indirect
)

View File

@@ -4,20 +4,26 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jmelahman/tag v0.5.2 h1:g6A/aHehu5tkA31mPoDsXBNr1FigZ9A82Y8WVgb/WsM=
github.com/jmelahman/tag v0.5.2/go.mod h1:qmuqk19B1BKkpcg3kn7l/Eey+UqucLxgOWkteUGiG4Q=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

67
uv.lock generated
View File

@@ -4443,7 +4443,7 @@ requires-dist = [
{ name = "numpy", marker = "extra == 'model-server'", specifier = "==2.4.1" },
{ name = "oauthlib", marker = "extra == 'backend'", specifier = "==3.2.2" },
{ name = "office365-rest-python-client", marker = "extra == 'backend'", specifier = "==2.6.2" },
{ name = "onyx-devtools", marker = "extra == 'dev'", specifier = "==0.6.3" },
{ name = "onyx-devtools", marker = "extra == 'dev'", specifier = "==0.7.0" },
{ name = "openai", specifier = "==2.14.0" },
{ name = "openapi-generator-cli", marker = "extra == 'dev'", specifier = "==7.17.0" },
{ name = "openinference-instrumentation", marker = "extra == 'backend'", specifier = "==0.1.42" },
@@ -4466,7 +4466,7 @@ requires-dist = [
{ name = "pygithub", marker = "extra == 'backend'", specifier = "==2.5.0" },
{ name = "pympler", marker = "extra == 'backend'", specifier = "==1.1" },
{ name = "pypandoc-binary", marker = "extra == 'backend'", specifier = "==1.16.2" },
{ name = "pypdf", marker = "extra == 'backend'", specifier = "==6.7.5" },
{ name = "pypdf", marker = "extra == 'backend'", specifier = "==6.8.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = "==8.3.5" },
{ name = "pytest-alembic", marker = "extra == 'dev'", specifier = "==0.12.1" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" },
@@ -4485,7 +4485,7 @@ requires-dist = [
{ name = "pywikibot", marker = "extra == 'backend'", specifier = "==9.0.0" },
{ name = "rapidfuzz", marker = "extra == 'backend'", specifier = "==3.13.0" },
{ name = "redis", marker = "extra == 'backend'", specifier = "==5.0.8" },
{ name = "release-tag", marker = "extra == 'dev'", specifier = "==0.4.3" },
{ name = "release-tag", marker = "extra == 'dev'", specifier = "==0.5.2" },
{ name = "reorder-python-imports-black", marker = "extra == 'dev'", specifier = "==3.14.0" },
{ name = "requests", marker = "extra == 'backend'", specifier = "==2.32.5" },
{ name = "requests-oauthlib", marker = "extra == 'backend'", specifier = "==1.3.1" },
@@ -4548,20 +4548,19 @@ requires-dist = [{ name = "onyx", extras = ["backend", "dev", "ee"], editable =
[[package]]
name = "onyx-devtools"
version = "0.6.3"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastapi" },
{ name = "openapi-generator-cli" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/e2/e7619722c3ccd18eb38100f776fb3dd6b4ae0fbbee09fca5af7c69a279b5/onyx_devtools-0.6.3-py3-none-any.whl", hash = "sha256:d3a5422945d9da12cafc185f64b39f6e727ee4cc92b37427deb7a38f9aad4966", size = 3945381, upload-time = "2026-03-05T20:39:25.896Z" },
{ url = "https://files.pythonhosted.org/packages/f2/09/513d2dabedc1e54ad4376830fc9b34a3d9c164bdbcdedfcdbb8b8154dc5a/onyx_devtools-0.6.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:efe300e9f3a2e7ae75f88a4f9e0a5c4c471478296cb1615b6a1f03d247582e13", size = 3978761, upload-time = "2026-03-05T20:39:28.822Z" },
{ url = "https://files.pythonhosted.org/packages/39/41/e757602a0de032d74ed01c7ee57f30e57728fb9cd4f922f50d2affda3889/onyx_devtools-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:594066eed3f917cfab5a8c7eac3d4a210df30259f2049f664787749709345e19", size = 3665378, upload-time = "2026-03-05T20:44:22.696Z" },
{ url = "https://files.pythonhosted.org/packages/33/1c/c93b65d0b32e202596a2647922a75c7011cb982f899ddfcfd171f792c58f/onyx_devtools-0.6.3-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:384ef66030b55c0fd68b3898782b5b4b868ff3de119569dfc8544e2ce534b98a", size = 3540890, upload-time = "2026-03-05T20:39:28.886Z" },
{ url = "https://files.pythonhosted.org/packages/f4/33/760eb656013f7f0cdff24570480d3dc4e52bbd8e6147ea1e8cf6fad7554f/onyx_devtools-0.6.3-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:82e218f3a49f64910c2c4c34d5dc12d1ea1520a27e0b0f6e4c0949ff9abaf0e1", size = 3945396, upload-time = "2026-03-05T20:39:34.323Z" },
{ url = "https://files.pythonhosted.org/packages/1a/eb/f54b3675c464df8a51194ff75afc97c2417659e3a209dc46948b47c28860/onyx_devtools-0.6.3-py3-none-win_amd64.whl", hash = "sha256:8af614ae7229290ef2417cb85270184a1e826ed9a3a34658da93851edb36df57", size = 4045936, upload-time = "2026-03-05T20:39:28.375Z" },
{ url = "https://files.pythonhosted.org/packages/04/b8/5bee38e748f3d4b8ec935766224db1bbc1214c91092e5822c080fccd9130/onyx_devtools-0.6.3-py3-none-win_arm64.whl", hash = "sha256:717589db4b42528d33ae96f8006ee6aad3555034dcfee724705b6576be6a6ec4", size = 3608268, upload-time = "2026-03-05T20:39:28.731Z" },
{ url = "https://files.pythonhosted.org/packages/22/9e/6957b11555da57d9e97092f4cd8ac09a86666264b0c9491838f4b27db5dc/onyx_devtools-0.7.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ad962a168d46ea11dcde9fa3b37e4f12ec520b4a4cb4d49d8732de110d46c4b6", size = 3998057, upload-time = "2026-03-12T03:09:11.585Z" },
{ url = "https://files.pythonhosted.org/packages/cd/90/c72f3d06ba677012d77c77de36195b6a32a15c755c79ba0282be74e3c366/onyx_devtools-0.7.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e46d252e2b048ff053b03519c3a875998780738d7c334eaa1c9a32ff445e3e1a", size = 3687753, upload-time = "2026-03-12T03:09:11.742Z" },
{ url = "https://files.pythonhosted.org/packages/10/42/4e9fe36eccf9f76d67ba8f4ff6539196a09cd60351fb63f5865e1544cbfa/onyx_devtools-0.7.0-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:f280bc9320e1cc310e7d753a371009bfaab02cc0e0cfd78559663b15655b5a50", size = 3560144, upload-time = "2026-03-12T03:12:24.02Z" },
{ url = "https://files.pythonhosted.org/packages/76/40/36dc12d99760b358c7f39b27361cb18fa9681ffe194107f982d0e1a74016/onyx_devtools-0.7.0-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:e31df751c7540ae7e70a7fe8e1153c79c31c2254af6aa4c72c0dd54fa381d2ab", size = 3964387, upload-time = "2026-03-12T03:09:11.356Z" },
{ url = "https://files.pythonhosted.org/packages/34/18/74744230c3820a5a7687335507ca5f1dbebab2c5325805041c1cd5703e6a/onyx_devtools-0.7.0-py3-none-win_amd64.whl", hash = "sha256:541bfd347c2d5b11e7f63ab5001d2594df91d215ad9d07b1562f5e715700f7e6", size = 4068030, upload-time = "2026-03-12T03:09:12.98Z" },
{ url = "https://files.pythonhosted.org/packages/8c/78/1320436607d3ffcb321ba7b064556c020ea15843a7e7d903fbb7529a71f5/onyx_devtools-0.7.0-py3-none-win_arm64.whl", hash = "sha256:83016330a9d39712431916cc25b2fb2cfcaa0112a55cc4f919d545da3a8974f9", size = 3626409, upload-time = "2026-03-12T03:09:10.222Z" },
]
[[package]]
@@ -5713,11 +5712,11 @@ wheels = [
[[package]]
name = "pypdf"
version = "6.7.5"
version = "6.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/52/37cc0aa9e9d1bf7729a737a0d83f8b3f851c8eb137373d9f71eafb0a3405/pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d", size = 5304278, upload-time = "2026-03-02T09:05:21.464Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/a3/e705b0805212b663a4c27b861c8a603dba0f8b4bb281f96f8e746576a50d/pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b", size = 5307831, upload-time = "2026-03-09T13:37:40.591Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/89/336673efd0a88956562658aba4f0bbef7cb92a6fbcbcaf94926dbc82b408/pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13", size = 331421, upload-time = "2026-03-02T09:05:19.722Z" },
{ url = "https://files.pythonhosted.org/packages/8c/ec/4ccf3bb86b1afe5d7176e1c8abcdbf22b53dd682ec2eda50e1caadcf6846/pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7", size = 332177, upload-time = "2026-03-09T13:37:38.774Z" },
]
[[package]]
@@ -6338,16 +6337,16 @@ wheels = [
[[package]]
name = "release-tag"
version = "0.4.3"
version = "0.5.2"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/18/c1d17d973f73f0aa7e2c45f852839ab909756e1bd9727d03babe400fcef0/release_tag-0.4.3-py3-none-any.whl", hash = "sha256:4206f4fa97df930c8176bfee4d3976a7385150ed14b317bd6bae7101ac8b66dd", size = 1181112, upload-time = "2025-12-03T00:18:19.445Z" },
{ url = "https://files.pythonhosted.org/packages/33/c7/ecc443953840ac313856b2181f55eb8d34fa2c733cdd1edd0bcceee0938d/release_tag-0.4.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a347a9ad3d2af16e5367e52b451fbc88a0b7b666850758e8f9a601554a8fb13", size = 1170517, upload-time = "2025-12-03T00:18:11.663Z" },
{ url = "https://files.pythonhosted.org/packages/ce/81/2f6ffa0d87c792364ca9958433fe088c8acc3d096ac9734040049c6ad506/release_tag-0.4.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2d1603aa37d8e4f5df63676bbfddc802fbc108a744ba28288ad25c997981c164", size = 1101663, upload-time = "2025-12-03T00:18:15.173Z" },
{ url = "https://files.pythonhosted.org/packages/7c/ed/9e4ebe400fc52e38dda6e6a45d9da9decd4535ab15e170b8d9b229a66730/release_tag-0.4.3-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:6db7b81a198e3ba6a87496a554684912c13f9297ea8db8600a80f4f971709d37", size = 1079322, upload-time = "2025-12-03T00:18:16.094Z" },
{ url = "https://files.pythonhosted.org/packages/2a/64/9e0ce6119e091ef9211fa82b9593f564eeec8bdd86eff6a97fe6e2fcb20f/release_tag-0.4.3-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:d79a9cf191dd2c29e1b3a35453fa364b08a7aadd15aeb2c556a7661c6cf4d5ad", size = 1181129, upload-time = "2025-12-03T00:18:15.82Z" },
{ url = "https://files.pythonhosted.org/packages/b8/09/d96acf18f0773b6355080a568ba48931faa9dbe91ab1abefc6f8c4df04a8/release_tag-0.4.3-py3-none-win_amd64.whl", hash = "sha256:3958b880375f2241d0cc2b9882363bf54b1d4d7ca8ffc6eecc63ab92f23307f0", size = 1260773, upload-time = "2025-12-03T00:18:14.723Z" },
{ url = "https://files.pythonhosted.org/packages/51/da/ecb6346df1ffb0752fe213e25062f802c10df2948717f0d5f9816c2df914/release_tag-0.4.3-py3-none-win_arm64.whl", hash = "sha256:7d5b08000e6e398d46f05a50139031046348fba6d47909f01e468bb7600c19df", size = 1142155, upload-time = "2025-12-03T00:18:20.647Z" },
{ url = "https://files.pythonhosted.org/packages/ab/92/01192a540b29cfadaa23850c8f6a2041d541b83a3fa1dc52a5f55212b3b6/release_tag-0.5.2-py3-none-any.whl", hash = "sha256:1e9ca7618bcfc63ad7a0728c84bbad52ef82d07586c4cc11365b44ea8f588069", size = 1264752, upload-time = "2026-03-11T00:27:18.674Z" },
{ url = "https://files.pythonhosted.org/packages/4f/77/81fb42a23cd0de61caf84266f7aac1950b1c324883788b7c48e5344f61ae/release_tag-0.5.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8fbc61ff7bac2b96fab09566ec45c6508c201efc3f081f57702e1761bbc178d5", size = 1255075, upload-time = "2026-03-11T00:27:24.442Z" },
{ url = "https://files.pythonhosted.org/packages/98/e6/769f8be94304529c1a531e995f2f3ac83f3c54738ce488b0abde75b20851/release_tag-0.5.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa3d7e495a0c516858a81878d03803539712677a3d6e015503de21cce19bea5e", size = 1163627, upload-time = "2026-03-11T00:27:26.412Z" },
{ url = "https://files.pythonhosted.org/packages/45/68/7543e9daa0dfd41c487bf140d91fd5879327bb7c001a96aa5264667c30a1/release_tag-0.5.2-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:e8b60453218d6926da1fdcb99c2e17c851be0d7ab1975e97951f0bff5f32b565", size = 1140133, upload-time = "2026-03-11T00:27:20.633Z" },
{ url = "https://files.pythonhosted.org/packages/6a/30/9087825696271012d889d136310dbdf0811976ae2b2f5a490f4e437903e1/release_tag-0.5.2-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:0e302ed60c2bf8b7ba5634842be28a27d83cec995869e112b0348b3f01a84ff5", size = 1264767, upload-time = "2026-03-11T00:27:28.355Z" },
{ url = "https://files.pythonhosted.org/packages/79/a3/5b51b0cbdbf2299f545124beab182cfdfe01bf5b615efbc94aee3a64ea67/release_tag-0.5.2-py3-none-win_amd64.whl", hash = "sha256:e3c0629d373a16b9a3da965e89fca893640ce9878ec548865df3609b70989a89", size = 1340816, upload-time = "2026-03-11T00:27:22.622Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/832c2023a8bd8414c93452bd8b43bf61cedfa5b9575f70c06fb911e51a29/release_tag-0.5.2-py3-none-win_arm64.whl", hash = "sha256:5f26b008e0be0c7a122acd8fcb1bb5c822f38e77fed0c0bf6c550cc226c6bf14", size = 1203191, upload-time = "2026-03-11T00:27:29.789Z" },
]
[[package]]
@@ -7233,21 +7232,19 @@ wheels = [
[[package]]
name = "tornado"
version = "6.5.2"
version = "6.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821, upload-time = "2025-08-08T18:27:00.78Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563, upload-time = "2025-08-08T18:26:42.945Z" },
{ url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729, upload-time = "2025-08-08T18:26:44.473Z" },
{ url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295, upload-time = "2025-08-08T18:26:46.021Z" },
{ url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644, upload-time = "2025-08-08T18:26:47.625Z" },
{ url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878, upload-time = "2025-08-08T18:26:50.599Z" },
{ url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549, upload-time = "2025-08-08T18:26:51.864Z" },
{ url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973, upload-time = "2025-08-08T18:26:53.625Z" },
{ url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954, upload-time = "2025-08-08T18:26:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023, upload-time = "2025-08-08T18:26:56.677Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427, upload-time = "2025-08-08T18:26:57.91Z" },
{ url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456, upload-time = "2025-08-08T18:26:59.207Z" },
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
{ url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
{ url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
]
[[package]]

View File

@@ -143,7 +143,9 @@ module.exports = {
"**/src/app/**/utils/*.test.ts",
"**/src/app/**/hooks/*.test.ts", // Pure packet processor tests
"**/src/refresh-components/**/*.test.ts",
"**/src/refresh-pages/**/*.test.ts",
"**/src/sections/**/*.test.ts",
"**/src/components/**/*.test.ts",
// Add more patterns here as you add more unit tests
],
},

View File

@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react";
import { OpenButton } from "@opal/components";
import { Disabled as DisabledProvider } from "@opal/core";
import { SvgSettings } from "@opal/icons";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
@@ -32,16 +33,9 @@ export const WithIcon: Story = {
},
};
export const Selected: Story = {
args: {
selected: true,
children: "Selected",
},
};
export const Open: Story = {
args: {
transient: true,
interaction: "hover",
children: "Open state",
},
};
@@ -53,18 +47,27 @@ export const Disabled: Story = {
},
};
export const LightProminence: Story = {
export const Foldable: Story = {
args: {
prominence: "light",
children: "Light prominence",
foldable: true,
icon: SvgSettings,
children: "Settings",
},
};
export const HeavyProminence: Story = {
export const FoldableDisabled: Story = {
args: {
prominence: "heavy",
children: "Heavy prominence",
foldable: true,
icon: SvgSettings,
children: "Settings",
},
decorators: [
(Story) => (
<DisabledProvider disabled>
<Story />
</DisabledProvider>
),
],
};
export const Sizes: Story = {
@@ -78,3 +81,12 @@ export const Sizes: Story = {
</div>
),
};
export const WithTooltip: Story = {
args: {
icon: SvgSettings,
children: "Settings",
tooltip: "Open settings",
tooltipSide: "bottom",
},
};

View File

@@ -17,7 +17,9 @@ OpenButton is a **tighter, specialized use-case** of SelectButton:
- It hardcodes `variant="select-heavy"` (SelectButton exposes `variant`)
- It adds a built-in chevron with CSS-driven rotation (SelectButton has no chevron)
- It auto-detects Radix `data-state="open"` to derive `interaction` (SelectButton has no Radix awareness)
- It does not support `foldable` or `rightIcon` (SelectButton does)
- It does not support `rightIcon` (SelectButton does)
Both components support `foldable` using the same pattern: `interactive-foldable-host` class + `Interactive.Foldable` wrapper around the label and trailing icon. When foldable, the left icon stays visible while the rest collapses. If you change the foldable implementation in one, update the other to match.
If you need a general-purpose stateful toggle, use `SelectButton`. If you need a popover/dropdown trigger with a chevron, use `OpenButton`.
@@ -26,10 +28,12 @@ If you need a general-purpose stateful toggle, use `SelectButton`. If you need a
```
Interactive.Stateful <- variant="select-heavy", interaction, state, disabled, onClick
└─ Interactive.Container <- height, rounding, padding (from `size`)
└─ div.opal-button.interactive-foreground
└─ div.opal-button.interactive-foreground [.interactive-foldable-host]
├─ div > Icon? (interactive-foreground-icon)
├─ <span>? .opal-button-label
└─ div > ChevronIcon .opal-open-button-chevron (interactive-foreground-icon)
├─ [Foldable]? (wraps label + chevron when foldable)
│ ├─ <span>? .opal-button-label
│ └─ div > ChevronIcon .opal-open-button-chevron
└─ <span>? / ChevronIcon (non-foldable)
```
- **`interaction` controls both the chevron and the hover visual state.** When `interaction` is `"hover"` (explicitly or via Radix `data-state="open"`), the chevron rotates 180° and the hover background activates.
@@ -44,6 +48,7 @@ Interactive.Stateful <- variant="select-heavy", interaction, state, di
| `interaction` | `"rest" \| "hover" \| "active"` | auto | JS-controlled interaction override. Falls back to Radix `data-state="open"` when omitted. |
| `icon` | `IconFunctionComponent` | — | Left icon component |
| `children` | `string` | — | Content between icon and chevron |
| `foldable` | `boolean` | `false` | When `true`, requires both `icon` and `children`; the left icon stays visible while the label + chevron collapse when not hovered. If `tooltip` is omitted on a disabled foldable button, the label text is used as the tooltip. |
| `size` | `SizeVariant` | `"lg"` | Size preset controlling height, rounding, and padding |
| `width` | `WidthVariant` | — | Width preset |
| `tooltip` | `string` | — | Tooltip text shown on hover |

View File

@@ -2,6 +2,7 @@ import "@opal/components/buttons/open-button/styles.css";
import "@opal/components/tooltip.css";
import {
Interactive,
useDisabled,
type InteractiveStatefulProps,
type InteractiveStatefulInteraction,
} from "@opal/core";
@@ -30,27 +31,46 @@ function ChevronIcon({ className, ...props }: IconProps) {
// Types
// ---------------------------------------------------------------------------
type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> & {
/** Left icon. */
icon?: IconFunctionComponent;
/**
* Content props — a discriminated union on `foldable` that enforces:
*
* - `foldable: true` → `icon` and `children` are required (icon stays visible,
* label + chevron fold away)
* - `foldable?: false` → at least one of `icon` or `children` must be provided
*/
type OpenButtonContentProps =
| {
foldable: true;
icon: IconFunctionComponent;
children: string;
}
| {
foldable?: false;
icon?: IconFunctionComponent;
children: string;
}
| {
foldable?: false;
icon: IconFunctionComponent;
children?: string;
};
/** Button label text. */
children?: string;
type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> &
OpenButtonContentProps & {
/**
* Size preset — controls gap, text size, and Container height/rounding.
*/
size?: SizeVariant;
/**
* Size preset — controls gap, text size, and Container height/rounding.
*/
size?: SizeVariant;
/** Width preset. */
width?: WidthVariant;
/** Width preset. */
width?: WidthVariant;
/** Tooltip text shown on hover. */
tooltip?: string;
/** Tooltip text shown on hover. */
tooltip?: string;
/** Which side the tooltip appears on. */
tooltipSide?: TooltipSide;
};
/** Which side the tooltip appears on. */
tooltipSide?: TooltipSide;
};
// ---------------------------------------------------------------------------
// OpenButton
@@ -60,12 +80,15 @@ function OpenButton({
icon: Icon,
children,
size = "lg",
foldable,
width,
tooltip,
tooltipSide = "top",
interaction,
...statefulProps
}: OpenButtonProps) {
const { isDisabled } = useDisabled();
// Derive open state: explicit prop → Radix data-state (injected via Slot chain)
const dataState = (statefulProps as Record<string, unknown>)["data-state"] as
| string
@@ -75,6 +98,17 @@ function OpenButton({
const isLarge = size === "lg";
const labelEl = children ? (
<span
className={cn(
"opal-button-label whitespace-nowrap",
isLarge ? "font-main-ui-body" : "font-secondary-body"
)}
>
{children}
</span>
) : null;
const button = (
<Interactive.Stateful
variant="select-heavy"
@@ -89,25 +123,34 @@ function OpenButton({
isLarge ? "default" : size === "2xs" ? "mini" : "compact"
}
>
<div className="opal-button interactive-foreground flex flex-row items-center gap-1">
{iconWrapper(Icon, size, false)}
{children && (
<span
className={cn(
"opal-button-label whitespace-nowrap",
isLarge ? "font-main-ui-body" : "font-secondary-body"
)}
>
{children}
</span>
<div
className={cn(
"opal-button interactive-foreground flex flex-row items-center gap-1",
foldable && "interactive-foldable-host"
)}
>
{iconWrapper(Icon, size, !foldable && !!children)}
{foldable ? (
<Interactive.Foldable>
{labelEl}
{iconWrapper(ChevronIcon, size, !!children)}
</Interactive.Foldable>
) : (
<>
{labelEl}
{iconWrapper(ChevronIcon, size, !!children)}
</>
)}
{iconWrapper(ChevronIcon, size, false)}
</div>
</Interactive.Container>
</Interactive.Stateful>
);
if (!tooltip) return button;
const resolvedTooltip =
tooltip ?? (foldable && isDisabled && children ? children : undefined);
if (!resolvedTooltip) return button;
return (
<TooltipPrimitive.Root>
@@ -118,7 +161,7 @@ function OpenButton({
side={tooltipSide}
sideOffset={4}
>
{tooltip}
{resolvedTooltip}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>

View File

@@ -17,7 +17,9 @@ Interactive.Stateful → Interactive.Container → content row (icon + label + t
- OpenButton hardcodes `variant="select-heavy"` (SelectButton exposes `variant`)
- OpenButton adds a built-in chevron with CSS-driven rotation (SelectButton has no chevron)
- OpenButton auto-detects Radix `data-state="open"` to derive `interaction` (SelectButton has no Radix awareness)
- OpenButton does not support `foldable` or `rightIcon` (SelectButton does)
- OpenButton does not support `rightIcon` (SelectButton does)
Both components support `foldable` using the same pattern: `interactive-foldable-host` class + `Interactive.Foldable` wrapper around the label and trailing icon. When foldable, the left icon stays visible while the rest collapses. If you change the foldable implementation in one, update the other to match.
Use SelectButton for general-purpose stateful toggles. Use `OpenButton` for popover/dropdown triggers with a chevron.

View File

@@ -0,0 +1,87 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Card } from "@opal/components";
const BACKGROUND_VARIANTS = ["none", "light", "heavy"] as const;
const BORDER_VARIANTS = ["none", "dashed", "solid"] as const;
const SIZE_VARIANTS = ["lg", "md", "sm", "xs", "2xs", "fit"] as const;
const meta: Meta<typeof Card> = {
title: "opal/components/Card",
component: Card,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof Card>;
export const Default: Story = {
render: () => (
<Card>
<p>Default card with light background, no border, lg size.</p>
</Card>
),
};
export const BackgroundVariants: Story = {
render: () => (
<div className="flex flex-col gap-4 w-96">
{BACKGROUND_VARIANTS.map((bg) => (
<Card key={bg} backgroundVariant={bg} borderVariant="solid">
<p>backgroundVariant: {bg}</p>
</Card>
))}
</div>
),
};
export const BorderVariants: Story = {
render: () => (
<div className="flex flex-col gap-4 w-96">
{BORDER_VARIANTS.map((border) => (
<Card key={border} borderVariant={border}>
<p>borderVariant: {border}</p>
</Card>
))}
</div>
),
};
export const SizeVariants: Story = {
render: () => (
<div className="flex flex-col gap-4 w-96">
{SIZE_VARIANTS.map((size) => (
<Card key={size} sizeVariant={size} borderVariant="solid">
<p>sizeVariant: {size}</p>
</Card>
))}
</div>
),
};
export const AllCombinations: Story = {
render: () => (
<div className="flex flex-col gap-8">
{SIZE_VARIANTS.map((size) => (
<div key={size}>
<p className="font-bold pb-2">sizeVariant: {size}</p>
<div className="grid grid-cols-3 gap-4">
{BACKGROUND_VARIANTS.map((bg) =>
BORDER_VARIANTS.map((border) => (
<Card
key={`${size}-${bg}-${border}`}
sizeVariant={size}
backgroundVariant={bg}
borderVariant={border}
>
<p className="text-xs">
bg: {bg}, border: {border}
</p>
</Card>
))
)}
</div>
</div>
))}
</div>
),
};

View File

@@ -0,0 +1,67 @@
# Card
**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`.
## Architecture
The `sizeVariant` controls both padding and border-radius, mirroring the same mapping used by `Button` and `Interactive.Container`:
| Size | Padding | Rounding |
|-----------|---------|----------------|
| `lg` | `p-2` | `rounded-12` |
| `md` | `p-1` | `rounded-08` |
| `sm` | `p-1` | `rounded-08` |
| `xs` | `p-0.5` | `rounded-04` |
| `2xs` | `p-0.5` | `rounded-04` |
| `fit` | `p-0` | `rounded-12` |
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `sizeVariant` | `SizeVariant` | `"lg"` | Controls padding and border-radius |
| `backgroundVariant` | `"none" \| "light" \| "heavy"` | `"light"` | Background fill intensity |
| `borderVariant` | `"none" \| "dashed" \| "solid"` | `"none"` | Border style |
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
| `children` | `React.ReactNode` | — | Card content |
## Background Variants
- **`none`** — Transparent background. Use for seamless inline content.
- **`light`** — Subtle tinted background (`bg-background-tint-00`). The default, suitable for most cards.
- **`heavy`** — Stronger tinted background (`bg-background-tint-01`). Use for emphasis or nested cards that need visual separation.
## Border Variants
- **`none`** — No border. Use when cards are visually grouped or in tight layouts.
- **`dashed`** — Dashed border. Use for placeholder or empty states.
- **`solid`** — Solid border. Use for prominent, standalone cards.
## Usage
```tsx
import { Card } from "@opal/components";
// Default card (light background, no border, lg padding + rounding)
<Card>
<h2>Card Title</h2>
<p>Card content</p>
</Card>
// Compact card with solid border
<Card borderVariant="solid" sizeVariant="sm">
<p>Compact card</p>
</Card>
// Empty state card
<Card backgroundVariant="none" borderVariant="dashed">
<p>No items yet</p>
</Card>
// Heavy background, tight padding
<Card backgroundVariant="heavy" sizeVariant="xs">
<p>Highlighted content</p>
</Card>
```

View File

@@ -0,0 +1,101 @@
import "@opal/components/cards/card/styles.css";
import type { SizeVariant } from "@opal/shared";
import { sizeVariants } from "@opal/shared";
import { cn } from "@opal/utils";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type BackgroundVariant = "none" | "light" | "heavy";
type BorderVariant = "none" | "dashed" | "solid";
type CardProps = {
/**
* Size preset — controls padding and border-radius.
*
* Padding comes from the shared size scale. Rounding follows the same
* mapping as `Button` / `Interactive.Container`:
*
* | Size | Rounding |
* |--------|------------|
* | `lg` | `default` |
* | `md``sm` | `compact` |
* | `xs``2xs` | `mini` |
* | `fit` | `default` |
*
* @default "lg"
*/
sizeVariant?: SizeVariant;
/**
* Background fill intensity.
* - `"none"`: transparent background.
* - `"light"`: subtle tinted background (`bg-background-tint-00`).
* - `"heavy"`: stronger tinted background (`bg-background-tint-01`).
*
* @default "light"
*/
backgroundVariant?: BackgroundVariant;
/**
* Border style.
* - `"none"`: no border.
* - `"dashed"`: dashed border.
* - `"solid"`: solid border.
*
* @default "none"
*/
borderVariant?: BorderVariant;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
children?: React.ReactNode;
};
// ---------------------------------------------------------------------------
// Rounding
// ---------------------------------------------------------------------------
/** Maps a size variant to a rounding class, mirroring the Button pattern. */
const roundingForSize: Record<SizeVariant, string> = {
lg: "rounded-12",
md: "rounded-08",
sm: "rounded-08",
xs: "rounded-04",
"2xs": "rounded-04",
fit: "rounded-12",
};
// ---------------------------------------------------------------------------
// Card
// ---------------------------------------------------------------------------
function Card({
sizeVariant = "lg",
backgroundVariant = "light",
borderVariant = "none",
ref,
children,
}: CardProps) {
const { padding } = sizeVariants[sizeVariant];
const rounding = roundingForSize[sizeVariant];
return (
<div
ref={ref}
className={cn("opal-card", padding, rounding)}
data-background={backgroundVariant}
data-border={borderVariant}
>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
export { Card, type CardProps, type BackgroundVariant, type BorderVariant };

View File

@@ -0,0 +1,29 @@
.opal-card {
@apply w-full overflow-clip;
}
/* Background variants */
.opal-card[data-background="none"] {
@apply bg-transparent;
}
.opal-card[data-background="light"] {
@apply bg-background-tint-00;
}
.opal-card[data-background="heavy"] {
@apply bg-background-tint-01;
}
/* Border variants */
.opal-card[data-border="none"] {
border: none;
}
.opal-card[data-border="dashed"] {
@apply border border-dashed;
}
.opal-card[data-border="solid"] {
@apply border;
}

View File

@@ -0,0 +1,51 @@
import type { Meta, StoryObj } from "@storybook/react";
import { EmptyMessageCard } from "@opal/components";
import { SvgSparkle, SvgUsers } from "@opal/icons";
const SIZE_VARIANTS = ["lg", "md", "sm", "xs", "2xs", "fit"] as const;
const meta: Meta<typeof EmptyMessageCard> = {
title: "opal/components/EmptyMessageCard",
component: EmptyMessageCard,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof EmptyMessageCard>;
export const Default: Story = {
args: {
title: "No items available.",
},
};
export const WithCustomIcon: Story = {
args: {
icon: SvgSparkle,
title: "No agents selected.",
},
};
export const SizeVariants: Story = {
render: () => (
<div className="flex flex-col gap-4 w-96">
{SIZE_VARIANTS.map((size) => (
<EmptyMessageCard
key={size}
sizeVariant={size}
title={`sizeVariant: ${size}`}
/>
))}
</div>
),
};
export const Multiple: Story = {
render: () => (
<div className="flex flex-col gap-4 w-96">
<EmptyMessageCard title="No models available." />
<EmptyMessageCard icon={SvgSparkle} title="No agents selected." />
<EmptyMessageCard icon={SvgUsers} title="No groups added." />
</div>
),
};

View File

@@ -0,0 +1,30 @@
# EmptyMessageCard
**Import:** `import { EmptyMessageCard, type EmptyMessageCardProps } from "@opal/components";`
A pre-configured Card for empty states. Renders a transparent card with a dashed border containing a muted icon and message text using the `Content` layout.
## Props
| Prop | Type | Default | Description |
| ------------- | -------------------------- | ---------- | ------------------------------------------------ |
| `icon` | `IconFunctionComponent` | `SvgEmpty` | Icon displayed alongside the title |
| `title` | `string` | — | Primary message text (required) |
| `sizeVariant` | `SizeVariant` | `"lg"` | Size preset controlling padding and rounding |
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
## Usage
```tsx
import { EmptyMessageCard } from "@opal/components";
import { SvgSparkle, SvgFileText } from "@opal/icons";
// Default empty state
<EmptyMessageCard title="No items yet." />
// With custom icon
<EmptyMessageCard icon={SvgSparkle} title="No agents selected." />
// With custom size
<EmptyMessageCard sizeVariant="sm" icon={SvgFileText} title="No documents available." />
```

View File

@@ -0,0 +1,57 @@
import { Card } from "@opal/components/cards/card/components";
import { Content } from "@opal/layouts";
import { SvgEmpty } from "@opal/icons";
import type { SizeVariant } from "@opal/shared";
import type { IconFunctionComponent } from "@opal/types";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type EmptyMessageCardProps = {
/** Icon displayed alongside the title. */
icon?: IconFunctionComponent;
/** Primary message text. */
title: string;
/** Size preset controlling padding and rounding of the card. */
sizeVariant?: SizeVariant;
/** Ref forwarded to the root Card div. */
ref?: React.Ref<HTMLDivElement>;
};
// ---------------------------------------------------------------------------
// EmptyMessageCard
// ---------------------------------------------------------------------------
function EmptyMessageCard({
icon = SvgEmpty,
title,
sizeVariant = "lg",
ref,
}: EmptyMessageCardProps) {
return (
<Card
ref={ref}
backgroundVariant="none"
borderVariant="dashed"
sizeVariant={sizeVariant}
>
<Content
icon={icon}
title={title}
sizePreset="secondary"
variant="body"
prominence="muted"
/>
</Card>
);
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
export { EmptyMessageCard, type EmptyMessageCardProps };

View File

@@ -31,3 +31,17 @@ export {
type TagProps,
type TagColor,
} from "@opal/components/tag/components";
/* Card */
export {
Card,
type CardProps,
type BackgroundVariant,
type BorderVariant,
} from "@opal/components/cards/card/components";
/* EmptyMessageCard */
export {
EmptyMessageCard,
type EmptyMessageCardProps,
} from "@opal/components/cards/empty-message-card/components";

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Interactive } from "@opal/core";
import { Interactive, Disabled } from "@opal/core";
// ---------------------------------------------------------------------------
// Variant / Prominence mappings for the matrix story
@@ -9,8 +9,6 @@ const VARIANT_PROMINENCE_MAP: Record<string, string[]> = {
default: ["primary", "secondary", "tertiary", "internal"],
action: ["primary", "secondary", "tertiary", "internal"],
danger: ["primary", "secondary", "tertiary", "internal"],
select: ["light", "heavy"],
sidebar: ["light"],
none: [],
};
@@ -35,39 +33,39 @@ export default meta;
// Stories
// ---------------------------------------------------------------------------
/** Basic Interactive.Base + Container with text content. */
/** Basic Interactive.Stateless + Container with text content. */
export const Default: StoryObj = {
render: () => (
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
<Interactive.Base
<Interactive.Stateless
variant="default"
prominence="secondary"
onClick={() => {}}
>
<Interactive.Container border>
<span>Secondary</span>
<span className="interactive-foreground">Secondary</span>
</Interactive.Container>
</Interactive.Base>
</Interactive.Stateless>
<Interactive.Base
<Interactive.Stateless
variant="default"
prominence="primary"
onClick={() => {}}
>
<Interactive.Container border>
<span>Primary</span>
<span className="interactive-foreground">Primary</span>
</Interactive.Container>
</Interactive.Base>
</Interactive.Stateless>
<Interactive.Base
<Interactive.Stateless
variant="default"
prominence="tertiary"
onClick={() => {}}
>
<Interactive.Container border>
<span>Tertiary</span>
<span className="interactive-foreground">Tertiary</span>
</Interactive.Container>
</Interactive.Base>
</Interactive.Stateless>
</div>
),
};
@@ -91,11 +89,13 @@ export const VariantMatrix: StoryObj = {
</div>
{prominences.length === 0 ? (
<Interactive.Base variant="none" onClick={() => {}}>
<Interactive.Stateless variant="none" onClick={() => {}}>
<Interactive.Container border>
<span>none (no prominence)</span>
<span style={{ color: "var(--text-01)" }}>
none (no prominence)
</span>
</Interactive.Container>
</Interactive.Base>
</Interactive.Stateless>
) : (
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{prominences.map((prominence) => (
@@ -108,16 +108,18 @@ export const VariantMatrix: StoryObj = {
gap: "0.25rem",
}}
>
<Interactive.Base
<Interactive.Stateless
// Cast required because the discriminated union can't be
// resolved from dynamic strings at the type level.
{...({ variant, prominence } as any)}
onClick={() => {}}
>
<Interactive.Container border>
<span>{prominence}</span>
<span className="interactive-foreground">
{prominence}
</span>
</Interactive.Container>
</Interactive.Base>
</Interactive.Stateless>
<span
style={{
fontSize: "0.625rem",
@@ -141,16 +143,16 @@ export const Sizes: StoryObj = {
render: () => (
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
{SIZE_VARIANTS.map((size) => (
<Interactive.Base
<Interactive.Stateless
key={size}
variant="default"
prominence="secondary"
onClick={() => {}}
>
<Interactive.Container border heightVariant={size}>
<span>{size}</span>
<span className="interactive-foreground">{size}</span>
</Interactive.Container>
</Interactive.Base>
</Interactive.Stateless>
))}
</div>
),
@@ -160,15 +162,15 @@ export const Sizes: StoryObj = {
export const WidthFull: StoryObj = {
render: () => (
<div style={{ width: 400 }}>
<Interactive.Base
<Interactive.Stateless
variant="default"
prominence="secondary"
onClick={() => {}}
>
<Interactive.Container border widthVariant="full">
<span>Full width container</span>
<span className="interactive-foreground">Full width container</span>
</Interactive.Container>
</Interactive.Base>
</Interactive.Stateless>
</div>
),
};
@@ -178,73 +180,86 @@ export const Rounding: StoryObj = {
render: () => (
<div style={{ display: "flex", gap: "0.75rem" }}>
{ROUNDING_VARIANTS.map((rounding) => (
<Interactive.Base
<Interactive.Stateless
key={rounding}
variant="default"
prominence="secondary"
onClick={() => {}}
>
<Interactive.Container border roundingVariant={rounding}>
<span>{rounding}</span>
<span className="interactive-foreground">{rounding}</span>
</Interactive.Container>
</Interactive.Base>
</Interactive.Stateless>
))}
</div>
),
};
/** Disabled state prevents clicks and shows disabled styling. */
export const Disabled: StoryObj = {
export const DisabledStory: StoryObj = {
name: "Disabled",
render: () => (
<div style={{ display: "flex", gap: "0.75rem" }}>
<Interactive.Base
variant="default"
prominence="secondary"
onClick={() => {}}
disabled
>
<Interactive.Container border>
<span>Disabled</span>
</Interactive.Container>
</Interactive.Base>
<Disabled disabled>
<Interactive.Stateless
variant="default"
prominence="secondary"
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Disabled</span>
</Interactive.Container>
</Interactive.Stateless>
</Disabled>
<Interactive.Base
<Interactive.Stateless
variant="default"
prominence="secondary"
onClick={() => {}}
>
<Interactive.Container border>
<span>Enabled</span>
<span className="interactive-foreground">Enabled</span>
</Interactive.Container>
</Interactive.Base>
</Interactive.Stateless>
</div>
),
};
/** Transient prop forces the hover/active visual state. */
export const Transient: StoryObj = {
/** Interaction override forces the hover/active visual state. */
export const Interaction: StoryObj = {
render: () => (
<div style={{ display: "flex", gap: "0.75rem" }}>
<Interactive.Base
<Interactive.Stateless
variant="default"
prominence="secondary"
interaction="hover"
onClick={() => {}}
transient
>
<Interactive.Container border>
<span>Forced hover</span>
<span className="interactive-foreground">Forced hover</span>
</Interactive.Container>
</Interactive.Base>
</Interactive.Stateless>
<Interactive.Base
<Interactive.Stateless
variant="default"
prominence="secondary"
interaction="active"
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Forced active</span>
</Interactive.Container>
</Interactive.Stateless>
<Interactive.Stateless
variant="default"
prominence="secondary"
onClick={() => {}}
>
<Interactive.Container border>
<span>Normal</span>
<span className="interactive-foreground">Normal (rest)</span>
</Interactive.Container>
</Interactive.Base>
</Interactive.Stateless>
</div>
),
};
@@ -253,25 +268,25 @@ export const Transient: StoryObj = {
export const WithBorder: StoryObj = {
render: () => (
<div style={{ display: "flex", gap: "0.75rem" }}>
<Interactive.Base
<Interactive.Stateless
variant="default"
prominence="secondary"
onClick={() => {}}
>
<Interactive.Container border>
<span>With border</span>
<span className="interactive-foreground">With border</span>
</Interactive.Container>
</Interactive.Base>
</Interactive.Stateless>
<Interactive.Base
<Interactive.Stateless
variant="default"
prominence="secondary"
onClick={() => {}}
>
<Interactive.Container>
<span>Without border</span>
<span className="interactive-foreground">Without border</span>
</Interactive.Container>
</Interactive.Base>
</Interactive.Stateless>
</div>
),
};
@@ -279,51 +294,57 @@ export const WithBorder: StoryObj = {
/** Using href to render as a link. */
export const AsLink: StoryObj = {
render: () => (
<Interactive.Base variant="action" href="/settings">
<Interactive.Stateless variant="action" href="/settings">
<Interactive.Container border>
<span>Go to Settings</span>
<span className="interactive-foreground">Go to Settings</span>
</Interactive.Container>
</Interactive.Base>
</Interactive.Stateless>
),
};
/** Select variant with selected and unselected states. */
/** Stateful select variant with selected and unselected states. */
export const SelectVariant: StoryObj = {
render: () => (
<div style={{ display: "flex", gap: "0.75rem" }}>
<Interactive.Base
variant="select"
prominence="light"
selected
<Interactive.Stateful
variant="select-light"
state="selected"
onClick={() => {}}
>
<Interactive.Container border>
<span>Selected (light)</span>
<span className="interactive-foreground">Selected (light)</span>
</Interactive.Container>
</Interactive.Base>
</Interactive.Stateful>
<Interactive.Base variant="select" prominence="light" onClick={() => {}}>
<Interactive.Container border>
<span>Unselected (light)</span>
</Interactive.Container>
</Interactive.Base>
<Interactive.Base
variant="select"
prominence="heavy"
selected
<Interactive.Stateful
variant="select-light"
state="empty"
onClick={() => {}}
>
<Interactive.Container border>
<span>Selected (heavy)</span>
<span className="interactive-foreground">Unselected (light)</span>
</Interactive.Container>
</Interactive.Base>
</Interactive.Stateful>
<Interactive.Base variant="select" prominence="heavy" onClick={() => {}}>
<Interactive.Stateful
variant="select-heavy"
state="selected"
onClick={() => {}}
>
<Interactive.Container border>
<span>Unselected (heavy)</span>
<span className="interactive-foreground">Selected (heavy)</span>
</Interactive.Container>
</Interactive.Base>
</Interactive.Stateful>
<Interactive.Stateful
variant="select-heavy"
state="empty"
onClick={() => {}}
>
<Interactive.Container border>
<span className="interactive-foreground">Unselected (heavy)</span>
</Interactive.Container>
</Interactive.Stateful>
</div>
),
};

View File

@@ -89,7 +89,7 @@ export { default as SvgHistory } from "@opal/icons/history";
export { default as SvgHourglass } from "@opal/icons/hourglass";
export { default as SvgImage } from "@opal/icons/image";
export { default as SvgImageSmall } from "@opal/icons/image-small";
export { default as SvgImport } from "@opal/icons/import";
export { default as SvgImport } from "@opal/icons/import-icon";
export { default as SvgInfo } from "@opal/icons/info";
export { default as SvgInfoSmall } from "@opal/icons/info-small";
export { default as SvgKey } from "@opal/icons/key";

View File

@@ -1,87 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { BodyLayout } from "./BodyLayout";
import { SvgSettings, SvgStar, SvgRefreshCw } from "@opal/icons";
const meta = {
title: "Layouts/BodyLayout",
component: BodyLayout,
tags: ["autodocs"],
parameters: {
layout: "centered",
},
} satisfies Meta<typeof BodyLayout>;
export default meta;
type Story = StoryObj<typeof meta>;
// ---------------------------------------------------------------------------
// Size presets
// ---------------------------------------------------------------------------
export const MainContent: Story = {
args: {
sizePreset: "main-content",
title: "Last synced 2 minutes ago",
},
};
export const MainUi: Story = {
args: {
sizePreset: "main-ui",
title: "Document count: 1,234",
},
};
export const Secondary: Story = {
args: {
sizePreset: "secondary",
title: "Updated 5 min ago",
},
};
// ---------------------------------------------------------------------------
// With icon
// ---------------------------------------------------------------------------
export const WithIcon: Story = {
args: {
sizePreset: "main-ui",
title: "Settings",
icon: SvgSettings,
},
};
// ---------------------------------------------------------------------------
// Orientations
// ---------------------------------------------------------------------------
export const Vertical: Story = {
args: {
sizePreset: "main-ui",
title: "Stacked layout",
icon: SvgStar,
orientation: "vertical",
},
};
export const Reverse: Story = {
args: {
sizePreset: "main-ui",
title: "Reverse layout",
icon: SvgRefreshCw,
orientation: "reverse",
},
};
// ---------------------------------------------------------------------------
// Prominence
// ---------------------------------------------------------------------------
export const Muted: Story = {
args: {
sizePreset: "main-ui",
title: "Muted body text",
prominence: "muted",
},
};

View File

@@ -1,98 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { HeadingLayout } from "./HeadingLayout";
import { SvgSettings, SvgStar } from "@opal/icons";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
const meta = {
title: "Layouts/HeadingLayout",
component: HeadingLayout,
tags: ["autodocs"],
parameters: {
layout: "centered",
},
decorators: [
(Story) => (
<TooltipPrimitive.Provider>
<Story />
</TooltipPrimitive.Provider>
),
],
} satisfies Meta<typeof HeadingLayout>;
export default meta;
type Story = StoryObj<typeof meta>;
// ---------------------------------------------------------------------------
// Size presets
// ---------------------------------------------------------------------------
export const Headline: Story = {
args: {
sizePreset: "headline",
title: "Welcome to Onyx",
description: "Your enterprise search and AI assistant platform.",
},
};
export const Section: Story = {
args: {
sizePreset: "section",
title: "Configuration",
},
};
// ---------------------------------------------------------------------------
// With icon
// ---------------------------------------------------------------------------
export const WithIcon: Story = {
args: {
sizePreset: "headline",
title: "Settings",
icon: SvgSettings,
},
};
export const SectionWithIcon: Story = {
args: {
sizePreset: "section",
variant: "section",
title: "Favorites",
icon: SvgStar,
},
};
// ---------------------------------------------------------------------------
// Variants
// ---------------------------------------------------------------------------
export const SectionVariant: Story = {
args: {
sizePreset: "headline",
variant: "section",
title: "Inline Icon Heading",
icon: SvgSettings,
},
};
// ---------------------------------------------------------------------------
// Editable
// ---------------------------------------------------------------------------
export const Editable: Story = {
args: {
sizePreset: "headline",
title: "Click to edit me",
editable: true,
},
};
export const EditableSection: Story = {
args: {
sizePreset: "section",
title: "Editable Section Title",
editable: true,
description: "This title can be edited inline.",
},
};

View File

@@ -1,154 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { LabelLayout } from "./LabelLayout";
import { SvgSettings, SvgStar } from "@opal/icons";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
const meta = {
title: "Layouts/LabelLayout",
component: LabelLayout,
tags: ["autodocs"],
parameters: {
layout: "centered",
},
decorators: [
(Story) => (
<TooltipPrimitive.Provider>
<Story />
</TooltipPrimitive.Provider>
),
],
} satisfies Meta<typeof LabelLayout>;
export default meta;
type Story = StoryObj<typeof meta>;
// ---------------------------------------------------------------------------
// Size presets
// ---------------------------------------------------------------------------
export const MainContent: Story = {
args: {
sizePreset: "main-content",
title: "Display Name",
},
};
export const MainUi: Story = {
args: {
sizePreset: "main-ui",
title: "Email Address",
},
};
export const SecondaryPreset: Story = {
args: {
sizePreset: "secondary",
title: "API Key",
},
};
// ---------------------------------------------------------------------------
// With description
// ---------------------------------------------------------------------------
export const WithDescription: Story = {
args: {
sizePreset: "main-content",
title: "Workspace Name",
description: "The name displayed across your organization.",
},
};
// ---------------------------------------------------------------------------
// With icon
// ---------------------------------------------------------------------------
export const WithIcon: Story = {
args: {
sizePreset: "main-ui",
title: "Settings",
icon: SvgSettings,
},
};
// ---------------------------------------------------------------------------
// Optional
// ---------------------------------------------------------------------------
export const Optional: Story = {
args: {
sizePreset: "main-content",
title: "Phone Number",
optional: true,
},
};
// ---------------------------------------------------------------------------
// Aux icons
// ---------------------------------------------------------------------------
export const AuxInfoGray: Story = {
args: {
sizePreset: "main-content",
title: "Connection Status",
auxIcon: "info-gray",
},
};
export const AuxWarning: Story = {
args: {
sizePreset: "main-content",
title: "Rate Limit",
auxIcon: "warning",
},
};
export const AuxError: Story = {
args: {
sizePreset: "main-content",
title: "API Key",
auxIcon: "error",
},
};
// ---------------------------------------------------------------------------
// With tag
// ---------------------------------------------------------------------------
export const WithTag: Story = {
args: {
sizePreset: "main-ui",
title: "Knowledge Graph",
tag: { title: "Beta", color: "blue" },
},
};
// ---------------------------------------------------------------------------
// Editable
// ---------------------------------------------------------------------------
export const Editable: Story = {
args: {
sizePreset: "main-ui",
title: "Click to edit",
editable: true,
},
};
// ---------------------------------------------------------------------------
// Combined
// ---------------------------------------------------------------------------
export const FullFeatured: Story = {
args: {
sizePreset: "main-content",
title: "Custom Field",
icon: SvgStar,
description: "A custom field with all extras enabled.",
optional: true,
auxIcon: "info-blue",
tag: { title: "New", color: "green" },
editable: true,
},
};

View File

@@ -1,134 +0,0 @@
"use client";
import type { IconFunctionComponent } from "@opal/types";
import { cn } from "@opal/utils";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type BodySizePreset = "main-content" | "main-ui" | "secondary";
type BodyOrientation = "vertical" | "inline" | "reverse";
type BodyProminence = "default" | "muted";
interface BodyPresetConfig {
/** Icon width/height (CSS value). */
iconSize: string;
/** Tailwind padding class for the icon container. */
iconContainerPadding: string;
/** Tailwind font class for the title. */
titleFont: string;
/** Title line-height — also used as icon container min-height (CSS value). */
lineHeight: string;
/** Gap between icon container and title (CSS value). */
gap: string;
}
/** Props for {@link BodyLayout}. Does not support editing or descriptions. */
interface BodyLayoutProps {
/** Optional icon component. */
icon?: IconFunctionComponent;
/** Main title text (read-only — editing is not supported). */
title: string;
/** Size preset. Default: `"main-ui"`. */
sizePreset?: BodySizePreset;
/** Layout orientation. Default: `"inline"`. */
orientation?: BodyOrientation;
/** Title prominence. Default: `"default"`. */
prominence?: BodyProminence;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
// Presets
// ---------------------------------------------------------------------------
const BODY_PRESETS: Record<BodySizePreset, BodyPresetConfig> = {
"main-content": {
iconSize: "1rem",
iconContainerPadding: "p-1",
titleFont: "font-main-content-body",
lineHeight: "1.5rem",
gap: "0.125rem",
},
"main-ui": {
iconSize: "1rem",
iconContainerPadding: "p-0.5",
titleFont: "font-main-ui-action",
lineHeight: "1.25rem",
gap: "0.25rem",
},
secondary: {
iconSize: "0.75rem",
iconContainerPadding: "p-0.5",
titleFont: "font-secondary-action",
lineHeight: "1rem",
gap: "0.125rem",
},
};
// ---------------------------------------------------------------------------
// BodyLayout
// ---------------------------------------------------------------------------
function BodyLayout({
icon: Icon,
title,
sizePreset = "main-ui",
orientation = "inline",
prominence = "default",
ref,
}: BodyLayoutProps) {
const config = BODY_PRESETS[sizePreset];
const titleColorClass =
prominence === "muted" ? "text-text-03" : "text-text-04";
return (
<div
ref={ref}
className="opal-content-body"
data-orientation={orientation}
style={{ gap: config.gap }}
>
{Icon && (
<div
className={cn(
"opal-content-body-icon-container shrink-0",
config.iconContainerPadding
)}
style={{ minHeight: config.lineHeight }}
>
<Icon
className="opal-content-body-icon text-text-03"
style={{ width: config.iconSize, height: config.iconSize }}
/>
</div>
)}
<span
className={cn(
"opal-content-body-title",
config.titleFont,
titleColorClass
)}
style={{ height: config.lineHeight }}
>
{title}
</span>
</div>
);
}
export {
BodyLayout,
type BodyLayoutProps,
type BodySizePreset,
type BodyOrientation,
type BodyProminence,
};

View File

@@ -1,218 +0,0 @@
"use client";
import { Button } from "@opal/components/buttons/button/components";
import type { SizeVariant } from "@opal/shared";
import SvgEdit from "@opal/icons/edit";
import type { IconFunctionComponent } from "@opal/types";
import { cn } from "@opal/utils";
import { useRef, useState } from "react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type HeadingSizePreset = "headline" | "section";
type HeadingVariant = "heading" | "section";
interface HeadingPresetConfig {
/** Icon width/height (CSS value). */
iconSize: string;
/** Tailwind padding class for the icon container. */
iconContainerPadding: string;
/** Gap between icon container and content (CSS value). */
gap: string;
/** Tailwind font class for the title. */
titleFont: string;
/** Title line-height — also used as icon container min-height (CSS value). */
lineHeight: string;
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
editButtonSize: SizeVariant;
/** Tailwind padding class for the edit button container. */
editButtonPadding: string;
}
interface HeadingLayoutProps {
/** Optional icon component. */
icon?: IconFunctionComponent;
/** Main title text. */
title: string;
/** Optional description below the title. */
description?: string;
/** Enable inline editing of the title. */
editable?: boolean;
/** Called when the user commits an edit. */
onTitleChange?: (newTitle: string) => void;
/** Size preset. Default: `"headline"`. */
sizePreset?: HeadingSizePreset;
/** Variant controls icon placement. `"heading"` = top, `"section"` = inline. Default: `"heading"`. */
variant?: HeadingVariant;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
// Presets
// ---------------------------------------------------------------------------
const HEADING_PRESETS: Record<HeadingSizePreset, HeadingPresetConfig> = {
headline: {
iconSize: "2rem",
iconContainerPadding: "p-0.5",
gap: "0.25rem",
titleFont: "font-heading-h2",
lineHeight: "2.25rem",
editButtonSize: "md",
editButtonPadding: "p-1",
},
section: {
iconSize: "1.25rem",
iconContainerPadding: "p-1",
gap: "0rem",
titleFont: "font-heading-h3",
lineHeight: "1.75rem",
editButtonSize: "sm",
editButtonPadding: "p-0.5",
},
};
// ---------------------------------------------------------------------------
// HeadingLayout
// ---------------------------------------------------------------------------
function HeadingLayout({
sizePreset = "headline",
variant = "heading",
icon: Icon,
title,
description,
editable,
onTitleChange,
ref,
}: HeadingLayoutProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
const config = HEADING_PRESETS[sizePreset];
const iconPlacement = variant === "heading" ? "top" : "left";
function startEditing() {
setEditValue(title);
setEditing(true);
}
function commit() {
const value = editValue.trim();
if (value && value !== title) onTitleChange?.(value);
setEditing(false);
}
return (
<div
ref={ref}
className="opal-content-heading"
data-icon-placement={iconPlacement}
style={{ gap: iconPlacement === "left" ? config.gap : undefined }}
>
{Icon && (
<div
className={cn(
"opal-content-heading-icon-container shrink-0",
config.iconContainerPadding
)}
style={{ minHeight: config.lineHeight }}
>
<Icon
className="opal-content-heading-icon"
style={{ width: config.iconSize, height: config.iconSize }}
/>
</div>
)}
<div className="opal-content-heading-body">
<div className="opal-content-heading-title-row">
{editing ? (
<div className="opal-content-heading-input-sizer">
<span
className={cn(
"opal-content-heading-input-mirror",
config.titleFont
)}
>
{editValue || "\u00A0"}
</span>
<input
ref={inputRef}
className={cn(
"opal-content-heading-input",
config.titleFont,
"text-text-04"
)}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
size={1}
autoFocus
onFocus={(e) => e.currentTarget.select()}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") {
setEditValue(title);
setEditing(false);
}
}}
style={{ height: config.lineHeight }}
/>
</div>
) : (
<span
className={cn(
"opal-content-heading-title",
config.titleFont,
"text-text-04",
editable && "cursor-pointer"
)}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
>
{title}
</span>
)}
{editable && !editing && (
<div
className={cn(
"opal-content-heading-edit-button",
config.editButtonPadding
)}
>
<Button
icon={SvgEdit}
prominence="internal"
size={config.editButtonSize}
tooltip="Edit"
tooltipSide="right"
onClick={startEditing}
/>
</div>
)}
</div>
{description && (
<div className="opal-content-heading-description font-secondary-body text-text-03">
{description}
</div>
)}
</div>
</div>
);
}
export { HeadingLayout, type HeadingLayoutProps, type HeadingSizePreset };

View File

@@ -1,286 +0,0 @@
"use client";
import { Button } from "@opal/components/buttons/button/components";
import { Tag, type TagProps } from "@opal/components/tag/components";
import type { SizeVariant } from "@opal/shared";
import SvgAlertCircle from "@opal/icons/alert-circle";
import SvgAlertTriangle from "@opal/icons/alert-triangle";
import SvgEdit from "@opal/icons/edit";
import SvgXOctagon from "@opal/icons/x-octagon";
import type { IconFunctionComponent } from "@opal/types";
import { cn } from "@opal/utils";
import { useRef, useState } from "react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type LabelSizePreset = "main-content" | "main-ui" | "secondary";
type LabelAuxIcon = "info-gray" | "info-blue" | "warning" | "error";
interface LabelPresetConfig {
iconSize: string;
iconContainerPadding: string;
iconColorClass: string;
titleFont: string;
lineHeight: string;
gap: string;
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
editButtonSize: SizeVariant;
editButtonPadding: string;
optionalFont: string;
/** Aux icon size = lineHeight 2 × p-0.5. */
auxIconSize: string;
}
interface LabelLayoutProps {
/** Optional icon component. */
icon?: IconFunctionComponent;
/** Main title text. */
title: string;
/** Optional description text below the title. */
description?: string;
/** Enable inline editing of the title. */
editable?: boolean;
/** Called when the user commits an edit. */
onTitleChange?: (newTitle: string) => void;
/** When `true`, renders "(Optional)" beside the title. */
optional?: boolean;
/** Auxiliary status icon rendered beside the title. */
auxIcon?: LabelAuxIcon;
/** Tag rendered beside the title. */
tag?: TagProps;
/** Size preset. Default: `"main-ui"`. */
sizePreset?: LabelSizePreset;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
// Presets
// ---------------------------------------------------------------------------
const LABEL_PRESETS: Record<LabelSizePreset, LabelPresetConfig> = {
"main-content": {
iconSize: "1rem",
iconContainerPadding: "p-1",
iconColorClass: "text-text-04",
titleFont: "font-main-content-emphasis",
lineHeight: "1.5rem",
gap: "0.125rem",
editButtonSize: "sm",
editButtonPadding: "p-0",
optionalFont: "font-main-content-muted",
auxIconSize: "1.25rem",
},
"main-ui": {
iconSize: "1rem",
iconContainerPadding: "p-0.5",
iconColorClass: "text-text-03",
titleFont: "font-main-ui-action",
lineHeight: "1.25rem",
gap: "0.25rem",
editButtonSize: "xs",
editButtonPadding: "p-0",
optionalFont: "font-main-ui-muted",
auxIconSize: "1rem",
},
secondary: {
iconSize: "0.75rem",
iconContainerPadding: "p-0.5",
iconColorClass: "text-text-04",
titleFont: "font-secondary-action",
lineHeight: "1rem",
gap: "0.125rem",
editButtonSize: "2xs",
editButtonPadding: "p-0",
optionalFont: "font-secondary-action",
auxIconSize: "0.75rem",
},
};
// ---------------------------------------------------------------------------
// LabelLayout
// ---------------------------------------------------------------------------
const AUX_ICON_CONFIG: Record<
LabelAuxIcon,
{ icon: IconFunctionComponent; colorClass: string }
> = {
"info-gray": { icon: SvgAlertCircle, colorClass: "text-text-02" },
"info-blue": { icon: SvgAlertCircle, colorClass: "text-status-info-05" },
warning: { icon: SvgAlertTriangle, colorClass: "text-status-warning-05" },
error: { icon: SvgXOctagon, colorClass: "text-status-error-05" },
};
function LabelLayout({
icon: Icon,
title,
description,
editable,
onTitleChange,
optional,
auxIcon,
tag,
sizePreset = "main-ui",
ref,
}: LabelLayoutProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
const config = LABEL_PRESETS[sizePreset];
function startEditing() {
setEditValue(title);
setEditing(true);
}
function commit() {
const value = editValue.trim();
if (value && value !== title) onTitleChange?.(value);
setEditing(false);
}
return (
<div ref={ref} className="opal-content-label" style={{ gap: config.gap }}>
{Icon && (
<div
className={cn(
"opal-content-label-icon-container shrink-0",
config.iconContainerPadding
)}
style={{ minHeight: config.lineHeight }}
>
<Icon
className={cn("opal-content-label-icon", config.iconColorClass)}
style={{ width: config.iconSize, height: config.iconSize }}
/>
</div>
)}
<div className="opal-content-label-body">
<div className="opal-content-label-title-row">
{editing ? (
<div className="opal-content-label-input-sizer">
<span
className={cn(
"opal-content-label-input-mirror",
config.titleFont
)}
>
{editValue || "\u00A0"}
</span>
<input
ref={inputRef}
className={cn(
"opal-content-label-input",
config.titleFont,
"text-text-04"
)}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
size={1}
autoFocus
onFocus={(e) => e.currentTarget.select()}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") {
setEditValue(title);
setEditing(false);
}
}}
style={{ height: config.lineHeight }}
/>
</div>
) : (
<span
className={cn(
"opal-content-label-title",
config.titleFont,
"text-text-04",
editable && "cursor-pointer"
)}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
>
{title}
</span>
)}
{optional && (
<span
className={cn(config.optionalFont, "text-text-03 shrink-0")}
style={{ height: config.lineHeight }}
>
(Optional)
</span>
)}
{auxIcon &&
(() => {
const { icon: AuxIcon, colorClass } = AUX_ICON_CONFIG[auxIcon];
return (
<div
className="opal-content-label-aux-icon shrink-0 p-0.5"
style={{ height: config.lineHeight }}
>
<AuxIcon
className={colorClass}
style={{
width: config.auxIconSize,
height: config.auxIconSize,
}}
/>
</div>
);
})()}
{tag && <Tag {...tag} />}
{editable && !editing && (
<div
className={cn(
"opal-content-label-edit-button",
config.editButtonPadding
)}
>
<Button
icon={SvgEdit}
prominence="internal"
size={config.editButtonSize}
tooltip="Edit"
tooltipSide="right"
onClick={startEditing}
/>
</div>
)}
</div>
{description && (
<div className="opal-content-label-description font-secondary-body text-text-03">
{description}
</div>
)}
</div>
</div>
);
}
export {
LabelLayout,
type LabelLayoutProps,
type LabelSizePreset,
type LabelAuxIcon,
};

View File

@@ -14,6 +14,7 @@ import {
QwenIcon,
OllamaIcon,
LMStudioIcon,
LiteLLMIcon,
ZAIIcon,
} from "@/components/icons/icons";
import {
@@ -21,12 +22,14 @@ import {
OpenRouterModelResponse,
BedrockModelResponse,
LMStudioModelResponse,
LiteLLMProxyModelResponse,
ModelConfiguration,
LLMProviderName,
BedrockFetchParams,
OllamaFetchParams,
LMStudioFetchParams,
OpenRouterFetchParams,
LiteLLMProxyFetchParams,
} from "@/interfaces/llm";
import { SvgAws, SvgOpenrouter } from "@opal/icons";
@@ -37,6 +40,7 @@ export const AGGREGATOR_PROVIDERS = new Set([
"openrouter",
"ollama_chat",
"lm_studio",
"litellm_proxy",
"vertex_ai",
]);
@@ -73,6 +77,7 @@ export const getProviderIcon = (
bedrock: SvgAws,
bedrock_converse: SvgAws,
openrouter: SvgOpenrouter,
litellm_proxy: LiteLLMIcon,
vertex_ai: GeminiIcon,
};
@@ -338,6 +343,65 @@ export const fetchLMStudioModels = async (
}
};
/**
* Fetches LiteLLM Proxy models directly without any form state dependencies.
* Uses snake_case params to match API structure.
*/
export const fetchLiteLLMProxyModels = async (
params: LiteLLMProxyFetchParams
): Promise<{ models: ModelConfiguration[]; error?: string }> => {
const apiBase = params.api_base;
const apiKey = params.api_key;
if (!apiBase) {
return { models: [], error: "API Base is required" };
}
if (!apiKey) {
return { models: [], error: "API Key is required" };
}
try {
const response = await fetch("/api/admin/llm/litellm/available-models", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
api_base: apiBase,
api_key: apiKey,
provider_name: params.provider_name,
}),
signal: params.signal,
});
if (!response.ok) {
let errorMessage = "Failed to fetch models";
try {
const errorData = await response.json();
errorMessage = errorData.detail || errorData.message || errorMessage;
} catch {
// ignore JSON parsing errors
}
return { models: [], error: errorMessage };
}
const data: LiteLLMProxyModelResponse[] = await response.json();
const models: ModelConfiguration[] = data.map((modelData) => ({
name: modelData.model_name,
display_name: modelData.model_name,
is_visible: true,
max_input_tokens: null,
supports_image_input: false,
supports_reasoning: false,
}));
return { models };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
return { models: [], error: errorMessage };
}
};
/**
* Fetches models for a provider. Accepts form values directly and maps them
* to the expected fetch params format internally.
@@ -385,6 +449,13 @@ export const fetchModels = async (
api_key: formValues.api_key,
provider_name: formValues.name,
});
case LLMProviderName.LITELLM_PROXY:
return fetchLiteLLMProxyModels({
api_base: formValues.api_base,
api_key: formValues.api_key,
provider_name: formValues.name,
signal,
});
default:
return { models: [], error: `Unknown provider: ${providerName}` };
}
@@ -397,6 +468,7 @@ export function canProviderFetchModels(providerName?: string) {
case LLMProviderName.OLLAMA_CHAT:
case LLMProviderName.LM_STUDIO:
case LLMProviderName.OPENROUTER:
case LLMProviderName.LITELLM_PROXY:
return true;
default:
return false;

View File

@@ -0,0 +1 @@
export { default } from "@/refresh-pages/admin/UsersPage";

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { ReactNode, useState } from "react";
import { cn } from "@/lib/utils";
import { ChatFileType, FileDescriptor } from "@/app/app/interfaces";
import Attachment from "@/refresh-components/Attachment";
import { InMessageImage } from "@/app/app/components/files/images/InMessageImage";
@@ -9,10 +10,27 @@ import PreviewModal from "@/sections/modals/PreviewModal";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import ExpandableContentWrapper from "@/components/tools/ExpandableContentWrapper";
interface FileContainerProps {
children: ReactNode;
className?: string;
id?: string;
}
interface FileDisplayProps {
files: FileDescriptor[];
}
function FileContainer({ children, className, id }: FileContainerProps) {
return (
<div
id={id}
className={cn("flex w-full flex-col items-end gap-2 py-2", className)}
>
{children}
</div>
);
}
export default function FileDisplay({ files }: FileDisplayProps) {
const [close, setClose] = useState(true);
const [previewingFile, setPreviewingFile] = useState<FileDescriptor | null>(
@@ -41,7 +59,7 @@ export default function FileDisplay({ files }: FileDisplayProps) {
)}
{textFiles.length > 0 && (
<div id="onyx-file" className="flex flex-col items-end gap-2 py-2">
<FileContainer id="onyx-file">
{textFiles.map((file) => (
<Attachment
key={file.id}
@@ -49,40 +67,36 @@ export default function FileDisplay({ files }: FileDisplayProps) {
open={() => setPreviewingFile(file)}
/>
))}
</div>
</FileContainer>
)}
{imageFiles.length > 0 && (
<div id="onyx-image" className="flex flex-col items-end gap-2 py-2">
<FileContainer id="onyx-image">
{imageFiles.map((file) => (
<InMessageImage key={file.id} fileId={file.id} />
))}
</div>
</FileContainer>
)}
{csvFiles.length > 0 && (
<div className="flex flex-col items-end gap-2 py-2">
{csvFiles.map((file) => {
return (
<div key={file.id} className="w-fit">
{close ? (
<>
<ExpandableContentWrapper
fileDescriptor={file}
close={() => setClose(false)}
ContentComponent={CsvContent}
/>
</>
) : (
<Attachment
open={() => setClose(true)}
fileName={file.name || file.id}
/>
)}
</div>
);
})}
</div>
<FileContainer className="overflow-auto">
{csvFiles.map((file) =>
close ? (
<ExpandableContentWrapper
key={file.id}
fileDescriptor={file}
close={() => setClose(false)}
ContentComponent={CsvContent}
/>
) : (
<Attachment
key={file.id}
open={() => setClose(true)}
fileName={file.name || file.id}
/>
)
)}
</FileContainer>
)}
</>
);

View File

@@ -249,6 +249,7 @@ export default function MessageToolbar({
<SelectButton
icon={SvgThumbsUp}
onClick={() => handleFeedbackClick("like")}
variant="select-light"
state={isFeedbackTransient("like") ? "selected" : "empty"}
tooltip={
currentFeedback === "like" ? "Remove Like" : "Good Response"
@@ -258,6 +259,7 @@ export default function MessageToolbar({
<SelectButton
icon={SvgThumbsDown}
onClick={() => handleFeedbackClick("dislike")}
variant="select-light"
state={isFeedbackTransient("dislike") ? "selected" : "empty"}
tooltip={
currentFeedback === "dislike"
@@ -283,7 +285,7 @@ export default function MessageToolbar({
});
regenerator(llmDescriptor);
}}
folded
foldable
/>
</div>
)}

View File

@@ -98,7 +98,7 @@ export default function ArtifactsTab({
const handleWebappDownload = () => {
if (!sessionId) return;
const link = document.createElement("a");
link.href = `/api/build/sessions/${sessionId}/webapp/download`;
link.href = `/api/build/sessions/${sessionId}/webapp-download`;
link.download = "";
document.body.appendChild(link);
link.click();

View File

@@ -11,7 +11,7 @@ import { Button } from "@opal/components";
import { SvgBubbleText, SvgSearchMenu, SvgSidebar } from "@opal/icons";
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import { useSettingsContext } from "@/providers/SettingsProvider";
import { AppMode, useAppMode } from "@/providers/AppModeProvider";
import type { AppMode } from "@/providers/QueryControllerProvider";
import useAppFocus from "@/hooks/useAppFocus";
import { useQueryController } from "@/providers/QueryControllerProvider";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
@@ -58,15 +58,15 @@ const footerMarkdownComponents = {
*/
export default function NRFChrome() {
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
const { appMode, setAppMode } = useAppMode();
const { state, setAppMode } = useQueryController();
const settings = useSettingsContext();
const { isMobile } = useScreenSize();
const { setFolded } = useAppSidebarContext();
const appFocus = useAppFocus();
const { classification } = useQueryController();
const [modePopoverOpen, setModePopoverOpen] = useState(false);
const effectiveMode: AppMode = appFocus.isNewSession() ? appMode : "chat";
const effectiveMode: AppMode =
appFocus.isNewSession() && state.phase === "idle" ? state.appMode : "chat";
const customFooterContent =
settings?.enterpriseSettings?.custom_lower_disclaimer_content ||
@@ -78,7 +78,7 @@ export default function NRFChrome() {
isPaidEnterpriseFeaturesEnabled &&
settings.isSearchModeAvailable &&
appFocus.isNewSession() &&
!classification;
state.phase === "idle";
const showHeader = isMobile || showModeToggle;

View File

@@ -175,7 +175,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
const isStreaming = currentChatState === "streaming";
// Query controller for search/chat classification (EE feature)
const { submit: submitQuery, classification } = useQueryController();
const { submit: submitQuery, state } = useQueryController();
// Determine if retrieval (search) is enabled based on the agent
const retrievalEnabled = useMemo(() => {
@@ -186,7 +186,8 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
}, [liveAgent]);
// Check if we're in search mode
const isSearch = classification === "search";
const isSearch =
state.phase === "searching" || state.phase === "search-results";
// Anchor for scroll positioning (matches ChatPage pattern)
const anchorMessage = messageHistory.at(-2) ?? messageHistory[0];
@@ -317,7 +318,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
};
// Use submitQuery which will classify the query and either:
// - Route to search (sets classification to "search" and shows SearchUI)
// - Route to search (sets phase to "searching"/"search-results" and shows SearchUI)
// - Route to chat (calls onChat callback)
await submitQuery(submittedMessage, onChat);
},

View File

@@ -31,6 +31,7 @@ const SETTINGS_LAYOUT_PREFIXES = [
ADMIN_PATHS.LLM_MODELS,
ADMIN_PATHS.AGENTS,
ADMIN_PATHS.USERS,
ADMIN_PATHS.USERS_V2,
ADMIN_PATHS.TOKEN_RATE_LIMITS,
ADMIN_PATHS.SEARCH_SETTINGS,
ADMIN_PATHS.DOCUMENT_PROCESSING,

View File

@@ -60,27 +60,28 @@ const CsvContent: React.FC<ContentComponentProps> = ({
}
const csvData = await response.text();
const rows = csvData.trim().split("\n");
const rows = parseCSV(csvData.trim());
const firstRow = rows[0];
if (!firstRow) {
throw new Error("CSV file is empty");
}
const parsedHeaders = firstRow.split(",");
const parsedHeaders = firstRow;
setHeaders(parsedHeaders);
const parsedData: Record<string, string>[] = rows.slice(1).map((row) => {
const values = row.split(",");
return parsedHeaders.reduce<Record<string, string>>(
(obj, header, index) => {
const val = values[index];
if (val !== undefined) {
obj[header] = val;
}
return obj;
},
{}
);
});
const parsedData: Record<string, string>[] = rows
.slice(1)
.map((fields) => {
return parsedHeaders.reduce<Record<string, string>>(
(obj, header, index) => {
const val = fields[index];
if (val !== undefined) {
obj[header] = val;
}
return obj;
},
{}
);
});
setData(parsedData);
csvCache.set(id, { headers: parsedHeaders, data: parsedData });
} catch (error) {
@@ -173,3 +174,53 @@ const csvCache = new Map<
string,
{ headers: string[]; data: Record<string, string>[] }
>();
export function parseCSV(text: string): string[][] {
const rows: string[][] = [];
let field = "";
let fields: string[] = [];
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (inQuotes) {
if (char === '"') {
if (i + 1 < text.length && text[i + 1] === '"') {
field += '"';
i++;
} else {
inQuotes = false;
}
} else {
field += char;
}
} else if (char === '"') {
inQuotes = true;
} else if (char === ",") {
fields.push(field);
field = "";
} else if (char === "\n" || char === "\r") {
if (char === "\r" && i + 1 < text.length && text[i + 1] === "\n") {
i++;
}
fields.push(field);
field = "";
rows.push(fields);
fields = [];
} else {
field += char;
}
}
if (inQuotes) {
throw new Error("Malformed CSV: unterminated quoted field");
}
if (field.length > 0 || fields.length > 0) {
fields.push(field);
rows.push(fields);
}
return rows;
}

View File

@@ -40,12 +40,7 @@ export default function ExpandableContentWrapper({
};
const Content = (
<div
className={cn(
!expanded ? "w-message-default" : "w-full",
"!rounded !rounded-lg overflow-y-hidden h-full"
)}
>
<div className="w-message-default max-w-full !rounded-lg overflow-y-hidden h-full">
<CardHeader className="w-full bg-background-tint-02 top-0 p-3">
<div className="flex justify-between items-center">
<Text className="text-ellipsis line-clamp-1" text03 mainUiAction>
@@ -83,12 +78,10 @@ export default function ExpandableContentWrapper({
)}
>
<CardContent className="p-0">
{!expanded && (
<ContentComponent
fileDescriptor={fileDescriptor}
expanded={expanded}
/>
)}
<ContentComponent
fileDescriptor={fileDescriptor}
expanded={expanded}
/>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,84 @@
import { parseCSV } from "./CSVContent";
describe("parseCSV", () => {
it("parses simple comma-separated rows", () => {
expect(parseCSV("a,b,c\n1,2,3")).toEqual([
["a", "b", "c"],
["1", "2", "3"],
]);
});
it("preserves commas inside quoted fields", () => {
expect(parseCSV('name,address\nAlice,"123 Main St, Apt 4"')).toEqual([
["name", "address"],
["Alice", "123 Main St, Apt 4"],
]);
});
it("handles escaped double quotes inside quoted fields", () => {
expect(parseCSV('a,b\n"say ""hello""",world')).toEqual([
["a", "b"],
['say "hello"', "world"],
]);
});
it("handles newlines inside quoted fields", () => {
expect(parseCSV('a,b\n"line1\nline2",val')).toEqual([
["a", "b"],
["line1\nline2", "val"],
]);
});
it("handles CRLF line endings", () => {
expect(parseCSV("a,b\r\n1,2\r\n3,4")).toEqual([
["a", "b"],
["1", "2"],
["3", "4"],
]);
});
it("handles empty fields", () => {
expect(parseCSV("a,b,c\n1,,3")).toEqual([
["a", "b", "c"],
["1", "", "3"],
]);
});
it("handles a single element", () => {
expect(parseCSV("a")).toEqual([["a"]]);
});
it("handles a single row with no newline", () => {
expect(parseCSV("a,b,c")).toEqual([["a", "b", "c"]]);
});
it("handles quoted fields that are entirely empty", () => {
expect(parseCSV('a,b\n"",val')).toEqual([
["a", "b"],
["", "val"],
]);
});
it("handles multiple quoted fields with commas", () => {
expect(parseCSV('"foo, bar","baz, qux"\n"1, 2","3, 4"')).toEqual([
["foo, bar", "baz, qux"],
["1, 2", "3, 4"],
]);
});
it("throws on unterminated quoted field", () => {
expect(() => parseCSV('a,b\n"foo,bar')).toThrow(
"Malformed CSV: unterminated quoted field"
);
});
it("throws on unterminated quote at end of input", () => {
expect(() => parseCSV('"unterminated')).toThrow(
"Malformed CSV: unterminated quoted field"
);
});
it("returns empty array for empty input", () => {
expect(parseCSV("")).toEqual([]);
});
});

View File

@@ -1,55 +0,0 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { AppModeContext, AppMode } from "@/providers/AppModeProvider";
import { useUser } from "@/providers/UserProvider";
import { useSettingsContext } from "@/providers/SettingsProvider";
export interface AppModeProviderProps {
children: React.ReactNode;
}
/**
* Provider for application mode (Search/Chat).
*
* This controls how user queries are handled:
* - **search**: Forces search mode - quick document lookup
* - **chat**: Forces chat mode - conversation with follow-up questions
*
* The initial mode is read from the user's persisted `default_app_mode` preference.
* When search mode is unavailable (admin setting or no connectors), the mode is locked to "chat".
*/
export function AppModeProvider({ children }: AppModeProviderProps) {
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
const { user } = useUser();
const { isSearchModeAvailable } = useSettingsContext();
const persistedMode = user?.preferences?.default_app_mode;
const [appMode, setAppModeState] = useState<AppMode>("chat");
useEffect(() => {
if (!isPaidEnterpriseFeaturesEnabled || !isSearchModeAvailable) {
setAppModeState("chat");
return;
}
if (persistedMode) {
setAppModeState(persistedMode.toLowerCase() as AppMode);
}
}, [isPaidEnterpriseFeaturesEnabled, isSearchModeAvailable, persistedMode]);
const setAppMode = useCallback(
(mode: AppMode) => {
if (!isPaidEnterpriseFeaturesEnabled || !isSearchModeAvailable) return;
setAppModeState(mode);
},
[isPaidEnterpriseFeaturesEnabled, isSearchModeAvailable]
);
return (
<AppModeContext.Provider value={{ appMode, setAppMode }}>
{children}
</AppModeContext.Provider>
);
}

View File

@@ -8,14 +8,15 @@ import {
SearchFullResponse,
} from "@/lib/search/interfaces";
import { classifyQuery, searchDocuments } from "@/ee/lib/search/svc";
import { useAppMode } from "@/providers/AppModeProvider";
import useAppFocus from "@/hooks/useAppFocus";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { useSettingsContext } from "@/providers/SettingsProvider";
import { useUser } from "@/providers/UserProvider";
import {
QueryControllerContext,
QueryClassification,
QueryControllerValue,
QueryState,
AppMode,
} from "@/providers/QueryControllerProvider";
interface QueryControllerProviderProps {
@@ -25,19 +26,53 @@ interface QueryControllerProviderProps {
export function QueryControllerProvider({
children,
}: QueryControllerProviderProps) {
const { appMode, setAppMode } = useAppMode();
const appFocus = useAppFocus();
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
const settings = useSettingsContext();
const { isSearchModeAvailable: searchUiEnabled } = settings;
const { user } = useUser();
// Query state
// ── Merged query state (discriminated union) ──────────────────────────
const [state, setState] = useState<QueryState>({
phase: "idle",
appMode: "chat",
});
// Persistent app-mode preference — survives phase transitions and is
// used to restore the correct mode when resetting back to idle.
const appModeRef = useRef<AppMode>("chat");
// ── App mode sync from user preferences ───────────────────────────────
const persistedMode = user?.preferences?.default_app_mode;
useEffect(() => {
let mode: AppMode = "chat";
if (isPaidEnterpriseFeaturesEnabled && searchUiEnabled && persistedMode) {
const lower = persistedMode.toLowerCase();
mode = (["auto", "search", "chat"] as const).includes(lower as AppMode)
? (lower as AppMode)
: "chat";
}
appModeRef.current = mode;
setState((prev) =>
prev.phase === "idle" ? { phase: "idle", appMode: mode } : prev
);
}, [isPaidEnterpriseFeaturesEnabled, searchUiEnabled, persistedMode]);
const setAppMode = useCallback(
(mode: AppMode) => {
if (!isPaidEnterpriseFeaturesEnabled || !searchUiEnabled) return;
setState((prev) => {
if (prev.phase !== "idle") return prev;
appModeRef.current = mode;
return { phase: "idle", appMode: mode };
});
},
[isPaidEnterpriseFeaturesEnabled, searchUiEnabled]
);
// ── Ancillary state ───────────────────────────────────────────────────
const [query, setQuery] = useState<string | null>(null);
const [classification, setClassification] =
useState<QueryClassification>(null);
const [isClassifying, setIsClassifying] = useState(false);
// Search state
const [searchResults, setSearchResults] = useState<SearchDocWithContent[]>(
[]
);
@@ -51,7 +86,7 @@ export function QueryControllerProvider({
const searchAbortRef = useRef<AbortController | null>(null);
/**
* Perform document search
* Perform document search (pure data-fetching, no phase side effects)
*/
const performSearch = useCallback(
async (searchQuery: string, filters?: BaseFilters): Promise<void> => {
@@ -85,19 +120,15 @@ export function QueryControllerProvider({
setLlmSelectedDocIds(response.llm_selected_doc_ids ?? null);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
return;
throw err;
}
setError("Document search failed. Please try again.");
setSearchResults([]);
setLlmSelectedDocIds(null);
} finally {
// After we've performed a search, we automatically switch to "search" mode.
// This is a "sticky" implementation; on purpose.
setAppMode("search");
}
},
[setAppMode]
[]
);
/**
@@ -112,8 +143,6 @@ export function QueryControllerProvider({
const controller = new AbortController();
classifyAbortRef.current = controller;
setIsClassifying(true);
try {
const response: SearchFlowClassificationResponse = await classifyQuery(
classifyQueryText,
@@ -129,8 +158,6 @@ export function QueryControllerProvider({
setError("Query classification failed. Falling back to chat.");
return "chat";
} finally {
setIsClassifying(false);
}
},
[]
@@ -148,62 +175,51 @@ export function QueryControllerProvider({
setQuery(submitQuery);
setError(null);
// 1.
// We always route through chat if we're not Enterprise Enabled.
//
// 2.
// We always route through chat if the admin has disabled the Search UI.
//
// 3.
// We only go down the classification route if we're in the "New Session" tab.
// Everywhere else, we always use the chat-flow.
//
// 4.
// If we're in the "New Session" tab and the app-mode is "Chat", we continue with the chat-flow anyways.
const currentAppMode = appModeRef.current;
// Always route through chat if:
// 1. Not Enterprise Enabled
// 2. Admin has disabled the Search UI
// 3. Not in the "New Session" tab
// 4. In "New Session" tab but app-mode is "Chat"
if (
!isPaidEnterpriseFeaturesEnabled ||
!searchUiEnabled ||
!appFocus.isNewSession() ||
appMode === "chat"
currentAppMode === "chat"
) {
setClassification("chat");
setState({ phase: "chat" });
setSearchResults([]);
setLlmSelectedDocIds(null);
onChat(submitQuery);
return;
}
if (appMode === "search") {
await performSearch(submitQuery, filters);
setClassification("search");
// Search mode: immediately show SearchUI with loading state
if (currentAppMode === "search") {
setState({ phase: "searching" });
try {
await performSearch(submitQuery, filters);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") return;
throw err;
}
setState({ phase: "search-results" });
return;
}
// # Note (@raunakab)
//
// Interestingly enough, for search, we do:
// 1. setClassification("search")
// 2. performSearch
//
// But for chat, we do:
// 1. performChat
// 2. setClassification("chat")
//
// The ChatUI has a nice loading UI, so it's fine for us to prematurely set the
// classification-state before the chat has finished loading.
//
// However, the SearchUI does not. Prematurely setting the classification-state
// will lead to a slightly ugly UI.
// Auto mode: classify first, then route
setState({ phase: "classifying" });
try {
const result = await performClassification(submitQuery);
if (result === "search") {
setState({ phase: "searching" });
await performSearch(submitQuery, filters);
setClassification("search");
setState({ phase: "search-results" });
appModeRef.current = "search";
} else {
setClassification("chat");
setState({ phase: "chat" });
setSearchResults([]);
setLlmSelectedDocIds(null);
onChat(submitQuery);
@@ -213,14 +229,13 @@ export function QueryControllerProvider({
return;
}
setClassification("chat");
setState({ phase: "chat" });
setSearchResults([]);
setLlmSelectedDocIds(null);
onChat(submitQuery);
}
},
[
appMode,
appFocus,
performClassification,
performSearch,
@@ -235,7 +250,14 @@ export function QueryControllerProvider({
const refineSearch = useCallback(
async (filters: BaseFilters): Promise<void> => {
if (!query) return;
await performSearch(query, filters);
setState({ phase: "searching" });
try {
await performSearch(query, filters);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") return;
throw err;
}
setState({ phase: "search-results" });
},
[query, performSearch]
);
@@ -254,7 +276,7 @@ export function QueryControllerProvider({
}
setQuery(null);
setClassification(null);
setState({ phase: "idle", appMode: appModeRef.current });
setSearchResults([]);
setLlmSelectedDocIds(null);
setError(null);
@@ -262,8 +284,8 @@ export function QueryControllerProvider({
const value: QueryControllerValue = useMemo(
() => ({
classification,
isClassifying,
state,
setAppMode,
searchResults,
llmSelectedDocIds,
error,
@@ -272,8 +294,8 @@ export function QueryControllerProvider({
reset,
}),
[
classification,
isClassifying,
state,
setAppMode,
searchResults,
llmSelectedDocIds,
error,
@@ -283,7 +305,7 @@ export function QueryControllerProvider({
]
);
// Sync classification state with navigation context
// Sync state with navigation context
useEffect(reset, [appFocus, reset]);
return (

View File

@@ -56,7 +56,7 @@ export default function SearchCard({
return (
<Interactive.Stateless onClick={handleClick} prominence="secondary">
<Interactive.Container heightVariant="fit">
<Interactive.Container heightVariant="fit" widthVariant="full">
<Section alignItems="start" gap={0} padding={0.25}>
{/* Title Row */}
<Section

View File

@@ -18,16 +18,17 @@ import { getTimeFilterDate, TimeFilter } from "@/lib/time";
import useTags from "@/hooks/useTags";
import { SourceIcon } from "@/components/SourceIcon";
import Text from "@/refresh-components/texts/Text";
import LineItem from "@/refresh-components/buttons/LineItem";
import { Section } from "@/layouts/general-layouts";
import Popover, { PopoverMenu } from "@/refresh-components/Popover";
import { SvgCheck, SvgClock, SvgTag } from "@opal/icons";
import FilterButton from "@/refresh-components/buttons/FilterButton";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import useFilter from "@/hooks/useFilter";
import { LineItemButton } from "@opal/components";
import { useQueryController } from "@/providers/QueryControllerProvider";
import { cn } from "@/lib/utils";
import { toast } from "@/hooks/useToast";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
// ============================================================================
// Types
@@ -51,22 +52,17 @@ const TIME_FILTER_OPTIONS: { value: TimeFilter; label: string }[] = [
{ value: "year", label: "Past year" },
];
// ============================================================================
// SearchResults Component (default export)
// ============================================================================
/**
* Component for displaying search results with source filter sidebar.
*/
export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
// Available tags from backend
const { tags: availableTags } = useTags();
const {
state,
searchResults: results,
llmSelectedDocIds,
error,
refineSearch: onRefineSearch,
} = useQueryController();
const prevErrorRef = useRef<string | null>(null);
// Show a toast notification when a new error occurs
@@ -197,6 +193,15 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
const showEmpty = !error && results.length === 0;
// Show a centered spinner while search is in-flight (after all hooks)
if (state.phase === "searching") {
return (
<div className="flex-1 min-h-0 w-full flex items-center justify-center">
<SimpleLoader />
</div>
);
}
return (
<div className="flex-1 min-h-0 w-full flex flex-col gap-3">
{/* ── Top row: Filters + Result count ── */}
@@ -226,18 +231,19 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
<Popover.Content align="start" width="md">
<PopoverMenu>
{TIME_FILTER_OPTIONS.map((opt) => (
<LineItem
<LineItemButton
key={opt.value}
onClick={() => {
setTimeFilter(opt.value);
setTimeFilterOpen(false);
onRefineSearch(buildFilters({ time: opt.value }));
}}
selected={timeFilter === opt.value}
state={timeFilter === opt.value ? "selected" : "empty"}
icon={timeFilter === opt.value ? SvgCheck : SvgClock}
>
{opt.label}
</LineItem>
title={opt.label}
sizePreset="main-ui"
variant="section"
/>
))}
</PopoverMenu>
</Popover.Content>
@@ -278,7 +284,7 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
t.tag_value === tag.tag_value
);
return (
<LineItem
<LineItemButton
key={`${tag.tag_key}=${tag.tag_value}`}
onClick={() => {
const next = isSelected
@@ -291,11 +297,12 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
setSelectedTags(next);
onRefineSearch(buildFilters({ tags: next }));
}}
selected={isSelected}
state={isSelected ? "selected" : "empty"}
icon={isSelected ? SvgCheck : SvgTag}
>
{tag.tag_value}
</LineItem>
title={tag.tag_value}
sizePreset="main-ui"
variant="section"
/>
);
})}
</PopoverMenu>
@@ -357,7 +364,7 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-4 px-1">
<Section gap={0.25} height="fit">
{sourcesWithMeta.map(({ source, meta, count }) => (
<LineItem
<LineItemButton
key={source}
icon={(props) => (
<SourceIcon
@@ -367,12 +374,15 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
/>
)}
onClick={() => handleSourceToggle(source)}
selected={selectedSources.includes(source)}
emphasized
state={
selectedSources.includes(source) ? "selected" : "empty"
}
title={meta.displayName}
selectVariant="select-heavy"
sizePreset="main-ui"
variant="section"
rightChildren={<Text text03>{count}</Text>}
>
{meta.displayName}
</LineItem>
/>
))}
</Section>
</div>

View File

@@ -0,0 +1,122 @@
"use client";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
import { UserStatus } from "@/lib/types";
import type { UserRole, InvitedUserSnapshot } from "@/lib/types";
import type {
UserRow,
UserGroupInfo,
} from "@/refresh-pages/admin/UsersPage/interfaces";
// ---------------------------------------------------------------------------
// Backend response shape (GET /manage/users/accepted/all)
// ---------------------------------------------------------------------------
interface FullUserSnapshot {
id: string;
email: string;
role: UserRole;
is_active: boolean;
password_configured: boolean;
personal_name: string | null;
created_at: string;
updated_at: string;
groups: UserGroupInfo[];
is_scim_synced: boolean;
}
// ---------------------------------------------------------------------------
// Converters
// ---------------------------------------------------------------------------
function toUserRow(snapshot: FullUserSnapshot): UserRow {
return {
id: snapshot.id,
email: snapshot.email,
role: snapshot.role,
status: snapshot.is_active ? UserStatus.ACTIVE : UserStatus.INACTIVE,
is_active: snapshot.is_active,
is_scim_synced: snapshot.is_scim_synced,
personal_name: snapshot.personal_name,
created_at: snapshot.created_at,
updated_at: snapshot.updated_at,
groups: snapshot.groups,
};
}
function emailToUserRow(
email: string,
status: UserStatus.INVITED | UserStatus.REQUESTED
): UserRow {
return {
id: null,
email,
role: null,
status,
is_active: false,
is_scim_synced: false,
personal_name: null,
created_at: null,
updated_at: null,
groups: [],
};
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export default function useAdminUsers() {
const {
data: acceptedData,
isLoading: acceptedLoading,
error: acceptedError,
mutate: acceptedMutate,
} = useSWR<FullUserSnapshot[]>(
"/api/manage/users/accepted/all",
errorHandlingFetcher
);
const {
data: invitedData,
isLoading: invitedLoading,
error: invitedError,
mutate: invitedMutate,
} = useSWR<InvitedUserSnapshot[]>(
"/api/manage/users/invited",
errorHandlingFetcher
);
const {
data: requestedData,
isLoading: requestedLoading,
error: requestedError,
mutate: requestedMutate,
} = useSWR<InvitedUserSnapshot[]>(
NEXT_PUBLIC_CLOUD_ENABLED ? "/api/tenants/users/pending" : null,
errorHandlingFetcher
);
const acceptedRows = (acceptedData ?? []).map(toUserRow);
const invitedRows = (invitedData ?? []).map((u) =>
emailToUserRow(u.email, UserStatus.INVITED)
);
const requestedRows = (requestedData ?? []).map((u) =>
emailToUserRow(u.email, UserStatus.REQUESTED)
);
const users = [...invitedRows, ...requestedRows, ...acceptedRows];
const isLoading = acceptedLoading || invitedLoading || requestedLoading;
const error = acceptedError ?? invitedError ?? requestedError;
function refresh() {
acceptedMutate();
invitedMutate();
requestedMutate();
}
return { users, isLoading, error, refresh };
}

View File

@@ -5,6 +5,7 @@
//
// This is useful in determining what `SidebarTab` should be active, for example.
import { useMemo } from "react";
import { SEARCH_PARAM_NAMES } from "@/app/app/services/searchParams";
import { usePathname, useSearchParams } from "next/navigation";
@@ -66,31 +67,25 @@ export default function useAppFocus(): AppFocus {
const pathname = usePathname();
const searchParams = useSearchParams();
// Check if we're viewing a shared chat
if (pathname.startsWith("/app/shared/")) {
return new AppFocus("shared-chat");
}
// Check if we're on the user settings page
if (pathname.startsWith("/app/settings")) {
return new AppFocus("user-settings");
}
// Check if we're on the agents page
if (pathname.startsWith("/app/agents")) {
return new AppFocus("more-agents");
}
// Check search params for chat, agent, or project
const chatId = searchParams.get(SEARCH_PARAM_NAMES.CHAT_ID);
if (chatId) return new AppFocus({ type: "chat", id: chatId });
const agentId = searchParams.get(SEARCH_PARAM_NAMES.PERSONA_ID);
if (agentId) return new AppFocus({ type: "agent", id: agentId });
const projectId = searchParams.get(SEARCH_PARAM_NAMES.PROJECT_ID);
if (projectId) return new AppFocus({ type: "project", id: projectId });
// No search params means we're on a new session
return new AppFocus("new-session");
// Memoize on the values that determine which AppFocus is constructed.
// AppFocus is immutable, so same inputs → same instance.
return useMemo(() => {
if (pathname.startsWith("/app/shared/")) {
return new AppFocus("shared-chat");
}
if (pathname.startsWith("/app/settings")) {
return new AppFocus("user-settings");
}
if (pathname.startsWith("/app/agents")) {
return new AppFocus("more-agents");
}
if (chatId) return new AppFocus({ type: "chat", id: chatId });
if (agentId) return new AppFocus({ type: "agent", id: agentId });
if (projectId) return new AppFocus({ type: "project", id: projectId });
return new AppFocus("new-session");
}, [pathname, chatId, agentId, projectId]);
}

View File

@@ -38,7 +38,7 @@ function measure(el: HTMLElement): { x: number; y: number } | null {
*/
export default function useContainerCenter(): ContainerCenter {
const pathname = usePathname();
const { isSmallScreen } = useScreenSize();
const { isMediumScreen } = useScreenSize();
const [center, setCenter] = useState<{ x: number | null; y: number | null }>(
() => {
if (typeof document === "undefined") return NULL_CENTER;
@@ -68,9 +68,9 @@ export default function useContainerCenter(): ContainerCenter {
}, [pathname]);
return {
centerX: isSmallScreen ? null : center.x,
centerY: isSmallScreen ? null : center.y,
hasContainerCenter: isSmallScreen
centerX: isMediumScreen ? null : center.x,
centerY: isMediumScreen ? null : center.y,
hasContainerCenter: isMediumScreen
? false
: center.x !== null && center.y !== null,
};

View File

@@ -2,6 +2,7 @@
import {
DESKTOP_SMALL_BREAKPOINT_PX,
DESKTOP_MEDIUM_BREAKPOINT_PX,
MOBILE_SIDEBAR_BREAKPOINT_PX,
} from "@/lib/constants";
import { useState, useCallback } from "react";
@@ -12,6 +13,7 @@ export interface ScreenSize {
width: number;
isMobile: boolean;
isSmallScreen: boolean;
isMediumScreen: boolean;
}
export default function useScreenSize(): ScreenSize {
@@ -34,11 +36,13 @@ export default function useScreenSize(): ScreenSize {
const isMobile = sizes.width <= MOBILE_SIDEBAR_BREAKPOINT_PX;
const isSmall = sizes.width <= DESKTOP_SMALL_BREAKPOINT_PX;
const isMedium = sizes.width <= DESKTOP_MEDIUM_BREAKPOINT_PX;
return {
height: sizes.height,
width: sizes.width,
isMobile: isMounted && isMobile,
isSmallScreen: isMounted && isSmall,
isMediumScreen: isMounted && isMedium,
};
}

View File

@@ -0,0 +1,56 @@
"use client";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import type { InvitedUserSnapshot } from "@/lib/types";
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
import type { StatusCountMap } from "@/refresh-pages/admin/UsersPage/interfaces";
type UserCountsResponse = {
role_counts: Record<string, number>;
status_counts: Record<string, number>;
};
type UserCounts = {
activeCount: number | null;
invitedCount: number | null;
pendingCount: number | null;
roleCounts: Record<string, number>;
statusCounts: StatusCountMap;
refreshCounts: () => void;
};
export default function useUserCounts(): UserCounts {
const { data: countsData, mutate: refreshCounts } =
useSWR<UserCountsResponse>(
"/api/manage/users/counts",
errorHandlingFetcher
);
const { data: invitedUsers } = useSWR<InvitedUserSnapshot[]>(
"/api/manage/users/invited",
errorHandlingFetcher
);
const { data: pendingUsers } = useSWR<InvitedUserSnapshot[]>(
NEXT_PUBLIC_CLOUD_ENABLED ? "/api/tenants/users/pending" : null,
errorHandlingFetcher
);
const activeCount = countsData?.status_counts?.active ?? null;
const inactiveCount = countsData?.status_counts?.inactive ?? null;
return {
activeCount,
invitedCount: invitedUsers?.length ?? null,
pendingCount: pendingUsers?.length ?? null,
roleCounts: countsData?.role_counts ?? {},
statusCounts: {
...(activeCount !== null ? { active: activeCount } : {}),
...(inactiveCount !== null ? { inactive: inactiveCount } : {}),
...(invitedUsers ? { invited: invitedUsers.length } : {}),
...(pendingUsers ? { requested: pendingUsers.length } : {}),
} satisfies StatusCountMap,
refreshCounts,
};
}

Some files were not shown because too many files have changed in this diff Show More