Compare commits

..

24 Commits

Author SHA1 Message Date
Raunak Bhagat
1812849238 Merge branch 'main' into refactor/table-footer 2026-03-16 17:13:57 -07:00
Raunak Bhagat
8f24a5ea53 Merge branch 'main' into refactor/table-footer 2026-03-16 16:51:28 -07:00
Raunak Bhagat
ffcbb3134c Merge main 2026-03-16 16:25:01 -07:00
Raunak Bhagat
0807b8cff4 refactor: clean up Footer pagination usage
- Use singular/plural "item"/"items" for count variant units
- Limit pagination sizes to md/lg only
- Remove unused size prop from FooterSelectionModeProps
- Remove unused TableSize type import
2026-03-16 15:55:53 -07:00
Raunak Bhagat
886ae70fad chore: init refactor/table-footer branch 2026-03-16 15:55:04 -07:00
Raunak Bhagat
5d2dd6e7b8 refactor: rename showPages to hidePages (inverted default)
Users must now explicitly opt out of showing pages with hidePages,
rather than opt in with showPages. Default behavior unchanged.
2026-03-16 15:54:53 -07:00
Raunak Bhagat
bcd64cc2f6 fix: use sizeVariants.lg.height for go-to-page input 2026-03-16 15:39:18 -07:00
Raunak Bhagat
f1eeb9e372 refactor: unify pagination callbacks to onChange
Consolidate onArrowClick and onPageClick into a single onChange
callback across all variants. Fix React.ReactNode import in Footer.
2026-03-16 15:33:17 -07:00
Raunak Bhagat
aed3db2322 fix: remove unused ELLIPSIS_SIZE constant 2026-03-16 15:14:14 -07:00
Raunak Bhagat
7fe753c0ce fix: remove stale goto prop from README documentation 2026-03-16 15:02:50 -07:00
Raunak Bhagat
375da7aaa6 fix: make go-to-page popover size-independent and polish styling
- Remove size prop from GoToPagePopup so it renders consistently
- Fixed input height (36px), width (7rem), rounded-08, font-main-ui-body
- Popover uses rounded-12, p-1, gap-1
- Submit button always renders at lg size
2026-03-16 14:58:45 -07:00
Raunak Bhagat
d3b46f7d9b feat: add go-to-page popup to all Pagination variants
Replace the old `goto` callback prop with an inline popover that lets
users type a page number and jump directly to it. The popup is
activated by clicking the page indicator (simple/count) or the
ellipsis button (list). Input is number-only and the submit button
is disabled when the value is out of range.
2026-03-16 14:43:07 -07:00
Raunak Bhagat
5ba61c7331 fix: clamp currentPage upper bound to totalPages 2026-03-16 13:55:57 -07:00
Raunak Bhagat
2be27028a4 Fix spacing 2026-03-16 13:53:40 -07:00
Raunak Bhagat
ab1fc67204 fix: address PR review comments
- Use explicit named imports from react instead of React namespace
- Clamp nav button page values to prevent out-of-bounds
- Replace inline marginLeft styles with Tailwind ml-1
- Normalize currentPage alongside totalPages
- Use gap-1 token instead of gap-[4px] in PaginationCount
2026-03-16 13:34:16 -07:00
Raunak Bhagat
640590cfbf fix: remove unnecessary cn wrapper and unused size prop 2026-03-16 13:29:51 -07:00
Raunak Bhagat
0ff2fd4bca fix: remove conditional gap sizing in PaginationSimple 2026-03-16 13:24:36 -07:00
Raunak Bhagat
994ab1b4b7 refactor: rename showSummary to showPages in Pagination 2026-03-16 13:12:15 -07:00
Raunak Bhagat
50d821f233 fix: address PR review comments
- Combine duplicate @opal/components imports in Footer.tsx
- Add onPageClick to List story args to prevent throw on click
- Fix README size default from "varies" to "lg"
2026-03-16 13:09:49 -07:00
Raunak Bhagat
8d51546361 fix: set fixed width for count variant page number
Page number between arrows in variant="count" is now 28px for lg/md
and 20px for sm, ensuring consistent layout.
2026-03-16 13:03:36 -07:00
Raunak Bhagat
108652e86c fix: pagination list ellipsis threshold and slot width
Change ellipsis threshold to >7 pages (was >5). Always render exactly
7 slots when truncating for constant component width. Size ellipsis
slots to match icon-only Button dimensions (36/28/24px).
2026-03-16 12:51:59 -07:00
Raunak Bhagat
cdcb77d146 refactor: simplify Pagination list variant API
Replace onChange/onPageClick split with a single onPageClick callback
for the list variant. Remove PaginationBase (no longer shared). Default
size is now "lg" for all variants. Update all consumers, stories,
README, and JSDoc.
2026-03-16 12:34:09 -07:00
Raunak Bhagat
fd4202c5fd refactor: update Pagination count variant API and fix spacing
Update variant="count" to use onArrowClick, showSummary, units, and
goto props (matching simple variant pattern). Buttons section (arrows +
page number) renders with no internal gap. Fixed 4px gap between
summary, buttons, and goto sections. Update stories, README, and
JSDoc to reflect current API for all variants.
2026-03-16 12:28:16 -07:00
Raunak Bhagat
9f4d60090d refactor: move Pagination to opal with three-variant API
Move Pagination from refresh-components to @opal/components with a
discriminated union API: variant="simple" (arrows + summary),
variant="count" (range display), and variant="list" (numbered pages,
default). Inline table/Pagination.tsx into Footer.tsx then replace
with the opal import. Remove internal-only stories (Footer, DataTable)
and the old refresh-components Pagination + story.
2026-03-16 12:14:17 -07:00
147 changed files with 11524 additions and 6352 deletions

View File

@@ -1,103 +0,0 @@
"""add_hook_and_hook_execution_log_tables
Revision ID: 689433b0d8de
Revises: 93a2e195e25c
Create Date: 2026-03-13 11:25:06.547474
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID as PGUUID
# revision identifiers, used by Alembic.
revision = "689433b0d8de"
down_revision = "93a2e195e25c"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"hook",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column(
"hook_point",
sa.Enum("document_ingestion", "query_processing", native_enum=False),
nullable=False,
),
sa.Column("endpoint_url", sa.Text(), nullable=True),
sa.Column("api_key", sa.LargeBinary(), nullable=True),
sa.Column("is_reachable", sa.Boolean(), nullable=True),
sa.Column(
"fail_strategy",
sa.Enum("hard", "soft", native_enum=False),
nullable=False,
),
sa.Column("timeout_seconds", sa.Float(), nullable=False),
sa.Column(
"is_active", sa.Boolean(), nullable=False, server_default=sa.text("false")
),
sa.Column(
"deleted", sa.Boolean(), nullable=False, server_default=sa.text("false")
),
sa.Column("creator_id", PGUUID(as_uuid=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["creator_id"], ["user.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_hook_one_non_deleted_per_point",
"hook",
["hook_point"],
unique=True,
postgresql_where=sa.text("deleted = false"),
)
op.create_table(
"hook_execution_log",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("hook_id", sa.Integer(), nullable=False),
sa.Column(
"is_success",
sa.Boolean(),
nullable=False,
),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("status_code", sa.Integer(), nullable=True),
sa.Column("duration_ms", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["hook_id"], ["hook.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_hook_execution_log_hook_id", "hook_execution_log", ["hook_id"])
op.create_index(
"ix_hook_execution_log_created_at", "hook_execution_log", ["created_at"]
)
def downgrade() -> None:
op.drop_index("ix_hook_execution_log_created_at", table_name="hook_execution_log")
op.drop_index("ix_hook_execution_log_hook_id", table_name="hook_execution_log")
op.drop_table("hook_execution_log")
op.drop_index("ix_hook_one_non_deleted_per_point", table_name="hook")
op.drop_table("hook")

View File

@@ -36,11 +36,9 @@ from onyx.db.memory import add_memory
from onyx.db.memory import update_memory_at_index
from onyx.db.memory import UserMemoryContext
from onyx.db.models import Persona
from onyx.llm.constants import LlmProviderNames
from onyx.llm.interfaces import LLM
from onyx.llm.interfaces import LLMUserIdentity
from onyx.llm.interfaces import ToolChoiceOptions
from onyx.llm.utils import is_true_openai_model
from onyx.prompts.chat_prompts import IMAGE_GEN_REMINDER
from onyx.prompts.chat_prompts import OPEN_URL_REMINDER
from onyx.server.query_and_chat.placement import Placement
@@ -74,70 +72,6 @@ from shared_configs.contextvars import get_current_tenant_id
logger = setup_logger()
class EmptyLLMResponseError(RuntimeError):
"""Raised when the streamed LLM response completes without a usable answer."""
def __init__(
self,
*,
provider: str,
model: str,
tool_choice: ToolChoiceOptions,
client_error_msg: str,
error_code: str = "EMPTY_LLM_RESPONSE",
is_retryable: bool = True,
) -> None:
super().__init__(client_error_msg)
self.provider = provider
self.model = model
self.tool_choice = tool_choice
self.client_error_msg = client_error_msg
self.error_code = error_code
self.is_retryable = is_retryable
def _build_empty_llm_response_error(
llm: LLM,
llm_step_result: LlmStepResult,
tool_choice: ToolChoiceOptions,
) -> EmptyLLMResponseError:
provider = llm.config.model_provider
model = llm.config.model_name
# OpenAI quota exhaustion has reached us as a streamed "stop" with zero content.
# When the stream is completely empty and there is no reasoning/tool output, surface
# the likely account-level cause instead of a generic tool-calling error.
if (
not llm_step_result.reasoning
and provider == LlmProviderNames.OPENAI
and is_true_openai_model(provider, model)
):
return EmptyLLMResponseError(
provider=provider,
model=model,
tool_choice=tool_choice,
client_error_msg=(
"The selected OpenAI model returned an empty streamed response "
"before producing any tokens. This commonly happens when the API "
"key or project has no remaining quota or billing is not enabled. "
"Verify quota and billing for this key and try again."
),
error_code="BUDGET_EXCEEDED",
is_retryable=False,
)
return EmptyLLMResponseError(
provider=provider,
model=model,
tool_choice=tool_choice,
client_error_msg=(
"The selected model returned no final answer before the stream "
"completed. No text or tool calls were received from the upstream "
"provider."
),
)
def _looks_like_xml_tool_call_payload(text: str | None) -> bool:
"""Detect XML-style marshaled tool calls emitted as plain text."""
if not text:
@@ -679,12 +613,7 @@ def run_llm_loop(
)
citation_processor.update_citation_mapping(project_citation_mapping)
llm_step_result = LlmStepResult(
reasoning=None,
answer=None,
tool_calls=None,
raw_answer=None,
)
llm_step_result: LlmStepResult | None = None
# Pass the total budget to construct_message_history, which will handle token allocation
available_tokens = llm.config.max_input_tokens
@@ -1155,18 +1084,12 @@ def run_llm_loop(
# As long as 1 tool with citeable documents is called at any point, we ask the LLM to try to cite
should_cite_documents = True
if not llm_step_result.answer and not llm_step_result.tool_calls:
raise _build_empty_llm_response_error(
llm=llm,
llm_step_result=llm_step_result,
tool_choice=tool_choice,
)
if not llm_step_result.answer:
if not llm_step_result or not llm_step_result.answer:
raise RuntimeError(
"The LLM did not return a final answer after tool execution. "
"Typically this indicates invalid tool-call output, a model/provider mismatch, "
"or serving API misconfiguration."
"The LLM did not return an answer. "
"Typically this is an issue with LLMs that do not support tool calling natively, "
"or the model serving API is not configured correctly. "
"This may also happen with models that are lower quality outputting invalid tool calls."
)
emitter.emit(

View File

@@ -1013,10 +1013,6 @@ def run_llm_step_pkt_generator(
accumulated_reasoning = ""
accumulated_answer = ""
accumulated_raw_answer = ""
stream_chunk_count = 0
actionable_chunk_count = 0
empty_chunk_count = 0
finish_reasons: set[str] = set()
xml_tool_call_content_filter = _XmlToolCallContentFilter()
processor_state: Any = None
@@ -1149,7 +1145,6 @@ def run_llm_step_pkt_generator(
user_identity=user_identity,
timeout_override=timeout_override,
):
stream_chunk_count += 1
if packet.usage:
usage = packet.usage
span_generation.span_data.usage = {
@@ -1159,21 +1154,16 @@ def run_llm_step_pkt_generator(
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
}
# Note: LLM cost tracking is now handled in multi_llm.py
finish_reason = packet.choice.finish_reason
if finish_reason:
finish_reasons.add(str(finish_reason))
delta = packet.choice.delta
# Weird behavior from some model providers, just log and ignore for now
if (
not delta.content
delta.content is None
and delta.reasoning_content is None
and not delta.tool_calls
and delta.tool_calls is None
):
empty_chunk_count += 1
logger.warning(
"LLM packet is empty (no content, reasoning, or tool calls). "
f"finish_reason={finish_reason}. Skipping: {packet}"
f"LLM packet is empty (no contents, reasoning or tool calls). Skipping: {packet}"
)
continue
@@ -1182,8 +1172,6 @@ def run_llm_step_pkt_generator(
time.monotonic() - stream_start_time
)
first_action_recorded = True
if _delta_has_action(delta):
actionable_chunk_count += 1
if custom_token_processor:
# The custom token processor can modify the deltas for specific custom logic
@@ -1319,15 +1307,6 @@ def run_llm_step_pkt_generator(
else:
logger.debug("Tool calls: []")
if actionable_chunk_count == 0:
logger.warning(
"LLM stream completed with no actionable deltas. "
f"chunks={stream_chunk_count}, empty_chunks={empty_chunk_count}, "
f"finish_reasons={sorted(finish_reasons)}, "
f"provider={llm.config.model_provider}, model={llm.config.model_name}, "
f"tool_choice={tool_choice}, tools_sent={len(tool_definitions)}"
)
return (
LlmStepResult(
reasoning=accumulated_reasoning if accumulated_reasoning else None,

View File

@@ -29,7 +29,6 @@ from onyx.chat.compression import compress_chat_history
from onyx.chat.compression import find_summary_for_branch
from onyx.chat.compression import get_compression_params
from onyx.chat.emitter import get_default_emitter
from onyx.chat.llm_loop import EmptyLLMResponseError
from onyx.chat.llm_loop import run_llm_loop
from onyx.chat.models import AnswerStream
from onyx.chat.models import ChatBasicResponse
@@ -926,28 +925,9 @@ def handle_stream_message_objects(
db_session.rollback()
return
except EmptyLLMResponseError as e:
stack_trace = traceback.format_exc()
logger.warning(
"LLM returned an empty response "
f"(provider={e.provider}, model={e.model}, tool_choice={e.tool_choice})"
)
yield StreamingError(
error=e.client_error_msg,
stack_trace=stack_trace,
error_code=e.error_code,
is_retryable=e.is_retryable,
details={
"model": e.model,
"provider": e.provider,
"tool_choice": e.tool_choice.value,
},
)
db_session.rollback()
except Exception as e:
logger.exception(f"Failed to process chat message due to {e}")
error_msg = str(e)
stack_trace = traceback.format_exc()
if llm:
@@ -1066,46 +1046,10 @@ def llm_loop_completion_handle(
)
_CITATION_LINK_START_PATTERN = re.compile(r"\s*\[\[\d+\]\]\(")
def _find_markdown_link_end(text: str, destination_start: int) -> int | None:
depth = 0
i = destination_start
while i < len(text):
curr = text[i]
if curr == "\\":
i += 2
continue
if curr == "(":
depth += 1
elif curr == ")":
if depth == 0:
return i
depth -= 1
i += 1
return None
def remove_answer_citations(answer: str) -> str:
stripped_parts: list[str] = []
cursor = 0
pattern = r"\s*\[\[\d+\]\]\(http[s]?://[^\s]+\)"
while match := _CITATION_LINK_START_PATTERN.search(answer, cursor):
stripped_parts.append(answer[cursor : match.start()])
link_end = _find_markdown_link_end(answer, match.end())
if link_end is None:
stripped_parts.append(answer[match.start() :])
return "".join(stripped_parts)
cursor = link_end + 1
stripped_parts.append(answer[cursor:])
return "".join(stripped_parts)
return re.sub(pattern, "", answer)
@log_function_time()
@@ -1143,11 +1087,8 @@ def gather_stream(
raise ValueError("Message ID is required")
if answer is None:
if error_msg is not None:
answer = ""
else:
# This should never be the case as these non-streamed flows do not have a stop-generation signal
raise RuntimeError("Answer was not generated")
# This should never be the case as these non-streamed flows do not have a stop-generation signal
raise RuntimeError("Answer was not generated")
return ChatBasicResponse(
answer=answer,

View File

@@ -318,6 +318,9 @@ VERIFY_CREATE_OPENSEARCH_INDEX_ON_INIT_MT = (
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

@@ -511,7 +511,7 @@ def add_credential_to_connector(
user: User,
connector_id: int,
credential_id: int,
cc_pair_name: str,
cc_pair_name: str | None,
access_type: AccessType,
groups: list[int] | None,
auto_sync_options: dict | None = None,

View File

@@ -304,13 +304,3 @@ class LLMModelFlowType(str, PyEnum):
CHAT = "chat"
VISION = "vision"
CONTEXTUAL_RAG = "contextual_rag"
class HookPoint(str, PyEnum):
DOCUMENT_INGESTION = "document_ingestion"
QUERY_PROCESSING = "query_processing"
class HookFailStrategy(str, PyEnum):
HARD = "hard" # exception propagates, pipeline aborts
SOFT = "soft" # log error, return original input, pipeline continues

View File

@@ -1,233 +0,0 @@
import datetime
from uuid import UUID
from sqlalchemy import delete
from sqlalchemy import select
from sqlalchemy.engine import CursorResult
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.db.constants import UNSET
from onyx.db.constants import UnsetType
from onyx.db.enums import HookFailStrategy
from onyx.db.enums import HookPoint
from onyx.db.models import Hook
from onyx.db.models import HookExecutionLog
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
# ── Hook CRUD ────────────────────────────────────────────────────────────
def get_hook_by_id(
*,
db_session: Session,
hook_id: int,
include_deleted: bool = False,
include_creator: bool = False,
) -> Hook | None:
stmt = select(Hook).where(Hook.id == hook_id)
if not include_deleted:
stmt = stmt.where(Hook.deleted.is_(False))
if include_creator:
stmt = stmt.options(selectinload(Hook.creator))
return db_session.scalar(stmt)
def get_non_deleted_hook_by_hook_point(
*,
db_session: Session,
hook_point: HookPoint,
include_creator: bool = False,
) -> Hook | None:
stmt = (
select(Hook).where(Hook.hook_point == hook_point).where(Hook.deleted.is_(False))
)
if include_creator:
stmt = stmt.options(selectinload(Hook.creator))
return db_session.scalar(stmt)
def get_hooks(
*,
db_session: Session,
include_deleted: bool = False,
include_creator: bool = False,
) -> list[Hook]:
stmt = select(Hook)
if not include_deleted:
stmt = stmt.where(Hook.deleted.is_(False))
if include_creator:
stmt = stmt.options(selectinload(Hook.creator))
stmt = stmt.order_by(Hook.hook_point, Hook.created_at.desc())
return list(db_session.scalars(stmt).all())
def create_hook__no_commit(
*,
db_session: Session,
name: str,
hook_point: HookPoint,
endpoint_url: str | None = None,
api_key: str | None = None,
fail_strategy: HookFailStrategy,
timeout_seconds: float,
is_active: bool = False,
creator_id: UUID | None = None,
) -> Hook:
"""Create a new hook for the given hook point.
At most one non-deleted hook per hook point is allowed. Raises
OnyxError(CONFLICT) if a hook already exists, including under concurrent
duplicate creates where the partial unique index fires an IntegrityError.
"""
existing = get_non_deleted_hook_by_hook_point(
db_session=db_session, hook_point=hook_point
)
if existing:
raise OnyxError(
OnyxErrorCode.CONFLICT,
f"A hook for '{hook_point.value}' already exists (id={existing.id}).",
)
hook = Hook(
name=name,
hook_point=hook_point,
endpoint_url=endpoint_url,
api_key=api_key,
fail_strategy=fail_strategy,
timeout_seconds=timeout_seconds,
is_active=is_active,
creator_id=creator_id,
)
# Use a savepoint so that a failed insert only rolls back this operation,
# not the entire outer transaction.
savepoint = db_session.begin_nested()
try:
db_session.add(hook)
savepoint.commit()
except IntegrityError as exc:
savepoint.rollback()
if "ix_hook_one_non_deleted_per_point" in str(exc.orig):
raise OnyxError(
OnyxErrorCode.CONFLICT,
f"A hook for '{hook_point.value}' already exists.",
)
raise # re-raise unrelated integrity errors (FK violations, etc.)
return hook
def update_hook__no_commit(
*,
db_session: Session,
hook_id: int,
name: str | None = None,
endpoint_url: str | None | UnsetType = UNSET,
api_key: str | None | UnsetType = UNSET,
fail_strategy: HookFailStrategy | None = None,
timeout_seconds: float | None = None,
is_active: bool | None = None,
is_reachable: bool | None = None,
include_creator: bool = False,
) -> Hook:
"""Update hook fields.
Sentinel conventions:
- endpoint_url, api_key: pass UNSET to leave unchanged; pass None to clear.
- name, fail_strategy, timeout_seconds, is_active, is_reachable: pass None to leave unchanged.
"""
hook = get_hook_by_id(
db_session=db_session, hook_id=hook_id, include_creator=include_creator
)
if hook is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, f"Hook with id {hook_id} not found.")
if name is not None:
hook.name = name
if not isinstance(endpoint_url, UnsetType):
hook.endpoint_url = endpoint_url
if not isinstance(api_key, UnsetType):
hook.api_key = api_key # type: ignore[assignment] # EncryptedString coerces str → SensitiveValue at the ORM level
if fail_strategy is not None:
hook.fail_strategy = fail_strategy
if timeout_seconds is not None:
hook.timeout_seconds = timeout_seconds
if is_active is not None:
hook.is_active = is_active
if is_reachable is not None:
hook.is_reachable = is_reachable
db_session.flush()
return hook
def delete_hook__no_commit(
*,
db_session: Session,
hook_id: int,
) -> None:
hook = get_hook_by_id(db_session=db_session, hook_id=hook_id)
if hook is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, f"Hook with id {hook_id} not found.")
hook.deleted = True
hook.is_active = False
db_session.flush()
# ── HookExecutionLog CRUD ────────────────────────────────────────────────
def create_hook_execution_log__no_commit(
*,
db_session: Session,
hook_id: int,
is_success: bool,
error_message: str | None = None,
status_code: int | None = None,
duration_ms: int | None = None,
) -> HookExecutionLog:
log = HookExecutionLog(
hook_id=hook_id,
is_success=is_success,
error_message=error_message,
status_code=status_code,
duration_ms=duration_ms,
)
db_session.add(log)
db_session.flush()
return log
def get_hook_execution_logs(
*,
db_session: Session,
hook_id: int,
limit: int,
) -> list[HookExecutionLog]:
stmt = (
select(HookExecutionLog)
.where(HookExecutionLog.hook_id == hook_id)
.order_by(HookExecutionLog.created_at.desc())
.limit(limit)
)
return list(db_session.scalars(stmt).all())
def cleanup_old_execution_logs__no_commit(
*,
db_session: Session,
max_age_days: int,
) -> int:
"""Delete execution logs older than max_age_days. Returns the number of rows deleted."""
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
days=max_age_days
)
result: CursorResult = db_session.execute( # type: ignore[assignment]
delete(HookExecutionLog)
.where(HookExecutionLog.created_at < cutoff)
.execution_options(synchronize_session=False)
)
return result.rowcount

View File

@@ -64,8 +64,6 @@ from onyx.db.enums import (
BuildSessionStatus,
EmbeddingPrecision,
HierarchyNodeType,
HookFailStrategy,
HookPoint,
IndexingMode,
OpenSearchDocumentMigrationStatus,
OpenSearchTenantMigrationStatus,
@@ -5180,90 +5178,3 @@ class CacheStore(Base):
expires_at: Mapped[datetime.datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
class Hook(Base):
"""Pairs a HookPoint with a customer-provided API endpoint.
At most one non-deleted Hook per HookPoint is allowed, enforced by a
partial unique index on (hook_point) where deleted=false.
"""
__tablename__ = "hook"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False)
hook_point: Mapped[HookPoint] = mapped_column(
Enum(HookPoint, native_enum=False), nullable=False
)
endpoint_url: Mapped[str | None] = mapped_column(Text, nullable=True)
api_key: Mapped[SensitiveValue[str] | None] = mapped_column(
EncryptedString(), nullable=True
)
is_reachable: Mapped[bool | None] = mapped_column(
Boolean, nullable=True, default=None
) # null = never validated, true = last check passed, false = last check failed
fail_strategy: Mapped[HookFailStrategy] = mapped_column(
Enum(HookFailStrategy, native_enum=False),
nullable=False,
default=HookFailStrategy.HARD,
)
timeout_seconds: Mapped[float] = mapped_column(Float, nullable=False, default=30.0)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
creator_id: Mapped[UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("user.id", ondelete="SET NULL"),
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,
)
creator: Mapped["User | None"] = relationship("User", foreign_keys=[creator_id])
execution_logs: Mapped[list["HookExecutionLog"]] = relationship(
"HookExecutionLog", back_populates="hook", cascade="all, delete-orphan"
)
__table_args__ = (
Index(
"ix_hook_one_non_deleted_per_point",
"hook_point",
unique=True,
postgresql_where=(deleted == False), # noqa: E712
),
)
class HookExecutionLog(Base):
"""Records hook executions for health monitoring and debugging.
Currently only failures are logged; the is_success column exists so
success logging can be added later without a schema change.
Retention: rows older than 30 days are deleted by a nightly Celery task.
"""
__tablename__ = "hook_execution_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
hook_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("hook.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
is_success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
)
hook: Mapped["Hook"] = relationship("Hook", back_populates="execution_logs")

View File

@@ -1,23 +1,12 @@
# Default value for the maximum number of tokens a chunk can hold, if none is
# specified when creating an index.
import os
from enum import Enum
from onyx.configs.app_configs import (
OPENSEARCH_OVERRIDE_DEFAULT_NUM_HYBRID_SEARCH_CANDIDATES,
)
DEFAULT_MAX_CHUNK_SIZE = 512
# By default OpenSearch will only return a maximum of this many results in a
# given search. This value is configurable in the index settings.
DEFAULT_OPENSEARCH_MAX_RESULT_WINDOW = 10_000
# For documents which do not have a value for LAST_UPDATED_FIELD_NAME, we assume
# that the document was last updated this many days ago for the purpose of time
# cutoff filtering during retrieval.
ASSUMED_DOCUMENT_AGE_DAYS = 90
# Size of the dynamic list used to consider elements during kNN graph creation.
# Higher values improve search quality but increase indexing time. Values
# typically range between 100 - 512.
@@ -37,10 +26,10 @@ M = 32 # Set relatively high for better accuracy.
# 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).
# Defaults to 100 for now. Initially this defaulted to 750 but we were seeing
# poor search performance.
DEFAULT_NUM_HYBRID_SUBQUERY_CANDIDATES = int(
os.environ.get("DEFAULT_NUM_HYBRID_SUBQUERY_CANDIDATES", 100)
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 to decide the top k neighbors for the HNSW
@@ -50,43 +39,23 @@ DEFAULT_NUM_HYBRID_SUBQUERY_CANDIDATES = int(
# 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_SUBQUERY_CANDIDATES
EF_SEARCH = DEFAULT_NUM_HYBRID_SEARCH_CANDIDATES
# 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).
SEARCH_KEYWORD_WEIGHT = 0.45
class HybridSearchSubqueryConfiguration(Enum):
TITLE_VECTOR_CONTENT_VECTOR_TITLE_CONTENT_COMBINED_KEYWORD = 1
# Current default.
CONTENT_VECTOR_TITLE_CONTENT_COMBINED_KEYWORD = 2
# 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,
SEARCH_KEYWORD_WEIGHT,
]
# Will raise and block application start if HYBRID_SEARCH_SUBQUERY_CONFIGURATION
# is set but not a valid value. If not set, defaults to
# CONTENT_VECTOR_TITLE_CONTENT_COMBINED_KEYWORD.
HYBRID_SEARCH_SUBQUERY_CONFIGURATION: HybridSearchSubqueryConfiguration = (
HybridSearchSubqueryConfiguration(
int(os.environ["HYBRID_SEARCH_SUBQUERY_CONFIGURATION"])
)
if os.environ.get("HYBRID_SEARCH_SUBQUERY_CONFIGURATION", None) is not None
else HybridSearchSubqueryConfiguration.CONTENT_VECTOR_TITLE_CONTENT_COMBINED_KEYWORD
)
class HybridSearchNormalizationPipeline(Enum):
# Current default.
MIN_MAX = 1
# NOTE: Using z-score normalization is better for hybrid search from a
# theoretical standpoint. Empirically on a small dataset of up to 10K docs,
# it's not very different. Likely more impactful at scale.
# https://opensearch.org/blog/introducing-the-z-score-normalization-technique-for-hybrid-search/
ZSCORE = 2
# Will raise and block application start if HYBRID_SEARCH_NORMALIZATION_PIPELINE
# is set but not a valid value. If not set, defaults to MIN_MAX.
HYBRID_SEARCH_NORMALIZATION_PIPELINE: HybridSearchNormalizationPipeline = (
HybridSearchNormalizationPipeline(
int(os.environ["HYBRID_SEARCH_NORMALIZATION_PIPELINE"])
)
if os.environ.get("HYBRID_SEARCH_NORMALIZATION_PIPELINE", None) is not None
else HybridSearchNormalizationPipeline.MIN_MAX
)
assert sum(HYBRID_SEARCH_NORMALIZATION_WEIGHTS) == 1.0

View File

@@ -55,13 +55,16 @@ from onyx.document_index.opensearch.schema import PERSONAS_FIELD_NAME
from onyx.document_index.opensearch.schema import USER_PROJECTS_FIELD_NAME
from onyx.document_index.opensearch.search import DocumentQuery
from onyx.document_index.opensearch.search import (
get_min_max_normalization_pipeline_name_and_config,
MIN_MAX_NORMALIZATION_PIPELINE_CONFIG,
)
from onyx.document_index.opensearch.search import (
get_normalization_pipeline_name_and_config,
MIN_MAX_NORMALIZATION_PIPELINE_NAME,
)
from onyx.document_index.opensearch.search import (
get_zscore_normalization_pipeline_name_and_config,
ZSCORE_NORMALIZATION_PIPELINE_CONFIG,
)
from onyx.document_index.opensearch.search import (
ZSCORE_NORMALIZATION_PIPELINE_NAME,
)
from onyx.indexing.models import DocMetadataAwareIndexChunk
from onyx.indexing.models import Document
@@ -100,19 +103,13 @@ def set_cluster_state(client: OpenSearchClient) -> None:
"is not the first time running Onyx against this instance of OpenSearch, these "
"settings have likely already been set. Not taking any further action..."
)
min_max_normalization_pipeline_name, min_max_normalization_pipeline_config = (
get_min_max_normalization_pipeline_name_and_config()
)
zscore_normalization_pipeline_name, zscore_normalization_pipeline_config = (
get_zscore_normalization_pipeline_name_and_config()
client.create_search_pipeline(
pipeline_id=MIN_MAX_NORMALIZATION_PIPELINE_NAME,
pipeline_body=MIN_MAX_NORMALIZATION_PIPELINE_CONFIG,
)
client.create_search_pipeline(
pipeline_id=min_max_normalization_pipeline_name,
pipeline_body=min_max_normalization_pipeline_config,
)
client.create_search_pipeline(
pipeline_id=zscore_normalization_pipeline_name,
pipeline_body=zscore_normalization_pipeline_config,
pipeline_id=ZSCORE_NORMALIZATION_PIPELINE_NAME,
pipeline_body=ZSCORE_NORMALIZATION_PIPELINE_CONFIG,
)
@@ -943,10 +940,14 @@ class OpenSearchDocumentIndex(DocumentIndex):
index_filters=filters,
include_hidden=False,
)
normalization_pipeline_name, _ = get_normalization_pipeline_name_and_config()
# NOTE: Using z-score normalization here because it's better for hybrid
# search from a theoretical standpoint. Empirically on a small dataset
# of up to 10K docs, it's not very different. Likely more impactful at
# scale.
# https://opensearch.org/blog/introducing-the-z-score-normalization-technique-for-hybrid-search/
search_hits: list[SearchHit[DocumentChunk]] = self._client.search(
body=query_body,
search_pipeline_id=normalization_pipeline_name,
search_pipeline_id=ZSCORE_NORMALIZATION_PIPELINE_NAME,
)
# Good place for a breakpoint to inspect the search hits if you have "explain" enabled.

View File

@@ -13,21 +13,10 @@ from onyx.configs.constants import INDEX_SEPARATOR
from onyx.context.search.models import IndexFilters
from onyx.context.search.models import Tag
from onyx.document_index.interfaces_new import TenantState
from onyx.document_index.opensearch.constants import ASSUMED_DOCUMENT_AGE_DAYS
from onyx.document_index.opensearch.constants import (
DEFAULT_NUM_HYBRID_SUBQUERY_CANDIDATES,
DEFAULT_NUM_HYBRID_SEARCH_CANDIDATES,
)
from onyx.document_index.opensearch.constants import (
DEFAULT_OPENSEARCH_MAX_RESULT_WINDOW,
)
from onyx.document_index.opensearch.constants import (
HYBRID_SEARCH_NORMALIZATION_PIPELINE,
)
from onyx.document_index.opensearch.constants import (
HYBRID_SEARCH_SUBQUERY_CONFIGURATION,
)
from onyx.document_index.opensearch.constants import HybridSearchNormalizationPipeline
from onyx.document_index.opensearch.constants import HybridSearchSubqueryConfiguration
from onyx.document_index.opensearch.constants import HYBRID_SEARCH_NORMALIZATION_WEIGHTS
from onyx.document_index.opensearch.schema import ACCESS_CONTROL_LIST_FIELD_NAME
from onyx.document_index.opensearch.schema import ANCESTOR_HIERARCHY_NODE_IDS_FIELD_NAME
from onyx.document_index.opensearch.schema import CHUNK_INDEX_FIELD_NAME
@@ -54,113 +43,49 @@ from onyx.document_index.opensearch.schema import USER_PROJECTS_FIELD_NAME
# TODO(andrei): Turn all magic dictionaries to pydantic models.
def _get_hybrid_search_normalization_weights() -> list[float]:
if (
HYBRID_SEARCH_SUBQUERY_CONFIGURATION
is HybridSearchSubqueryConfiguration.TITLE_VECTOR_CONTENT_VECTOR_TITLE_CONTENT_COMBINED_KEYWORD
):
# 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).
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.
hybrid_search_normalization_weights = [
search_title_vector_weight,
search_content_vector_weight,
search_keyword_weight,
]
elif (
HYBRID_SEARCH_SUBQUERY_CONFIGURATION
is HybridSearchSubqueryConfiguration.CONTENT_VECTOR_TITLE_CONTENT_COMBINED_KEYWORD
):
search_content_vector_weight = 0.5
# Single keyword weight for both title and content (merged from former
# title keyword + content keyword).
search_keyword_weight = 0.5
# 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_content_vector_weight,
search_keyword_weight,
]
else:
raise ValueError(
f"Bug: Unhandled hybrid search subquery configuration: {HYBRID_SEARCH_SUBQUERY_CONFIGURATION}."
)
assert (
sum(hybrid_search_normalization_weights) == 1.0
), "Bug: Hybrid search normalization weights do not sum to 1.0."
return hybrid_search_normalization_weights
def get_min_max_normalization_pipeline_name_and_config() -> tuple[str, dict[str, Any]]:
min_max_normalization_pipeline_name = "normalization_pipeline_min_max"
min_max_normalization_pipeline_config: dict[str, Any] = {
"description": "Normalization for keyword and vector scores using min-max",
"phase_results_processors": [
{
# https://docs.opensearch.org/latest/search-plugins/search-pipelines/normalization-processor/
"normalization-processor": {
"normalization": {"technique": "min_max"},
"combination": {
"technique": "arithmetic_mean",
"parameters": {
"weights": _get_hybrid_search_normalization_weights()
},
},
}
MIN_MAX_NORMALIZATION_PIPELINE_NAME = "normalization_pipeline_min_max"
MIN_MAX_NORMALIZATION_PIPELINE_CONFIG: dict[str, Any] = {
"description": "Normalization for keyword and vector scores using min-max",
"phase_results_processors": [
{
# https://docs.opensearch.org/latest/search-plugins/search-pipelines/normalization-processor/
"normalization-processor": {
"normalization": {"technique": "min_max"},
"combination": {
"technique": "arithmetic_mean",
"parameters": {"weights": HYBRID_SEARCH_NORMALIZATION_WEIGHTS},
},
}
],
}
return min_max_normalization_pipeline_name, min_max_normalization_pipeline_config
}
],
}
def get_zscore_normalization_pipeline_name_and_config() -> tuple[str, dict[str, Any]]:
zscore_normalization_pipeline_name = "normalization_pipeline_zscore"
zscore_normalization_pipeline_config: dict[str, Any] = {
"description": "Normalization for keyword and vector scores using z-score",
"phase_results_processors": [
{
# https://docs.opensearch.org/latest/search-plugins/search-pipelines/normalization-processor/
"normalization-processor": {
"normalization": {"technique": "z_score"},
"combination": {
"technique": "arithmetic_mean",
"parameters": {
"weights": _get_hybrid_search_normalization_weights()
},
},
}
ZSCORE_NORMALIZATION_PIPELINE_NAME = "normalization_pipeline_zscore"
ZSCORE_NORMALIZATION_PIPELINE_CONFIG: dict[str, Any] = {
"description": "Normalization for keyword and vector scores using z-score",
"phase_results_processors": [
{
# https://docs.opensearch.org/latest/search-plugins/search-pipelines/normalization-processor/
"normalization-processor": {
"normalization": {"technique": "z_score"},
"combination": {
"technique": "arithmetic_mean",
"parameters": {"weights": HYBRID_SEARCH_NORMALIZATION_WEIGHTS},
},
}
],
}
return zscore_normalization_pipeline_name, zscore_normalization_pipeline_config
}
],
}
def get_normalization_pipeline_name_and_config() -> tuple[str, dict[str, Any]]:
if (
HYBRID_SEARCH_NORMALIZATION_PIPELINE
is HybridSearchNormalizationPipeline.MIN_MAX
):
return get_min_max_normalization_pipeline_name_and_config()
elif (
HYBRID_SEARCH_NORMALIZATION_PIPELINE is HybridSearchNormalizationPipeline.ZSCORE
):
return get_zscore_normalization_pipeline_name_and_config()
else:
raise ValueError(
f"Bug: Unhandled hybrid search normalization pipeline: {HYBRID_SEARCH_NORMALIZATION_PIPELINE}."
)
# By default OpenSearch will only return a maximum of this many results in a
# given search. This value is configurable in the index settings.
DEFAULT_OPENSEARCH_MAX_RESULT_WINDOW = 10_000
# For documents which do not have a value for LAST_UPDATED_FIELD_NAME, we assume
# that the document was last updated this many days ago for the purpose of time
# cutoff filtering during retrieval.
ASSUMED_DOCUMENT_AGE_DAYS = 90
class DocumentQuery:
@@ -332,7 +257,7 @@ class DocumentQuery:
# TODO(andrei, yuhong): We can tune this more dynamically based on
# num_hits.
max_results_per_subquery = DEFAULT_NUM_HYBRID_SUBQUERY_CANDIDATES
max_results_per_subquery = DEFAULT_NUM_HYBRID_SEARCH_CANDIDATES
hybrid_search_subqueries = DocumentQuery._get_hybrid_search_subqueries(
query_text, query_vector, vector_candidates=max_results_per_subquery
@@ -460,7 +385,7 @@ class DocumentQuery:
# 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_SUBQUERY_CANDIDATES,
vector_candidates: int = DEFAULT_NUM_HYBRID_SEARCH_CANDIDATES,
) -> list[dict[str, Any]]:
"""Returns subqueries for hybrid search.
@@ -470,18 +395,20 @@ class DocumentQuery:
The return of this function is not sufficient to be directly supplied to
the OpenSearch client. See get_hybrid_search_query.
Matches:
- Title vector
- Content vector
- Keyword (title + content, match and phrase)
Normalization is not performed here.
The weights of each of these subqueries should be configured in a search
pipeline.
The exact subqueries executed depend on the
HYBRID_SEARCH_SUBQUERY_CONFIGURATION setting.
NOTE: For OpenSearch, 5 is the maximum number of query clauses allowed
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
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
@@ -510,115 +437,74 @@ class DocumentQuery:
similarity search.
"""
# Build sub-queries for hybrid search. Order must match normalization
# pipeline weights.
if (
HYBRID_SEARCH_SUBQUERY_CONFIGURATION
is HybridSearchSubqueryConfiguration.TITLE_VECTOR_CONTENT_VECTOR_TITLE_CONTENT_COMBINED_KEYWORD
):
return [
DocumentQuery._get_title_vector_similarity_search_query(
query_vector, vector_candidates
),
DocumentQuery._get_content_vector_similarity_search_query(
query_vector, vector_candidates
),
DocumentQuery._get_title_content_combined_keyword_search_query(
query_text
),
]
elif (
HYBRID_SEARCH_SUBQUERY_CONFIGURATION
is HybridSearchSubqueryConfiguration.CONTENT_VECTOR_TITLE_CONTENT_COMBINED_KEYWORD
):
return [
DocumentQuery._get_content_vector_similarity_search_query(
query_vector, vector_candidates
),
DocumentQuery._get_title_content_combined_keyword_search_query(
query_text
),
]
else:
raise ValueError(
f"Bug: Unhandled hybrid search subquery configuration: {HYBRID_SEARCH_SUBQUERY_CONFIGURATION}"
)
@staticmethod
def _get_title_vector_similarity_search_query(
query_vector: list[float],
vector_candidates: int = DEFAULT_NUM_HYBRID_SUBQUERY_CANDIDATES,
) -> dict[str, Any]:
return {
"knn": {
TITLE_VECTOR_FIELD_NAME: {
"vector": query_vector,
"k": vector_candidates,
# pipeline weights: title vector, content vector, keyword (title + content).
hybrid_search_queries: list[dict[str, Any]] = [
# 1. Title vector search
{
"knn": {
TITLE_VECTOR_FIELD_NAME: {
"vector": query_vector,
"k": vector_candidates,
}
}
}
}
@staticmethod
def _get_content_vector_similarity_search_query(
query_vector: list[float],
vector_candidates: int = DEFAULT_NUM_HYBRID_SUBQUERY_CANDIDATES,
) -> dict[str, Any]:
return {
"knn": {
CONTENT_VECTOR_FIELD_NAME: {
"vector": query_vector,
"k": vector_candidates,
},
# 2. Content vector search
{
"knn": {
CONTENT_VECTOR_FIELD_NAME: {
"vector": query_vector,
"k": vector_candidates,
}
}
}
}
},
# 3. Keyword (title + content) match and phrase search.
{
"bool": {
"should": [
{
"match": {
TITLE_FIELD_NAME: {
"query": query_text,
"operator": "or",
# The title fields are strongly discounted as they are included in the content.
# It just acts as a minor boost
"boost": 0.1,
}
}
},
{
"match_phrase": {
TITLE_FIELD_NAME: {
"query": query_text,
"slop": 1,
"boost": 0.2,
}
}
},
{
"match": {
CONTENT_FIELD_NAME: {
"query": query_text,
"operator": "or",
"boost": 1.0,
}
}
},
{
"match_phrase": {
CONTENT_FIELD_NAME: {
"query": query_text,
"slop": 1,
"boost": 1.5,
}
}
},
]
}
},
]
@staticmethod
def _get_title_content_combined_keyword_search_query(
query_text: str,
) -> dict[str, Any]:
return {
"bool": {
"should": [
{
"match": {
TITLE_FIELD_NAME: {
"query": query_text,
"operator": "or",
# The title fields are strongly discounted as they are included in the content.
# It just acts as a minor boost
"boost": 0.1,
}
}
},
{
"match_phrase": {
TITLE_FIELD_NAME: {
"query": query_text,
"slop": 1,
"boost": 0.2,
}
}
},
{
"match": {
CONTENT_FIELD_NAME: {
"query": query_text,
"operator": "or",
"boost": 1.0,
}
}
},
{
"match_phrase": {
CONTENT_FIELD_NAME: {
"query": query_text,
"slop": 1,
"boost": 1.5,
}
}
},
]
}
}
return hybrid_search_queries
@staticmethod
def _get_search_filters(

View File

@@ -501,31 +501,20 @@ def query_vespa(
response = http_client.post(SEARCH_ENDPOINT, json=params)
response.raise_for_status()
except httpx.HTTPError as e:
response_text = (
e.response.text if isinstance(e, httpx.HTTPStatusError) else None
)
status_code = (
e.response.status_code if isinstance(e, httpx.HTTPStatusError) else None
)
yql_value = params.get("yql", "")
yql_length = len(str(yql_value))
# Log each detail on its own line so log collectors capture them
# as separate entries rather than truncating a single multiline msg
error_base = "Failed to query Vespa"
logger.error(
f"Failed to query Vespa | "
f"status={status_code} | "
f"yql_length={yql_length} | "
f"exception={str(e)}"
f"{error_base}:\n"
f"Request URL: {e.request.url}\n"
f"Request Headers: {e.request.headers}\n"
f"Request Payload: {params}\n"
f"Exception: {str(e)}"
+ (
f"\nResponse: {e.response.text}"
if isinstance(e, httpx.HTTPStatusError)
else ""
)
)
if response_text:
logger.error(f"Vespa error response: {response_text[:1000]}")
logger.error(f"Vespa request URL: {e.request.url}")
# Re-raise with diagnostics so callers see what actually went wrong
raise httpx.HTTPError(
f"Failed to query Vespa (status={status_code}, " f"yql_length={yql_length})"
) from e
raise httpx.HTTPError(error_base) from e
response_json: dict[str, Any] = response.json()

View File

@@ -43,22 +43,6 @@ def build_vespa_filters(
return ""
return f"({' or '.join(eq_elems)})"
def _build_weighted_set_filter(key: str, vals: list[str] | None) -> str:
"""Build a Vespa weightedSet filter for large value lists.
Uses Vespa's native weightedSet() operator instead of OR-chained
'contains' clauses. This is critical for fields like
access_control_list where a single user may have tens of thousands
of ACL entries — OR clauses at that scale cause Vespa to reject
the query with HTTP 400."""
if not key or not vals:
return ""
filtered = [val for val in vals if val]
if not filtered:
return ""
items = ", ".join(f'"{val}":1' for val in filtered)
return f"weightedSet({key}, {{{items}}})"
def _build_int_or_filters(key: str, vals: list[int] | None) -> str:
"""For an integer field filter.
Returns a bare clause or ""."""
@@ -173,16 +157,11 @@ def build_vespa_filters(
if filters.tenant_id and MULTI_TENANT:
filter_parts.append(build_tenant_id_filter(filters.tenant_id))
# ACL filters — use weightedSet for efficient matching against the
# access_control_list weightedset<string> field. OR-chaining thousands
# of 'contains' clauses causes Vespa to reject the query (HTTP 400)
# for users with large numbers of external permission groups.
# ACL filters
if filters.access_control_list is not None:
_append(
filter_parts,
_build_weighted_set_filter(
ACCESS_CONTROL_LIST, filters.access_control_list
),
_build_or_filters(ACCESS_CONTROL_LIST, filters.access_control_list),
)
# Source type filters

View File

@@ -219,26 +219,13 @@ def litellm_exception_to_error_msg(
"ratelimiterror"
):
upstream_detail = upstream_detail.split(":", 1)[1].strip()
upstream_detail_lower = upstream_detail.lower()
if (
"insufficient_quota" in upstream_detail_lower
or "exceeded your current quota" in upstream_detail_lower
):
error_msg = (
f"{provider_name} quota exceeded: {upstream_detail}"
if upstream_detail
else f"{provider_name} quota exceeded: Verify billing and quota for this API key."
)
error_code = "BUDGET_EXCEEDED"
is_retryable = False
else:
error_msg = (
f"{provider_name} rate limit: {upstream_detail}"
if upstream_detail
else f"{provider_name} rate limit exceeded: Please slow down your requests and try again later."
)
error_code = "RATE_LIMIT"
is_retryable = True
error_msg = (
f"{provider_name} rate limit: {upstream_detail}"
if upstream_detail
else f"{provider_name} rate limit exceeded: Please slow down your requests and try again later."
)
error_code = "RATE_LIMIT"
is_retryable = True
elif isinstance(core_exception, ServiceUnavailableError):
provider_name = (
llm.config.model_provider

View File

@@ -408,7 +408,7 @@ class FailedConnectorIndexingStatus(BaseModel):
"""Simplified version of ConnectorIndexingStatus for failed indexing attempts"""
cc_pair_id: int
name: str
name: str | None
error_msg: str | None
is_deletable: bool
connector_id: int
@@ -422,7 +422,7 @@ class ConnectorStatus(BaseModel):
"""
cc_pair_id: int
name: str
name: str | None
connector: ConnectorSnapshot
credential: CredentialSnapshot
access_type: AccessType
@@ -453,7 +453,7 @@ class DocsCountOperator(str, Enum):
class ConnectorIndexingStatusLite(BaseModel):
cc_pair_id: int
name: str
name: str | None
source: DocumentSource
access_type: AccessType
cc_pair_status: ConnectorCredentialPairStatus
@@ -488,7 +488,7 @@ class ConnectorCredentialPairIdentifier(BaseModel):
class ConnectorCredentialPairMetadata(BaseModel):
name: str
name: str | None = None
access_type: AccessType
auto_sync_options: dict[str, Any] | None = None
groups: list[int] = Field(default_factory=list)
@@ -501,7 +501,7 @@ class CCStatusUpdateRequest(BaseModel):
class ConnectorCredentialPairDescriptor(BaseModel):
id: int
name: str
name: str | None = None
connector: ConnectorSnapshot
credential: CredentialSnapshot
access_type: AccessType
@@ -511,7 +511,7 @@ class CCPairSummary(BaseModel):
"""Simplified connector-credential pair information with just essential data"""
id: int
name: str
name: str | None
source: DocumentSource
access_type: AccessType

View File

@@ -15,7 +15,7 @@
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.562.0",
"next": "16.1.7",
"next": "16.1.5",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
@@ -1711,9 +1711,9 @@
}
},
"node_modules/@next/env": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz",
"integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.5.tgz",
"integrity": "sha512-CRSCPJiSZoi4Pn69RYBDI9R7YK2g59vLexPQFXY0eyw+ILevIenCywzg+DqmlBik9zszEnw2HLFOUlLAcJbL7g==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -1727,9 +1727,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz",
"integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.5.tgz",
"integrity": "sha512-eK7Wdm3Hjy/SCL7TevlH0C9chrpeOYWx2iR7guJDaz4zEQKWcS1IMVfMb9UKBFMg1XgzcPTYPIp1Vcpukkjg6Q==",
"cpu": [
"arm64"
],
@@ -1743,9 +1743,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz",
"integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.5.tgz",
"integrity": "sha512-foQscSHD1dCuxBmGkbIr6ScAUF6pRoDZP6czajyvmXPAOFNnQUJu2Os1SGELODjKp/ULa4fulnBWoHV3XdPLfA==",
"cpu": [
"x64"
],
@@ -1759,9 +1759,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz",
"integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.5.tgz",
"integrity": "sha512-qNIb42o3C02ccIeSeKjacF3HXotGsxh/FMk/rSRmCzOVMtoWH88odn2uZqF8RLsSUWHcAqTgYmPD3pZ03L9ZAA==",
"cpu": [
"arm64"
],
@@ -1775,9 +1775,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz",
"integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.5.tgz",
"integrity": "sha512-U+kBxGUY1xMAzDTXmuVMfhaWUZQAwzRaHJ/I6ihtR5SbTVUEaDRiEU9YMjy1obBWpdOBuk1bcm+tsmifYSygfw==",
"cpu": [
"arm64"
],
@@ -1791,9 +1791,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz",
"integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.5.tgz",
"integrity": "sha512-gq2UtoCpN7Ke/7tKaU7i/1L7eFLfhMbXjNghSv0MVGF1dmuoaPeEVDvkDuO/9LVa44h5gqpWeJ4mRRznjDv7LA==",
"cpu": [
"x64"
],
@@ -1807,9 +1807,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz",
"integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.5.tgz",
"integrity": "sha512-bQWSE729PbXT6mMklWLf8dotislPle2L70E9q6iwETYEOt092GDn0c+TTNj26AjmeceSsC4ndyGsK5nKqHYXjQ==",
"cpu": [
"x64"
],
@@ -1823,9 +1823,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz",
"integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.5.tgz",
"integrity": "sha512-LZli0anutkIllMtTAWZlDqdfvjWX/ch8AFK5WgkNTvaqwlouiD1oHM+WW8RXMiL0+vAkAJyAGEzPPjO+hnrSNQ==",
"cpu": [
"arm64"
],
@@ -1839,9 +1839,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz",
"integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.5.tgz",
"integrity": "sha512-7is37HJTNQGhjPpQbkKjKEboHYQnCgpVt/4rBrrln0D9nderNxZ8ZWs8w1fAtzUx7wEyYjQ+/13myFgFj6K2Ng==",
"cpu": [
"x64"
],
@@ -4971,15 +4971,12 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.8",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
"integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==",
"version": "2.9.17",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz",
"integrity": "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/body-parser": {
@@ -8978,14 +8975,14 @@
}
},
"node_modules/next": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz",
"integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.5.tgz",
"integrity": "sha512-f+wE+NSbiQgh3DSAlTaw2FwY5yGdVViAtp8TotNQj4kk4Q8Bh1sC/aL9aH+Rg1YAVn18OYXsRDT7U/079jgP7w==",
"license": "MIT",
"dependencies": {
"@next/env": "16.1.7",
"@next/env": "16.1.5",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@@ -8997,14 +8994,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.1.7",
"@next/swc-darwin-x64": "16.1.7",
"@next/swc-linux-arm64-gnu": "16.1.7",
"@next/swc-linux-arm64-musl": "16.1.7",
"@next/swc-linux-x64-gnu": "16.1.7",
"@next/swc-linux-x64-musl": "16.1.7",
"@next/swc-win32-arm64-msvc": "16.1.7",
"@next/swc-win32-x64-msvc": "16.1.7",
"@next/swc-darwin-arm64": "16.1.5",
"@next/swc-darwin-x64": "16.1.5",
"@next/swc-linux-arm64-gnu": "16.1.5",
"@next/swc-linux-arm64-musl": "16.1.5",
"@next/swc-linux-x64-gnu": "16.1.5",
"@next/swc-linux-x64-musl": "16.1.5",
"@next/swc-win32-arm64-msvc": "16.1.5",
"@next/swc-win32-x64-msvc": "16.1.5",
"sharp": "^0.34.4"
},
"peerDependencies": {

View File

@@ -16,7 +16,7 @@
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.562.0",
"next": "16.1.7",
"next": "16.1.5",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",

View File

@@ -0,0 +1,239 @@
from __future__ import annotations
import re
from onyx.context.search.models import SavedSearchDoc
from onyx.context.search.models import SearchDoc
from onyx.server.query_and_chat.placement import Placement
from onyx.server.query_and_chat.streaming_models import AgentResponseDelta
from onyx.server.query_and_chat.streaming_models import AgentResponseStart
from onyx.server.query_and_chat.streaming_models import CitationInfo
from onyx.server.query_and_chat.streaming_models import GeneratedImage
from onyx.server.query_and_chat.streaming_models import ImageGenerationFinal
from onyx.server.query_and_chat.streaming_models import ImageGenerationToolStart
from onyx.server.query_and_chat.streaming_models import OpenUrlDocuments
from onyx.server.query_and_chat.streaming_models import OpenUrlStart
from onyx.server.query_and_chat.streaming_models import OpenUrlUrls
from onyx.server.query_and_chat.streaming_models import Packet
from onyx.server.query_and_chat.streaming_models import ReasoningDelta
from onyx.server.query_and_chat.streaming_models import ReasoningStart
from onyx.server.query_and_chat.streaming_models import SearchToolDocumentsDelta
from onyx.server.query_and_chat.streaming_models import SearchToolQueriesDelta
from onyx.server.query_and_chat.streaming_models import SearchToolStart
from onyx.server.query_and_chat.streaming_models import SectionEnd
_CANNOT_SHOW_STEP_RESULTS_STR = "[Cannot display step results]"
def _adjust_message_text_for_agent_search_results(
adjusted_message_text: str,
final_documents: list[SavedSearchDoc], # noqa: ARG001
) -> str:
# Remove all [Q<integer>] patterns (sub-question citations)
return re.sub(r"\[Q\d+\]", "", adjusted_message_text)
def _replace_d_citations_with_links(
message_text: str, final_documents: list[SavedSearchDoc]
) -> str:
def replace_citation(match: re.Match[str]) -> str:
d_number = match.group(1)
try:
doc_index = int(d_number) - 1
if 0 <= doc_index < len(final_documents):
doc = final_documents[doc_index]
link = doc.link if doc.link else ""
return f"[[{d_number}]]({link})"
return match.group(0)
except (ValueError, IndexError):
return match.group(0)
return re.sub(r"\[D(\d+)\]", replace_citation, message_text)
def create_message_packets(
message_text: str,
final_documents: list[SavedSearchDoc] | None,
turn_index: int,
is_legacy_agentic: bool = False,
) -> list[Packet]:
packets: list[Packet] = []
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=AgentResponseStart(
final_documents=SearchDoc.from_saved_search_docs(final_documents or []),
),
)
)
adjusted_message_text = message_text
if is_legacy_agentic:
if final_documents is not None:
adjusted_message_text = _adjust_message_text_for_agent_search_results(
message_text, final_documents
)
adjusted_message_text = _replace_d_citations_with_links(
adjusted_message_text, final_documents
)
else:
adjusted_message_text = re.sub(r"\[Q\d+\]", "", message_text)
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=AgentResponseDelta(
content=adjusted_message_text,
),
),
)
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=SectionEnd(),
)
)
return packets
def create_citation_packets(
citation_info_list: list[CitationInfo], turn_index: int
) -> list[Packet]:
packets: list[Packet] = []
# Emit each citation as a separate CitationInfo packet
for citation_info in citation_info_list:
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=citation_info,
)
)
packets.append(Packet(placement=Placement(turn_index=turn_index), obj=SectionEnd()))
return packets
def create_reasoning_packets(reasoning_text: str, turn_index: int) -> list[Packet]:
packets: list[Packet] = []
packets.append(
Packet(placement=Placement(turn_index=turn_index), obj=ReasoningStart())
)
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=ReasoningDelta(
reasoning=reasoning_text,
),
),
)
packets.append(Packet(placement=Placement(turn_index=turn_index), obj=SectionEnd()))
return packets
def create_image_generation_packets(
images: list[GeneratedImage], turn_index: int
) -> list[Packet]:
packets: list[Packet] = []
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=ImageGenerationToolStart(),
)
)
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=ImageGenerationFinal(images=images),
),
)
packets.append(Packet(placement=Placement(turn_index=turn_index), obj=SectionEnd()))
return packets
def create_fetch_packets(
fetch_docs: list[SavedSearchDoc],
urls: list[str],
turn_index: int,
) -> list[Packet]:
packets: list[Packet] = []
# Emit start packet
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=OpenUrlStart(),
)
)
# Emit URLs packet
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=OpenUrlUrls(urls=urls),
)
)
# Emit documents packet
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=OpenUrlDocuments(
documents=SearchDoc.from_saved_search_docs(fetch_docs)
),
)
)
packets.append(Packet(placement=Placement(turn_index=turn_index), obj=SectionEnd()))
return packets
def create_search_packets(
search_queries: list[str],
saved_search_docs: list[SavedSearchDoc],
is_internet_search: bool,
turn_index: int,
) -> list[Packet]:
packets: list[Packet] = []
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=SearchToolStart(
is_internet_search=is_internet_search,
),
)
)
# Emit queries if present
if search_queries:
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=SearchToolQueriesDelta(queries=search_queries),
),
)
# Emit documents if present
if saved_search_docs:
packets.append(
Packet(
placement=Placement(turn_index=turn_index),
obj=SearchToolDocumentsDelta(
documents=SearchDoc.from_saved_search_docs(saved_search_docs)
),
),
)
packets.append(Packet(placement=Placement(turn_index=turn_index), obj=SectionEnd()))
return packets

View File

@@ -698,7 +698,7 @@ py-key-value-aio==0.4.4
# via fastmcp
pyairtable==3.0.1
# via onyx
pyasn1==0.6.3
pyasn1==0.6.2
# via
# pyasn1-modules
# rsa

View File

@@ -263,7 +263,7 @@ oauthlib==3.2.2
# via
# kubernetes
# requests-oauthlib
onyx-devtools==0.7.1
onyx-devtools==0.7.0
# via onyx
openai==2.14.0
# via
@@ -326,7 +326,7 @@ pure-eval==0.2.3
# via stack-data
py==1.11.0
# via retry
pyasn1==0.6.3
pyasn1==0.6.2
# via
# pyasn1-modules
# rsa

View File

@@ -195,7 +195,7 @@ propcache==0.4.1
# yarl
py==1.11.0
# via retry
pyasn1==0.6.3
pyasn1==0.6.2
# via
# pyasn1-modules
# rsa

View File

@@ -285,7 +285,7 @@ psutil==7.1.3
# via accelerate
py==1.11.0
# via retry
pyasn1==0.6.3
pyasn1==0.6.2
# via
# pyasn1-modules
# rsa

View File

@@ -1,471 +0,0 @@
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import sys
from dataclasses import asdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from typing import TypedDict
from typing import TypeGuard
import aiohttp
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
logger = logging.getLogger(__name__)
DEFAULT_API_BASE = "http://localhost:3000"
INTERNAL_SEARCH_TOOL_NAME = "internal_search"
INTERNAL_SEARCH_IN_CODE_TOOL_ID = "SearchTool"
MAX_REQUEST_ATTEMPTS = 5
RETRIABLE_STATUS_CODES = {429, 500, 502, 503, 504}
@dataclass(frozen=True)
class QuestionRecord:
question_id: str
question: str
@dataclass(frozen=True)
class AnswerRecord:
question_id: str
answer: str
document_ids: list[str]
@dataclass(frozen=True)
class FailedQuestionRecord:
question_id: str
error: str
class Citation(TypedDict, total=False):
citation_number: int
document_id: str
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Submit questions to Onyx chat with internal search forced and write "
"answers to a JSONL file."
)
)
parser.add_argument(
"--questions-file",
type=Path,
required=True,
help="Path to the input questions JSONL file.",
)
parser.add_argument(
"--output-file",
type=Path,
required=True,
help="Path to the output answers JSONL file.",
)
parser.add_argument(
"--api-key",
type=str,
required=True,
help="API key used to authenticate against Onyx.",
)
parser.add_argument(
"--api-base",
type=str,
default=DEFAULT_API_BASE,
help=(
"Frontend base URL for Onyx. If `/api` is omitted, it will be added "
f"automatically. Default: {DEFAULT_API_BASE}"
),
)
parser.add_argument(
"--parallelism",
type=int,
default=1,
help="Number of questions to process in parallel. Default: 1.",
)
parser.add_argument(
"--max-questions",
type=int,
default=None,
help="Optional cap on how many questions to process. Defaults to all.",
)
return parser.parse_args()
def normalize_api_base(api_base: str) -> str:
normalized = api_base.rstrip("/")
if normalized.endswith("/api"):
return normalized
return f"{normalized}/api"
def load_questions(questions_file: Path) -> list[QuestionRecord]:
if not questions_file.exists():
raise FileNotFoundError(f"Questions file not found: {questions_file}")
questions: list[QuestionRecord] = []
with questions_file.open("r", encoding="utf-8") as file:
for line_number, line in enumerate(file, start=1):
stripped_line = line.strip()
if not stripped_line:
continue
try:
payload = json.loads(stripped_line)
except json.JSONDecodeError as exc:
raise ValueError(
f"Invalid JSON on line {line_number} of {questions_file}"
) from exc
question_id = payload.get("question_id")
question = payload.get("question")
if not isinstance(question_id, str) or not question_id:
raise ValueError(
f"Line {line_number} is missing a non-empty `question_id`."
)
if not isinstance(question, str) or not question:
raise ValueError(
f"Line {line_number} is missing a non-empty `question`."
)
questions.append(QuestionRecord(question_id=question_id, question=question))
return questions
async def read_json_response(
response: aiohttp.ClientResponse,
) -> dict[str, Any] | list[dict[str, Any]]:
response_text = await response.text()
if response.status >= 400:
raise RuntimeError(
f"Request to {response.url} failed with {response.status}: {response_text}"
)
try:
payload = json.loads(response_text)
except json.JSONDecodeError as exc:
raise RuntimeError(
f"Request to {response.url} returned non-JSON content: {response_text}"
) from exc
if not isinstance(payload, (dict, list)):
raise RuntimeError(
f"Unexpected response payload type from {response.url}: {type(payload)}"
)
return payload
async def request_json_with_retries(
session: aiohttp.ClientSession,
method: str,
url: str,
headers: dict[str, str],
json_payload: dict[str, Any] | None = None,
) -> dict[str, Any] | list[dict[str, Any]]:
backoff_seconds = 1.0
for attempt in range(1, MAX_REQUEST_ATTEMPTS + 1):
try:
async with session.request(
method=method,
url=url,
headers=headers,
json=json_payload,
) as response:
if (
response.status in RETRIABLE_STATUS_CODES
and attempt < MAX_REQUEST_ATTEMPTS
):
response_text = await response.text()
logger.warning(
"Retryable response from %s on attempt %s/%s: %s %s",
url,
attempt,
MAX_REQUEST_ATTEMPTS,
response.status,
response_text,
)
await asyncio.sleep(backoff_seconds)
backoff_seconds *= 2
continue
return await read_json_response(response)
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
if attempt == MAX_REQUEST_ATTEMPTS:
raise RuntimeError(
f"Request to {url} failed after {MAX_REQUEST_ATTEMPTS} attempts."
) from exc
logger.warning(
"Request to %s failed on attempt %s/%s: %s",
url,
attempt,
MAX_REQUEST_ATTEMPTS,
exc,
)
await asyncio.sleep(backoff_seconds)
backoff_seconds *= 2
raise RuntimeError(f"Request to {url} failed unexpectedly.")
def extract_document_ids(citation_info: object) -> list[str]:
if not isinstance(citation_info, list):
return []
sorted_citations = sorted(
(citation for citation in citation_info if _is_valid_citation(citation)),
key=_citation_sort_key,
)
document_ids: list[str] = []
seen_document_ids: set[str] = set()
for citation in sorted_citations:
document_id = citation["document_id"]
if document_id not in seen_document_ids:
seen_document_ids.add(document_id)
document_ids.append(document_id)
return document_ids
def _is_valid_citation(citation: object) -> TypeGuard[Citation]:
return (
isinstance(citation, dict)
and isinstance(citation.get("document_id"), str)
and bool(citation["document_id"])
)
def _citation_sort_key(citation: Citation) -> int:
citation_number = citation.get("citation_number")
if isinstance(citation_number, int):
return citation_number
return sys.maxsize
async def fetch_internal_search_tool_id(
session: aiohttp.ClientSession,
api_base: str,
headers: dict[str, str],
) -> int:
payload = await request_json_with_retries(
session=session,
method="GET",
url=f"{api_base}/tool",
headers=headers,
)
if not isinstance(payload, list):
raise RuntimeError("Expected `/tool` to return a list.")
for tool in payload:
if not isinstance(tool, dict):
continue
if tool.get("in_code_tool_id") == INTERNAL_SEARCH_IN_CODE_TOOL_ID:
tool_id = tool.get("id")
if isinstance(tool_id, int):
return tool_id
for tool in payload:
if not isinstance(tool, dict):
continue
if tool.get("name") == INTERNAL_SEARCH_TOOL_NAME:
tool_id = tool.get("id")
if isinstance(tool_id, int):
return tool_id
raise RuntimeError(
"Could not find the internal search tool in `/tool`. "
"Make sure SearchTool is available for this environment."
)
async def submit_question(
session: aiohttp.ClientSession,
api_base: str,
headers: dict[str, str],
internal_search_tool_id: int,
question_record: QuestionRecord,
) -> AnswerRecord:
payload = {
"message": question_record.question,
"chat_session_info": {"persona_id": 0},
"parent_message_id": None,
"file_descriptors": [],
"allowed_tool_ids": [internal_search_tool_id],
"forced_tool_id": internal_search_tool_id,
"stream": False,
}
response_payload = await request_json_with_retries(
session=session,
method="POST",
url=f"{api_base}/chat/send-chat-message",
headers=headers,
json_payload=payload,
)
if not isinstance(response_payload, dict):
raise RuntimeError(
"Expected `/chat/send-chat-message` to return an object when `stream=false`."
)
answer = response_payload.get("answer_citationless")
if not isinstance(answer, str):
answer = response_payload.get("answer")
if not isinstance(answer, str):
raise RuntimeError(
f"Response for question {question_record.question_id} is missing `answer`."
)
return AnswerRecord(
question_id=question_record.question_id,
answer=answer,
document_ids=extract_document_ids(response_payload.get("citation_info")),
)
async def generate_answers(
questions: list[QuestionRecord],
output_file: Path,
api_base: str,
api_key: str,
parallelism: int,
) -> None:
if parallelism < 1:
raise ValueError("`--parallelism` must be at least 1.")
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
timeout = aiohttp.ClientTimeout(
total=None,
connect=30,
sock_connect=30,
sock_read=600,
)
connector = aiohttp.TCPConnector(limit=parallelism)
output_file.parent.mkdir(parents=True, exist_ok=True)
with output_file.open("a", encoding="utf-8") as file:
async with aiohttp.ClientSession(
timeout=timeout, connector=connector
) as session:
internal_search_tool_id = await fetch_internal_search_tool_id(
session=session,
api_base=api_base,
headers=headers,
)
logger.info("Using internal search tool id %s", internal_search_tool_id)
semaphore = asyncio.Semaphore(parallelism)
progress_lock = asyncio.Lock()
write_lock = asyncio.Lock()
completed = 0
successful = 0
failed_questions: list[FailedQuestionRecord] = []
total = len(questions)
async def process_question(question_record: QuestionRecord) -> None:
nonlocal completed
nonlocal successful
try:
async with semaphore:
result = await submit_question(
session=session,
api_base=api_base,
headers=headers,
internal_search_tool_id=internal_search_tool_id,
question_record=question_record,
)
except Exception as exc:
async with progress_lock:
completed += 1
failed_questions.append(
FailedQuestionRecord(
question_id=question_record.question_id,
error=str(exc),
)
)
logger.exception(
"Failed question %s (%s/%s)",
question_record.question_id,
completed,
total,
)
return
async with write_lock:
file.write(json.dumps(asdict(result), ensure_ascii=False))
file.write("\n")
file.flush()
async with progress_lock:
completed += 1
successful += 1
logger.info("Processed %s/%s questions", completed, total)
await asyncio.gather(
*(process_question(question_record) for question_record in questions)
)
if failed_questions:
logger.warning(
"Completed with %s failed questions and %s successful questions.",
len(failed_questions),
successful,
)
for failed_question in failed_questions:
logger.warning(
"Failed question %s: %s",
failed_question.question_id,
failed_question.error,
)
def main() -> None:
args = parse_args()
questions = load_questions(args.questions_file)
api_base = normalize_api_base(args.api_base)
if args.max_questions is not None:
if args.max_questions < 1:
raise ValueError("`--max-questions` must be at least 1 when provided.")
questions = questions[: args.max_questions]
logger.info("Loaded %s questions from %s", len(questions), args.questions_file)
logger.info("Writing answers to %s", args.output_file)
asyncio.run(
generate_answers(
questions=questions,
output_file=args.output_file,
api_base=api_base,
api_key=args.api_key,
parallelism=args.parallelism,
)
)
if __name__ == "__main__":
main()

View File

@@ -1,291 +0,0 @@
"""
Script to upload files from a directory as individual file connectors in Onyx.
Each file gets its own connector named after the file.
Usage:
python upload_files_as_connectors.py --data-dir /path/to/files --api-key YOUR_KEY
python upload_files_as_connectors.py --data-dir /path/to/files --api-key YOUR_KEY --api-base http://onyxserver:3000
python upload_files_as_connectors.py --data-dir /path/to/files --api-key YOUR_KEY --file-glob '*.zip'
Requires:
pip install requests
"""
import argparse
import fnmatch
import os
import sys
import threading
import time
import requests
REQUEST_TIMEOUT = 900 # 15 minutes
def _elapsed_printer(label: str, stop_event: threading.Event) -> None:
"""Print a live elapsed-time counter until stop_event is set."""
start = time.monotonic()
while not stop_event.wait(timeout=1):
elapsed = int(time.monotonic() - start)
m, s = divmod(elapsed, 60)
print(f"\r {label} ... {m:02d}:{s:02d}", end="", flush=True)
elapsed = int(time.monotonic() - start)
m, s = divmod(elapsed, 60)
print(f"\r {label} ... {m:02d}:{s:02d} done")
def _timed_request(label: str, fn: object) -> requests.Response:
"""Run a request function while displaying a live elapsed timer."""
stop = threading.Event()
t = threading.Thread(target=_elapsed_printer, args=(label, stop), daemon=True)
t.start()
try:
resp = fn() # type: ignore[operator]
finally:
stop.set()
t.join()
return resp
def upload_file(
session: requests.Session, base_url: str, file_path: str
) -> dict | None:
"""Upload a single file and return the response with file_paths and file_names."""
with open(file_path, "rb") as f:
resp = _timed_request(
"Uploading",
lambda: session.post(
f"{base_url}/api/manage/admin/connector/file/upload",
files={"files": (os.path.basename(file_path), f)},
timeout=REQUEST_TIMEOUT,
),
)
if not resp.ok:
print(f" ERROR uploading: {resp.text}")
return None
return resp.json()
def create_connector(
session: requests.Session,
base_url: str,
name: str,
file_paths: list[str],
file_names: list[str],
zip_metadata_file_id: str | None,
) -> int | None:
"""Create a file connector and return its ID."""
resp = _timed_request(
"Creating connector",
lambda: session.post(
f"{base_url}/api/manage/admin/connector",
json={
"name": name,
"source": "file",
"input_type": "load_state",
"connector_specific_config": {
"file_locations": file_paths,
"file_names": file_names,
"zip_metadata_file_id": zip_metadata_file_id,
},
"refresh_freq": None,
"prune_freq": None,
"indexing_start": None,
"access_type": "public",
"groups": [],
},
timeout=REQUEST_TIMEOUT,
),
)
if not resp.ok:
print(f" ERROR creating connector: {resp.text}")
return None
return resp.json()["id"]
def create_credential(
session: requests.Session, base_url: str, name: str
) -> int | None:
"""Create a dummy credential for the file connector."""
resp = session.post(
f"{base_url}/api/manage/credential",
json={
"credential_json": {},
"admin_public": True,
"source": "file",
"curator_public": True,
"groups": [],
"name": name,
},
timeout=REQUEST_TIMEOUT,
)
if not resp.ok:
print(f" ERROR creating credential: {resp.text}")
return None
return resp.json()["id"]
def link_credential(
session: requests.Session,
base_url: str,
connector_id: int,
credential_id: int,
name: str,
) -> bool:
"""Link the connector to the credential (create CC pair)."""
resp = session.put(
f"{base_url}/api/manage/connector/{connector_id}/credential/{credential_id}",
json={
"name": name,
"access_type": "public",
"groups": [],
"auto_sync_options": None,
"processing_mode": "REGULAR",
},
timeout=REQUEST_TIMEOUT,
)
if not resp.ok:
print(f" ERROR linking credential: {resp.text}")
return False
return True
def run_connector(
session: requests.Session,
base_url: str,
connector_id: int,
credential_id: int,
) -> bool:
"""Trigger the connector to start indexing."""
resp = session.post(
f"{base_url}/api/manage/admin/connector/run-once",
json={
"connector_id": connector_id,
"credentialIds": [credential_id],
"from_beginning": False,
},
timeout=REQUEST_TIMEOUT,
)
if not resp.ok:
print(f" ERROR running connector: {resp.text}")
return False
return True
def process_file(session: requests.Session, base_url: str, file_path: str) -> bool:
"""Process a single file through the full connector creation flow."""
file_name = os.path.basename(file_path)
connector_name = file_name
print(f"Processing: {file_name}")
# Step 1: Upload
upload_resp = upload_file(session, base_url, file_path)
if not upload_resp:
return False
# Step 2: Create connector
connector_id = create_connector(
session,
base_url,
name=f"FileConnector-{connector_name}",
file_paths=upload_resp["file_paths"],
file_names=upload_resp["file_names"],
zip_metadata_file_id=upload_resp.get("zip_metadata_file_id"),
)
if connector_id is None:
return False
# Step 3: Create credential
credential_id = create_credential(session, base_url, name=connector_name)
if credential_id is None:
return False
# Step 4: Link connector to credential
if not link_credential(
session, base_url, connector_id, credential_id, connector_name
):
return False
# Step 5: Trigger indexing
if not run_connector(session, base_url, connector_id, credential_id):
return False
print(f" OK (connector_id={connector_id})")
return True
def get_authenticated_session(api_key: str) -> requests.Session:
"""Create a session authenticated with an API key."""
session = requests.Session()
session.headers.update({"Authorization": f"Bearer {api_key}"})
return session
def main() -> None:
parser = argparse.ArgumentParser(
description="Upload files as individual Onyx file connectors."
)
parser.add_argument(
"--data-dir",
required=True,
help="Directory containing files to upload.",
)
parser.add_argument(
"--api-base",
default="http://localhost:3000",
help="Base URL for the Onyx API (default: http://localhost:3000).",
)
parser.add_argument(
"--api-key",
required=True,
help="API key for authentication.",
)
parser.add_argument(
"--file-glob",
default=None,
help="Glob pattern to filter files (e.g. '*.json', '*.zip').",
)
args = parser.parse_args()
data_dir = args.data_dir
base_url = args.api_base.rstrip("/")
api_key = args.api_key
file_glob = args.file_glob
if not os.path.isdir(data_dir):
print(f"Error: {data_dir} is not a directory")
sys.exit(1)
script_path = os.path.realpath(__file__)
files = sorted(
os.path.join(data_dir, f)
for f in os.listdir(data_dir)
if os.path.isfile(os.path.join(data_dir, f))
and os.path.realpath(os.path.join(data_dir, f)) != script_path
and (file_glob is None or fnmatch.fnmatch(f, file_glob))
)
if not files:
print(f"No files found in {data_dir}")
sys.exit(1)
print(f"Found {len(files)} file(s) in {data_dir}\n")
session = get_authenticated_session(api_key)
success = 0
failed = 0
for file_path in files:
if process_file(session, base_url, file_path):
success += 1
else:
failed += 1
# Small delay to avoid overwhelming the server
time.sleep(0.5)
print(f"\nDone: {success} succeeded, {failed} failed out of {len(files)} files.")
if __name__ == "__main__":
main()

View File

@@ -297,10 +297,6 @@ def index_batch_params(
class TestDocumentIndexOld:
"""Tests the old DocumentIndex interface."""
# TODO(ENG-3864)(andrei): Re-enable this test.
@pytest.mark.xfail(
reason="Flaky test: Retrieved chunks vary non-deterministically before and after changing user projects and personas. Likely a timing issue with the index being updated."
)
def test_update_single_can_clear_user_projects_and_personas(
self,
document_indices: list[DocumentIndex],

View File

@@ -22,8 +22,6 @@ from onyx.document_index.interfaces_new import TenantState
from onyx.document_index.opensearch.client import OpenSearchIndexClient
from onyx.document_index.opensearch.client import wait_for_opensearch_with_timeout
from onyx.document_index.opensearch.constants import DEFAULT_MAX_CHUNK_SIZE
from onyx.document_index.opensearch.constants import HybridSearchNormalizationPipeline
from onyx.document_index.opensearch.constants import HybridSearchSubqueryConfiguration
from onyx.document_index.opensearch.opensearch_document_index import (
generate_opensearch_filtered_access_control_list,
)
@@ -33,14 +31,9 @@ from onyx.document_index.opensearch.schema import DocumentSchema
from onyx.document_index.opensearch.schema import get_opensearch_doc_chunk_id
from onyx.document_index.opensearch.search import DocumentQuery
from onyx.document_index.opensearch.search import (
get_min_max_normalization_pipeline_name_and_config,
)
from onyx.document_index.opensearch.search import (
get_normalization_pipeline_name_and_config,
)
from onyx.document_index.opensearch.search import (
get_zscore_normalization_pipeline_name_and_config,
MIN_MAX_NORMALIZATION_PIPELINE_CONFIG,
)
from onyx.document_index.opensearch.search import MIN_MAX_NORMALIZATION_PIPELINE_NAME
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA
@@ -56,46 +49,6 @@ def _patch_global_tenant_state(monkeypatch: pytest.MonkeyPatch, state: bool) ->
monkeypatch.setattr("onyx.document_index.opensearch.schema.MULTI_TENANT", state)
def _patch_hybrid_search_subquery_configuration(
monkeypatch: pytest.MonkeyPatch, configuration: HybridSearchSubqueryConfiguration
) -> None:
"""
Patches HYBRID_SEARCH_SUBQUERY_CONFIGURATION wherever necessary for this
test file.
Args:
monkeypatch: The test instance's monkeypatch instance, used for
patching.
configuration: The intended state of
HYBRID_SEARCH_SUBQUERY_CONFIGURATION.
"""
monkeypatch.setattr(
"onyx.document_index.opensearch.constants.HYBRID_SEARCH_SUBQUERY_CONFIGURATION",
configuration,
)
monkeypatch.setattr(
"onyx.document_index.opensearch.search.HYBRID_SEARCH_SUBQUERY_CONFIGURATION",
configuration,
)
def _patch_hybrid_search_normalization_pipeline(
monkeypatch: pytest.MonkeyPatch, pipeline: HybridSearchNormalizationPipeline
) -> None:
"""
Patches HYBRID_SEARCH_NORMALIZATION_PIPELINE wherever necessary for this
test file.
"""
monkeypatch.setattr(
"onyx.document_index.opensearch.constants.HYBRID_SEARCH_NORMALIZATION_PIPELINE",
pipeline,
)
monkeypatch.setattr(
"onyx.document_index.opensearch.search.HYBRID_SEARCH_NORMALIZATION_PIPELINE",
pipeline,
)
def _create_test_document_chunk(
document_id: str,
content: str,
@@ -191,27 +144,14 @@ def test_client(
@pytest.fixture(scope="function")
def search_pipeline(test_client: OpenSearchIndexClient) -> Generator[None, None, None]:
"""Creates a search pipeline for testing with automatic cleanup."""
min_max_normalization_pipeline_name, min_max_normalization_pipeline_config = (
get_min_max_normalization_pipeline_name_and_config()
)
zscore_normalization_pipeline_name, zscore_normalization_pipeline_config = (
get_zscore_normalization_pipeline_name_and_config()
)
test_client.create_search_pipeline(
pipeline_id=min_max_normalization_pipeline_name,
pipeline_body=min_max_normalization_pipeline_config,
)
test_client.create_search_pipeline(
pipeline_id=zscore_normalization_pipeline_name,
pipeline_body=zscore_normalization_pipeline_config,
pipeline_id=MIN_MAX_NORMALIZATION_PIPELINE_NAME,
pipeline_body=MIN_MAX_NORMALIZATION_PIPELINE_CONFIG,
)
yield # Test runs here.
try:
test_client.delete_search_pipeline(
pipeline_id=min_max_normalization_pipeline_name,
)
test_client.delete_search_pipeline(
pipeline_id=zscore_normalization_pipeline_name,
pipeline_id=MIN_MAX_NORMALIZATION_PIPELINE_NAME,
)
except Exception:
pass
@@ -437,19 +377,18 @@ class TestOpenSearchClient:
self, test_client: OpenSearchIndexClient
) -> None:
"""Tests creating and deleting a search pipeline."""
# Precondition.
pipeline_name, pipeline_config = get_normalization_pipeline_name_and_config()
# Under test and postcondition.
# Should not raise.
test_client.create_search_pipeline(
pipeline_id=pipeline_name,
pipeline_body=pipeline_config,
pipeline_id=MIN_MAX_NORMALIZATION_PIPELINE_NAME,
pipeline_body=MIN_MAX_NORMALIZATION_PIPELINE_CONFIG,
)
# Under test and postcondition.
# Should not raise.
test_client.delete_search_pipeline(pipeline_id=pipeline_name)
test_client.delete_search_pipeline(
pipeline_id=MIN_MAX_NORMALIZATION_PIPELINE_NAME
)
def test_index_document(
self, test_client: OpenSearchIndexClient, monkeypatch: pytest.MonkeyPatch
@@ -795,13 +734,13 @@ class TestOpenSearchClient:
properties_to_update={"hidden": True},
)
def test_hybrid_search_configurations_and_pipelines(
def test_hybrid_search_with_pipeline(
self,
test_client: OpenSearchIndexClient,
search_pipeline: None, # noqa: ARG002
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Tests all hybrid search configurations and pipelines."""
"""Tests hybrid search with a normalization pipeline."""
# Precondition.
_patch_global_tenant_state(monkeypatch, False)
tenant_state = TenantState(tenant_id=POSTGRES_DEFAULT_SCHEMA, multitenant=False)
@@ -810,6 +749,7 @@ class TestOpenSearchClient:
)
settings = DocumentSchema.get_index_settings()
test_client.create_index(mappings=mappings, settings=settings)
# Index documents.
docs = {
"doc-1": _create_test_document_chunk(
@@ -840,58 +780,40 @@ class TestOpenSearchClient:
# Refresh index to make documents searchable.
test_client.refresh_index()
for configuration in HybridSearchSubqueryConfiguration:
_patch_hybrid_search_subquery_configuration(monkeypatch, configuration)
for pipeline in HybridSearchNormalizationPipeline:
_patch_hybrid_search_normalization_pipeline(monkeypatch, pipeline)
pipeline_name, pipeline_config = (
get_normalization_pipeline_name_and_config()
)
test_client.create_search_pipeline(
pipeline_id=pipeline_name,
pipeline_body=pipeline_config,
)
# Search query.
query_text = "Python programming"
query_vector = _generate_test_vector(0.12)
search_body = DocumentQuery.get_hybrid_search_query(
query_text=query_text,
query_vector=query_vector,
num_hits=5,
tenant_state=tenant_state,
# We're not worried about filtering here. tenant_id in this object
# is not relevant.
index_filters=IndexFilters(access_control_list=None, tenant_id=None),
include_hidden=False,
)
# Search query.
query_text = "Python programming"
query_vector = _generate_test_vector(0.12)
search_body = DocumentQuery.get_hybrid_search_query(
query_text=query_text,
query_vector=query_vector,
num_hits=5,
tenant_state=tenant_state,
# We're not worried about filtering here. tenant_id in this object
# is not relevant.
index_filters=IndexFilters(
access_control_list=None, tenant_id=None
),
include_hidden=False,
)
# Under test.
results = test_client.search(
body=search_body, search_pipeline_id=MIN_MAX_NORMALIZATION_PIPELINE_NAME
)
# Under test.
results = test_client.search(
body=search_body, search_pipeline_id=pipeline_name
)
# Postcondition.
assert len(results) == len(docs)
# Assert that all the chunks above are present.
assert all(
chunk.document_chunk.document_id in docs.keys() for chunk in results
)
# Make sure the chunk contents are preserved.
for i, chunk in enumerate(results):
assert (
chunk.document_chunk == docs[chunk.document_chunk.document_id]
)
# Make sure score reporting seems reasonable (it should not be None
# or 0).
assert chunk.score
# Make sure there is some kind of match highlight only for the first
# result. The other results are so bad they're not expected to have
# match highlights.
if i == 0:
assert chunk.match_highlights.get(CONTENT_FIELD_NAME, [])
# Postcondition.
assert len(results) == len(docs)
# Assert that all the chunks above are present.
assert all(chunk.document_chunk.document_id in docs.keys() for chunk in results)
# Make sure the chunk contents are preserved.
for i, chunk in enumerate(results):
assert chunk.document_chunk == docs[chunk.document_chunk.document_id]
# Make sure score reporting seems reasonable (it should not be None
# or 0).
assert chunk.score
# Make sure there is some kind of match highlight only for the first
# result. The other results are so bad they're not expected to have
# match highlights.
if i == 0:
assert chunk.match_highlights.get(CONTENT_FIELD_NAME, [])
def test_search_empty_index(
self,
@@ -923,10 +845,11 @@ class TestOpenSearchClient:
index_filters=IndexFilters(access_control_list=None, tenant_id=None),
include_hidden=False,
)
pipeline_name, _ = get_normalization_pipeline_name_and_config()
# Under test.
results = test_client.search(body=search_body, search_pipeline_id=pipeline_name)
results = test_client.search(
body=search_body, search_pipeline_id=MIN_MAX_NORMALIZATION_PIPELINE_NAME
)
# Postcondition.
assert len(results) == 0
@@ -1025,10 +948,11 @@ class TestOpenSearchClient:
),
include_hidden=False,
)
pipeline_name, _ = get_normalization_pipeline_name_and_config()
# Under test.
results = test_client.search(body=search_body, search_pipeline_id=pipeline_name)
results = test_client.search(
body=search_body, search_pipeline_id=MIN_MAX_NORMALIZATION_PIPELINE_NAME
)
# Postcondition.
# Should only get the public, non-hidden document, and the private
@@ -1143,10 +1067,11 @@ class TestOpenSearchClient:
index_filters=IndexFilters(access_control_list=[], tenant_id=None),
include_hidden=False,
)
pipeline_name, _ = get_normalization_pipeline_name_and_config()
# Under test.
results = test_client.search(body=search_body, search_pipeline_id=pipeline_name)
results = test_client.search(
body=search_body, search_pipeline_id=MIN_MAX_NORMALIZATION_PIPELINE_NAME
)
# Postcondition.
# Should only get public, non-hidden documents (3 out of 5).
@@ -1516,16 +1441,15 @@ class TestOpenSearchClient:
),
include_hidden=False,
)
pipeline_name, _ = get_normalization_pipeline_name_and_config()
# Under test.
last_week_results = test_client.search(
body=last_week_search_body,
search_pipeline_id=pipeline_name,
search_pipeline_id=MIN_MAX_NORMALIZATION_PIPELINE_NAME,
)
last_six_months_results = test_client.search(
body=last_six_months_search_body,
search_pipeline_id=pipeline_name,
search_pipeline_id=MIN_MAX_NORMALIZATION_PIPELINE_NAME,
)
# Postcondition.

View File

@@ -1,13 +1,9 @@
"""Tests for llm_loop.py, including history construction and empty-response paths."""
from unittest.mock import Mock
"""Tests for llm_loop.py, specifically the construct_message_history function."""
import pytest
from onyx.chat.llm_loop import _build_empty_llm_response_error
from onyx.chat.llm_loop import _try_fallback_tool_extraction
from onyx.chat.llm_loop import construct_message_history
from onyx.chat.llm_loop import EmptyLLMResponseError
from onyx.chat.models import ChatLoadedFile
from onyx.chat.models import ChatMessageSimple
from onyx.chat.models import ContextFileMetadata
@@ -17,7 +13,6 @@ from onyx.chat.models import LlmStepResult
from onyx.chat.models import ToolCallSimple
from onyx.configs.constants import MessageType
from onyx.file_store.models import ChatFileType
from onyx.llm.interfaces import LLMConfig
from onyx.llm.interfaces import ToolChoiceOptions
from onyx.server.query_and_chat.placement import Placement
from onyx.tools.models import ToolCallKickoff
@@ -1172,57 +1167,3 @@ class TestFallbackToolExtraction:
assert result is llm_step_result
assert attempted is False
class TestEmptyLlmResponseClassification:
def _make_llm(self, provider: str = "openai", model: str = "gpt-5.2") -> Mock:
llm = Mock()
llm.config = LLMConfig(
model_provider=provider,
model_name=model,
temperature=0.0,
max_input_tokens=4096,
)
return llm
def test_openai_empty_stream_is_classified_as_budget_exceeded(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("onyx.chat.llm_loop.is_true_openai_model", lambda *_: True)
err = _build_empty_llm_response_error(
llm=self._make_llm(),
llm_step_result=LlmStepResult(
reasoning=None,
answer=None,
tool_calls=None,
raw_answer=None,
),
tool_choice=ToolChoiceOptions.AUTO,
)
assert isinstance(err, EmptyLLMResponseError)
assert err.error_code == "BUDGET_EXCEEDED"
assert err.is_retryable is False
assert "quota" in err.client_error_msg.lower()
def test_reasoning_only_response_uses_generic_empty_response_error(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("onyx.chat.llm_loop.is_true_openai_model", lambda *_: True)
err = _build_empty_llm_response_error(
llm=self._make_llm(),
llm_step_result=LlmStepResult(
reasoning="scratchpad only",
answer=None,
tool_calls=None,
raw_answer=None,
),
tool_choice=ToolChoiceOptions.AUTO,
)
assert isinstance(err, EmptyLLMResponseError)
assert err.error_code == "EMPTY_LLM_RESPONSE"
assert err.is_retryable is True
assert "quota" not in err.client_error_msg.lower()

View File

@@ -1,34 +0,0 @@
from onyx.chat.process_message import remove_answer_citations
def test_remove_answer_citations_strips_http_markdown_citation() -> None:
answer = "The answer is Paris [[1]](https://example.com/doc)."
assert remove_answer_citations(answer) == "The answer is Paris."
def test_remove_answer_citations_strips_empty_markdown_citation() -> None:
answer = "The answer is Paris [[1]]()."
assert remove_answer_citations(answer) == "The answer is Paris."
def test_remove_answer_citations_strips_citation_with_parentheses_in_url() -> None:
answer = (
"The answer is Paris "
"[[1]](https://en.wikipedia.org/wiki/Function_(mathematics))."
)
assert remove_answer_citations(answer) == "The answer is Paris."
def test_remove_answer_citations_preserves_non_citation_markdown_links() -> None:
answer = (
"See [reference](https://example.com/Function_(mathematics)) "
"for context [[1]](https://en.wikipedia.org/wiki/Function_(mathematics))."
)
assert (
remove_answer_citations(answer)
== "See [reference](https://example.com/Function_(mathematics)) for context."
)

View File

@@ -3,10 +3,7 @@ from unittest.mock import Mock
import pytest
from onyx.chat import process_message
from onyx.chat.models import AnswerStream
from onyx.chat.models import StreamingError
from onyx.configs import app_configs
from onyx.server.query_and_chat.models import MessageResponseIDInfo
from onyx.server.query_and_chat.models import SendMessageRequest
@@ -38,26 +35,3 @@ def test_mock_llm_response_requires_integration_mode() -> None:
db_session=Mock(),
)
)
def test_gather_stream_returns_empty_answer_when_streaming_error_only() -> None:
packets: AnswerStream = iter(
[
MessageResponseIDInfo(
user_message_id=None,
reserved_assistant_message_id=42,
),
StreamingError(
error="OpenAI quota exceeded",
error_code="BUDGET_EXCEEDED",
is_retryable=False,
),
]
)
result = process_message.gather_stream(packets)
assert result.answer == ""
assert result.answer_citationless == ""
assert result.error_msg == "OpenAI quota exceeded"
assert result.message_id == 42

View File

@@ -44,13 +44,13 @@ class TestBuildVespaFilters:
assert result == f'({SOURCE_TYPE} contains "web") and '
def test_acl(self) -> None:
"""Test with acls — uses weightedSet operator for efficient matching."""
"""Test with acls."""
# Single ACL
filters = IndexFilters(access_control_list=["user1"])
result = build_vespa_filters(filters)
assert (
result
== f'!({HIDDEN}=true) and weightedSet(access_control_list, {{"user1":1}}) and '
== f'!({HIDDEN}=true) and (access_control_list contains "user1") and '
)
# Multiple ACL's
@@ -58,7 +58,7 @@ class TestBuildVespaFilters:
result = build_vespa_filters(filters)
assert (
result
== f'!({HIDDEN}=true) and weightedSet(access_control_list, {{"user2":1, "group2":1}}) and '
== f'!({HIDDEN}=true) and (access_control_list contains "user2" or access_control_list contains "group2") and '
)
def test_tenant_filter(self) -> None:
@@ -250,7 +250,7 @@ class TestBuildVespaFilters:
result = build_vespa_filters(filters)
expected = f"!({HIDDEN}=true) and "
expected += 'weightedSet(access_control_list, {"user1":1, "group1":1}) and '
expected += '(access_control_list contains "user1" or access_control_list contains "group1") and '
expected += f'({SOURCE_TYPE} contains "web") and '
expected += f'({METADATA_LIST} contains "color{INDEX_SEPARATOR}red") and '
# Knowledge scope filters are OR'd together
@@ -290,38 +290,6 @@ class TestBuildVespaFilters:
expected = f'!({HIDDEN}=true) and (({DOCUMENT_SETS} contains "engineering") or ({DOCUMENT_ID} contains "{str(id1)}")) and '
assert expected == result
def test_acl_large_list_uses_weighted_set(self) -> None:
"""Verify that large ACL lists produce a weightedSet clause
instead of OR-chained contains — this is what prevents Vespa
HTTP 400 errors for users with thousands of permission groups."""
acl = [f"external_group:google_drive_{i}" for i in range(10_000)]
acl += ["user_email:user@example.com", "__PUBLIC__"]
filters = IndexFilters(access_control_list=acl)
result = build_vespa_filters(filters)
assert "weightedSet(access_control_list, {" in result
# Must NOT contain OR-chained contains clauses
assert "access_control_list contains" not in result
# All entries should be present
assert '"external_group:google_drive_0":1' in result
assert '"external_group:google_drive_9999":1' in result
assert '"user_email:user@example.com":1' in result
assert '"__PUBLIC__":1' in result
def test_acl_empty_strings_filtered(self) -> None:
"""Empty strings in the ACL list should be filtered out."""
filters = IndexFilters(access_control_list=["user1", "", "group1"])
result = build_vespa_filters(filters)
assert (
result
== f'!({HIDDEN}=true) and weightedSet(access_control_list, {{"user1":1, "group1":1}}) and '
)
# All empty
filters = IndexFilters(access_control_list=["", ""])
result = build_vespa_filters(filters)
assert result == f"!({HIDDEN}=true) and "
def test_empty_or_none_values(self) -> None:
"""Test with empty or None values in filter lists."""
# Empty strings in document set

View File

@@ -12,11 +12,6 @@ services:
api_server:
ports:
- "8080:8080"
deploy:
resources:
limits:
cpus: "${API_SERVER_CPU_LIMIT:-0}"
memory: "${API_SERVER_MEM_LIMIT:-0}"
# Uncomment the block below to enable the MCP server for Onyx.
# mcp_server:

View File

@@ -19,13 +19,12 @@
## Output template to file and inspect
* cd charts/onyx
* helm template test-output . --set auth.opensearch.values.opensearch_admin_password='StrongPassword123!' > test-output.yaml
* helm template test-output . > test-output.yaml
## Test the entire cluster manually
* cd charts/onyx
* helm install onyx . -n onyx --set postgresql.primary.persistence.enabled=false --set auth.opensearch.values.opensearch_admin_password='StrongPassword123!'
* helm install onyx . -n onyx --set postgresql.primary.persistence.enabled=false
* the postgres flag is to keep the storage ephemeral for testing. You probably don't want to set that in prod.
* the OpenSearch admin password must be set on first install unless you are supplying `auth.opensearch.existingSecret`.
* no flag for ephemeral vespa storage yet, might be good for testing
* kubectl -n onyx port-forward service/onyx-nginx 8080:80
* this will forward the local port 8080 to the installed chart for you to run tests, etc.

View File

@@ -5,7 +5,7 @@ home: https://www.onyx.app/
sources:
- "https://github.com/onyx-dot-app/onyx"
type: application
version: 0.4.36
version: 0.4.35
appVersion: latest
annotations:
category: Productivity

View File

@@ -1,9 +1,6 @@
# Values for chart-testing (ct lint/install)
# This file is automatically used by ct when running lint and install commands
auth:
opensearch:
values:
opensearch_admin_password: "placeholder-OpenSearch1!"
userauth:
values:
user_auth_secret: "placeholder-for-ci-testing"

View File

@@ -1177,17 +1177,12 @@ auth:
# Secrets values IF existingSecret is empty. Key here must match the value
# in secretKeys to be used. Values will be base64 encoded in the k8s
# cluster.
# For the bundled OpenSearch chart, the admin password is consumed during
# initial cluster setup. Changing this value later will update Onyx's
# client credentials, but will not rotate the OpenSearch admin password.
# Set this before first install or use existingSecret to preserve the
# current secret on upgrade.
# Password must meet OpenSearch complexity requirements:
# min 8 chars, uppercase, lowercase, digit, and special character.
# Required when auth.opensearch.enabled=true and no existing secret exists.
# CHANGE THIS FOR PRODUCTION.
values:
opensearch_admin_username: "admin"
opensearch_admin_password: ""
opensearch_admin_password: "OnyxDev1!"
userauth:
# -- Used for password reset / verification tokens and OAuth/OIDC state signing.
# Disabled by default to preserve upgrade compatibility for existing Helm customers.

View File

@@ -144,7 +144,7 @@ dev = [
"matplotlib==3.10.8",
"mypy-extensions==1.0.0",
"mypy==1.13.0",
"onyx-devtools==0.7.1",
"onyx-devtools==0.7.0",
"openapi-generator-cli==7.17.0",
"pandas-stubs~=2.3.3",
"pre-commit==3.2.2",

View File

@@ -4,11 +4,9 @@ import (
"bufio"
"errors"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
@@ -94,57 +92,12 @@ Examples:
return cmd
}
func isPortAvailable(port int) bool {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
return false
}
_ = ln.Close()
return true
}
func getProcessOnPort(port int) string {
out, err := exec.Command("lsof", "-i", fmt.Sprintf(":%d", port), "-t").Output()
if err != nil || len(strings.TrimSpace(string(out))) == 0 {
return "an unknown process"
}
pid := strings.Split(strings.TrimSpace(string(out)), "\n")[0]
nameOut, err := exec.Command("ps", "-p", pid, "-o", "comm=").Output()
if err != nil || len(strings.TrimSpace(string(nameOut))) == 0 {
return fmt.Sprintf("process (PID %s)", pid)
}
return fmt.Sprintf("%s (PID %s)", strings.TrimSpace(string(nameOut)), pid)
}
func resolvePort(port string) string {
portNum, err := strconv.Atoi(port)
if err != nil {
log.Fatalf("Invalid port %q: %v", port, err)
}
if isPortAvailable(portNum) {
return port
}
proc := getProcessOnPort(portNum)
candidate := portNum + 1
for candidate <= 65535 {
if isPortAvailable(candidate) {
log.Warnf("⚠ Port %d is in use by %s, using available port %d instead.", portNum, proc, candidate)
return strconv.Itoa(candidate)
}
candidate++
}
log.Fatalf("No available ports found starting from %d", portNum)
return port
}
func runBackendService(name, module, port string, opts *BackendOptions) {
root, err := paths.GitRoot()
if err != nil {
log.Fatalf("Failed to find git root: %v", err)
}
port = resolvePort(port)
envFile := ensureBackendEnvFile(root)
fileVars := loadBackendEnvFile(envFile)

22
uv.lock generated
View File

@@ -4458,7 +4458,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.7.1" },
{ 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" },
@@ -4563,19 +4563,19 @@ requires-dist = [{ name = "onyx", extras = ["backend", "dev", "ee"], editable =
[[package]]
name = "onyx-devtools"
version = "0.7.1"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastapi" },
{ name = "openapi-generator-cli" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/9d/74bcd02583706bdf90c8ac9084eb60bd71d0671392152410ab21b7b68ea1/onyx_devtools-0.7.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:178385dce0b413fd2a1f761055a99f556ec536ef5c32963fc273e751813621eb", size = 4007974, upload-time = "2026-03-17T21:10:39.267Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f8/d8ddb32120428c083c60eb07244479da6e07eaebd31847658a049ab33815/onyx_devtools-0.7.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7960ae6e440ebf1584e02d9e1d0c9ef543b1d54c2584cdcace15695aec3121b2", size = 3696924, upload-time = "2026-03-17T21:10:50.716Z" },
{ url = "https://files.pythonhosted.org/packages/87/21/1e427280066db42ff9dd5f34c70b9dca5d9781f96d0d9a88aaa454fdb432/onyx_devtools-0.7.1-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:6785dda88ca0a3d8464a9bfab76a253ed90da89d53a9c4a67227980f37df1ccf", size = 3568300, upload-time = "2026-03-17T21:10:41.997Z" },
{ url = "https://files.pythonhosted.org/packages/0e/0e/afbbe1164b3d016ddb5352353cb2541eef5a8b2c04e8f02d5d1319cb8b8c/onyx_devtools-0.7.1-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:9e77f2b725c0c00061a3dda5eba199404b51638cec0bf54fc7611fee1f26db34", size = 3974668, upload-time = "2026-03-17T21:10:43.879Z" },
{ url = "https://files.pythonhosted.org/packages/8a/a5/22840643289ef4ca83931b7a79fba8f1db7e626b4b870d4b4f8206c4ff5f/onyx_devtools-0.7.1-py3-none-win_amd64.whl", hash = "sha256:de37daa0e4db9b5dccf94408a3422be4f821e380ab70081bd1032cec1e3c91e6", size = 4078640, upload-time = "2026-03-17T21:10:40.275Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c1/a0295506a521d9942b0f55523781a113e4555420d800a386d5a2eb46a7ad/onyx_devtools-0.7.1-py3-none-win_arm64.whl", hash = "sha256:ab88c53ebda6dff27350316b4ac9bd5f258cd586c2109971a9d976411e1e22ea", size = 3636787, upload-time = "2026-03-17T21:10:37.492Z" },
{ 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]]
@@ -5458,11 +5458,11 @@ wheels = [
[[package]]
name = "pyasn1"
version = "0.6.3"
version = "0.6.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
{ url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
]
[[package]]

View File

@@ -1,7 +1,7 @@
import "@opal/components/buttons/button/styles.css";
import "@opal/components/tooltip.css";
import { Interactive, type InteractiveStatelessProps } from "@opal/core";
import type { ContainerSizeVariants, ExtremaSizeVariants } from "@opal/types";
import type { SizeVariant, WidthVariant } from "@opal/shared";
import type { TooltipSide } from "@opal/components";
import type { IconFunctionComponent } from "@opal/types";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
@@ -31,7 +31,7 @@ type ButtonProps = InteractiveStatelessProps &
/**
* Size preset — controls gap, text size, and Container height/rounding.
*/
size?: ContainerSizeVariants;
size?: SizeVariant;
/** HTML button type. When provided, Container renders a `<button>` element. */
type?: "submit" | "button" | "reset";
@@ -40,7 +40,7 @@ type ButtonProps = InteractiveStatelessProps &
tooltip?: string;
/** Width preset. `"fit"` shrink-wraps, `"full"` stretches to parent width. */
width?: ExtremaSizeVariants;
width?: WidthVariant;
/** Which side the tooltip appears on. */
tooltipSide?: TooltipSide;

View File

@@ -1,4 +1,4 @@
import type { ContainerSizeVariants } from "@opal/types";
import type { SizeVariant } from "@opal/shared";
import type { IconFunctionComponent } from "@opal/types";
import { cn } from "@opal/utils";
@@ -13,7 +13,7 @@ const iconVariants = {
function iconWrapper(
Icon: IconFunctionComponent | undefined,
size: ContainerSizeVariants,
size: SizeVariant,
includeSpacer: boolean
) {
const { padding: p, size: s } = iconVariants[size];

View File

@@ -6,7 +6,7 @@ import {
type InteractiveStatefulProps,
InteractiveContainerRoundingVariant,
} from "@opal/core";
import type { ExtremaSizeVariants } from "@opal/types";
import { type WidthVariant } from "@opal/shared";
import type { TooltipSide } from "@opal/components";
import type { DistributiveOmit } from "@opal/types";
import type { ContentActionProps } from "@opal/layouts/content-action/components";
@@ -51,7 +51,7 @@ type LineItemButtonOwnProps = {
roundingVariant?: InteractiveContainerRoundingVariant;
/** Container width. @default "full" */
width?: ExtremaSizeVariants;
width?: WidthVariant;
/** HTML button type. @default "button" */
type?: "submit" | "button" | "reset";

View File

@@ -6,7 +6,7 @@ import {
type InteractiveStatefulProps,
type InteractiveStatefulInteraction,
} from "@opal/core";
import type { ContainerSizeVariants, ExtremaSizeVariants } from "@opal/types";
import type { SizeVariant, WidthVariant } from "@opal/shared";
import type { InteractiveContainerRoundingVariant } from "@opal/core";
import type { TooltipSide } from "@opal/components";
import type { IconFunctionComponent, IconProps } from "@opal/types";
@@ -64,10 +64,10 @@ type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> & {
/**
* Size preset — controls gap, text size, and Container height/rounding.
*/
size?: ContainerSizeVariants;
size?: SizeVariant;
/** Width preset. */
width?: ExtremaSizeVariants;
width?: WidthVariant;
/**
* Content justify mode. When `"between"`, icon+label group left and

View File

@@ -5,7 +5,7 @@ import {
useDisabled,
type InteractiveStatefulProps,
} from "@opal/core";
import type { ContainerSizeVariants, ExtremaSizeVariants } from "@opal/types";
import type { SizeVariant, WidthVariant } from "@opal/shared";
import type { TooltipSide } from "@opal/components";
import type { IconFunctionComponent } from "@opal/types";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
@@ -48,7 +48,7 @@ type SelectButtonProps = InteractiveStatefulProps &
/**
* Size preset — controls gap, text size, and Container height/rounding.
*/
size?: ContainerSizeVariants;
size?: SizeVariant;
/** HTML button type. Container renders a `<button>` element. */
type?: "submit" | "button" | "reset";
@@ -57,7 +57,7 @@ type SelectButtonProps = InteractiveStatefulProps &
tooltip?: string;
/** Width preset. `"fit"` shrink-wraps, `"full"` stretches to parent width. */
width?: ExtremaSizeVariants;
width?: WidthVariant;
/** Which side the tooltip appears on. */
tooltipSide?: TooltipSide;

View File

@@ -1,6 +1,6 @@
import "@opal/components/cards/card/styles.css";
import type { ContainerSizeVariants } from "@opal/types";
import { containerSizeVariants } from "@opal/shared";
import type { SizeVariant } from "@opal/shared";
import { sizeVariants } from "@opal/shared";
import { cn } from "@opal/utils";
// ---------------------------------------------------------------------------
@@ -26,7 +26,7 @@ type CardProps = {
*
* @default "lg"
*/
sizeVariant?: ContainerSizeVariants;
sizeVariant?: SizeVariant;
/**
* Background fill intensity.
@@ -59,7 +59,7 @@ type CardProps = {
// ---------------------------------------------------------------------------
/** Maps a size variant to a rounding class, mirroring the Button pattern. */
const roundingForSize: Record<ContainerSizeVariants, string> = {
const roundingForSize: Record<SizeVariant, string> = {
lg: "rounded-12",
md: "rounded-08",
sm: "rounded-08",
@@ -79,7 +79,7 @@ function Card({
ref,
children,
}: CardProps) {
const { padding } = containerSizeVariants[sizeVariant];
const { padding } = sizeVariants[sizeVariant];
const rounding = roundingForSize[sizeVariant];
return (

View File

@@ -1,7 +1,7 @@
import { Card } from "@opal/components/cards/card/components";
import { Content } from "@opal/layouts";
import { SvgEmpty } from "@opal/icons";
import type { ContainerSizeVariants } from "@opal/types";
import type { SizeVariant } from "@opal/shared";
import type { IconFunctionComponent } from "@opal/types";
// ---------------------------------------------------------------------------
@@ -16,7 +16,7 @@ type EmptyMessageCardProps = {
title: string;
/** Size preset controlling padding and rounding of the card. */
sizeVariant?: ContainerSizeVariants;
sizeVariant?: SizeVariant;
/** Ref forwarded to the root Card div. */
ref?: React.Ref<HTMLDivElement>;

View File

@@ -52,10 +52,3 @@ export {
type PaginationProps,
type PaginationSize,
} from "@opal/components/pagination/components";
/* Table */
export { Table } from "@opal/components/table/components";
export { createTableColumns } from "@opal/components/table/columns";
export type { DataTableProps } from "@opal/components/table/types";

View File

@@ -3,7 +3,7 @@
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { SvgArrowRight, SvgChevronLeft, SvgChevronRight } from "@opal/icons";
import { containerSizeVariants } from "@opal/shared";
import { sizeVariants } from "@opal/shared";
import type { WithoutStyles } from "@opal/types";
import { cn } from "@opal/utils";
import * as PopoverPrimitive from "@radix-ui/react-popover";
@@ -252,7 +252,7 @@ function GoToPagePopup({ totalPages, onSubmit, children }: GoToPagePopupProps) {
autoFocus
className={cn(
"w-[7rem] bg-transparent px-1.5 py-1 rounded-08",
containerSizeVariants.lg.height,
sizeVariants.lg.height,
"border border-border-02 focus:outline-none focus:border-border-04",
"font-main-ui-body",
"text-text-04 placeholder:text-text-02"

View File

@@ -1,82 +0,0 @@
# Table
Config-driven table component with sorting, pagination, column visibility,
row selection, drag-and-drop reordering, and server-side mode.
## Usage
```tsx
import { Table, createTableColumns } from "@opal/components";
interface User {
id: string;
email: string;
name: string | null;
status: "active" | "invited";
}
const tc = createTableColumns<User>();
const columns = [
tc.qualifier({ content: "avatar-user", getInitials: (r) => r.name?.[0] ?? "?" }),
tc.column("email", {
header: "Name",
weight: 22,
minWidth: 140,
cell: (email, row) => <span>{row.name ?? email}</span>,
}),
tc.column("status", {
header: "Status",
weight: 14,
cell: (status) => <span>{status}</span>,
}),
tc.actions(),
];
function UsersTable({ users }: { users: User[] }) {
return (
<Table
data={users}
columns={columns}
getRowId={(r) => r.id}
pageSize={10}
footer={{ mode: "summary" }}
/>
);
}
```
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `data` | `TData[]` | required | Row data array |
| `columns` | `OnyxColumnDef<TData>[]` | required | Column definitions from `createTableColumns()` |
| `getRowId` | `(row: TData) => string` | required | Unique row identifier |
| `pageSize` | `number` | `10` | Rows per page (`Infinity` disables pagination) |
| `size` | `"md" \| "lg"` | `"lg"` | Density variant |
| `footer` | `DataTableFooterConfig` | — | Footer mode (`"selection"` or `"summary"`) |
| `initialSorting` | `SortingState` | — | Initial sort state |
| `initialColumnVisibility` | `VisibilityState` | — | Initial column visibility |
| `draggable` | `DataTableDraggableConfig` | — | Enable drag-and-drop reordering |
| `onSelectionChange` | `(ids: string[]) => void` | — | Selection callback |
| `onRowClick` | `(row: TData) => void` | — | Row click handler |
| `searchTerm` | `string` | — | Global text filter |
| `height` | `number \| string` | — | Max scrollable height |
| `headerBackground` | `string` | — | Sticky header background |
| `serverSide` | `ServerSideConfig` | — | Server-side pagination/sorting/filtering |
| `emptyState` | `ReactNode` | — | Empty state content |
## Column Builder
`createTableColumns<TData>()` returns a builder with:
- `tc.qualifier(opts)` — leading avatar/icon/checkbox column
- `tc.column(accessor, opts)` — data column with sorting/resizing
- `tc.display(opts)` — non-accessor custom column
- `tc.actions(opts)` — trailing actions column with visibility/sorting popovers
## Footer Modes
- **`"selection"`** — shows selection count, optional view/clear buttons, count pagination
- **`"summary"`** — shows "Showing X~Y of Z", list pagination, optional extra element

View File

@@ -1,148 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Table, createTableColumns } from "@opal/components";
// ---------------------------------------------------------------------------
// Sample data
// ---------------------------------------------------------------------------
interface User {
id: string;
email: string;
name: string;
role: "admin" | "user" | "viewer";
status: "active" | "invited" | "inactive";
}
const USERS: User[] = [
{
id: "1",
email: "alice@example.com",
name: "Alice Johnson",
role: "admin",
status: "active",
},
{
id: "2",
email: "bob@example.com",
name: "Bob Smith",
role: "user",
status: "active",
},
{
id: "3",
email: "carol@example.com",
name: "Carol White",
role: "viewer",
status: "invited",
},
{
id: "4",
email: "dave@example.com",
name: "Dave Brown",
role: "user",
status: "inactive",
},
{
id: "5",
email: "eve@example.com",
name: "Eve Davis",
role: "admin",
status: "active",
},
{
id: "6",
email: "frank@example.com",
name: "Frank Miller",
role: "viewer",
status: "active",
},
{
id: "7",
email: "grace@example.com",
name: "Grace Lee",
role: "user",
status: "invited",
},
{
id: "8",
email: "hank@example.com",
name: "Hank Wilson",
role: "user",
status: "active",
},
{
id: "9",
email: "iris@example.com",
name: "Iris Taylor",
role: "viewer",
status: "active",
},
{
id: "10",
email: "jack@example.com",
name: "Jack Moore",
role: "admin",
status: "active",
},
{
id: "11",
email: "kate@example.com",
name: "Kate Anderson",
role: "user",
status: "inactive",
},
{
id: "12",
email: "leo@example.com",
name: "Leo Thomas",
role: "viewer",
status: "active",
},
];
// ---------------------------------------------------------------------------
// Columns
// ---------------------------------------------------------------------------
const tc = createTableColumns<User>();
const columns = [
tc.qualifier({
content: "avatar-user",
getInitials: (r) =>
r.name
.split(" ")
.map((n) => n[0])
.join(""),
}),
tc.column("name", { header: "Name", weight: 25, minWidth: 120 }),
tc.column("email", { header: "Email", weight: 30, minWidth: 160 }),
tc.column("role", { header: "Role", weight: 15, minWidth: 80 }),
tc.column("status", { header: "Status", weight: 15, minWidth: 80 }),
tc.actions(),
];
// ---------------------------------------------------------------------------
// Story
// ---------------------------------------------------------------------------
const meta: Meta<typeof Table> = {
title: "opal/components/Table",
component: Table,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof Table>;
export const Default: Story = {
render: () => (
<Table
data={USERS}
columns={columns}
getRowId={(r) => r.id}
pageSize={8}
footer={{ mode: "summary" }}
/>
),
};

View File

@@ -1,70 +0,0 @@
import { cn } from "@opal/utils";
import type { WithoutStyles } from "@/types";
import type { ExtremaSizeVariants, SizeVariants } from "@opal/types";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type TableSize = Extract<SizeVariants, "md" | "lg">;
type TableVariant = "rows" | "card";
type TableQualifier = null | "icon" | "checkbox";
type SelectionBehavior = "no-select" | "single-select" | "multi-select";
interface TableProps
extends WithoutStyles<React.TableHTMLAttributes<HTMLTableElement>> {
ref?: React.Ref<HTMLTableElement>;
/** Size preset for the table. @default "lg" */
size?: TableSize;
/** Visual row variant. @default "card" */
variant?: TableVariant;
/** Row selection behavior. @default "no-select" */
selectionBehavior?: SelectionBehavior;
/** Leading qualifier column type. @default null */
qualifier?: TableQualifier;
/** Height behavior. `"fit"` = shrink to content, `"full"` = fill available space. */
heightVariant?: ExtremaSizeVariants;
/** Explicit pixel width for the table (e.g. from `table.getTotalSize()`).
* When provided the table uses exactly this width instead of stretching
* to fill its container, which prevents `table-layout: fixed` from
* redistributing extra space across columns on resize. */
width?: number;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
function Table({
ref,
size = "lg",
variant = "card",
selectionBehavior = "no-select",
qualifier = null,
heightVariant,
width,
...props
}: TableProps) {
return (
<table
ref={ref}
className={cn("border-separate border-spacing-0", "min-w-full")}
style={{ tableLayout: "fixed", width: width ?? undefined }}
data-size={size}
data-variant={variant}
data-selection={selectionBehavior}
data-qualifier={qualifier ?? undefined}
data-height={heightVariant}
{...props}
/>
);
}
export default Table;
export type {
TableProps,
TableSize,
TableVariant,
TableQualifier,
SelectionBehavior,
};

View File

@@ -1,8 +1,8 @@
import "@opal/core/animations/styles.css";
import React, { createContext, useContext, useState, useCallback } from "react";
import { cn } from "@opal/utils";
import type { WithoutStyles, ExtremaSizeVariants } from "@opal/types";
import { widthVariants } from "@opal/shared";
import type { WithoutStyles } from "@opal/types";
import { widthVariants, type WidthVariant } from "@opal/shared";
// ---------------------------------------------------------------------------
// Context-per-group registry
@@ -40,7 +40,7 @@ interface HoverableRootProps
children: React.ReactNode;
group: string;
/** Width preset. @default "auto" */
widthVariant?: ExtremaSizeVariants;
widthVariant?: WidthVariant;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
@@ -84,7 +84,7 @@ interface HoverableItemProps
function HoverableRoot({
group,
children,
widthVariant = "full",
widthVariant = "auto",
ref,
onMouseEnter: consumerMouseEnter,
onMouseLeave: consumerMouseLeave,

View File

@@ -5,10 +5,10 @@ import React from "react";
import { cn } from "@opal/utils";
import type { WithoutStyles } from "@opal/types";
import {
containerSizeVariants,
type ContainerSizeVariants,
sizeVariants,
type SizeVariant,
widthVariants,
type ExtremaSizeVariants,
type WidthVariant,
} from "@opal/shared";
import { useDisabled } from "@opal/core/disabled/components";
@@ -73,14 +73,14 @@ interface InteractiveContainerProps
*
* @default "lg"
*/
heightVariant?: ContainerSizeVariants;
heightVariant?: SizeVariant;
/**
* Width preset controlling the container's horizontal size.
*
* @default "fit"
*/
widthVariant?: ExtremaSizeVariants;
widthVariant?: WidthVariant;
}
// ---------------------------------------------------------------------------
@@ -119,7 +119,7 @@ function InteractiveContainer({
target?: string;
rel?: string;
};
const { height, minWidth, padding } = containerSizeVariants[heightVariant];
const { height, minWidth, padding } = sizeVariants[heightVariant];
const sharedProps = {
...rest,
className: cn(

View File

@@ -1,8 +1,5 @@
import { Content, type ContentProps } from "@opal/layouts/content/components";
import {
containerSizeVariants,
type ContainerSizeVariants,
} from "@opal/shared";
import { sizeVariants, type SizeVariant } from "@opal/shared";
import { cn } from "@opal/utils";
// ---------------------------------------------------------------------------
@@ -18,9 +15,9 @@ type ContentActionProps = ContentProps & {
* Uses the shared `SizeVariant` scale from `@opal/shared`.
*
* @default "lg"
* @see {@link ContainerSizeVariants} for the full list of presets.
* @see {@link SizeVariant} for the full list of presets.
*/
paddingVariant?: ContainerSizeVariants;
paddingVariant?: SizeVariant;
};
// ---------------------------------------------------------------------------
@@ -57,7 +54,7 @@ function ContentAction({
paddingVariant = "lg",
...contentProps
}: ContentActionProps) {
const { padding } = containerSizeVariants[paddingVariant];
const { padding } = sizeVariants[paddingVariant];
return (
<div className="flex flex-row items-stretch w-full">

View File

@@ -1,7 +1,7 @@
"use client";
import { Button } from "@opal/components/buttons/button/components";
import type { ContainerSizeVariants } from "@opal/types";
import type { SizeVariant } from "@opal/shared";
import SvgEdit from "@opal/icons/edit";
import type { IconFunctionComponent } from "@opal/types";
import { cn } from "@opal/utils";
@@ -25,7 +25,7 @@ interface ContentLgPresetConfig {
/** 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: ContainerSizeVariants;
editButtonSize: SizeVariant;
/** Tailwind padding class for the edit button container. */
editButtonPadding: string;
}

View File

@@ -2,7 +2,7 @@
import { Button } from "@opal/components/buttons/button/components";
import { Tag, type TagProps } from "@opal/components/tag/components";
import type { ContainerSizeVariants } from "@opal/types";
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";
@@ -27,7 +27,7 @@ interface ContentMdPresetConfig {
lineHeight: string;
gap: string;
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
editButtonSize: ContainerSizeVariants;
editButtonSize: SizeVariant;
editButtonPadding: string;
optionalFont: string;
/** Aux icon size = lineHeight 2 × p-0.5. */

View File

@@ -1,7 +1,7 @@
"use client";
import { Button } from "@opal/components/buttons/button/components";
import type { ContainerSizeVariants } from "@opal/types";
import type { SizeVariant } from "@opal/shared";
import SvgEdit from "@opal/icons/edit";
import type { IconFunctionComponent } from "@opal/types";
import { cn } from "@opal/utils";
@@ -31,7 +31,7 @@ interface ContentXlPresetConfig {
/** 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: ContainerSizeVariants;
editButtonSize: SizeVariant;
/** Tailwind padding class for the edit button container. */
editButtonPadding: string;
}

View File

@@ -18,8 +18,7 @@ import {
} from "@opal/layouts/content/ContentMd";
import type { TagProps } from "@opal/components/tag/components";
import type { IconFunctionComponent } from "@opal/types";
import { widthVariants } from "@opal/shared";
import type { ExtremaSizeVariants } from "@opal/types";
import { widthVariants, type WidthVariant } from "@opal/shared";
// ---------------------------------------------------------------------------
// Shared types
@@ -60,7 +59,7 @@ interface ContentBaseProps {
*
* @default "fit"
*/
widthVariant?: ExtremaSizeVariants;
widthVariant?: WidthVariant;
/** When `true`, the title color hooks into `Interactive.Stateful`/`Interactive.Stateless`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
@@ -129,7 +128,7 @@ function Content(props: ContentProps) {
const {
sizePreset = "headline",
variant = "heading",
widthVariant = "full",
widthVariant = "auto",
withInteractive,
ref,
...rest

View File

@@ -6,12 +6,18 @@
* circular imports and gives every consumer a single source of truth.
*/
import type {
SizeVariants,
OverridableExtremaSizeVariants,
ContainerSizeVariants,
ExtremaSizeVariants,
} from "@opal/types";
// ---------------------------------------------------------------------------
// Size Variants
//
// A named scale of size presets (lg → 2xs, plus fit) that map to Tailwind
// utility classes for height, min-width, and padding.
//
// Consumers:
// - Interactive.Container (height + min-width + padding)
// - Button (icon sizing)
// - ContentAction (padding only)
// - Content (ContentXl / ContentLg / ContentMd) (edit-button size)
// ---------------------------------------------------------------------------
/**
* Size-variant scale.
@@ -28,16 +34,7 @@ import type {
* | `2xs` | 1rem (16px) | `p-0.5` |
* | `fit` | h-fit | `p-0` |
*/
type ContainerProperties = {
height: string;
minWidth: string;
padding: string;
};
const containerSizeVariants: Record<
ContainerSizeVariants,
ContainerProperties
> = {
fit: { height: "h-fit", minWidth: "", padding: "p-0" },
const sizeVariants = {
lg: { height: "h-[2.25rem]", minWidth: "min-w-[2.25rem]", padding: "p-2" },
md: { height: "h-[1.75rem]", minWidth: "min-w-[1.75rem]", padding: "p-1" },
sm: { height: "h-[1.5rem]", minWidth: "min-w-[1.5rem]", padding: "p-1" },
@@ -47,14 +44,18 @@ const containerSizeVariants: Record<
padding: "p-0.5",
},
"2xs": { height: "h-[1rem]", minWidth: "min-w-[1rem]", padding: "p-0.5" },
fit: { height: "h-fit", minWidth: "", padding: "p-0" },
} as const;
/** Named size preset key. */
type SizeVariant = keyof typeof sizeVariants;
// ---------------------------------------------------------------------------
// Width/Height Variants
// Width Variants
//
// A named scale of width/height presets that map to Tailwind width/height utility classes.
// A named scale of width presets that map to Tailwind width utility classes.
//
// Consumers (for width):
// Consumers:
// - Interactive.Container (widthVariant)
// - Button (width)
// - Content (widthVariant)
@@ -69,31 +70,13 @@ const containerSizeVariants: Record<
* | `fit` | `w-fit` |
* | `full` | `w-full` |
*/
const widthVariants: Record<ExtremaSizeVariants, string> = {
const widthVariants = {
auto: "w-auto",
fit: "w-fit",
full: "w-full",
} as const;
/**
* Height-variant scale.
*
* | Key | Tailwind class |
* |--------|----------------|
* | `auto` | `h-auto` |
* | `fit` | `h-fit` |
* | `full` | `h-full` |
*/
const heightVariants: Record<ExtremaSizeVariants, string> = {
fit: "h-fit",
full: "h-full",
} as const;
/** Named width preset key. */
type WidthVariant = keyof typeof widthVariants;
export {
type ExtremaSizeVariants,
type ContainerSizeVariants,
type OverridableExtremaSizeVariants,
type SizeVariants,
containerSizeVariants,
widthVariants,
heightVariants,
};
export { sizeVariants, type SizeVariant, widthVariants, type WidthVariant };

View File

@@ -1,61 +1,5 @@
import type { SVGProps } from "react";
// ---------------------------------------------------------------------------
// Size Variants
//
// A named scale of size presets (lg → 2xs, plus fit) that map to Tailwind
// utility classes for height, min-width, and padding.
//
// Consumers:
// - Interactive.Container (height + min-width + padding)
// - Button (icon sizing)
// - ContentAction (padding only)
// - Content (ContentXl / ContentLg / ContentMd) (edit-button size)
// ---------------------------------------------------------------------------
// Base Size Types:
/**
* Full range of size variants.
*
* This is the complete scale of size presets available in the design system.
* Components needing the full range use this type directly.
*/
export type SizeVariants = "fit" | "full" | "lg" | "md" | "sm" | "xs" | "2xs";
// Convenience Size Types:
//
// NOTE (@raunakab + @nmgarza5)
// There are many components throughout the library that need to "extract" very specific sizings from the full gamut that is available.
// For those components, we've extracted these below "convenience" types.
/**
* Size variants for container components (excludes "full").
*
* Used by components that control height, min-width, and padding.
* Excludes "full" since containers need a fixed height preset.
*/
export type ContainerSizeVariants = Exclude<SizeVariants, "full">;
/**
* Extreme size variants ("fit" and "full" only).
*
* Used for width and height properties that only support extremal values.
*/
export type ExtremaSizeVariants = Extract<SizeVariants, "fit" | "full">;
/**
* Size variants with numeric overrides.
*
* Allows size specification as a named preset or a custom numeric value.
* Used in components that need programmatic sizing flexibility.
*/
export type OverridableExtremaSizeVariants = ExtremaSizeVariants | number;
// ---------------------------------------------------------------------------
// Icon Props
// ---------------------------------------------------------------------------
/**
* Base props for SVG icon components.
*

View File

@@ -2,14 +2,7 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"@opal/*": ["./src/*"],
// TODO (@raunakab): Remove this once the table component migration is
// complete. The table internals still import app-layer modules (e.g.
// @/refresh-components/texts/Text, @/refresh-components/Popover) via the
// @/ alias. Without this entry the IDE cannot resolve those paths since
// opal's tsconfig only defines @opal/*. Once all @/ deps are replaced
// with opal-internal equivalents, this line should be deleted.
"@/*": ["../../src/*"]
"@opal/*": ["./src/*"]
}
},
"include": ["src/**/*"],

View File

@@ -24,8 +24,10 @@ import {
} from "@/app/craft/onboarding/constants";
import { LLMProviderDescriptor } from "@/interfaces/llm";
import { LLM_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import { buildOnboardingInitialValues as buildInitialValues } from "@/sections/modals/llmConfig/utils";
import { testApiKeyHelper } from "@/sections/modals/llmConfig/svc";
import {
buildInitialValues,
testApiKeyHelper,
} from "@/sections/onboarding/components/llmConnectionHelpers";
import OnboardingInfoPages from "@/app/craft/onboarding/components/OnboardingInfoPages";
import OnboardingUserInfo from "@/app/craft/onboarding/components/OnboardingUserInfo";
import OnboardingLlmSetup, {

View File

@@ -5,17 +5,17 @@
/* ---- TableCell ---- */
.tbl-cell[data-size="lg"] {
.tbl-cell[data-size="regular"] {
@apply px-1 py-0.5;
}
.tbl-cell[data-size="md"] {
.tbl-cell[data-size="small"] {
@apply pl-0.5 pr-1.5 py-1.5;
}
.tbl-cell-inner[data-size="lg"] {
.tbl-cell-inner[data-size="regular"] {
@apply h-10 px-1;
}
.tbl-cell-inner[data-size="md"] {
.tbl-cell-inner[data-size="small"] {
@apply h-6 px-0.5;
}
@@ -25,10 +25,10 @@
@apply relative sticky top-0 z-20;
background: var(--table-header-bg, transparent);
}
.table-head[data-size="lg"] {
.table-head[data-size="regular"] {
@apply px-2 py-1;
}
.table-head[data-size="md"] {
.table-head[data-size="small"] {
@apply p-1.5;
}
.table-head[data-bottom-border] {
@@ -36,15 +36,15 @@
}
/* Inner text wrapper */
.table-head[data-size="lg"] .table-head-label {
.table-head[data-size="regular"] .table-head-label {
@apply py-2 px-0.5;
}
.table-head[data-size="md"] .table-head-label {
.table-head[data-size="small"] .table-head-label {
@apply py-1;
}
/* Sort button wrapper */
.table-head[data-size="lg"] .table-head-sort {
.table-head[data-size="regular"] .table-head-sort {
@apply py-1.5;
}
@@ -112,14 +112,14 @@
@apply w-px whitespace-nowrap py-1 sticky top-0 z-20;
background: var(--table-header-bg, transparent);
}
.tbl-qualifier[data-type="head"][data-size="md"] {
.tbl-qualifier[data-type="head"][data-size="small"] {
@apply py-0.5;
}
.tbl-qualifier[data-type="cell"] {
@apply w-px whitespace-nowrap py-1 pl-1;
}
.tbl-qualifier[data-type="cell"][data-size="md"] {
.tbl-qualifier[data-type="cell"][data-size="small"] {
@apply py-0.5 pl-0.5;
}
@@ -135,9 +135,9 @@
/* ---- Footer ---- */
.table-footer[data-size="lg"] {
.table-footer[data-size="regular"] {
@apply min-h-[2.75rem];
}
.table-footer[data-size="md"] {
.table-footer[data-size="small"] {
@apply min-h-[2.25rem];
}

View File

@@ -16,6 +16,7 @@
@import "css/sizes.css";
@import "css/square-button.css";
@import "css/switch.css";
@import "css/table.css";
@import "css/z-index.css";
/* KH Teka Font */

View File

@@ -14,7 +14,7 @@ import Link from "next/link";
interface ConnectorTitleProps {
connector: Connector<any>;
ccPairId: number;
ccPairName: string;
ccPairName: string | null | undefined;
isPublic?: boolean;
owner?: string;
isLink?: boolean;

View File

@@ -126,38 +126,6 @@ export function useAdminLLMProviders() {
* - `error` — The SWR error object, if any.
* - `mutate` — SWR `mutate` function to trigger a revalidation.
*/
/**
* Fetches the descriptor for a single well-known (built-in) LLM provider.
*
* Hits `GET /api/admin/llm/built-in/options/{providerEndpoint}` which returns
* the provider descriptor including its known models and the recommended
* default model.
*
* Used inside individual provider modals to pre-populate model lists
* before the user has entered credentials.
*
* @param providerEndpoint - The provider's API endpoint name (e.g. "openai", "anthropic").
* Pass `null` to suppress the request.
*/
export function useWellKnownLLMProvider(providerEndpoint: string | null) {
const { data, error, isLoading } = useSWR<WellKnownLLMProviderDescriptor>(
providerEndpoint
? `/api/admin/llm/built-in/options/${providerEndpoint}`
: null,
errorHandlingFetcher,
{
revalidateOnFocus: false,
dedupingInterval: 60000,
}
);
return {
wellKnownLLMProvider: data ?? null,
isLoading,
error,
};
}
export function useWellKnownLLMProviders() {
const {
data: wellKnownLLMProviders,

View File

@@ -1,6 +1,6 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { INTERNAL_URL, IS_DEV } from "@/lib/constants";
import { IS_DEV } from "@/lib/constants";
// Target format for OpenAI Realtime API
const TARGET_SAMPLE_RATE = 24000;
@@ -247,7 +247,7 @@ class VoiceRecorderSession {
const { token } = await tokenResponse.json();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = IS_DEV ? new URL(INTERNAL_URL).host : window.location.host;
const host = IS_DEV ? "localhost:8080" : window.location.host;
const path = IS_DEV
? "/voice/transcribe/stream"
: "/api/voice/transcribe/stream";

View File

@@ -1,8 +1,3 @@
import type {
OnboardingState,
OnboardingActions,
} from "@/interfaces/onboarding";
export enum LLMProviderName {
OPENAI = "openai",
ANTHROPIC = "anthropic",
@@ -114,19 +109,11 @@ export interface LLMProviderResponse<T> {
default_vision: DefaultModel | null;
}
export type LLMModalVariant = "onboarding" | "llm-configuration";
export interface LLMProviderFormProps {
variant?: LLMModalVariant;
existingLlmProvider?: LLMProviderView;
shouldMarkAsDefault?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
// Onboarding-specific (only when variant === "onboarding")
onboardingState?: OnboardingState;
onboardingActions?: OnboardingActions;
llmDescriptor?: WellKnownLLMProviderDescriptor;
}
// Param types for model fetching functions - use snake_case to match API structure
@@ -144,6 +131,14 @@ export interface OllamaFetchParams {
signal?: AbortSignal;
}
export interface LMStudioFetchParams {
api_base?: string;
api_key?: string;
api_key_changed?: boolean;
provider_name?: string;
signal?: AbortSignal;
}
export interface OpenRouterFetchParams {
api_base?: string;
api_key?: string;
@@ -166,18 +161,10 @@ export interface VertexAIFetchParams {
model_configurations?: ModelConfiguration[];
}
export interface LMStudioFetchParams {
api_base?: string;
api_key?: string;
api_key_changed?: boolean;
provider_name?: string;
signal?: AbortSignal;
}
export type FetchModelsParams =
| BedrockFetchParams
| OllamaFetchParams
| LMStudioFetchParams
| OpenRouterFetchParams
| LiteLLMProxyFetchParams
| VertexAIFetchParams
| LMStudioFetchParams;
| VertexAIFetchParams;

View File

@@ -87,7 +87,7 @@ export async function forceDeleteCredential<T>(credentialId: number) {
export function linkCredential(
connectorId: number,
credentialId: number,
name: string,
name?: string,
accessType?: AccessType,
groups?: number[],
autoSyncOptions?: Record<string, any>,
@@ -101,7 +101,7 @@ export function linkCredential(
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
name: name || null,
access_type: accessType !== undefined ? accessType : "public",
groups: groups || null,
auto_sync_options: autoSyncOptions || null,

View File

@@ -3,7 +3,7 @@
* Plays audio chunks as they arrive for smooth, low-latency playback.
*/
import { INTERNAL_URL, IS_DEV } from "@/lib/constants";
import { IS_DEV } from "@/lib/constants";
/**
* HTTPStreamingTTSPlayer - Uses HTTP streaming with MediaSource Extensions
@@ -384,7 +384,7 @@ export class WebSocketStreamingTTSPlayer {
const { token } = await tokenResponse.json();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = IS_DEV ? new URL(INTERNAL_URL).host : window.location.host;
const host = IS_DEV ? "localhost:8080" : window.location.host;
const path = IS_DEV
? "/voice/synthesize/stream"
: "/api/voice/synthesize/stream";

View File

@@ -184,7 +184,7 @@ export interface DocumentBoostStatus {
export interface FailedConnectorIndexingStatus {
cc_pair_id: number;
name: string;
name: string | null;
error_msg: string | null;
is_deletable: boolean;
connector_id: number;
@@ -207,7 +207,7 @@ export interface IndexAttemptSnapshot {
export interface ConnectorStatus<ConnectorConfigType, ConnectorCredentialType> {
cc_pair_id: number;
name: string;
name: string | null;
connector: Connector<ConnectorConfigType>;
credential: Credential<ConnectorCredentialType>;
access_type: AccessType;
@@ -230,7 +230,7 @@ export interface ConnectorIndexingStatus<
export interface ConnectorIndexingStatusLite {
cc_pair_id: number;
name: string;
name: string | null;
source: ValidSources;
access_type: AccessType;
in_progress: boolean;
@@ -343,7 +343,7 @@ export interface DeletionAttemptSnapshot {
// DOCUMENT SETS
export interface CCPairDescriptor<ConnectorType, CredentialType> {
id: number;
name: string;
name: string | null;
connector: Connector<ConnectorType>;
credential: Credential<CredentialType>;
access_type: AccessType;
@@ -364,7 +364,7 @@ export interface FederatedConnectorDescriptor {
// Simplified interfaces with minimal data
export interface CCPairSummary {
id: number;
name: string;
name: string | null;
source: ValidSources;
access_type: AccessType;
}

View File

@@ -10,10 +10,15 @@ import React, {
} from "react";
import { useUser } from "@/providers/UserProvider";
import { useVoiceStatus } from "@/hooks/useVoiceStatus";
import { INTERNAL_URL, IS_DEV } from "@/lib/constants";
// --- TTS Configuration Constants ---
/** Dev server port - used to detect Next.js dev environment */
const DEV_PORT = "3000";
/** Backend port for direct WebSocket connection in development */
const BACKEND_PORT = "8080";
/** WebSocket path for TTS streaming (backend-direct, used in dev) */
const TTS_WS_PATH = "/voice/synthesize/stream";
@@ -466,8 +471,9 @@ export function VoiceModeProvider({ children }: { children: React.ReactNode }) {
// WebSocket connections, so we connect directly to the backend (port 8080).
// In production, the reverse proxy handles the /api prefix routing.
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = IS_DEV ? new URL(INTERNAL_URL).host : window.location.host;
const path = IS_DEV ? TTS_WS_PATH : TTS_WS_PATH_PROXIED;
const isDev = window.location.port === DEV_PORT;
const host = isDev ? "localhost:" + BACKEND_PORT : window.location.host;
const path = isDev ? TTS_WS_PATH : TTS_WS_PATH_PROXIED;
// Auth: the token query param is validated server-side by
// current_user_from_websocket (single-use, 60s TTL, same checks as HTTP auth).
return `${protocol}//${host}${path}?token=${encodeURIComponent(token)}`;

View File

@@ -209,7 +209,12 @@ export const PasswordInput: Story = {
name: "PasswordInputTypeInField",
render: () => (
<FormikWrapper initialValues={{ apiKey: "" }}>
<PasswordInputTypeInField name="apiKey" placeholder="sk-..." />
<PasswordInputTypeInField
name="apiKey"
label="API Key"
subtext="Your key is stored encrypted."
placeholder="sk-..."
/>
</FormikWrapper>
),
};

View File

@@ -5,25 +5,34 @@ import PasswordInputTypeIn, {
PasswordInputTypeInProps,
} from "@/refresh-components/inputs/PasswordInputTypeIn";
import { useOnChangeEvent, useOnBlurEvent } from "@/hooks/formHooks";
import { FieldLabel } from "@/components/Field";
export interface PasswordInputTypeInFieldProps
extends Omit<PasswordInputTypeInProps, "value"> {
name: string;
/** Optional label to display above the input */
label?: string;
/** Optional subtext to display below the label */
subtext?: string;
}
export default function PasswordInputTypeInField({
name,
label,
subtext,
onChange: onChangeProp,
onBlur: onBlurProp,
placeholder,
...inputProps
}: PasswordInputTypeInFieldProps) {
const [field, meta] = useField(name);
const onChange = useOnChangeEvent(name, onChangeProp);
const onBlur = useOnBlurEvent(name, onBlurProp);
const hasError = meta.touched && meta.error;
// Don't show error styling for disabled fields
const showError = hasError && !inputProps.disabled;
return (
const input = (
<PasswordInputTypeIn
{...inputProps}
id={name}
@@ -31,7 +40,19 @@ export default function PasswordInputTypeInField({
value={field.value ?? ""}
onChange={onChange}
onBlur={onBlur}
placeholder={placeholder ?? label ?? "API Key"}
error={showError ? true : inputProps.error}
/>
);
if (!label) {
return input;
}
return (
<div className="w-full flex flex-col gap-1">
<FieldLabel name={name} label={label} subtext={subtext} />
{input}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useTableSize } from "./TableSizeContext";
import type { TableSize } from "./TableSizeContext";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
interface ActionsContainerProps {
type: "head" | "cell";

View File

@@ -20,13 +20,13 @@ import Divider from "@/refresh-components/Divider";
interface ColumnVisibilityPopoverProps<TData extends RowData = RowData> {
table: Table<TData>;
columnVisibility: VisibilityState;
size?: "md" | "lg";
size?: "regular" | "small";
}
function ColumnVisibilityPopover<TData extends RowData>({
table,
columnVisibility,
size = "lg",
size = "regular",
}: ColumnVisibilityPopoverProps<TData>) {
const [open, setOpen] = useState(false);
const hideableColumns = table
@@ -39,8 +39,8 @@ function ColumnVisibilityPopover<TData extends RowData>({
<Button
icon={SvgColumn}
interaction={open ? "hover" : "rest"}
size={size === "md" ? "sm" : "md"}
prominence="tertiary"
size={size === "small" ? "sm" : "md"}
prominence="internal"
tooltip="Columns"
/>
</Popover.Trigger>
@@ -80,7 +80,7 @@ function ColumnVisibilityPopover<TData extends RowData>({
// ---------------------------------------------------------------------------
interface CreateColumnVisibilityColumnOptions {
size?: "md" | "lg";
size?: "regular" | "small";
}
function createColumnVisibilityColumn<TData>(

View File

@@ -1,30 +1,30 @@
"use client";
"use no memo";
import "@opal/components/table/styles.css";
import { useEffect, useMemo } from "react";
import { flexRender } from "@tanstack/react-table";
import useDataTable, { toOnyxSortDirection } from "./hooks/useDataTable";
import useColumnWidths from "./hooks/useColumnWidths";
import useDraggableRows from "./hooks/useDraggableRows";
import TableElement from "./TableElement";
import TableHeader from "./TableHeader";
import TableBody from "./TableBody";
import TableRow from "./TableRow";
import TableHead from "./TableHead";
import TableCell from "./TableCell";
import TableQualifier from "./TableQualifier";
import QualifierContainer from "./QualifierContainer";
import ActionsContainer from "./ActionsContainer";
import DragOverlayRow from "./DragOverlayRow";
import Footer from "./Footer";
import { TableSizeProvider } from "./TableSizeContext";
import { ColumnVisibilityPopover } from "./ColumnVisibilityPopover";
import { SortingPopover } from "./SortingPopover";
import type { WidthConfig } from "./hooks/useColumnWidths";
import useDataTable, {
toOnyxSortDirection,
} from "@/refresh-components/table/hooks/useDataTable";
import useColumnWidths from "@/refresh-components/table/hooks/useColumnWidths";
import useDraggableRows from "@/refresh-components/table/hooks/useDraggableRows";
import Table from "@/refresh-components/table/Table";
import TableHeader from "@/refresh-components/table/TableHeader";
import TableBody from "@/refresh-components/table/TableBody";
import TableRow from "@/refresh-components/table/TableRow";
import TableHead from "@/refresh-components/table/TableHead";
import TableCell from "@/refresh-components/table/TableCell";
import TableQualifier from "@/refresh-components/table/TableQualifier";
import QualifierContainer from "@/refresh-components/table/QualifierContainer";
import ActionsContainer from "@/refresh-components/table/ActionsContainer";
import DragOverlayRow from "@/refresh-components/table/DragOverlayRow";
import Footer from "@/refresh-components/table/Footer";
import { TableSizeProvider } from "@/refresh-components/table/TableSizeContext";
import { ColumnVisibilityPopover } from "@/refresh-components/table/ColumnVisibilityPopover";
import { SortingPopover } from "@/refresh-components/table/SortingPopover";
import type { WidthConfig } from "@/refresh-components/table/hooks/useColumnWidths";
import type { ColumnDef } from "@tanstack/react-table";
import { cn } from "@opal/utils";
import { cn } from "@/lib/utils";
import type {
DataTableProps,
DataTableFooterConfig,
@@ -32,8 +32,8 @@ import type {
OnyxDataColumn,
OnyxQualifierColumn,
OnyxActionsColumn,
} from "./types";
import type { TableSize } from "./TableSizeContext";
} from "@/refresh-components/table/types";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
// ---------------------------------------------------------------------------
// Internal: resolve size-dependent widths and build TanStack columns
@@ -116,7 +116,7 @@ function processColumns<TData>(
* <DataTable data={items} columns={columns} footer={{ mode: "selection" }} />
* ```
*/
export function Table<TData>(props: DataTableProps<TData>) {
export default function DataTable<TData>(props: DataTableProps<TData>) {
const {
data,
columns,
@@ -126,7 +126,7 @@ export function Table<TData>(props: DataTableProps<TData>) {
initialColumnVisibility,
draggable,
footer,
size = "lg",
size = "regular",
onSelectionChange,
onRowClick,
searchTerm,
@@ -303,7 +303,7 @@ export function Table<TData>(props: DataTableProps<TData>) {
: undefined),
}}
>
<TableElement
<Table
width={
Object.keys(columnWidths).length > 0
? Object.values(columnWidths).reduce((sum, w) => sum + w, 0)
@@ -539,7 +539,7 @@ export function Table<TData>(props: DataTableProps<TData>) {
);
})}
</TableBody>
</TableElement>
</Table>
</div>
{footer && renderFooter(footer)}

View File

@@ -1,11 +1,14 @@
import { memo } from "react";
import { type Row, flexRender } from "@tanstack/react-table";
import TableRow from "./TableRow";
import TableCell from "./TableCell";
import QualifierContainer from "./QualifierContainer";
import TableQualifier from "./TableQualifier";
import ActionsContainer from "./ActionsContainer";
import type { OnyxColumnDef, OnyxQualifierColumn } from "./types";
import TableRow from "@/refresh-components/table/TableRow";
import TableCell from "@/refresh-components/table/TableCell";
import QualifierContainer from "@/refresh-components/table/QualifierContainer";
import TableQualifier from "@/refresh-components/table/TableQualifier";
import ActionsContainer from "@/refresh-components/table/ActionsContainer";
import type {
OnyxColumnDef,
OnyxQualifierColumn,
} from "@/refresh-components/table/types";
interface DragOverlayRowProps<TData> {
row: Row<TData>;

View File

@@ -1,10 +1,9 @@
"use client";
import { cn } from "@opal/utils";
import { cn } from "@/lib/utils";
import { Button, Pagination } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
import { useTableSize } from "./TableSizeContext";
import type { TableSize } from "./TableSizeContext";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import { SvgEye, SvgXCircle } from "@opal/icons";
import type { ReactNode } from "react";
@@ -41,8 +40,6 @@ interface FooterSelectionModeProps {
totalPages: number;
/** Called when the user navigates to a different page. */
onPageChange: (page: number) => void;
/** Controls overall footer sizing. `"lg"` (default) or `"md"`. */
size?: TableSize;
className?: string;
}
@@ -100,7 +97,7 @@ function getSelectionMessage(
*/
export default function Footer(props: FooterProps) {
const resolvedSize = useTableSize();
const isSmall = resolvedSize === "md";
const isSmall = resolvedSize === "small";
return (
<div
className={cn(
@@ -144,8 +141,8 @@ export default function Footer(props: FooterProps) {
currentPage={props.currentPage}
totalPages={props.totalPages}
onChange={props.onPageChange}
units="items"
size={isSmall ? "sm" : "md"}
units="item(s)"
size={isSmall ? "md" : "lg"}
/>
) : (
<Pagination

View File

@@ -1,5 +1,5 @@
import { useTableSize } from "./TableSizeContext";
import type { TableSize } from "./TableSizeContext";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
interface QualifierContainerProps {
type: "head" | "cell";

View File

@@ -0,0 +1,462 @@
# DataTable
Config-driven table built on [TanStack Table](https://tanstack.com/table). Handles column sizing (weight-based proportional distribution), drag-and-drop row reordering, pagination, row selection, column visibility, and sorting out of the box.
## Quick Start
```tsx
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
interface Person {
name: string;
email: string;
role: string;
}
// Define columns at module scope (stable reference, no re-renders)
const tc = createTableColumns<Person>();
const columns = [
tc.qualifier(),
tc.column("name", { header: "Name", weight: 30, minWidth: 120 }),
tc.column("email", { header: "Email", weight: 40, minWidth: 150 }),
tc.column("role", { header: "Role", weight: 30, minWidth: 80 }),
tc.actions(),
];
function PeopleTable({ data }: { data: Person[] }) {
return (
<DataTable
data={data}
columns={columns}
pageSize={10}
footer={{ mode: "selection" }}
/>
);
}
```
## Column Builder API
`createTableColumns<TData>()` returns a typed builder with four methods. Each returns an `OnyxColumnDef<TData>` that you pass to the `columns` prop.
### `tc.qualifier(config?)`
Leading column for avatars, icons, images, or checkboxes.
| Option | Type | Default | Description |
|---|---|---|---|
| `content` | `"simple" \| "icon" \| "image" \| "avatar-icon" \| "avatar-user"` | `"simple"` | Body row content type |
| `headerContentType` | same as `content` | `"simple"` | Header row content type |
| `getInitials` | `(row: TData) => string` | - | Extract initials (for `"avatar-user"`) |
| `getIcon` | `(row: TData) => IconFunctionComponent` | - | Extract icon (for `"icon"` / `"avatar-icon"`) |
| `getImageSrc` | `(row: TData) => string` | - | Extract image src (for `"image"`) |
| `selectable` | `boolean` | `true` | Show selection checkboxes |
| `header` | `boolean` | `true` | Render qualifier content in the header |
Width is fixed: 56px at `"regular"` size, 40px at `"small"`.
```ts
tc.qualifier({
content: "avatar-user",
getInitials: (row) => row.initials,
})
```
### `tc.column(accessor, config)`
Data column with sorting, resizing, and hiding. The `accessor` is a type-safe deep key into `TData`.
| Option | Type | Default | Description |
|---|---|---|---|
| `header` | `string` | **required** | Column header label |
| `cell` | `(value: TValue, row: TData) => ReactNode` | renders value as string | Custom cell renderer |
| `enableSorting` | `boolean` | `true` | Allow sorting |
| `enableResizing` | `boolean` | `true` | Allow column resize |
| `enableHiding` | `boolean` | `true` | Allow hiding via actions popover |
| `icon` | `(sorted: SortDirection) => IconFunctionComponent` | - | Override the sort indicator icon |
| `weight` | `number` | `20` | Proportional width weight |
| `minWidth` | `number` | `50` | Minimum width in pixels |
```ts
tc.column("email", {
header: "Email",
weight: 28,
minWidth: 150,
cell: (value) => <Content sizePreset="main-ui" variant="body" title={value} prominence="muted" />,
})
```
### `tc.displayColumn(config)`
Non-accessor column for custom content (e.g. computed values, action buttons per row).
| Option | Type | Default | Description |
|---|---|---|---|
| `id` | `string` | **required** | Unique column ID |
| `header` | `string` | - | Optional header label |
| `cell` | `(row: TData) => ReactNode` | **required** | Cell renderer |
| `width` | `ColumnWidth` | **required** | `{ weight, minWidth? }` or `{ fixed }` |
| `enableHiding` | `boolean` | `true` | Allow hiding |
```ts
tc.displayColumn({
id: "fullName",
header: "Full Name",
cell: (row) => `${row.firstName} ${row.lastName}`,
width: { weight: 25, minWidth: 100 },
})
```
### `tc.actions(config?)`
Fixed-width column rendered at the trailing edge. Houses column visibility and sorting popovers in the header.
| Option | Type | Default | Description |
|---|---|---|---|
| `showColumnVisibility` | `boolean` | `true` | Show the column visibility popover |
| `showSorting` | `boolean` | `true` | Show the sorting popover |
| `sortingFooterText` | `string` | - | Footer text inside the sorting popover |
| `cell` | `(row: TData) => ReactNode` | - | Row-level cell renderer for action buttons |
Width is fixed: 88px at `"regular"`, 20px at `"small"`.
```ts
tc.actions({
sortingFooterText: "Everyone will see agents in this order.",
})
```
Row-level actions — the `cell` callback receives the row data and renders content in each body row. Clicks inside the cell automatically call `stopPropagation`, so they won't trigger row selection.
```tsx
tc.actions({
cell: (row) => (
<div className="flex gap-x-1">
<IconButton icon={SvgPencil} onClick={() => openEdit(row.id)} />
<IconButton icon={SvgTrash} onClick={() => confirmDelete(row.id)} />
</div>
),
})
```
## DataTable Props
`DataTableProps<TData>`:
| Prop | Type | Default | Description |
|---|---|---|---|
| `data` | `TData[]` | **required** | Row data |
| `columns` | `OnyxColumnDef<TData>[]` | **required** | Columns from `createTableColumns()` |
| `pageSize` | `number` | `10` (with footer) or `data.length` (without) | Rows per page. `Infinity` disables pagination |
| `initialSorting` | `SortingState` | `[]` | TanStack sorting state |
| `initialColumnVisibility` | `VisibilityState` | `{}` | Map of column ID to `false` to hide initially |
| `draggable` | `DataTableDraggableConfig<TData>` | - | Enable drag-and-drop (see below) |
| `footer` | `DataTableFooterConfig` | - | Footer mode (see below) |
| `size` | `"regular" \| "small"` | `"regular"` | Table density variant |
| `onRowClick` | `(row: TData) => void` | toggles selection | Called on row click, replaces default selection toggle |
| `height` | `number \| string` | - | Max height for scrollable body (header stays pinned). `300` or `"50vh"` |
| `headerBackground` | `string` | - | CSS color for the sticky header (prevents content showing through) |
| `searchTerm` | `string` | - | Search term for client-side global text filtering (case-insensitive match across all accessor columns) |
| `serverSide` | `ServerSideConfig` | - | Enable server-side mode for manual pagination, sorting, and filtering ([see below](#server-side-mode)) |
## Footer Config
The `footer` prop accepts a discriminated union on `mode`.
### Selection mode
For tables with selectable rows. Shows a selection message + count pagination.
```ts
footer={{
mode: "selection",
multiSelect: true, // default true
onView: () => { ... }, // optional "View" button
onClear: () => { ... }, // optional "Clear" button (falls back to default clearSelection)
}}
```
### Summary mode
For read-only tables. Shows "Showing X~Y of Z" + list pagination.
```ts
footer={{ mode: "summary" }}
```
## Draggable Config
Enable drag-and-drop row reordering. DnD is automatically disabled when column sorting is active.
```ts
<DataTable
data={items}
columns={columns}
draggable={{
getRowId: (row) => row.id,
onReorder: (ids, changedOrders) => {
// ids: new ordered array of all row IDs
// changedOrders: { [id]: newIndex } for rows that moved
setItems(ids.map((id) => items.find((r) => r.id === id)!));
},
}}
/>
```
| Option | Type | Description |
|---|---|---|
| `getRowId` | `(row: TData) => string` | Extract a unique string ID from each row |
| `onReorder` | `(ids: string[], changedOrders: Record<string, number>) => void \| Promise<void>` | Called after a successful reorder |
## Server-Side Mode
Pass the `serverSide` prop to switch from client-side to server-side pagination, sorting, and filtering. In this mode `data` should contain **only the current page slice** — TanStack operates with `manualPagination`, `manualSorting`, and `manualFiltering` enabled. Drag-and-drop is automatically disabled.
### `ServerSideConfig`
| Prop | Type | Description |
|---|---|---|
| `totalItems` | `number` | Total row count from the server, used to compute page count |
| `isLoading` | `boolean` | Shows a loading overlay (opacity + pointer-events-none) while data is being fetched |
| `onSortingChange` | `(sorting: SortingState) => void` | Fired when the user clicks a column header |
| `onPaginationChange` | `(pageIndex: number, pageSize: number) => void` | Fired on page navigation and on automatic resets from sort/search changes |
| `onSearchTermChange` | `(searchTerm: string) => void` | Fired when the `searchTerm` prop changes |
### Callback contract
The callbacks fire in a predictable order:
- **Sort change** — `onSortingChange` fires first, then the page resets to 0 and `onPaginationChange(0, pageSize)` fires.
- **Page navigation** — only `onPaginationChange` fires.
- **Search change** — `onSearchTermChange` fires, and the page resets to 0. `onPaginationChange` only fires if the page was actually on a non-zero page. When already on page 0, `searchTerm` drives the re-fetch independently (e.g. via your SWR key) — no `onPaginationChange` is needed.
Your data-fetching layer should include `searchTerm` in its fetch dependencies (e.g. SWR key) so that search changes trigger re-fetches regardless of pagination state.
### Full example
```tsx
import { useState } from "react";
import useSWR from "swr";
import type { SortingState } from "@tanstack/react-table";
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
interface User {
id: string;
name: string;
email: string;
}
const tc = createTableColumns<User>();
const columns = [
tc.qualifier(),
tc.column("name", { header: "Name", weight: 40, minWidth: 120 }),
tc.column("email", { header: "Email", weight: 60, minWidth: 150 }),
tc.actions(),
];
function UsersTable() {
const [searchTerm, setSearchTerm] = useState("");
const [sorting, setSorting] = useState<SortingState>([]);
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const { data: response, isLoading } = useSWR(
["/api/users", sorting, pageIndex, pageSize, searchTerm],
([url, sorting, pageIndex, pageSize, searchTerm]) =>
fetch(
`${url}?` +
new URLSearchParams({
page: String(pageIndex),
size: String(pageSize),
search: searchTerm,
...(sorting[0] && {
sortBy: sorting[0].id,
sortDir: sorting[0].desc ? "desc" : "asc",
}),
})
).then((r) => r.json())
);
return (
<div className="space-y-4">
<InputTypeIn
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search users..."
/>
<DataTable
data={response?.items ?? []}
columns={columns}
getRowId={(row) => row.id}
searchTerm={searchTerm}
pageSize={pageSize}
footer={{ mode: "summary" }}
serverSide={{
totalItems: response?.total ?? 0,
isLoading,
onSortingChange: setSorting,
onPaginationChange: (idx, size) => {
setPageIndex(idx);
setPageSize(size);
},
onSearchTermChange: () => {
// search state is already managed above via searchTerm prop;
// this callback is useful for analytics or debouncing
},
}}
/>
</div>
);
}
```
## Sizing
The `size` prop (`"regular"` or `"small"`) affects:
- Qualifier column width (56px vs 40px)
- Actions column width (88px vs 20px)
- Footer text styles and pagination size
- All child components via `TableSizeContext`
Column widths can be responsive to size using a function:
```ts
// In types.ts, width accepts:
width: ColumnWidth | ((size: TableSize) => ColumnWidth)
// Example (this is what qualifier/actions use internally):
width: (size) => size === "small" ? { fixed: 40 } : { fixed: 56 }
```
### Width system
Data columns use **weight-based proportional distribution**. A column with `weight: 40` gets twice the space of one with `weight: 20`. When the container is narrower than the sum of `minWidth` values, columns clamp to their minimums.
Fixed columns (`{ fixed: N }`) take exactly N pixels and don't participate in proportional distribution.
Resizing uses **splitter semantics**: dragging a column border grows that column and shrinks its neighbor by the same amount, keeping total width constant.
## Advanced Examples
### Scrollable table with pinned header
```tsx
<DataTable
data={allRows}
columns={columns}
height={300}
headerBackground="var(--background-tint-00)"
/>
```
### Hidden columns on load
```tsx
<DataTable
data={data}
columns={columns}
initialColumnVisibility={{ department: false, joinDate: false }}
footer={{ mode: "selection" }}
/>
```
### Icon-based data column
```tsx
const STATUS_ICONS = {
active: SvgCheckCircle,
pending: SvgClock,
inactive: SvgAlertCircle,
} as const;
tc.column("status", {
header: "Status",
weight: 14,
minWidth: 80,
cell: (value) => (
<Content
sizePreset="main-ui"
variant="body"
icon={STATUS_ICONS[value]}
title={value.charAt(0).toUpperCase() + value.slice(1)}
/>
),
})
```
### Non-selectable qualifier with icons
```ts
tc.qualifier({
content: "icon",
getIcon: (row) => row.icon,
selectable: false,
header: false,
})
```
### Small variant in a bordered container
```tsx
<div className="border border-border-01 rounded-lg overflow-hidden">
<DataTable
data={data}
columns={columns}
size="small"
pageSize={10}
footer={{ mode: "selection" }}
/>
</div>
```
### Server-side pagination
Minimal wiring for server-side mode — manage sorting/pagination state externally and pass the current page slice as `data`.
```tsx
<DataTable
data={currentPageRows}
columns={columns}
getRowId={(row) => row.id}
searchTerm={searchTerm}
pageSize={pageSize}
footer={{ mode: "summary" }}
serverSide={{
totalItems: totalCount,
isLoading,
onSortingChange: setSorting,
onPaginationChange: (idx, size) => {
setPageIndex(idx);
setPageSize(size);
},
onSearchTermChange: (term) => setSearchTerm(term),
}}
/>
```
### Custom row click handler
```tsx
<DataTable
data={data}
columns={columns}
onRowClick={(row) => router.push(`/users/${row.id}`)}
/>
```
## Source Files
| File | Purpose |
|---|---|
| `DataTable.tsx` | Main component |
| `columns.ts` | `createTableColumns` builder |
| `types.ts` | All TypeScript interfaces |
| `hooks/useDataTable.ts` | TanStack table wrapper hook |
| `hooks/useColumnWidths.ts` | Weight-based width system |
| `hooks/useDraggableRows.ts` | DnD hook (`@dnd-kit`) |
| `Footer.tsx` | Selection / Summary footer modes |
| `TableSizeContext.tsx` | Size context provider |

View File

@@ -21,7 +21,7 @@ import Text from "@/refresh-components/texts/Text";
interface SortingPopoverProps<TData extends RowData = RowData> {
table: Table<TData>;
sorting: SortingState;
size?: "md" | "lg";
size?: "regular" | "small";
footerText?: string;
ascendingLabel?: string;
descendingLabel?: string;
@@ -30,7 +30,7 @@ interface SortingPopoverProps<TData extends RowData = RowData> {
function SortingPopover<TData extends RowData>({
table,
sorting,
size = "lg",
size = "regular",
footerText,
ascendingLabel = "Ascending",
descendingLabel = "Descending",
@@ -48,8 +48,8 @@ function SortingPopover<TData extends RowData>({
<Button
icon={currentSort === null ? SvgArrowUpDown : SvgSortOrder}
interaction={open ? "hover" : "rest"}
size={size === "md" ? "sm" : "md"}
prominence="tertiary"
size={size === "small" ? "sm" : "md"}
prominence="internal"
tooltip="Sort"
/>
</Popover.Trigger>
@@ -149,7 +149,7 @@ function SortingPopover<TData extends RowData>({
// ---------------------------------------------------------------------------
interface CreateSortingColumnOptions {
size?: "md" | "lg";
size?: "regular" | "small";
footerText?: string;
ascendingLabel?: string;
descendingLabel?: string;

View File

@@ -0,0 +1,281 @@
import type { Meta, StoryObj } from "@storybook/react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import Table from "./Table";
import TableHeader from "./TableHeader";
import TableBody from "./TableBody";
import TableRow from "./TableRow";
import TableHead from "./TableHead";
import TableCell from "./TableCell";
import { TableSizeProvider } from "./TableSizeContext";
import Text from "@/refresh-components/texts/Text";
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta: Meta<typeof Table> = {
title: "refresh-components/table/Table",
component: Table,
tags: ["autodocs"],
decorators: [
(Story) => (
<TooltipPrimitive.Provider>
<TableSizeProvider size="regular">
<div style={{ maxWidth: 800, padding: 16 }}>
<Story />
</div>
</TableSizeProvider>
</TooltipPrimitive.Provider>
),
],
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof Table>;
// ---------------------------------------------------------------------------
// Sample data
// ---------------------------------------------------------------------------
const connectors = [
{
name: "Google Drive",
type: "Cloud Storage",
docs: 1_240,
status: "Active",
},
{ name: "Confluence", type: "Wiki", docs: 856, status: "Active" },
{ name: "Slack", type: "Messaging", docs: 3_102, status: "Syncing" },
{ name: "Notion", type: "Wiki", docs: 412, status: "Paused" },
{ name: "GitHub", type: "Code", docs: 2_890, status: "Active" },
];
// ---------------------------------------------------------------------------
// Stories
// ---------------------------------------------------------------------------
/** All primitive table components composed together (Table, TableHeader, TableBody, TableRow, TableHead, TableCell). */
export const ComposedPrimitives: Story = {
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead width={200}>Connector</TableHead>
<TableHead width={150}>Type</TableHead>
<TableHead width={120} alignment="right">
Documents
</TableHead>
<TableHead width={120}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((c) => (
<TableRow key={c.name}>
<TableCell>
<Text mainUiBody>{c.name}</Text>
</TableCell>
<TableCell>
<Text mainUiMuted text03>
{c.type}
</Text>
</TableCell>
<TableCell>
<Text mainUiMono text03>
{c.docs.toLocaleString()}
</Text>
</TableCell>
<TableCell>
<Text mainUiBody>{c.status}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};
/** Table rows with the "table" variant (bottom border instead of rounded corners). */
export const TableVariantRows: Story = {
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead width={200}>Connector</TableHead>
<TableHead width={150}>Type</TableHead>
<TableHead width={120}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((c) => (
<TableRow key={c.name} variant="table">
<TableCell>
<Text mainUiBody>{c.name}</Text>
</TableCell>
<TableCell>
<Text mainUiMuted text03>
{c.type}
</Text>
</TableCell>
<TableCell>
<Text mainUiBody>{c.status}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};
/** Row with selected state highlighted. */
export const SelectedRows: Story = {
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead width={200}>Connector</TableHead>
<TableHead width={150}>Type</TableHead>
<TableHead width={120}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((c, i) => (
<TableRow key={c.name} selected={i === 1 || i === 3}>
<TableCell>
<Text mainUiBody>{c.name}</Text>
</TableCell>
<TableCell>
<Text mainUiMuted text03>
{c.type}
</Text>
</TableCell>
<TableCell>
<Text mainUiBody>{c.status}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};
/** Sortable table headers with sort indicators. */
export const SortableHeaders: Story = {
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead width={200} sorted="ascending" onSort={() => {}}>
Connector
</TableHead>
<TableHead width={150} sorted="none" onSort={() => {}}>
Type
</TableHead>
<TableHead width={120} sorted="descending" onSort={() => {}}>
Documents
</TableHead>
<TableHead width={120}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((c) => (
<TableRow key={c.name}>
<TableCell>
<Text mainUiBody>{c.name}</Text>
</TableCell>
<TableCell>
<Text mainUiMuted text03>
{c.type}
</Text>
</TableCell>
<TableCell>
<Text mainUiMono text03>
{c.docs.toLocaleString()}
</Text>
</TableCell>
<TableCell>
<Text mainUiBody>{c.status}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};
/** Small size variant with denser spacing. */
export const SmallSize: Story = {
decorators: [
(Story) => (
<TooltipPrimitive.Provider>
<TableSizeProvider size="small">
<div style={{ maxWidth: 800, padding: 16 }}>
<Story />
</div>
</TableSizeProvider>
</TooltipPrimitive.Provider>
),
],
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead width={200}>Connector</TableHead>
<TableHead width={150}>Type</TableHead>
<TableHead width={120}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((c) => (
<TableRow key={c.name}>
<TableCell>
<Text secondaryBody>{c.name}</Text>
</TableCell>
<TableCell>
<Text secondaryBody text03>
{c.type}
</Text>
</TableCell>
<TableCell>
<Text secondaryBody>{c.status}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};
/** Disabled rows styling. */
export const DisabledRows: Story = {
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead width={200}>Connector</TableHead>
<TableHead width={150}>Type</TableHead>
<TableHead width={120}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((c, i) => (
<TableRow key={c.name} disabled={i === 2 || i === 4}>
<TableCell>
<Text mainUiBody>{c.name}</Text>
</TableCell>
<TableCell>
<Text mainUiMuted text03>
{c.type}
</Text>
</TableCell>
<TableCell>
<Text mainUiBody>{c.status}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};

View File

@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils";
import type { WithoutStyles } from "@/types";
interface TableProps
extends WithoutStyles<React.TableHTMLAttributes<HTMLTableElement>> {
ref?: React.Ref<HTMLTableElement>;
/** Explicit pixel width for the table (e.g. from `table.getTotalSize()`).
* When provided the table uses exactly this width instead of stretching
* to fill its container, which prevents `table-layout: fixed` from
* redistributing extra space across columns on resize. */
width?: number;
}
function Table({ ref, width, ...props }: TableProps) {
return (
<table
ref={ref}
className={cn("border-separate border-spacing-0", "min-w-full")}
style={{ tableLayout: "fixed", width: width ?? undefined }}
{...props}
/>
);
}
export default Table;
export type { TableProps };

View File

@@ -1,6 +1,6 @@
import { cn } from "@opal/utils";
import { useTableSize } from "./TableSizeContext";
import type { TableSize } from "./TableSizeContext";
import { cn } from "@/lib/utils";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import type { WithoutStyles } from "@/types";
interface TableCellProps

View File

@@ -1,7 +1,7 @@
import { cn } from "@opal/utils";
import { cn } from "@/lib/utils";
import Text from "@/refresh-components/texts/Text";
import { useTableSize } from "./TableSizeContext";
import type { TableSize } from "./TableSizeContext";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import type { WithoutStyles } from "@/types";
import { Button } from "@opal/components";
import { SvgChevronDown, SvgChevronUp, SvgHandle, SvgSort } from "@opal/icons";
@@ -30,7 +30,7 @@ interface TableHeadCustomProps {
icon?: (sorted: SortDirection) => IconFunctionComponent;
/** Text alignment for the column. Defaults to `"left"`. */
alignment?: "left" | "center" | "right";
/** Cell density. `"md"` uses tighter padding for denser layouts. */
/** Cell density. `"small"` uses tighter padding for denser layouts. */
size?: TableSize;
/** Column width in pixels. Applied as an inline style on the `<th>`. */
width?: number;
@@ -88,7 +88,7 @@ export default function TableHead({
}: TableHeadProps) {
const contextSize = useTableSize();
const resolvedSize = size ?? contextSize;
const isSmall = resolvedSize === "md";
const isSmall = resolvedSize === "small";
return (
<th
{...thProps}

View File

@@ -1,12 +1,12 @@
"use client";
import React from "react";
import { cn } from "@opal/utils";
import { useTableSize } from "./TableSizeContext";
import type { TableSize } from "./TableSizeContext";
import { cn } from "@/lib/utils";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { SvgUser } from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import type { QualifierContentType } from "./types";
import type { QualifierContentType } from "@/refresh-components/table/types";
import Checkbox from "@/refresh-components/inputs/Checkbox";
import Text from "@/refresh-components/texts/Text";
@@ -35,8 +35,8 @@ interface TableQualifierProps {
}
const iconSizes = {
lg: 16,
md: 14,
regular: 16,
small: 14,
} as const;
function getQualifierStyles(selected: boolean, disabled: boolean) {
@@ -115,7 +115,7 @@ function TableQualifier({
<div
className={cn(
"flex items-center justify-center rounded-full bg-background-neutral-inverted-00",
resolvedSize === "lg" ? "h-7 w-7" : "h-6 w-6"
resolvedSize === "regular" ? "h-7 w-7" : "h-6 w-6"
)}
>
<Text
@@ -138,7 +138,7 @@ function TableQualifier({
<div
className={cn(
"group relative inline-flex shrink-0 items-center justify-center",
resolvedSize === "lg" ? "h-9 w-9" : "h-7 w-7",
resolvedSize === "regular" ? "h-9 w-9" : "h-7 w-7",
disabled ? "cursor-not-allowed" : "cursor-default",
className
)}
@@ -147,7 +147,7 @@ function TableQualifier({
<div
className={cn(
"flex items-center justify-center overflow-hidden transition-colors",
resolvedSize === "lg" ? "h-9 w-9" : "h-7 w-7",
resolvedSize === "regular" ? "h-9 w-9" : "h-7 w-7",
isRound ? "rounded-full" : "rounded-08",
styles.container,
content === "image" && disabled && !selected && "opacity-50"

View File

@@ -1,8 +1,8 @@
"use client";
import { cn } from "@opal/utils";
import { useTableSize } from "./TableSizeContext";
import type { TableSize } from "./TableSizeContext";
import { cn } from "@/lib/utils";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import type { WithoutStyles } from "@/types";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
@@ -95,7 +95,7 @@ function SortableTableRow({
{...listeners}
>
<SvgHandle
size={resolvedSize === "md" ? 12 : 16}
size={resolvedSize === "small" ? 12 : 16}
className="text-border-02"
/>
</button>

View File

@@ -1,11 +1,10 @@
"use client";
import { createContext, useContext } from "react";
import type { SizeVariants } from "@opal/types";
type TableSize = Extract<SizeVariants, "md" | "lg">;
type TableSize = "regular" | "small";
const TableSizeContext = createContext<TableSize>("lg");
const TableSizeContext = createContext<TableSize>("regular");
interface TableSizeProviderProps {
size: TableSize;

View File

@@ -13,10 +13,10 @@ import type {
OnyxDataColumn,
OnyxDisplayColumn,
OnyxActionsColumn,
} from "./types";
import type { TableSize } from "./TableSizeContext";
} from "@/refresh-components/table/types";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import type { IconFunctionComponent } from "@opal/types";
import type { SortDirection } from "./TableHead";
import type { SortDirection } from "@/refresh-components/table/TableHead";
// ---------------------------------------------------------------------------
// Qualifier column config
@@ -160,7 +160,7 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
id: "qualifier",
def,
width: (size: TableSize) =>
size === "md" ? { fixed: 40 } : { fixed: 56 },
size === "small" ? { fixed: 40 } : { fixed: 56 },
content,
headerContentType: config?.headerContentType,
getInitials: config?.getInitials,
@@ -246,7 +246,7 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
id: "__actions",
def,
width: (size: TableSize) =>
size === "md" ? { fixed: 20 } : { fixed: 88 },
size === "small" ? { fixed: 20 } : { fixed: 88 },
showColumnVisibility: config?.showColumnVisibility ?? true,
showSorting: config?.showSorting ?? true,
sortingFooterText: config?.sortingFooterText,

View File

@@ -4,9 +4,9 @@ import type {
SortingState,
VisibilityState,
} from "@tanstack/react-table";
import type { TableSize } from "./TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import type { IconFunctionComponent } from "@opal/types";
import type { SortDirection } from "./TableHead";
import type { SortDirection } from "@/refresh-components/table/TableHead";
// ---------------------------------------------------------------------------
// Column width (mirrors useColumnWidths types)
@@ -166,7 +166,7 @@ export interface DataTableProps<TData> {
draggable?: DataTableDraggableConfig;
/** Footer configuration. */
footer?: DataTableFooterConfig;
/** Table size variant. @default "lg" */
/** Table size variant. @default "regular" */
size?: TableSize;
/** Called whenever the set of selected row IDs changes. Receives IDs produced by `getRowId`. */
onSelectionChange?: (selectedIds: string[]) => void;

View File

@@ -21,6 +21,7 @@ import useTags from "@/hooks/useTags";
import { useDocumentSets } from "@/lib/hooks/useDocumentSets";
import { useAgents } from "@/hooks/useAgents";
import { AppPopup } from "@/app/app/components/AppPopup";
import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal";
import { useUser } from "@/providers/UserProvider";
import NoAgentModal from "@/components/modals/NoAgentModal";
import PreviewModal from "@/sections/modals/PreviewModal";
@@ -419,6 +420,10 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
}
}, [currentChatSessionId]);
const [stackTraceModalContent, setStackTraceModalContent] = useState<
string | null
>(null);
const handleResubmitLastMessage = useCallback(() => {
// Grab the last user-type message
const lastUserMsg = messageHistory
@@ -655,6 +660,13 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
/>
)}
{stackTraceModalContent && (
<ExceptionTraceModal
onOutsideClick={() => setStackTraceModalContent(null)}
exceptionTrace={stackTraceModalContent}
/>
)}
<FederatedOAuthModal />
<AppLayouts.Root enableBackground={!appFocus.isProject()}>

View File

@@ -35,16 +35,16 @@ import {
WellKnownLLMProviderDescriptor,
} from "@/interfaces/llm";
import { getModalForExistingProvider } from "@/sections/modals/llmConfig/getModal";
import OpenAIModal from "@/sections/modals/llmConfig/OpenAIModal";
import AnthropicModal from "@/sections/modals/llmConfig/AnthropicModal";
import OllamaModal from "@/sections/modals/llmConfig/OllamaModal";
import AzureModal from "@/sections/modals/llmConfig/AzureModal";
import BedrockModal from "@/sections/modals/llmConfig/BedrockModal";
import VertexAIModal from "@/sections/modals/llmConfig/VertexAIModal";
import OpenRouterModal from "@/sections/modals/llmConfig/OpenRouterModal";
import CustomModal from "@/sections/modals/llmConfig/CustomModal";
import LMStudioForm from "@/sections/modals/llmConfig/LMStudioForm";
import LiteLLMProxyModal from "@/sections/modals/llmConfig/LiteLLMProxyModal";
import { OpenAIModal } from "@/sections/modals/llmConfig/OpenAIModal";
import { AnthropicModal } from "@/sections/modals/llmConfig/AnthropicModal";
import { OllamaModal } from "@/sections/modals/llmConfig/OllamaModal";
import { AzureModal } from "@/sections/modals/llmConfig/AzureModal";
import { BedrockModal } from "@/sections/modals/llmConfig/BedrockModal";
import { VertexAIModal } from "@/sections/modals/llmConfig/VertexAIModal";
import { OpenRouterModal } from "@/sections/modals/llmConfig/OpenRouterModal";
import { CustomModal } from "@/sections/modals/llmConfig/CustomModal";
import { LMStudioForm } from "@/sections/modals/llmConfig/LMStudioForm";
import { LiteLLMProxyModal } from "@/sections/modals/llmConfig/LiteLLMProxyModal";
import { Section } from "@/layouts/general-layouts";
const route = ADMIN_ROUTES.LLM_MODELS;
@@ -53,20 +53,6 @@ const route = ADMIN_ROUTES.LLM_MODELS;
// Provider form mapping (keyed by provider name from the API)
// ============================================================================
// Client-side ordering for the "Add Provider" cards. The backend may return
// wellKnownLLMProviders in an arbitrary order, so we sort explicitly here.
const PROVIDER_DISPLAY_ORDER: string[] = [
"openai",
"anthropic",
"vertex_ai",
"bedrock",
"azure",
"litellm_proxy",
"ollama_chat",
"openrouter",
"lm_studio",
];
const PROVIDER_MODAL_MAP: Record<
string,
(
@@ -470,32 +456,23 @@ export default function LLMConfigurationPage() {
/>
<div className="grid grid-cols-2 gap-2">
{[...(wellKnownLLMProviders ?? [])]
.sort((a, b) => {
const aIndex = PROVIDER_DISPLAY_ORDER.indexOf(a.name);
const bIndex = PROVIDER_DISPLAY_ORDER.indexOf(b.name);
return (
(aIndex === -1 ? Infinity : aIndex) -
(bIndex === -1 ? Infinity : bIndex)
{wellKnownLLMProviders?.map((provider) => {
const formFn = PROVIDER_MODAL_MAP[provider.name];
if (!formFn) {
toast.error(
`No modal mapping for provider "${provider.name}".`
);
})
.map((provider) => {
const formFn = PROVIDER_MODAL_MAP[provider.name];
if (!formFn) {
toast.error(
`No modal mapping for provider "${provider.name}".`
);
return null;
}
return (
<NewProviderCard
key={provider.name}
provider={provider}
isFirstProvider={isFirstProvider}
formFn={formFn}
/>
);
})}
return null;
}
return (
<NewProviderCard
key={provider.name}
provider={provider}
isFirstProvider={isFirstProvider}
formFn={formFn}
/>
);
})}
<NewCustomProviderCard isFirstProvider={isFirstProvider} />
</div>
</GeneralLayouts.Section>

View File

@@ -12,7 +12,6 @@ import {
SvgKey,
} from "@opal/icons";
import { Disabled } from "@opal/core";
import LineItem from "@/refresh-components/buttons/LineItem";
import Popover from "@/refresh-components/Popover";
import Separator from "@/refresh-components/Separator";
import { Section } from "@/layouts/general-layouts";
@@ -79,17 +78,18 @@ export default function UserRowActions({
return (
<>
{user.id && (
<LineItem
<Button
prominence="tertiary"
icon={SvgUsers}
onClick={() => openModal(Modal.EDIT_GROUPS)}
>
Groups &amp; Roles
</LineItem>
</Button>
)}
<Disabled disabled>
<LineItem danger icon={SvgUserX}>
<Button prominence="tertiary" variant="danger" icon={SvgUserX}>
Deactivate User
</LineItem>
</Button>
</Disabled>
<Separator paddingXRem={0.5} />
<Text as="p" secondaryBody text03 className="px-3 py-1">
@@ -102,18 +102,20 @@ export default function UserRowActions({
switch (user.status) {
case UserStatus.INVITED:
return (
<LineItem
danger
<Button
prominence="tertiary"
variant="danger"
icon={SvgXCircle}
onClick={() => openModal(Modal.CANCEL_INVITE)}
>
Cancel Invite
</LineItem>
</Button>
);
case UserStatus.REQUESTED:
return (
<LineItem
<Button
prominence="tertiary"
icon={SvgUserCheck}
onClick={() => {
setPopoverOpen(false);
@@ -131,34 +133,37 @@ export default function UserRowActions({
}}
>
Approve
</LineItem>
</Button>
);
case UserStatus.ACTIVE:
return (
<>
{user.id && (
<LineItem
<Button
prominence="tertiary"
icon={SvgUsers}
onClick={() => openModal(Modal.EDIT_GROUPS)}
>
Groups &amp; Roles
</LineItem>
</Button>
)}
<LineItem
<Button
prominence="tertiary"
icon={SvgKey}
onClick={() => openModal(Modal.RESET_PASSWORD)}
>
Reset Password
</LineItem>
</Button>
<Separator paddingXRem={0.5} />
<LineItem
danger
<Button
prominence="tertiary"
variant="danger"
icon={SvgUserX}
onClick={() => openModal(Modal.DEACTIVATE)}
>
Deactivate User
</LineItem>
</Button>
</>
);
@@ -166,34 +171,38 @@ export default function UserRowActions({
return (
<>
{user.id && (
<LineItem
<Button
prominence="tertiary"
icon={SvgUsers}
onClick={() => openModal(Modal.EDIT_GROUPS)}
>
Groups &amp; Roles
</LineItem>
</Button>
)}
<LineItem
<Button
prominence="tertiary"
icon={SvgKey}
onClick={() => openModal(Modal.RESET_PASSWORD)}
>
Reset Password
</LineItem>
</Button>
<Separator paddingXRem={0.5} />
<LineItem
<Button
prominence="tertiary"
icon={SvgUserPlus}
onClick={() => openModal(Modal.ACTIVATE)}
>
Activate User
</LineItem>
</Button>
<Separator paddingXRem={0.5} />
<LineItem
danger
<Button
prominence="tertiary"
variant="danger"
icon={SvgUserX}
onClick={() => openModal(Modal.DELETE)}
>
Delete User
</LineItem>
</Button>
</>
);

View File

@@ -1,7 +1,8 @@
"use client";
import { useMemo, useState } from "react";
import { Table, createTableColumns } from "@opal/components";
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
import { Content } from "@opal/layouts";
import { Button } from "@opal/components";
import { SvgDownload } from "@opal/icons";
@@ -215,7 +216,7 @@ export default function UsersTable({
roleCounts={roleCounts}
statusCounts={statusCounts}
/>
<Table
<DataTable
data={filteredUsers}
columns={columns}
getRowId={(row) => row.id ?? row.email}

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