Compare commits

..

1 Commits

Author SHA1 Message Date
Nik
113cdb4a5d feat(chat): add DB schema and Pydantic models for multi-model answers 2026-03-24 17:47:38 -07:00
140 changed files with 1368 additions and 4441 deletions

View File

@@ -6,4 +6,3 @@
3134e5f840c12c8f32613ce520101a047c89dcc2 # refactor(whitespace): rm temporary react fragments (#7161)
ed3f72bc75f3e3a9ae9e4d8cd38278f9c97e78b4 # refactor(whitespace): rm react fragment #7190
7b927e79c25f4ddfd18a067f489e122acd2c89de # chore(format): format files where `ruff` and `black` agree (#9339)

View File

@@ -7,15 +7,6 @@ on:
merge_group:
pull_request:
branches: [main]
paths:
- "backend/**"
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/pr-external-dependency-unit-tests.yml"
- ".github/actions/setup-python-and-install-dependencies/**"
- ".github/actions/setup-playwright/**"
- "deployment/docker_compose/docker-compose.yml"
- "deployment/docker_compose/docker-compose.dev.yml"
push:
tags:
- "v*.*.*"

View File

@@ -7,13 +7,6 @@ on:
merge_group:
pull_request:
branches: [main]
paths:
- "backend/**"
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/pr-python-connector-tests.yml"
- ".github/actions/setup-python-and-install-dependencies/**"
- ".github/actions/setup-playwright/**"
push:
tags:
- "v*.*.*"

View File

@@ -0,0 +1,36 @@
"""add preferred_response_id and model_display_name to chat_message
Revision ID: a3f8b2c1d4e5
Create Date: 2026-03-22
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "a3f8b2c1d4e5"
down_revision = "b728689f45b1"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"chat_message",
sa.Column(
"preferred_response_id",
sa.Integer(),
sa.ForeignKey("chat_message.id"),
nullable=True,
),
)
op.add_column(
"chat_message",
sa.Column("model_display_name", sa.String(), nullable=True),
)
def downgrade() -> None:
op.drop_column("chat_message", "model_display_name")
op.drop_column("chat_message", "preferred_response_id")

View File

@@ -25,13 +25,10 @@ from onyx.redis.redis_pool import get_redis_client
from shared_configs.configs import MULTI_TENANT
from shared_configs.configs import TENANT_ID_PREFIX
# Maximum tenants to provision in a single task run.
# Each tenant takes ~80s (alembic migrations), so 5 tenants ≈ 7 minutes.
_MAX_TENANTS_PER_RUN = 5
# Time limits sized for worst-case batch: _MAX_TENANTS_PER_RUN × ~90s + buffer.
_TENANT_PROVISIONING_SOFT_TIME_LIMIT = 60 * 10 # 10 minutes
_TENANT_PROVISIONING_TIME_LIMIT = 60 * 15 # 15 minutes
# Soft time limit for tenant pre-provisioning tasks (in seconds)
_TENANT_PROVISIONING_SOFT_TIME_LIMIT = 60 * 5 # 5 minutes
# Hard time limit for tenant pre-provisioning tasks (in seconds)
_TENANT_PROVISIONING_TIME_LIMIT = 60 * 10 # 10 minutes
@shared_task(
@@ -88,26 +85,9 @@ def check_available_tenants(self: Task) -> None: # noqa: ARG001
f"To provision: {tenants_to_provision}"
)
batch_size = min(tenants_to_provision, _MAX_TENANTS_PER_RUN)
if batch_size < tenants_to_provision:
task_logger.info(
f"Capping batch to {batch_size} "
f"(need {tenants_to_provision}, will catch up next cycle)"
)
provisioned = 0
for i in range(batch_size):
task_logger.info(f"Provisioning tenant {i + 1}/{batch_size}")
try:
if pre_provision_tenant():
provisioned += 1
except Exception:
task_logger.exception(
f"Failed to provision tenant {i + 1}/{batch_size}, "
"continuing with remaining tenants"
)
task_logger.info(f"Provisioning complete: {provisioned}/{batch_size} succeeded")
# just provision one tenant each time we run this ... increase if needed.
if tenants_to_provision > 0:
pre_provision_tenant()
except Exception:
task_logger.exception("Error in check_available_tenants task")
@@ -121,13 +101,11 @@ def check_available_tenants(self: Task) -> None: # noqa: ARG001
)
def pre_provision_tenant() -> bool:
def pre_provision_tenant() -> None:
"""
Pre-provision a new tenant and store it in the NewAvailableTenant table.
This function fully sets up the tenant with all necessary configurations,
so it's ready to be assigned to a user immediately.
Returns True if a tenant was successfully provisioned, False otherwise.
"""
# The MULTI_TENANT check is now done at the caller level (check_available_tenants)
# rather than inside this function
@@ -140,10 +118,10 @@ def pre_provision_tenant() -> bool:
# Allow multiple pre-provisioning tasks to run, but ensure they don't overlap
if not lock_provision.acquire(blocking=False):
task_logger.warning(
"Skipping pre_provision_tenant — could not acquire provision lock"
task_logger.debug(
"Skipping pre_provision_tenant task because it is already running"
)
return False
return
tenant_id: str | None = None
try:
@@ -183,7 +161,6 @@ def pre_provision_tenant() -> bool:
db_session.add(new_tenant)
db_session.commit()
task_logger.info(f"Successfully pre-provisioned tenant: {tenant_id}")
return True
except Exception:
db_session.rollback()
task_logger.error(
@@ -207,7 +184,6 @@ def pre_provision_tenant() -> bool:
asyncio.run(rollback_tenant_provisioning(tenant_id))
except Exception:
task_logger.exception(f"Error during rollback for tenant: {tenant_id}")
return False
finally:
try:
lock_provision.release()

View File

@@ -800,33 +800,6 @@ def update_user_group(
return db_user_group
def rename_user_group(
db_session: Session,
user_group_id: int,
new_name: str,
) -> UserGroup:
stmt = select(UserGroup).where(UserGroup.id == user_group_id)
db_user_group = db_session.scalar(stmt)
if db_user_group is None:
raise ValueError(f"UserGroup with id '{user_group_id}' not found")
_check_user_group_is_modifiable(db_user_group)
db_user_group.name = new_name
db_user_group.time_last_modified_by_user = func.now()
# CC pair documents in Vespa contain the group name, so we need to
# trigger a sync to update them with the new name.
_mark_user_group__cc_pair_relationships_outdated__no_commit(
db_session=db_session, user_group_id=user_group_id
)
if not DISABLE_VECTOR_DB:
db_user_group.is_up_to_date = False
db_session.commit()
return db_user_group
def prepare_user_group_for_deletion(db_session: Session, user_group_id: int) -> None:
stmt = select(UserGroup).where(UserGroup.id == user_group_id)
db_user_group = db_session.scalar(stmt)

View File

@@ -4,7 +4,6 @@ from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from ee.onyx.db.persona import update_persona_access
from ee.onyx.db.user_group import add_users_to_user_group
from ee.onyx.db.user_group import delete_user_group as db_delete_user_group
from ee.onyx.db.user_group import fetch_user_group
@@ -12,16 +11,13 @@ from ee.onyx.db.user_group import fetch_user_groups
from ee.onyx.db.user_group import fetch_user_groups_for_user
from ee.onyx.db.user_group import insert_user_group
from ee.onyx.db.user_group import prepare_user_group_for_deletion
from ee.onyx.db.user_group import rename_user_group
from ee.onyx.db.user_group import update_user_curator_relationship
from ee.onyx.db.user_group import update_user_group
from ee.onyx.server.user_group.models import AddUsersToUserGroupRequest
from ee.onyx.server.user_group.models import MinimalUserGroupSnapshot
from ee.onyx.server.user_group.models import SetCuratorRequest
from ee.onyx.server.user_group.models import UpdateGroupAgentsRequest
from ee.onyx.server.user_group.models import UserGroup
from ee.onyx.server.user_group.models import UserGroupCreate
from ee.onyx.server.user_group.models import UserGroupRename
from ee.onyx.server.user_group.models import UserGroupUpdate
from onyx.auth.users import current_admin_user
from onyx.auth.users import current_curator_or_admin_user
@@ -31,9 +27,6 @@ from onyx.configs.constants import PUBLIC_API_TAGS
from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.db.models import UserRole
from onyx.db.persona import get_persona_by_id
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.utils.logger import setup_logger
logger = setup_logger()
@@ -94,32 +87,6 @@ def create_user_group(
return UserGroup.from_model(db_user_group)
@router.patch("/admin/user-group/rename")
def rename_user_group_endpoint(
rename_request: UserGroupRename,
_: User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> UserGroup:
try:
return UserGroup.from_model(
rename_user_group(
db_session=db_session,
user_group_id=rename_request.id,
new_name=rename_request.name,
)
)
except IntegrityError:
raise OnyxError(
OnyxErrorCode.DUPLICATE_RESOURCE,
f"User group with name '{rename_request.name}' already exists.",
)
except ValueError as e:
msg = str(e)
if "not found" in msg.lower():
raise OnyxError(OnyxErrorCode.NOT_FOUND, msg)
raise OnyxError(OnyxErrorCode.CONFLICT, msg)
@router.patch("/admin/user-group/{user_group_id}")
def patch_user_group(
user_group_id: int,
@@ -194,38 +161,3 @@ def delete_user_group(
user_group = fetch_user_group(db_session, user_group_id)
if user_group:
db_delete_user_group(db_session, user_group)
@router.patch("/admin/user-group/{user_group_id}/agents")
def update_group_agents(
user_group_id: int,
request: UpdateGroupAgentsRequest,
user: User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> None:
for agent_id in request.added_agent_ids:
persona = get_persona_by_id(
persona_id=agent_id, user=user, db_session=db_session
)
current_group_ids = [g.id for g in persona.groups]
if user_group_id not in current_group_ids:
update_persona_access(
persona_id=agent_id,
creator_user_id=user.id,
db_session=db_session,
group_ids=current_group_ids + [user_group_id],
)
for agent_id in request.removed_agent_ids:
persona = get_persona_by_id(
persona_id=agent_id, user=user, db_session=db_session
)
current_group_ids = [g.id for g in persona.groups]
update_persona_access(
persona_id=agent_id,
creator_user_id=user.id,
db_session=db_session,
group_ids=[gid for gid in current_group_ids if gid != user_group_id],
)
db_session.commit()

View File

@@ -104,16 +104,6 @@ class AddUsersToUserGroupRequest(BaseModel):
user_ids: list[UUID]
class UserGroupRename(BaseModel):
id: int
name: str
class SetCuratorRequest(BaseModel):
user_id: UUID
is_curator: bool
class UpdateGroupAgentsRequest(BaseModel):
added_agent_ids: list[int]
removed_agent_ids: list[int]

View File

@@ -8,6 +8,7 @@ from onyx.configs.constants import MessageType
from onyx.context.search.models import SearchDoc
from onyx.file_store.models import InMemoryChatFile
from onyx.server.query_and_chat.models import MessageResponseIDInfo
from onyx.server.query_and_chat.models import MultiModelMessageResponseIDInfo
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 Packet
@@ -35,7 +36,13 @@ class CreateChatSessionID(BaseModel):
chat_session_id: UUID
AnswerStreamPart = Packet | MessageResponseIDInfo | StreamingError | CreateChatSessionID
AnswerStreamPart = (
Packet
| MessageResponseIDInfo
| MultiModelMessageResponseIDInfo
| StreamingError
| CreateChatSessionID
)
AnswerStream = Iterator[AnswerStreamPart]

View File

@@ -59,7 +59,6 @@ from onyx.db.chat import create_new_chat_message
from onyx.db.chat import get_chat_session_by_id
from onyx.db.chat import get_or_create_root_message
from onyx.db.chat import reserve_message_id
from onyx.db.enums import HookPoint
from onyx.db.memory import get_memories
from onyx.db.models import ChatMessage
from onyx.db.models import ChatSession
@@ -69,19 +68,11 @@ from onyx.db.models import UserFile
from onyx.db.projects import get_user_files_from_project
from onyx.db.tools import get_tools
from onyx.deep_research.dr_loop import run_deep_research_llm_loop
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import log_onyx_error
from onyx.error_handling.exceptions import OnyxError
from onyx.file_processing.extract_file_text import extract_file_text
from onyx.file_store.models import ChatFileType
from onyx.file_store.models import InMemoryChatFile
from onyx.file_store.utils import load_in_memory_chat_files
from onyx.file_store.utils import verify_user_files
from onyx.hooks.executor import execute_hook
from onyx.hooks.executor import HookSkipped
from onyx.hooks.executor import HookSoftFailed
from onyx.hooks.points.query_processing import QueryProcessingPayload
from onyx.hooks.points.query_processing import QueryProcessingResponse
from onyx.llm.factory import get_llm_for_persona
from onyx.llm.factory import get_llm_token_counter
from onyx.llm.interfaces import LLM
@@ -433,28 +424,6 @@ def determine_search_params(
)
def _resolve_query_processing_hook_result(
hook_result: QueryProcessingResponse | HookSkipped | HookSoftFailed,
message_text: str,
) -> str:
"""Apply the Query Processing hook result to the message text.
Returns the (possibly rewritten) message text, or raises OnyxError with
QUERY_REJECTED if the hook signals rejection (query is null or empty).
HookSkipped and HookSoftFailed are pass-throughs — the original text is
returned unchanged.
"""
if isinstance(hook_result, (HookSkipped, HookSoftFailed)):
return message_text
if not (hook_result.query and hook_result.query.strip()):
raise OnyxError(
OnyxErrorCode.QUERY_REJECTED,
hook_result.rejection_message
or "The hook extension for query processing did not return a valid query. No rejection reason was provided.",
)
return hook_result.query.strip()
def handle_stream_message_objects(
new_msg_req: SendMessageRequest,
user: User,
@@ -505,24 +474,16 @@ def handle_stream_message_objects(
db_session=db_session,
)
yield CreateChatSessionID(chat_session_id=chat_session.id)
chat_session = get_chat_session_by_id(
chat_session_id=chat_session.id,
user_id=user_id,
db_session=db_session,
eager_load_persona=True,
)
else:
chat_session = get_chat_session_by_id(
chat_session_id=new_msg_req.chat_session_id,
user_id=user_id,
db_session=db_session,
eager_load_persona=True,
)
persona = chat_session.persona
message_text = new_msg_req.message
user_identity = LLMUserIdentity(
user_id=llm_user_identifier, session_id=str(chat_session.id)
)
@@ -614,28 +575,6 @@ def handle_stream_message_objects(
if parent_message.message_type == MessageType.USER:
user_message = parent_message
else:
# New message — run the Query Processing hook before saving to DB.
# Skipped on regeneration: the message already exists and was accepted previously.
# Skip the hook for empty/whitespace-only messages — no meaningful query
# to process, and SendMessageRequest.message has no min_length guard.
if message_text.strip():
hook_result = execute_hook(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=QueryProcessingPayload(
query=message_text,
# Pass None for anonymous users or authenticated users without an email
# (e.g. some SSO flows). QueryProcessingPayload.user_email is str | None,
# so None is accepted and serialised as null in both cases.
user_email=None if user.is_anonymous else user.email,
chat_session_id=str(chat_session.id),
).model_dump(),
response_type=QueryProcessingResponse,
)
message_text = _resolve_query_processing_hook_result(
hook_result, message_text
)
user_message = create_new_chat_message(
chat_session_id=chat_session.id,
parent_message=parent_message,
@@ -975,17 +914,6 @@ def handle_stream_message_objects(
state_container=state_container,
)
except OnyxError as e:
if e.error_code is not OnyxErrorCode.QUERY_REJECTED:
log_onyx_error(e)
yield StreamingError(
error=e.detail,
error_code=e.error_code.code,
is_retryable=e.status_code >= 500,
)
db_session.rollback()
return
except ValueError as e:
logger.exception("Failed to process chat message.")

View File

@@ -16,7 +16,6 @@ from sqlalchemy import Row
from sqlalchemy import select
from sqlalchemy import update
from sqlalchemy.exc import MultipleResultsFound
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
@@ -29,7 +28,6 @@ from onyx.db.models import ChatMessage
from onyx.db.models import ChatMessage__SearchDoc
from onyx.db.models import ChatSession
from onyx.db.models import ChatSessionSharedStatus
from onyx.db.models import Persona
from onyx.db.models import SearchDoc as DBSearchDoc
from onyx.db.models import ToolCall
from onyx.db.models import User
@@ -55,19 +53,9 @@ def get_chat_session_by_id(
db_session: Session,
include_deleted: bool = False,
is_shared: bool = False,
eager_load_persona: bool = False,
) -> ChatSession:
stmt = select(ChatSession).where(ChatSession.id == chat_session_id)
if eager_load_persona:
stmt = stmt.options(
joinedload(ChatSession.persona).options(
selectinload(Persona.tools),
selectinload(Persona.user_files),
),
joinedload(ChatSession.project),
)
if is_shared:
stmt = stmt.where(ChatSession.shared_status == ChatSessionSharedStatus.PUBLIC)
else:

View File

@@ -2645,6 +2645,15 @@ class ChatMessage(Base):
nullable=True,
)
# For multi-model turns: the user message points to which assistant response
# was selected as the preferred one to continue the conversation with.
preferred_response_id: Mapped[int | None] = mapped_column(
ForeignKey("chat_message.id"), nullable=True
)
# The display name of the model that generated this assistant message
model_display_name: Mapped[str | None] = mapped_column(String, nullable=True)
# What does this message contain
reasoning_tokens: Mapped[str | None] = mapped_column(Text, nullable=True)
message: Mapped[str] = mapped_column(Text)
@@ -2712,6 +2721,12 @@ class ChatMessage(Base):
remote_side="ChatMessage.id",
)
preferred_response: Mapped["ChatMessage | None"] = relationship(
"ChatMessage",
foreign_keys=[preferred_response_id],
remote_side="ChatMessage.id",
)
# Chat messages only need to know their immediate tool call children
# If there are nested tool calls, they are stored in the tool_call_children relationship.
tool_calls: Mapped[list["ToolCall"] | None] = relationship(

View File

@@ -44,7 +44,6 @@ class OnyxErrorCode(Enum):
VALIDATION_ERROR = ("VALIDATION_ERROR", 400)
INVALID_INPUT = ("INVALID_INPUT", 400)
MISSING_REQUIRED_FIELD = ("MISSING_REQUIRED_FIELD", 400)
QUERY_REJECTED = ("QUERY_REJECTED", 400)
# ------------------------------------------------------------------
# Not Found (404)

View File

@@ -5,7 +5,6 @@ Usage (Celery tasks and FastAPI handlers):
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload={"query": "...", "user_email": "...", "chat_session_id": "..."},
response_type=QueryProcessingResponse,
)
if isinstance(result, HookSkipped):
@@ -15,7 +14,7 @@ Usage (Celery tasks and FastAPI handlers):
# hook failed but fail strategy is SOFT — continue with original behavior
...
else:
# result is a validated Pydantic model instance (spec.response_model)
# result is the response payload dict from the customer's endpoint
...
is_reachable update policy
@@ -54,11 +53,9 @@ The executor uses three sessions:
import json
import time
from typing import Any
from typing import TypeVar
import httpx
from pydantic import BaseModel
from pydantic import ValidationError
from sqlalchemy.orm import Session
from onyx.db.engine.sql_engine import get_session_with_current_tenant
@@ -84,9 +81,6 @@ class HookSoftFailed:
"""Hook was called but failed with SOFT fail strategy — continuing."""
T = TypeVar("T", bound=BaseModel)
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
@@ -274,21 +268,22 @@ def _persist_result(
# ---------------------------------------------------------------------------
def _execute_hook_inner(
hook: Hook,
def execute_hook(
*,
db_session: Session,
hook_point: HookPoint,
payload: dict[str, Any],
response_type: type[T],
) -> T | HookSoftFailed:
"""Make the HTTP call, validate the response, and return a typed model.
) -> dict[str, Any] | HookSkipped | HookSoftFailed:
"""Execute the hook for the given hook point synchronously."""
hook = _lookup_hook(db_session, hook_point)
if isinstance(hook, HookSkipped):
return hook
Raises OnyxError on HARD failure. Returns HookSoftFailed on SOFT failure.
"""
timeout = hook.timeout_seconds
hook_id = hook.id
fail_strategy = hook.fail_strategy
endpoint_url = hook.endpoint_url
current_is_reachable: bool | None = hook.is_reachable
if not endpoint_url:
raise ValueError(
f"hook_id={hook_id} is active but has no endpoint_url — "
@@ -305,36 +300,13 @@ def _execute_hook_inner(
headers: dict[str, str] = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
with httpx.Client(
timeout=timeout, follow_redirects=False
) as client: # SSRF guard: never follow redirects
with httpx.Client(timeout=timeout) as client:
response = client.post(endpoint_url, json=payload, headers=headers)
except Exception as e:
exc = e
duration_ms = int((time.monotonic() - start) * 1000)
outcome = _process_response(response=response, exc=exc, timeout=timeout)
# Validate the response payload against response_type.
# A validation failure downgrades the outcome to a failure so it is logged,
# is_reachable is left unchanged (server responded — just a bad payload),
# and fail_strategy is respected below.
validated_model: T | None = None
if outcome.is_success and outcome.response_payload is not None:
try:
validated_model = response_type.model_validate(outcome.response_payload)
except ValidationError as e:
msg = (
f"Hook response failed validation against {response_type.__name__}: {e}"
)
outcome = _HttpOutcome(
is_success=False,
updated_is_reachable=None, # server responded — reachability unchanged
status_code=outcome.status_code,
error_message=msg,
response_payload=None,
)
# Skip the is_reachable write when the value would not change — avoids a
# no-op DB round-trip on every call when the hook is already in the expected state.
if outcome.updated_is_reachable == current_is_reachable:
@@ -351,41 +323,8 @@ def _execute_hook_inner(
f"Hook execution failed (soft fail) for hook_id={hook_id}: {outcome.error_message}"
)
return HookSoftFailed()
if validated_model is None:
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
f"validated_model is None for successful hook call (hook_id={hook_id})",
if outcome.response_payload is None:
raise ValueError(
f"response_payload is None for successful hook call (hook_id={hook_id})"
)
return validated_model
def execute_hook(
*,
db_session: Session,
hook_point: HookPoint,
payload: dict[str, Any],
response_type: type[T],
) -> T | HookSkipped | HookSoftFailed:
"""Execute the hook for the given hook point synchronously.
Returns HookSkipped if no active hook is configured, HookSoftFailed if the
hook failed with SOFT fail strategy, or a validated response model on success.
Raises OnyxError on HARD failure or if the hook is misconfigured.
"""
hook = _lookup_hook(db_session, hook_point)
if isinstance(hook, HookSkipped):
return hook
fail_strategy = hook.fail_strategy
hook_id = hook.id
try:
return _execute_hook_inner(hook, payload, response_type)
except Exception:
if fail_strategy == HookFailStrategy.SOFT:
logger.exception(
f"Unexpected error in hook execution (soft fail) for hook_id={hook_id}"
)
return HookSoftFailed()
raise
return outcome.response_payload

View File

@@ -51,12 +51,13 @@ class HookPointSpec:
output_schema: ClassVar[dict[str, Any]]
def __init_subclass__(cls, **kwargs: object) -> None:
"""Enforce that every subclass declares all required class attributes.
"""Enforce that every concrete subclass declares all required class attributes.
Called automatically by Python whenever a class inherits from HookPointSpec.
Raises TypeError at import time if any required attribute is missing or if
payload_model / response_model are not Pydantic BaseModel subclasses.
input_schema and output_schema are derived automatically from the models.
Abstract subclasses (those still carrying unimplemented abstract methods) are
skipped — they are intermediate base classes and may not yet define everything.
Only fully concrete subclasses are validated, ensuring a clear TypeError at
import time rather than a confusing AttributeError at runtime.
"""
super().__init_subclass__(**kwargs)
missing = [attr for attr in _REQUIRED_ATTRS if not hasattr(cls, attr)]

View File

@@ -15,7 +15,7 @@ class QueryProcessingPayload(BaseModel):
description="Email of the user submitting the query, or null if unauthenticated."
)
chat_session_id: str = Field(
description="UUID of the chat session, formatted as a hyphenated lowercase string (e.g. '550e8400-e29b-41d4-a716-446655440000'). Always present — the session is guaranteed to exist by the time this hook fires."
description="UUID of the chat session. Always present — the session is guaranteed to exist by the time this hook fires."
)
@@ -25,7 +25,7 @@ class QueryProcessingResponse(BaseModel):
default=None,
description=(
"The query to use in the pipeline. "
"Null, empty string, whitespace-only, or absent = reject the query."
"Null, empty string, or absent = reject the query."
),
)
rejection_message: str | None = Field(

View File

@@ -11,6 +11,7 @@ class LLMOverride(BaseModel):
model_provider: str | None = None
model_version: str | None = None
temperature: float | None = None
display_name: str | None = None
# This disables the "model_" protected namespace for pydantic
model_config = {"protected_namespaces": ()}

View File

@@ -41,6 +41,16 @@ class MessageResponseIDInfo(BaseModel):
reserved_assistant_message_id: int
class MultiModelMessageResponseIDInfo(BaseModel):
"""Sent at the start of a multi-model streaming response.
Contains the user message ID and the reserved assistant message IDs
for each model being run in parallel."""
user_message_id: int | None
reserved_assistant_message_ids: list[int]
model_names: list[str]
class SourceTag(Tag):
source: DocumentSource
@@ -86,6 +96,9 @@ class SendMessageRequest(BaseModel):
message: str
llm_override: LLMOverride | None = None
# For multi-model mode: up to 3 LLM overrides to run in parallel.
# When provided with >1 entry, triggers multi-model streaming.
llm_overrides: list[LLMOverride] | None = None
# Test-only override for deterministic LiteLLM mock responses.
mock_llm_response: str | None = None
@@ -211,6 +224,8 @@ class ChatMessageDetail(BaseModel):
error: str | None = None
current_feedback: str | None = None # "like" | "dislike" | null
processing_duration_seconds: float | None = None
preferred_response_id: int | None = None
model_display_name: str | None = None
def model_dump(self, *args: list, **kwargs: dict[str, Any]) -> dict[str, Any]: # type: ignore
initial_dict = super().model_dump(mode="json", *args, **kwargs) # type: ignore
@@ -218,6 +233,11 @@ class ChatMessageDetail(BaseModel):
return initial_dict
class SetPreferredResponseRequest(BaseModel):
user_message_id: int
preferred_response_id: int
class ChatSessionDetailResponse(BaseModel):
chat_session_id: UUID
description: str | None

View File

@@ -8,3 +8,5 @@ class Placement(BaseModel):
tab_index: int = 0
# Used for tools/agents that call other tools, this currently doesn't support nested agents but can be added later
sub_turn_index: int | None = None
# For multi-model streaming: identifies which model (0, 1, 2) this packet belongs to.
model_index: int | None = None

View File

@@ -1,47 +0,0 @@
from sqlalchemy import inspect
from sqlalchemy.orm import Session
from onyx.db.chat import create_chat_session
from onyx.db.chat import get_chat_session_by_id
from onyx.db.models import Persona
from onyx.db.models import UserProject
from tests.external_dependency_unit.conftest import create_test_user
def test_eager_load_persona_loads_relationships(db_session: Session) -> None:
"""Verify that eager_load_persona pre-loads persona, its collections, and project."""
user = create_test_user(db_session, "eager-load")
persona = Persona(name="eager-load-test", description="test")
project = UserProject(name="eager-load-project", user_id=user.id)
db_session.add_all([persona, project])
db_session.flush()
chat_session = create_chat_session(
db_session=db_session,
description="test",
user_id=None,
persona_id=persona.id,
project_id=project.id,
)
loaded = get_chat_session_by_id(
chat_session_id=chat_session.id,
user_id=None,
db_session=db_session,
eager_load_persona=True,
)
try:
tmp = inspect(loaded)
assert tmp is not None
unloaded = tmp.unloaded
assert "persona" not in unloaded
assert "project" not in unloaded
tmp = inspect(loaded.persona)
assert tmp is not None
persona_unloaded = tmp.unloaded
assert "tools" not in persona_unloaded
assert "user_files" not in persona_unloaded
finally:
db_session.rollback()

View File

@@ -143,8 +143,8 @@ def use_mock_search_pipeline(
db_session: Session | None = None, # noqa: ARG001
auto_detect_filters: bool = False, # noqa: ARG001
llm: LLM | None = None, # noqa: ARG001
project_id_filter: int | None = None, # noqa: ARG001
persona_id_filter: int | None = None, # noqa: ARG001
project_id: int | None = None, # noqa: ARG001
persona_id: int | None = None, # noqa: ARG001
# Pre-fetched data (used by SearchTool to avoid DB access in parallel calls)
acl_filters: list[str] | None = None, # noqa: ARG001
embedding_model: EmbeddingModel | None = None, # noqa: ARG001

View File

@@ -1,53 +0,0 @@
"""Tests for user group rename DB operation."""
from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
from ee.onyx.db.user_group import rename_user_group
from onyx.db.models import UserGroup
class TestRenameUserGroup:
"""Tests for rename_user_group function."""
@patch("ee.onyx.db.user_group.DISABLE_VECTOR_DB", False)
@patch(
"ee.onyx.db.user_group._mark_user_group__cc_pair_relationships_outdated__no_commit"
)
def test_rename_succeeds_and_triggers_sync(
self, mock_mark_outdated: MagicMock
) -> None:
mock_session = MagicMock()
mock_group = MagicMock(spec=UserGroup)
mock_group.name = "Old Name"
mock_group.is_up_to_date = True
mock_session.scalar.return_value = mock_group
result = rename_user_group(mock_session, user_group_id=1, new_name="New Name")
assert result.name == "New Name"
assert result.is_up_to_date is False
mock_mark_outdated.assert_called_once()
mock_session.commit.assert_called_once()
def test_rename_group_not_found(self) -> None:
mock_session = MagicMock()
mock_session.scalar.return_value = None
with pytest.raises(ValueError, match="not found"):
rename_user_group(mock_session, user_group_id=999, new_name="New Name")
mock_session.commit.assert_not_called()
def test_rename_group_syncing_raises(self) -> None:
mock_session = MagicMock()
mock_group = MagicMock(spec=UserGroup)
mock_group.is_up_to_date = False
mock_session.scalar.return_value = mock_group
with pytest.raises(ValueError, match="currently syncing"):
rename_user_group(mock_session, user_group_id=1, new_name="New Name")
mock_session.commit.assert_not_called()

View File

@@ -1,216 +0,0 @@
"""
Unit tests for the check_available_tenants task.
Tests verify:
- Provisioning loop calls pre_provision_tenant the correct number of times
- Batch size is capped at _MAX_TENANTS_PER_RUN
- A failure in one provisioning call does not stop subsequent calls
- No provisioning happens when pool is already full
- TARGET_AVAILABLE_TENANTS is respected
"""
from unittest.mock import MagicMock
import pytest
from ee.onyx.background.celery.tasks.tenant_provisioning.tasks import (
_MAX_TENANTS_PER_RUN,
)
from ee.onyx.background.celery.tasks.tenant_provisioning.tasks import (
check_available_tenants,
)
# Access the underlying function directly, bypassing Celery's task wrapper
# which injects `self` as the first argument when bind=True.
_check_available_tenants = check_available_tenants.run
@pytest.fixture()
def _enable_multi_tenant(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.MULTI_TENANT",
True,
)
@pytest.fixture()
def mock_redis(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
mock_lock = MagicMock()
mock_lock.acquire.return_value = True
mock_client = MagicMock()
mock_client.lock.return_value = mock_lock
monkeypatch.setattr(
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.get_redis_client",
lambda tenant_id: mock_client, # noqa: ARG005
)
return mock_client
@pytest.fixture()
def mock_pre_provision(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
mock = MagicMock(return_value=True)
monkeypatch.setattr(
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.pre_provision_tenant",
mock,
)
return mock
def _mock_available_count(monkeypatch: pytest.MonkeyPatch, count: int) -> None:
"""Set up the DB session mock to return a specific available tenant count."""
mock_session = MagicMock()
mock_session.__enter__ = MagicMock(return_value=mock_session)
mock_session.__exit__ = MagicMock(return_value=False)
mock_session.query.return_value.count.return_value = count
monkeypatch.setattr(
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.get_session_with_shared_schema",
lambda: mock_session,
)
@pytest.mark.usefixtures("_enable_multi_tenant", "mock_redis")
class TestCheckAvailableTenants:
def test_provisions_all_needed_tenants(
self,
monkeypatch: pytest.MonkeyPatch,
mock_pre_provision: MagicMock,
) -> None:
"""When pool has 2 and target is 5, should provision 3."""
monkeypatch.setattr(
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.TARGET_AVAILABLE_TENANTS",
5,
)
_mock_available_count(monkeypatch, 2)
_check_available_tenants()
assert mock_pre_provision.call_count == 3
def test_batch_capped_at_max_per_run(
self,
monkeypatch: pytest.MonkeyPatch,
mock_pre_provision: MagicMock,
) -> None:
"""When pool needs more than _MAX_TENANTS_PER_RUN, cap the batch."""
monkeypatch.setattr(
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.TARGET_AVAILABLE_TENANTS",
20,
)
_mock_available_count(monkeypatch, 0)
_check_available_tenants()
assert mock_pre_provision.call_count == _MAX_TENANTS_PER_RUN
def test_no_provisioning_when_pool_full(
self,
monkeypatch: pytest.MonkeyPatch,
mock_pre_provision: MagicMock,
) -> None:
"""When pool already meets target, should not provision anything."""
monkeypatch.setattr(
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.TARGET_AVAILABLE_TENANTS",
5,
)
_mock_available_count(monkeypatch, 5)
_check_available_tenants()
assert mock_pre_provision.call_count == 0
def test_no_provisioning_when_pool_exceeds_target(
self,
monkeypatch: pytest.MonkeyPatch,
mock_pre_provision: MagicMock,
) -> None:
"""When pool exceeds target, should not provision anything."""
monkeypatch.setattr(
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.TARGET_AVAILABLE_TENANTS",
5,
)
_mock_available_count(monkeypatch, 8)
_check_available_tenants()
assert mock_pre_provision.call_count == 0
def test_failure_does_not_stop_remaining(
self,
monkeypatch: pytest.MonkeyPatch,
mock_pre_provision: MagicMock,
) -> None:
"""If one provisioning fails, the rest should still be attempted."""
monkeypatch.setattr(
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.TARGET_AVAILABLE_TENANTS",
5,
)
_mock_available_count(monkeypatch, 0)
# Fail on calls 2 and 4 (1-indexed)
call_count = 0
def side_effect() -> bool:
nonlocal call_count
call_count += 1
if call_count in (2, 4):
raise RuntimeError("provisioning failed")
return True
mock_pre_provision.side_effect = side_effect
_check_available_tenants()
# All 5 should be attempted despite 2 failures
assert mock_pre_provision.call_count == 5
def test_skips_when_not_multi_tenant(
self,
monkeypatch: pytest.MonkeyPatch,
mock_pre_provision: MagicMock,
) -> None:
"""Should not provision when multi-tenancy is disabled."""
monkeypatch.setattr(
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.MULTI_TENANT",
False,
)
_check_available_tenants()
assert mock_pre_provision.call_count == 0
def test_skips_when_lock_not_acquired(
self,
mock_redis: MagicMock,
mock_pre_provision: MagicMock,
) -> None:
"""Should skip when another instance holds the lock."""
mock_redis.lock.return_value.acquire.return_value = False
_check_available_tenants()
assert mock_pre_provision.call_count == 0
def test_lock_release_failure_does_not_raise(
self,
monkeypatch: pytest.MonkeyPatch,
mock_redis: MagicMock,
mock_pre_provision: MagicMock,
) -> None:
"""LockNotOwnedError on release should be caught, not propagated."""
from redis.exceptions import LockNotOwnedError
monkeypatch.setattr(
"ee.onyx.background.celery.tasks.tenant_provisioning.tasks.TARGET_AVAILABLE_TENANTS",
5,
)
_mock_available_count(monkeypatch, 4)
mock_redis.lock.return_value.release.side_effect = LockNotOwnedError("expired")
# Should not raise
_check_available_tenants()
assert mock_pre_provision.call_count == 1

View File

@@ -0,0 +1,134 @@
"""Unit tests for multi-model answer generation types.
Tests cover:
- Placement.model_index serialization
- MultiModelMessageResponseIDInfo round-trip
- SendMessageRequest.llm_overrides backward compatibility
- ChatMessageDetail new fields
"""
from uuid import uuid4
from onyx.llm.override_models import LLMOverride
from onyx.server.query_and_chat.models import ChatMessageDetail
from onyx.server.query_and_chat.models import MultiModelMessageResponseIDInfo
from onyx.server.query_and_chat.models import SendMessageRequest
from onyx.server.query_and_chat.placement import Placement
class TestPlacementModelIndex:
def test_default_none(self) -> None:
p = Placement(turn_index=0)
assert p.model_index is None
def test_set_value(self) -> None:
p = Placement(turn_index=0, model_index=2)
assert p.model_index == 2
def test_serializes(self) -> None:
p = Placement(turn_index=0, tab_index=1, model_index=1)
d = p.model_dump()
assert d["model_index"] == 1
def test_none_excluded_when_default(self) -> None:
p = Placement(turn_index=0)
d = p.model_dump()
assert d["model_index"] is None
class TestMultiModelMessageResponseIDInfo:
def test_round_trip(self) -> None:
info = MultiModelMessageResponseIDInfo(
user_message_id=42,
reserved_assistant_message_ids=[43, 44, 45],
model_names=["gpt-4", "claude-opus", "gemini-pro"],
)
d = info.model_dump()
restored = MultiModelMessageResponseIDInfo(**d)
assert restored.user_message_id == 42
assert restored.reserved_assistant_message_ids == [43, 44, 45]
assert restored.model_names == ["gpt-4", "claude-opus", "gemini-pro"]
def test_null_user_message_id(self) -> None:
info = MultiModelMessageResponseIDInfo(
user_message_id=None,
reserved_assistant_message_ids=[1, 2],
model_names=["a", "b"],
)
assert info.user_message_id is None
class TestSendMessageRequestOverrides:
def test_llm_overrides_default_none(self) -> None:
req = SendMessageRequest(
message="hello",
chat_session_id=uuid4(),
)
assert req.llm_overrides is None
def test_llm_overrides_accepts_list(self) -> None:
overrides = [
LLMOverride(model_provider="openai", model_version="gpt-4"),
LLMOverride(model_provider="anthropic", model_version="claude-opus"),
]
req = SendMessageRequest(
message="hello",
chat_session_id=uuid4(),
llm_overrides=overrides,
)
assert req.llm_overrides is not None
assert len(req.llm_overrides) == 2
def test_backward_compat_single_override(self) -> None:
req = SendMessageRequest(
message="hello",
chat_session_id=uuid4(),
llm_override=LLMOverride(model_provider="openai", model_version="gpt-4"),
)
assert req.llm_override is not None
assert req.llm_overrides is None
class TestChatMessageDetailMultiModel:
def test_defaults_none(self) -> None:
from onyx.configs.constants import MessageType
detail = ChatMessageDetail(
message_id=1,
message="hello",
message_type=MessageType.ASSISTANT,
time_sent="2026-03-22T00:00:00Z",
files=[],
)
assert detail.preferred_response_id is None
assert detail.model_display_name is None
def test_set_values(self) -> None:
from onyx.configs.constants import MessageType
detail = ChatMessageDetail(
message_id=1,
message="hello",
message_type=MessageType.USER,
time_sent="2026-03-22T00:00:00Z",
files=[],
preferred_response_id=42,
model_display_name="GPT-4",
)
assert detail.preferred_response_id == 42
assert detail.model_display_name == "GPT-4"
def test_serializes(self) -> None:
from onyx.configs.constants import MessageType
detail = ChatMessageDetail(
message_id=1,
message="hello",
message_type=MessageType.ASSISTANT,
time_sent="2026-03-22T00:00:00Z",
files=[],
model_display_name="Claude Opus",
)
d = detail.model_dump()
assert d["model_display_name"] == "Claude Opus"
assert d["preferred_response_id"] is None

View File

@@ -1,12 +1,4 @@
import pytest
from onyx.chat.process_message import _resolve_query_processing_hook_result
from onyx.chat.process_message import remove_answer_citations
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.hooks.executor import HookSkipped
from onyx.hooks.executor import HookSoftFailed
from onyx.hooks.points.query_processing import QueryProcessingResponse
def test_remove_answer_citations_strips_http_markdown_citation() -> None:
@@ -40,81 +32,3 @@ def test_remove_answer_citations_preserves_non_citation_markdown_links() -> None
remove_answer_citations(answer)
== "See [reference](https://example.com/Function_(mathematics)) for context."
)
# ---------------------------------------------------------------------------
# Query Processing hook response handling (_resolve_query_processing_hook_result)
# ---------------------------------------------------------------------------
def test_hook_skipped_leaves_message_text_unchanged() -> None:
result = _resolve_query_processing_hook_result(HookSkipped(), "original query")
assert result == "original query"
def test_hook_soft_failed_leaves_message_text_unchanged() -> None:
result = _resolve_query_processing_hook_result(HookSoftFailed(), "original query")
assert result == "original query"
def test_null_query_raises_query_rejected() -> None:
with pytest.raises(OnyxError) as exc_info:
_resolve_query_processing_hook_result(
QueryProcessingResponse(query=None), "original query"
)
assert exc_info.value.error_code is OnyxErrorCode.QUERY_REJECTED
def test_empty_string_query_raises_query_rejected() -> None:
"""Empty string is falsy — must be treated as rejection, same as None."""
with pytest.raises(OnyxError) as exc_info:
_resolve_query_processing_hook_result(
QueryProcessingResponse(query=""), "original query"
)
assert exc_info.value.error_code is OnyxErrorCode.QUERY_REJECTED
def test_whitespace_only_query_raises_query_rejected() -> None:
"""Whitespace-only string is truthy but meaningless — must be treated as rejection."""
with pytest.raises(OnyxError) as exc_info:
_resolve_query_processing_hook_result(
QueryProcessingResponse(query=" "), "original query"
)
assert exc_info.value.error_code is OnyxErrorCode.QUERY_REJECTED
def test_absent_query_field_raises_query_rejected() -> None:
"""query defaults to None when not provided."""
with pytest.raises(OnyxError) as exc_info:
_resolve_query_processing_hook_result(
QueryProcessingResponse(), "original query"
)
assert exc_info.value.error_code is OnyxErrorCode.QUERY_REJECTED
def test_rejection_message_surfaced_in_error_when_provided() -> None:
with pytest.raises(OnyxError) as exc_info:
_resolve_query_processing_hook_result(
QueryProcessingResponse(
query=None, rejection_message="Queries about X are not allowed."
),
"original query",
)
assert "Queries about X are not allowed." in str(exc_info.value)
def test_fallback_rejection_message_when_none() -> None:
"""No rejection_message → generic fallback used in OnyxError detail."""
with pytest.raises(OnyxError) as exc_info:
_resolve_query_processing_hook_result(
QueryProcessingResponse(query=None, rejection_message=None),
"original query",
)
assert "No rejection reason was provided." in str(exc_info.value)
def test_nonempty_query_rewrites_message_text() -> None:
result = _resolve_query_processing_hook_result(
QueryProcessingResponse(query="rewritten query"), "original query"
)
assert result == "rewritten query"

View File

@@ -7,7 +7,6 @@ from unittest.mock import patch
import httpx
import pytest
from pydantic import BaseModel
from onyx.db.enums import HookFailStrategy
from onyx.db.enums import HookPoint
@@ -16,15 +15,13 @@ from onyx.error_handling.exceptions import OnyxError
from onyx.hooks.executor import execute_hook
from onyx.hooks.executor import HookSkipped
from onyx.hooks.executor import HookSoftFailed
from onyx.hooks.points.query_processing import QueryProcessingResponse
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_PAYLOAD: dict[str, Any] = {"query": "test", "user_email": "u@example.com"}
# A valid QueryProcessingResponse payload — used by success-path tests.
_RESPONSE_PAYLOAD: dict[str, Any] = {"query": "better test"}
_RESPONSE_PAYLOAD: dict[str, Any] = {"rewritten_query": "better test"}
def _make_hook(
@@ -36,7 +33,6 @@ def _make_hook(
fail_strategy: HookFailStrategy = HookFailStrategy.SOFT,
hook_id: int = 1,
is_reachable: bool | None = None,
hook_point: HookPoint = HookPoint.QUERY_PROCESSING,
) -> MagicMock:
hook = MagicMock()
hook.is_active = is_active
@@ -46,7 +42,6 @@ def _make_hook(
hook.id = hook_id
hook.fail_strategy = fail_strategy
hook.is_reachable = is_reachable
hook.hook_point = hook_point
return hook
@@ -145,7 +140,6 @@ def test_early_exit_returns_skipped_with_no_db_writes(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
response_type=QueryProcessingResponse,
)
assert isinstance(result, HookSkipped)
@@ -158,9 +152,7 @@ def test_early_exit_returns_skipped_with_no_db_writes(
# ---------------------------------------------------------------------------
def test_success_returns_validated_model_and_sets_reachable(
db_session: MagicMock,
) -> None:
def test_success_returns_payload_and_sets_reachable(db_session: MagicMock) -> None:
hook = _make_hook()
with (
@@ -179,11 +171,9 @@ def test_success_returns_validated_model_and_sets_reachable(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
response_type=QueryProcessingResponse,
)
assert isinstance(result, QueryProcessingResponse)
assert result.query == _RESPONSE_PAYLOAD["query"]
assert result == _RESPONSE_PAYLOAD
_, update_kwargs = mock_update.call_args
assert update_kwargs["is_reachable"] is True
mock_log.assert_not_called()
@@ -210,11 +200,9 @@ def test_success_skips_reachable_write_when_already_true(db_session: MagicMock)
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
response_type=QueryProcessingResponse,
)
assert isinstance(result, QueryProcessingResponse)
assert result.query == _RESPONSE_PAYLOAD["query"]
assert result == _RESPONSE_PAYLOAD
mock_update.assert_not_called()
@@ -242,7 +230,6 @@ def test_non_dict_json_response_is_a_failure(db_session: MagicMock) -> None:
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
response_type=QueryProcessingResponse,
)
assert isinstance(result, HookSoftFailed)
@@ -278,7 +265,6 @@ def test_json_decode_failure_is_a_failure(db_session: MagicMock) -> None:
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
response_type=QueryProcessingResponse,
)
assert isinstance(result, HookSoftFailed)
@@ -402,7 +388,6 @@ def test_http_failure_paths(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
response_type=QueryProcessingResponse,
)
assert exc_info.value.error_code is OnyxErrorCode.HOOK_EXECUTION_FAILED
else:
@@ -410,7 +395,6 @@ def test_http_failure_paths(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
response_type=QueryProcessingResponse,
)
assert isinstance(result, expected_type)
@@ -458,7 +442,6 @@ def test_authorization_header(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
response_type=QueryProcessingResponse,
)
_, call_kwargs = mock_client.post.call_args
@@ -474,16 +457,16 @@ def test_authorization_header(
@pytest.mark.parametrize(
"http_exception,expect_onyx_error",
"http_exception,expected_result",
[
pytest.param(None, False, id="success_path"),
pytest.param(httpx.ConnectError("refused"), True, id="hard_fail_path"),
pytest.param(None, _RESPONSE_PAYLOAD, id="success_path"),
pytest.param(httpx.ConnectError("refused"), OnyxError, id="hard_fail_path"),
],
)
def test_persist_session_failure_is_swallowed(
db_session: MagicMock,
http_exception: Exception | None,
expect_onyx_error: bool,
expected_result: Any,
) -> None:
"""DB session failure in _persist_result must not mask the real return value or OnyxError."""
hook = _make_hook(fail_strategy=HookFailStrategy.HARD)
@@ -506,13 +489,12 @@ def test_persist_session_failure_is_swallowed(
side_effect=http_exception,
)
if expect_onyx_error:
if expected_result is OnyxError:
with pytest.raises(OnyxError) as exc_info:
execute_hook(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
response_type=QueryProcessingResponse,
)
assert exc_info.value.error_code is OnyxErrorCode.HOOK_EXECUTION_FAILED
else:
@@ -520,131 +502,8 @@ def test_persist_session_failure_is_swallowed(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
response_type=QueryProcessingResponse,
)
assert isinstance(result, QueryProcessingResponse)
assert result.query == _RESPONSE_PAYLOAD["query"]
# ---------------------------------------------------------------------------
# Response model validation
# ---------------------------------------------------------------------------
class _StrictResponse(BaseModel):
"""Strict model used to reliably trigger a ValidationError in tests."""
required_field: str # no default → missing key raises ValidationError
@pytest.mark.parametrize(
"fail_strategy,expected_type",
[
pytest.param(
HookFailStrategy.SOFT, HookSoftFailed, id="validation_failure_soft"
),
pytest.param(HookFailStrategy.HARD, OnyxError, id="validation_failure_hard"),
],
)
def test_response_validation_failure_respects_fail_strategy(
db_session: MagicMock,
fail_strategy: HookFailStrategy,
expected_type: type,
) -> None:
"""A response that fails response_model validation is treated like any other
hook failure: logged, is_reachable left unchanged, fail_strategy respected."""
hook = _make_hook(fail_strategy=fail_strategy)
with (
patch("onyx.hooks.executor.HOOKS_AVAILABLE", True),
patch(
"onyx.hooks.executor.get_non_deleted_hook_by_hook_point",
return_value=hook,
),
patch("onyx.hooks.executor.get_session_with_current_tenant"),
patch("onyx.hooks.executor.update_hook__no_commit") as mock_update,
patch("onyx.hooks.executor.create_hook_execution_log__no_commit") as mock_log,
patch("httpx.Client") as mock_client_cls,
):
# Response payload is missing required_field → ValidationError
_setup_client(mock_client_cls, response=_make_response(json_return={}))
if expected_type is OnyxError:
with pytest.raises(OnyxError) as exc_info:
execute_hook(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
response_type=_StrictResponse,
)
assert exc_info.value.error_code is OnyxErrorCode.HOOK_EXECUTION_FAILED
else:
result = execute_hook(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
response_type=_StrictResponse,
)
assert isinstance(result, HookSoftFailed)
# is_reachable must not be updated — server responded correctly
mock_update.assert_not_called()
# failure must be logged
mock_log.assert_called_once()
_, log_kwargs = mock_log.call_args
assert log_kwargs["is_success"] is False
assert "validation" in (log_kwargs["error_message"] or "").lower()
# ---------------------------------------------------------------------------
# Outer soft-fail guard in execute_hook
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"fail_strategy,expected_type",
[
pytest.param(HookFailStrategy.SOFT, HookSoftFailed, id="unexpected_exc_soft"),
pytest.param(HookFailStrategy.HARD, ValueError, id="unexpected_exc_hard"),
],
)
def test_unexpected_exception_in_inner_respects_fail_strategy(
db_session: MagicMock,
fail_strategy: HookFailStrategy,
expected_type: type,
) -> None:
"""An unexpected exception raised by _execute_hook_inner (not an OnyxError from
HARD fail — e.g. a bug or an assertion error) must be swallowed and return
HookSoftFailed for SOFT strategy, or re-raised for HARD strategy."""
hook = _make_hook(fail_strategy=fail_strategy)
with (
patch("onyx.hooks.executor.HOOKS_AVAILABLE", True),
patch(
"onyx.hooks.executor.get_non_deleted_hook_by_hook_point",
return_value=hook,
),
patch(
"onyx.hooks.executor._execute_hook_inner",
side_effect=ValueError("unexpected bug"),
),
):
if expected_type is HookSoftFailed:
result = execute_hook(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
response_type=QueryProcessingResponse,
)
assert isinstance(result, HookSoftFailed)
else:
with pytest.raises(ValueError, match="unexpected bug"):
execute_hook(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
response_type=QueryProcessingResponse,
)
assert result == expected_result
def test_is_reachable_failure_does_not_prevent_log(db_session: MagicMock) -> None:
@@ -676,7 +535,6 @@ def test_is_reachable_failure_does_not_prevent_log(db_session: MagicMock) -> Non
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
response_type=QueryProcessingResponse,
)
assert isinstance(result, HookSoftFailed)

View File

@@ -23,12 +23,6 @@ upstream web_server {
# Conditionally include MCP upstream configuration
include /etc/nginx/conf.d/mcp_upstream.conf.inc;
# WebSocket support: only set Connection "upgrade" for actual upgrade requests
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80 default_server;
@@ -52,10 +46,8 @@ server {
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Host $host;
# need to use 1.1 to support chunked transfers and WebSocket
# need to use 1.1 to support chunked transfers
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
# timeout settings

View File

@@ -23,12 +23,6 @@ upstream web_server {
# Conditionally include MCP upstream configuration
include /etc/nginx/conf.d/mcp_upstream.conf.inc;
# WebSocket support: only set Connection "upgrade" for actual upgrade requests
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80 default_server;
@@ -53,10 +47,8 @@ server {
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Host $host;
# need to use 1.1 to support chunked transfers and WebSocket
# need to use 1.1 to support chunked transfers
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
# we don't want nginx trying to do something clever with
@@ -100,8 +92,6 @@ server {
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
# we don't want nginx trying to do something clever with
# redirects, we set the Host: header above already.

View File

@@ -23,12 +23,6 @@ upstream web_server {
# Conditionally include MCP upstream configuration
include /etc/nginx/conf.d/mcp_upstream.conf.inc;
# WebSocket support: only set Connection "upgrade" for actual upgrade requests
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80 default_server;
@@ -53,10 +47,8 @@ server {
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Host $host;
# need to use 1.1 to support chunked transfers and WebSocket
# need to use 1.1 to support chunked transfers
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
# timeout settings
@@ -114,8 +106,6 @@ server {
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
# timeout settings

View File

@@ -28,12 +28,6 @@ data:
}
{{- end }}
# WebSocket support: only set Connection "upgrade" for actual upgrade requests
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server.conf: |
server {
listen 1024;
@@ -71,8 +65,6 @@ data:
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_redirect off;
# timeout settings

View File

@@ -10,7 +10,7 @@ data:
#!/usr/bin/env sh
set -eu
HOST="${PGINTO_HOST:-${POSTGRES_HOST:-localhost}}"
HOST="${POSTGRES_HOST:-localhost}"
PORT="${POSTGRES_PORT:-5432}"
USER="${POSTGRES_USER:-postgres}"
DB="${POSTGRES_DB:-postgres}"

View File

@@ -103,7 +103,7 @@ opensearch:
- name: OPENSEARCH_INITIAL_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: onyx-opensearch # Must match auth.opensearch.secretName or auth.opensearch.existingSecret if defined.
name: onyx-opensearch # Must match auth.opensearch.secretName.
key: opensearch_admin_password # Must match auth.opensearch.secretKeys value.
resources:
@@ -282,7 +282,7 @@ nginx:
# The ingress-nginx subchart doesn't auto-detect our custom ConfigMap changes.
# Workaround: Helm upgrade will restart if the following annotation value changes.
podAnnotations:
onyx.app/nginx-config-version: "2"
onyx.app/nginx-config-version: "1"
# Propagate DOMAIN into nginx so server_name continues to use the same env var
extraEnvs:

View File

@@ -83,14 +83,6 @@
"scope": [],
"rule": "Code changes must consider both regular Onyx deployments and Onyx lite deployments. Lite deployments disable the vector DB, Redis, model servers, and background workers by default, use PostgreSQL-backed cache/auth/file storage, and rely on the API server to handle background work. Do not assume those services are available unless the code path is explicitly limited to full deployments."
},
{
"scope": ["web/**"],
"rule": "In Onyx's Next.js app, the `app/ee/admin/` directory is a filesystem convention for Enterprise Edition route overrides — it does NOT add an `/ee/` prefix to the URL. Both `app/admin/groups/page.tsx` and `app/ee/admin/groups/page.tsx` serve the same URL `/admin/groups`. Hardcoded `/admin/...` paths in router.push() calls are correct and do NOT break EE deployments. Do not flag hardcoded admin paths as bugs."
},
{
"scope": ["web/**"],
"rule": "In Onyx, each API key creates a unique user row in the database with a unique `user_id` (UUID). There is a 1:1 mapping between API keys and their backing user records. Multiple API keys do NOT share the same `user_id`. Do not flag potential duplicate row IDs when using `user_id` from API key descriptors."
},
{
"scope": ["backend/**/*.py"],
"rule": "Never raise HTTPException directly in business code. Use `raise OnyxError(OnyxErrorCode.XXX, \"message\")` from `onyx.error_handling.exceptions`. A global FastAPI exception handler converts OnyxError into structured JSON responses with {\"error_code\": \"...\", \"detail\": \"...\"}. Error codes are defined in `onyx.error_handling.error_codes.OnyxErrorCode`. For upstream errors with dynamic HTTP status codes, use `status_code_override`: `raise OnyxError(OnyxErrorCode.BAD_GATEWAY, detail, status_code_override=upstream_status)`."

View File

@@ -1,9 +1,5 @@
import "@opal/components/tooltip.css";
import {
Disabled,
Interactive,
type InteractiveStatelessProps,
} from "@opal/core";
import { Interactive, type InteractiveStatelessProps } from "@opal/core";
import type { ContainerSizeVariants, ExtremaSizeVariants } from "@opal/types";
import type { TooltipSide } from "@opal/components";
import type { IconFunctionComponent } from "@opal/types";
@@ -36,6 +32,9 @@ type ButtonProps = InteractiveStatelessProps &
*/
size?: ContainerSizeVariants;
/** HTML button type. When provided, Container renders a `<button>` element. */
type?: "submit" | "button" | "reset";
/** Tooltip text shown on hover. */
tooltip?: string;
@@ -44,9 +43,6 @@ type ButtonProps = InteractiveStatelessProps &
/** Which side the tooltip appears on. */
tooltipSide?: TooltipSide;
/** Wraps the button in a Disabled context. `false` overrides parent contexts. */
disabled?: boolean;
};
// ---------------------------------------------------------------------------
@@ -63,7 +59,6 @@ function Button({
tooltip,
tooltipSide = "top",
responsiveHideText = false,
disabled,
...interactiveProps
}: ButtonProps) {
const isLarge = size === "lg";
@@ -81,7 +76,7 @@ function Button({
) : null;
const button = (
<Interactive.Stateless type={type} {...interactiveProps}>
<Interactive.Stateless {...interactiveProps}>
<Interactive.Container
type={type}
border={interactiveProps.prominence === "secondary"}
@@ -107,7 +102,9 @@ function Button({
</Interactive.Stateless>
);
const result = tooltip ? (
if (!tooltip) return button;
return (
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>{button}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
@@ -120,15 +117,7 @@ function Button({
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
) : (
button
);
if (disabled != null) {
return <Disabled disabled={disabled}>{result}</Disabled>;
}
return result;
}
export { Button, type ButtonProps };

View File

@@ -1,8 +0,0 @@
.opal-button-chevron {
transition: rotate 200ms ease;
}
.interactive[data-interaction="hover"] .opal-button-chevron,
.interactive[data-interaction="active"] .opal-button-chevron {
rotate: -180deg;
}

View File

@@ -1,22 +0,0 @@
import "@opal/components/buttons/chevron.css";
import type { IconProps } from "@opal/types";
import { SvgChevronDownSmall } from "@opal/icons";
import { cn } from "@opal/utils";
/**
* Chevron icon that rotates 180° when its parent `.interactive` enters
* hover / active state. Shared by OpenButton, FilterButton, and any
* future button that needs an animated dropdown indicator.
*
* Stable component identity — never causes React to remount the SVG.
*/
function ChevronIcon({ className, ...props }: IconProps) {
return (
<SvgChevronDownSmall
className={cn(className, "opal-button-chevron")}
{...props}
/>
);
}
export { ChevronIcon };

View File

@@ -1,107 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { FilterButton } from "@opal/components";
import { Disabled as DisabledProvider } from "@opal/core";
import { SvgUser, SvgActions, SvgTag } from "@opal/icons";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
const meta: Meta<typeof FilterButton> = {
title: "opal/components/FilterButton",
component: FilterButton,
tags: ["autodocs"],
decorators: [
(Story) => (
<TooltipPrimitive.Provider>
<Story />
</TooltipPrimitive.Provider>
),
],
};
export default meta;
type Story = StoryObj<typeof FilterButton>;
export const Empty: Story = {
args: {
icon: SvgUser,
children: "Everyone",
},
};
export const Active: Story = {
args: {
icon: SvgUser,
active: true,
children: "By alice@example.com",
onClear: () => console.log("clear"),
},
};
export const Open: Story = {
args: {
icon: SvgActions,
interaction: "hover",
children: "All Actions",
},
};
export const ActiveOpen: Story = {
args: {
icon: SvgActions,
active: true,
interaction: "hover",
children: "2 selected",
onClear: () => console.log("clear"),
},
};
export const Disabled: Story = {
args: {
icon: SvgTag,
children: "All Tags",
},
decorators: [
(Story) => (
<DisabledProvider disabled>
<Story />
</DisabledProvider>
),
],
};
export const DisabledActive: Story = {
args: {
icon: SvgTag,
active: true,
children: "2 tags",
onClear: () => console.log("clear"),
},
decorators: [
(Story) => (
<DisabledProvider disabled>
<Story />
</DisabledProvider>
),
],
};
export const StateComparison: Story = {
render: () => (
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<FilterButton icon={SvgUser} onClear={() => undefined}>
Everyone
</FilterButton>
<FilterButton icon={SvgUser} active onClear={() => console.log("clear")}>
By alice@example.com
</FilterButton>
</div>
),
};
export const WithTooltip: Story = {
args: {
icon: SvgUser,
children: "Everyone",
tooltip: "Filter by creator",
tooltipSide: "bottom",
},
};

View File

@@ -1,70 +0,0 @@
# FilterButton
**Import:** `import { FilterButton, type FilterButtonProps } from "@opal/components";`
A stateful filter trigger with a built-in chevron (when empty) and a clear button (when selected). Hardcodes `variant="select-filter"` and delegates to `Interactive.Stateful`, adding automatic open-state detection from Radix `data-state`. Designed to sit inside a `Popover.Trigger` for filter dropdowns.
## Relationship to OpenButton
FilterButton shares a similar call stack to `OpenButton`:
```
Interactive.Stateful → Interactive.Container → content row (icon + label + trailing indicator)
```
FilterButton is a **narrower, filter-specific** variant:
- It hardcodes `variant="select-filter"` (OpenButton uses `"select-heavy"`)
- It exposes `active?: boolean` instead of the raw `state` prop (maps to `"selected"` / `"empty"` internally)
- When active, the chevron is hidden via `visibility` and an absolutely-positioned clear `Button` with `prominence="tertiary"` overlays it — placed as a sibling outside the `<button>` to avoid nesting buttons
- It uses the shared `ChevronIcon` from `buttons/chevron` (same as OpenButton)
- It does not support `foldable`, `size`, or `width` — it is always `"lg"`
## Architecture
```
div.relative <- bounding wrapper
Interactive.Stateful <- variant="select-filter", interaction, state
└─ Interactive.Container (button) <- height="lg", default rounding/padding
└─ div.interactive-foreground
├─ div > Icon (interactive-foreground-icon)
├─ <span> label text
└─ ChevronIcon (when empty)
OR spacer div (when selected — reserves chevron space)
div.absolute <- clear Button overlay (when selected)
└─ Button (SvgX, size="2xs", prominence="tertiary")
```
- **Open-state detection** reads `data-state="open"` injected by Radix triggers (e.g. `Popover.Trigger`), falling back to the explicit `interaction` prop.
- **Chevron rotation** uses the shared `ChevronIcon` component and `buttons/chevron.css`, which rotates 180deg when `data-interaction="hover"`.
- **Clear button** is absolutely positioned outside the `<button>` element tree to avoid invalid nested `<button>` elements. An invisible spacer inside the button reserves the same space so layout doesn't shift between states.
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `icon` | `IconFunctionComponent` | **required** | Left icon component |
| `children` | `string` | **required** | Label text between icon and trailing indicator |
| `active` | `boolean` | `false` | Whether the filter has an active selection |
| `onClear` | `() => void` | **required** | Called when the clear (X) button is clicked |
| `interaction` | `"rest" \| "hover" \| "active"` | auto | JS-controlled interaction override. Falls back to Radix `data-state="open"`. |
| `tooltip` | `string` | — | Tooltip text shown on hover |
| `tooltipSide` | `TooltipSide` | `"top"` | Which side the tooltip appears on |
## Usage
```tsx
import { FilterButton } from "@opal/components";
import { SvgUser } from "@opal/icons";
// Inside a Popover (auto-detects open state)
<Popover.Trigger asChild>
<FilterButton
icon={SvgUser}
active={hasSelection}
onClear={() => clearSelection()}
>
{hasSelection ? selectionLabel : "Everyone"}
</FilterButton>
</Popover.Trigger>
```

View File

@@ -1,120 +0,0 @@
import {
Interactive,
type InteractiveStatefulInteraction,
type InteractiveStatefulProps,
} from "@opal/core";
import type { TooltipSide } from "@opal/components";
import type { IconFunctionComponent } from "@opal/types";
import { SvgX } from "@opal/icons";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { iconWrapper } from "@opal/components/buttons/icon-wrapper";
import { ChevronIcon } from "@opal/components/buttons/chevron";
import { Button } from "@opal/components/buttons/button/components";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface FilterButtonProps
extends Omit<InteractiveStatefulProps, "variant" | "state"> {
/** Left icon — always visible. */
icon: IconFunctionComponent;
/** Label text between icon and trailing indicator. */
children: string;
/** Whether the filter has an active selection. @default false */
active?: boolean;
/** Called when the clear (X) button is clicked in active state. */
onClear: () => void;
/** Tooltip text shown on hover. */
tooltip?: string;
/** Which side the tooltip appears on. */
tooltipSide?: TooltipSide;
}
// ---------------------------------------------------------------------------
// FilterButton
// ---------------------------------------------------------------------------
function FilterButton({
icon: Icon,
children,
onClear,
tooltip,
tooltipSide = "top",
active = false,
interaction,
...statefulProps
}: FilterButtonProps) {
// Derive open state: explicit prop > Radix data-state (injected via Slot chain)
const dataState = (statefulProps as Record<string, unknown>)["data-state"] as
| string
| undefined;
const resolvedInteraction: InteractiveStatefulInteraction =
interaction ?? (dataState === "open" ? "hover" : "rest");
const button = (
<div className="relative">
<Interactive.Stateful
{...statefulProps}
variant="select-filter"
interaction={resolvedInteraction}
state={active ? "selected" : "empty"}
>
<Interactive.Container type="button">
<div className="interactive-foreground flex flex-row items-center gap-1">
{iconWrapper(Icon, "lg", true)}
<span className="whitespace-nowrap font-main-ui-action">
{children}
</span>
<div style={{ visibility: active ? "hidden" : "visible" }}>
{iconWrapper(ChevronIcon, "lg", true)}
</div>
</div>
</Interactive.Container>
</Interactive.Stateful>
{active && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
{/* Force hover state so the X stays visually prominent against
the inverted selected background — without this it renders
dimmed and looks disabled. */}
<Button
icon={SvgX}
size="2xs"
prominence="tertiary"
tooltip="Clear filter"
interaction="hover"
onClick={(e) => {
e.stopPropagation();
onClear();
}}
/>
</div>
)}
</div>
);
if (!tooltip) return button;
return (
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>{button}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className="opal-tooltip"
side={tooltipSide}
sideOffset={4}
>
{tooltip}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
);
}
export { FilterButton, type FilterButtonProps };

View File

@@ -1,5 +1,8 @@
import "@opal/components/tooltip.css";
import {
Interactive,
type InteractiveStatefulState,
type InteractiveStatefulInteraction,
type InteractiveStatefulProps,
InteractiveContainerRoundingVariant,
} from "@opal/core";
@@ -19,26 +22,40 @@ type ContentPassthroughProps = DistributiveOmit<
"paddingVariant" | "widthVariant" | "ref" | "withInteractive"
>;
type LineItemButtonOwnProps = Pick<
InteractiveStatefulProps,
| "state"
| "interaction"
| "onClick"
| "href"
| "target"
| "group"
| "ref"
| "type"
> & {
type LineItemButtonOwnProps = {
/** Interactive select variant. @default "select-light" */
selectVariant?: "select-light" | "select-heavy";
/** Value state. @default "empty" */
state?: InteractiveStatefulState;
/** JS-controllable interaction state override. @default "rest" */
interaction?: InteractiveStatefulInteraction;
/** Click handler. */
onClick?: InteractiveStatefulProps["onClick"];
/** When provided, renders an anchor instead of a div. */
href?: string;
/** Anchor target (e.g. "_blank"). */
target?: string;
/** Interactive group key. */
group?: string;
/** Forwarded ref. */
ref?: React.Ref<HTMLElement>;
/** Corner rounding preset (height is always content-driven). @default "default" */
roundingVariant?: InteractiveContainerRoundingVariant;
/** Container width. @default "full" */
width?: ExtremaSizeVariants;
/** HTML button type. @default "button" */
type?: "submit" | "button" | "reset";
/** Tooltip text shown on hover. */
tooltip?: string;
@@ -62,11 +79,11 @@ function LineItemButton({
target,
group,
ref,
type = "button",
// Sizing
roundingVariant = "default",
width = "full",
type = "button",
tooltip,
tooltipSide = "top",

View File

@@ -40,6 +40,13 @@ export const Open: Story = {
},
};
export const Disabled: Story = {
args: {
disabled: true,
children: "Disabled",
},
};
export const Foldable: Story = {
args: {
foldable: true,

View File

@@ -1,3 +1,5 @@
import "@opal/components/buttons/open-button/styles.css";
import "@opal/components/tooltip.css";
import {
Interactive,
useDisabled,
@@ -7,11 +9,24 @@ import {
import type { ContainerSizeVariants, ExtremaSizeVariants } from "@opal/types";
import type { InteractiveContainerRoundingVariant } from "@opal/core";
import type { TooltipSide } from "@opal/components";
import type { IconFunctionComponent } from "@opal/types";
import type { IconFunctionComponent, IconProps } from "@opal/types";
import { SvgChevronDownSmall } from "@opal/icons";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@opal/utils";
import { iconWrapper } from "@opal/components/buttons/icon-wrapper";
import { ChevronIcon } from "@opal/components/buttons/chevron";
// ---------------------------------------------------------------------------
// Chevron (stable identity — never causes React to remount the SVG)
// ---------------------------------------------------------------------------
function ChevronIcon({ className, ...props }: IconProps) {
return (
<SvgChevronDownSmall
className={cn(className, "opal-open-button-chevron")}
{...props}
/>
);
}
// ---------------------------------------------------------------------------
// Types

View File

@@ -0,0 +1,8 @@
.opal-open-button-chevron {
transition: rotate 200ms ease;
}
.interactive[data-interaction="hover"] .opal-open-button-chevron,
.interactive[data-interaction="active"] .opal-open-button-chevron {
rotate: -180deg;
}

View File

@@ -1,4 +1,5 @@
import "@opal/components/buttons/select-button/styles.css";
import "@opal/components/tooltip.css";
import {
Interactive,
useDisabled,
@@ -49,6 +50,9 @@ type SelectButtonProps = InteractiveStatefulProps &
*/
size?: ContainerSizeVariants;
/** HTML button type. Container renders a `<button>` element. */
type?: "submit" | "button" | "reset";
/** Tooltip text shown on hover. */
tooltip?: string;

View File

@@ -1,5 +1,3 @@
import "@opal/components/tooltip.css";
/* Shared types */
export type TooltipSide = "top" | "bottom" | "left" | "right";
@@ -21,12 +19,6 @@ export {
type OpenButtonProps,
} from "@opal/components/buttons/open-button/components";
/* FilterButton */
export {
FilterButton,
type FilterButtonProps,
} from "@opal/components/buttons/filter-button/components";
/* LineItemButton */
export {
LineItemButton,

View File

@@ -32,13 +32,7 @@ function ColumnVisibilityPopover<TData extends RowData>({
// User-defined columns only (exclude internal qualifier/actions)
const dataColumns = table
.getAllLeafColumns()
.filter(
(col) =>
!col.id.startsWith("__") &&
col.id !== "qualifier" &&
typeof col.columnDef.header === "string" &&
col.columnDef.header.trim() !== ""
);
.filter((col) => !col.id.startsWith("__") && col.id !== "qualifier");
return (
<Popover open={open} onOpenChange={setOpen}>

View File

@@ -88,12 +88,9 @@ function HoverableRoot({
ref,
onMouseEnter: consumerMouseEnter,
onMouseLeave: consumerMouseLeave,
onFocusCapture: consumerFocusCapture,
onBlurCapture: consumerBlurCapture,
...props
}: HoverableRootProps) {
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const onMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
@@ -111,40 +108,16 @@ function HoverableRoot({
[consumerMouseLeave]
);
const onFocusCapture = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
setFocused(true);
consumerFocusCapture?.(e);
},
[consumerFocusCapture]
);
const onBlurCapture = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
if (
!(e.relatedTarget instanceof Node) ||
!e.currentTarget.contains(e.relatedTarget)
) {
setFocused(false);
}
consumerBlurCapture?.(e);
},
[consumerBlurCapture]
);
const active = hovered || focused;
const GroupContext = getOrCreateContext(group);
return (
<GroupContext.Provider value={active}>
<GroupContext.Provider value={hovered}>
<div
{...props}
ref={ref}
className={cn(widthVariants[widthVariant])}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onFocusCapture={onFocusCapture}
onBlurCapture={onBlurCapture}
>
{children}
</div>

View File

@@ -16,15 +16,3 @@
.hoverable-item[data-hoverable-variant="opacity-on-hover"][data-hoverable-local="true"]:hover {
opacity: 1;
}
/* Focus — item (or a focusable descendant) receives keyboard focus */
.hoverable-item[data-hoverable-variant="opacity-on-hover"]:has(:focus-visible) {
opacity: 1;
}
/* Focus ring on keyboard focus */
.hoverable-item:focus-visible {
outline: 2px solid var(--border-04);
outline-offset: 2px;
border-radius: 0.25rem;
}

View File

@@ -3,7 +3,7 @@ import type { Route } from "next";
import "@opal/core/interactive/shared.css";
import React from "react";
import { cn } from "@opal/utils";
import type { ButtonType, WithoutStyles } from "@opal/types";
import type { WithoutStyles } from "@opal/types";
import {
containerSizeVariants,
type ContainerSizeVariants,
@@ -52,7 +52,7 @@ interface InteractiveContainerProps
*
* Mutually exclusive with `href`.
*/
type?: ButtonType;
type?: "submit" | "button" | "reset";
/**
* When `true`, applies a 1px border using the theme's border color.

View File

@@ -8,7 +8,7 @@ Stateful interactive surface primitive for elements that maintain a value state
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `variant` | `"select-light" \| "select-heavy" \| "select-tinted" \| "select-filter" \| "sidebar"` | `"select-heavy"` | Color variant |
| `variant` | `"select-light" \| "select-heavy" \| "sidebar"` | `"select-heavy"` | Color variant |
| `state` | `"empty" \| "filled" \| "selected"` | `"empty"` | Current value state |
| `interaction` | `"rest" \| "hover" \| "active"` | `"rest"` | JS-controlled interaction override |
| `group` | `string` | — | Tailwind group class for `group-hover:*` |

View File

@@ -4,7 +4,7 @@ import React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@opal/utils";
import { useDisabled } from "@opal/core/disabled/components";
import type { ButtonType, WithoutStyles } from "@opal/types";
import type { WithoutStyles } from "@opal/types";
// ---------------------------------------------------------------------------
// Types
@@ -14,7 +14,6 @@ type InteractiveStatefulVariant =
| "select-light"
| "select-heavy"
| "select-tinted"
| "select-filter"
| "sidebar";
type InteractiveStatefulState = "empty" | "filled" | "selected";
type InteractiveStatefulInteraction = "rest" | "hover" | "active";
@@ -31,8 +30,6 @@ interface InteractiveStatefulProps
*
* - `"select-light"` — transparent selected background (for inline toggles)
* - `"select-heavy"` — tinted selected background (for list rows, model pickers)
* - `"select-tinted"` — like select-heavy but with a tinted rest background
* - `"select-filter"` — like select-tinted for empty/filled; selected state uses inverted tint backgrounds and inverted text (for filter buttons)
* - `"sidebar"` — for sidebar navigation items
*
* @default "select-heavy"
@@ -66,13 +63,6 @@ interface InteractiveStatefulProps
*/
group?: string;
/**
* HTML button type. When set to `"submit"`, `"button"`, or `"reset"`, the
* element is treated as inherently interactive for cursor styling purposes
* even without an explicit `onClick` or `href`.
*/
type?: ButtonType;
/**
* URL to navigate to when clicked. Passed through Slot to the child.
*/
@@ -104,7 +94,6 @@ function InteractiveStateful({
state = "empty",
interaction = "rest",
group,
type,
href,
target,
...props
@@ -115,7 +104,7 @@ function InteractiveStateful({
// so Radix Slot-injected handlers don't bypass this guard.
const classes = cn(
"interactive",
!props.onClick && !href && !type && "!cursor-default !select-auto",
!props.onClick && !href && "!cursor-default !select-auto",
group
);

View File

@@ -308,89 +308,6 @@
--interactive-foreground-icon: var(--action-link-03);
}
/* ===========================================================================
Select-Filter — empty/filled identical to Select-Tinted;
selected uses inverted tint backgrounds and inverted text
=========================================================================== */
/* ---------------------------------------------------------------------------
Select-Filter — Empty & Filled (identical colors)
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="select-filter"]:is(
[data-interactive-state="empty"],
[data-interactive-state="filled"]
) {
@apply bg-background-tint-01;
--interactive-foreground: var(--text-02);
--interactive-foreground-icon: var(--text-02);
}
.interactive[data-interactive-variant="select-filter"]:is(
[data-interactive-state="empty"],
[data-interactive-state="filled"]
):hover:not([data-disabled]),
.interactive[data-interactive-variant="select-filter"]:is(
[data-interactive-state="empty"],
[data-interactive-state="filled"]
)[data-interaction="hover"]:not([data-disabled]) {
@apply bg-background-tint-02;
--interactive-foreground: var(--text-04);
--interactive-foreground-icon: var(--text-04);
}
.interactive[data-interactive-variant="select-filter"]:is(
[data-interactive-state="empty"],
[data-interactive-state="filled"]
):active:not([data-disabled]),
.interactive[data-interactive-variant="select-filter"]:is(
[data-interactive-state="empty"],
[data-interactive-state="filled"]
)[data-interaction="active"]:not([data-disabled]) {
@apply bg-background-neutral-00;
--interactive-foreground: var(--text-05);
--interactive-foreground-icon: var(--text-05);
}
.interactive[data-interactive-variant="select-filter"]:is(
[data-interactive-state="empty"],
[data-interactive-state="filled"]
)[data-disabled] {
@apply bg-transparent;
--interactive-foreground: var(--text-01);
--interactive-foreground-icon: var(--text-01);
}
/* ---------------------------------------------------------------------------
Select-Filter — Selected
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="select-filter"][data-interactive-state="selected"] {
@apply bg-background-tint-inverted-03;
--interactive-foreground: var(--text-inverted-05);
--interactive-foreground-icon: var(--text-inverted-05);
}
.interactive[data-interactive-variant="select-filter"][data-interactive-state="selected"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-filter"][data-interactive-state="selected"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-inverted-04;
--interactive-foreground: var(--text-inverted-05);
--interactive-foreground-icon: var(--text-inverted-05);
}
.interactive[data-interactive-variant="select-filter"][data-interactive-state="selected"]:active:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-filter"][data-interactive-state="selected"][data-interaction="active"]:not(
[data-disabled]
) {
@apply bg-background-tint-inverted-04;
--interactive-foreground: var(--text-inverted-04);
--interactive-foreground-icon: var(--text-inverted-04);
}
.interactive[data-interactive-variant="select-filter"][data-interactive-state="selected"][data-disabled] {
@apply bg-background-neutral-04;
--interactive-foreground: var(--text-inverted-04);
--interactive-foreground-icon: var(--text-inverted-02);
}
/* ===========================================================================
Sidebar
=========================================================================== */

View File

@@ -4,7 +4,7 @@ import React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@opal/utils";
import { useDisabled } from "@opal/core/disabled/components";
import type { ButtonType, WithoutStyles } from "@opal/types";
import type { WithoutStyles } from "@opal/types";
// ---------------------------------------------------------------------------
// Types
@@ -53,13 +53,6 @@ interface InteractiveStatelessProps
*/
group?: string;
/**
* HTML button type. When set to `"submit"`, `"button"`, or `"reset"`, the
* element is treated as inherently interactive for cursor styling purposes
* even without an explicit `onClick` or `href`.
*/
type?: ButtonType;
/**
* URL to navigate to when clicked. Passed through Slot to the child.
*/
@@ -92,7 +85,6 @@ function InteractiveStateless({
prominence = "primary",
interaction = "rest",
group,
type,
href,
target,
...props
@@ -103,7 +95,7 @@ function InteractiveStateless({
// so Radix Slot-injected handlers don't bypass this guard.
const classes = cn(
"interactive",
!props.onClick && !href && !type && "!cursor-default !select-auto",
!props.onClick && !href && "!cursor-default !select-auto",
group
);

View File

@@ -1,20 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgEyeOff = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M11.78 11.78C10.6922 12.6092 9.36761 13.0685 8 13.0909C3.54545 13.0909 1 8 1 8C1.79157 6.52484 2.88945 5.23602 4.22 4.22M11.78 11.78L9.34909 9.34909M11.78 11.78L15 15M4.22 4.22L1 1M4.22 4.22L6.65091 6.65091M6.66364 3.06182C7.10167 2.95929 7.55013 2.90803 8 2.90909C12.4545 2.90909 15 8 15 8C14.6137 8.72266 14.153 9.40301 13.6255 10.03M9.34909 9.34909L6.65091 6.65091M9.34909 9.34909C8.99954 9.72422 8.49873 9.94737 7.98606 9.95641C6.922 9.97519 6.02481 9.078 6.04358 8.01394C6.05263 7.50127 6.27578 7.00046 6.65091 6.65091"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgEyeOff;

View File

@@ -68,7 +68,6 @@ export { default as SvgExpand } from "@opal/icons/expand";
export { default as SvgExternalLink } from "@opal/icons/external-link";
export { default as SvgEye } from "@opal/icons/eye";
export { default as SvgEyeClosed } from "@opal/icons/eye-closed";
export { default as SvgEyeOff } from "@opal/icons/eye-off";
export { default as SvgFiles } from "@opal/icons/files";
export { default as SvgFileBraces } from "@opal/icons/file-braces";
export { default as SvgFileChartPie } from "@opal/icons/file-chart-pie";
@@ -121,9 +120,7 @@ export { default as SvgNetworkGraph } from "@opal/icons/network-graph";
export { default as SvgNotificationBubble } from "@opal/icons/notification-bubble";
export { default as SvgOllama } from "@opal/icons/ollama";
export { default as SvgOnyxLogo } from "@opal/icons/onyx-logo";
export { default as SvgOnyxLogoTyped } from "@opal/icons/onyx-logo-typed";
export { default as SvgOnyxOctagon } from "@opal/icons/onyx-octagon";
export { default as SvgOnyxTyped } from "@opal/icons/onyx-typed";
export { default as SvgOpenai } from "@opal/icons/openai";
export { default as SvgOpenrouter } from "@opal/icons/openrouter";
export { default as SvgOrganization } from "@opal/icons/organization";

View File

@@ -1,27 +0,0 @@
import SvgOnyxLogo from "@opal/icons/onyx-logo";
import SvgOnyxTyped from "@opal/icons/onyx-typed";
import { cn } from "@opal/utils";
interface OnyxLogoTypedProps {
size?: number;
className?: string;
}
// # NOTE(@raunakab):
// This ratio is not some random, magical number; it is available on Figma.
const HEIGHT_TO_GAP_RATIO = 5 / 16;
const SvgOnyxLogoTyped = ({ size: height, className }: OnyxLogoTypedProps) => {
const gap = height != null ? height * HEIGHT_TO_GAP_RATIO : undefined;
return (
<div
className={cn(`flex flex-row items-center`, className)}
style={{ gap }}
>
<SvgOnyxLogo size={height} />
<SvgOnyxTyped size={height} />
</div>
);
};
export default SvgOnyxLogoTyped;

View File

@@ -1,27 +1,19 @@
import type { IconProps } from "@opal/types";
const SvgOnyxLogo = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 64 64"
fill="none"
viewBox="0 0 56 56"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M10.4014 13.25L18.875 32L10.3852 50.75L2 32L10.4014 13.25Z"
fill="var(--theme-primary-05)"
/>
<path
d="M53.5264 13.25L62 32L53.5102 50.75L45.125 32L53.5264 13.25Z"
fill="var(--theme-primary-05)"
/>
<path
d="M32 45.125L50.75 53.5625L32 62L13.25 53.5625L32 45.125Z"
fill="var(--theme-primary-05)"
/>
<path
d="M32 2L50.75 10.4375L32 18.875L13.25 10.4375L32 2Z"
fill="var(--theme-primary-05)"
fillRule="evenodd"
clipRule="evenodd"
d="M28 0 10.869 7.77 28 15.539l17.131-7.77L28 0Zm0 40.461-17.131 7.77L28 56l17.131-7.77L28 40.461Zm20.231-29.592L56 28.001l-7.769 17.131L40.462 28l7.769-17.131ZM15.538 28 7.77 10.869 0 28l7.769 17.131L15.538 28Z"
fill="currentColor"
/>
</svg>
);

View File

@@ -1,28 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgOnyxTyped = ({ size, ...props }: IconProps) => (
<svg
height={size}
viewBox="0 0 152 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M19.1795 51.2136C15.6695 51.2136 12.4353 50.3862 9.47691 48.7315C6.56865 47.0768 4.2621 44.8454 2.55726 42.0374C0.85242 39.1793 0 36.0955 0 32.7861C0 30.279 0.451281 27.9223 1.35384 25.716C2.30655 23.4596 3.76068 21.3285 5.71623 19.3228L11.8085 13.08C12.4604 12.6789 13.4131 12.3529 14.6666 12.1022C15.9202 11.8014 17.2991 11.6509 18.8034 11.6509C22.3134 11.6509 25.5225 12.4783 28.4307 14.133C31.3891 15.7877 33.7208 18.0441 35.4256 20.9023C37.1304 23.7103 37.9829 26.794 37.9829 30.1536C37.9829 32.6106 37.5065 34.9673 36.5538 37.2237C35.6512 39.4802 34.147 41.6864 32.041 43.8426L26.3248 49.7845C25.3219 50.2358 24.2188 50.5868 23.0154 50.8375C21.8621 51.0882 20.5835 51.2136 19.1795 51.2136ZM20.1572 43.8426C21.8621 43.8426 23.4917 43.4164 25.0461 42.5639C26.6005 41.6614 27.8541 40.3577 28.8068 38.6528C29.8097 36.948 30.3111 34.9172 30.3111 32.5605C30.3111 30.0032 29.6843 27.6966 28.4307 25.6408C27.2273 23.5849 25.6478 21.9803 23.6923 20.8271C21.7869 19.6236 19.8313 19.0219 17.8256 19.0219C16.0706 19.0219 14.4159 19.4732 12.8615 20.3758C11.3573 21.2282 10.1288 22.5068 9.17606 24.2117C8.22335 25.9166 7.747 27.9473 7.747 30.304C7.747 32.8613 8.34871 35.1679 9.55212 37.2237C10.7555 39.2796 12.31 40.9092 14.2154 42.1127C16.1709 43.2659 18.1515 43.8426 20.1572 43.8426Z"
fill="var(--theme-primary-05)"
/>
<path
d="M42.6413 50.4614V12.4031H50.6891V17.7433L55.5028 12.7039C56.0544 12.4532 56.8065 12.2276 57.7592 12.027C58.7621 11.7763 59.8903 11.6509 61.1438 11.6509C64.0521 11.6509 66.5843 12.3028 68.7404 13.6065C70.9467 14.8601 72.6264 16.6401 73.7797 18.9467C74.9831 21.2533 75.5848 23.961 75.5848 27.0698V50.4614H67.6122V29.1006C67.6122 26.9946 67.2612 25.1895 66.5592 23.6852C65.9074 22.1308 64.9547 20.9775 63.7011 20.2253C62.4977 19.4231 61.0686 19.0219 59.4139 19.0219C56.7564 19.0219 54.6253 19.9245 53.0208 21.7296C51.4663 23.4846 50.6891 25.9416 50.6891 29.1006V50.4614H42.6413Z"
fill="var(--theme-primary-05)"
/>
<path
d="M82.3035 64V56.0273H89.9753C91.2288 56.0273 92.2066 55.7264 92.9086 55.1247C93.6607 54.523 94.2625 53.5452 94.7137 52.1913L108.027 12.4031H116.751L103.664 49.4084C103.062 51.1634 102.461 52.5173 101.859 53.47C101.307 54.4227 100.53 55.4506 99.5274 56.5538L92.4573 64H82.3035ZM90.7274 46.6255L76.9633 12.4031H85.989L99.4522 46.6255H90.7274Z"
fill="var(--theme-primary-05)"
/>
<path
d="M115.657 50.4614L129.045 31.2066L116.033 12.4031H125.435L134.085 24.8134L142.358 12.4031H151.308L138.372 31.0562L151.684 50.4614H142.358L133.332 37.3742L124.683 50.4614H115.657Z"
fill="var(--theme-primary-05)"
/>
</svg>
);
export default SvgOnyxTyped;

View File

@@ -32,8 +32,6 @@ interface ContentMdPresetConfig {
optionalFont: string;
/** Aux icon size = lineHeight 2 × p-0.5. */
auxIconSize: string;
/** Left indent for the description so it aligns with the title (past the icon). */
descriptionIndent: string;
}
interface ContentMdProps {
@@ -87,7 +85,6 @@ const CONTENT_MD_PRESETS: Record<ContentMdSizePreset, ContentMdPresetConfig> = {
editButtonPadding: "p-0",
optionalFont: "font-main-content-muted",
auxIconSize: "1.25rem",
descriptionIndent: "1.625rem",
},
"main-ui": {
iconSize: "1rem",
@@ -100,7 +97,6 @@ const CONTENT_MD_PRESETS: Record<ContentMdSizePreset, ContentMdPresetConfig> = {
editButtonPadding: "p-0",
optionalFont: "font-main-ui-muted",
auxIconSize: "1rem",
descriptionIndent: "1.375rem",
},
secondary: {
iconSize: "0.75rem",
@@ -113,7 +109,6 @@ const CONTENT_MD_PRESETS: Record<ContentMdSizePreset, ContentMdPresetConfig> = {
editButtonPadding: "p-0",
optionalFont: "font-secondary-action",
auxIconSize: "0.75rem",
descriptionIndent: "1.125rem",
},
};
@@ -168,25 +163,22 @@ function ContentMd({
data-interactive={withInteractive || undefined}
style={{ gap: config.gap }}
>
<div
className="opal-content-md-header"
data-editing={editing || undefined}
>
{Icon && (
<div
className={cn(
"opal-content-md-icon-container shrink-0",
config.iconContainerPadding
)}
style={{ minHeight: config.lineHeight }}
>
<Icon
className={cn("opal-content-md-icon", config.iconColorClass)}
style={{ width: config.iconSize, height: config.iconSize }}
/>
</div>
)}
{Icon && (
<div
className={cn(
"opal-content-md-icon-container shrink-0",
config.iconContainerPadding
)}
style={{ minHeight: config.lineHeight }}
>
<Icon
className={cn("opal-content-md-icon", config.iconColorClass)}
style={{ width: config.iconSize, height: config.iconSize }}
/>
</div>
)}
<div className="opal-content-md-body">
<div className="opal-content-md-title-row">
{editing ? (
<div className="opal-content-md-input-sizer">
@@ -282,16 +274,13 @@ function ContentMd({
</div>
)}
</div>
</div>
{description && (
<div
className="opal-content-md-description font-secondary-body text-text-03"
style={Icon ? { paddingLeft: config.descriptionIndent } : undefined}
>
{description}
</div>
)}
{description && (
<div className="opal-content-md-description font-secondary-body text-text-03">
{description}
</div>
)}
</div>
</div>
);
}

View File

@@ -224,16 +224,7 @@
--------------------------------------------------------------------------- */
.opal-content-md {
@apply flex flex-col items-start;
}
.opal-content-md-header {
@apply flex flex-row items-center w-full;
}
.opal-content-md-header[data-editing] {
@apply rounded-08;
box-shadow: inset 0 0 0 1px var(--border-02);
@apply flex flex-row items-start;
}
/* ---------------------------------------------------------------------------
@@ -246,6 +237,15 @@
justify-content: center;
}
/* ---------------------------------------------------------------------------
Body column
--------------------------------------------------------------------------- */
.opal-content-md-body {
@apply flex flex-1 flex-col items-start;
min-width: 0.0625rem;
}
/* ---------------------------------------------------------------------------
Title row — title (or input) + edit button
--------------------------------------------------------------------------- */
@@ -267,7 +267,6 @@
.opal-content-md-input-sizer {
display: inline-grid;
align-items: stretch;
width: 100%;
}
.opal-content-md-input-sizer > * {

View File

@@ -86,15 +86,6 @@ export interface IconProps extends SVGProps<SVGSVGElement> {
/** Strips `className` and `style` from a props type to enforce design-system styling. */
export type WithoutStyles<T> = Omit<T, "className" | "style">;
/**
* HTML button `type` attribute values.
*
* Used by interactive primitives and button-like components to indicate that
* the element is inherently interactive for cursor-styling purposes, even
* without an explicit `onClick` or `href`.
*/
export type ButtonType = "submit" | "button" | "reset";
/** Like `Omit` but distributes over union types, preserving discriminated unions. */
export type DistributiveOmit<T, K extends keyof any> = T extends any
? Omit<T, K>

View File

@@ -0,0 +1,320 @@
"use client";
import Text from "@/refresh-components/texts/Text";
import { Persona } from "./interfaces";
import { useRouter } from "next/navigation";
import Checkbox from "@/refresh-components/inputs/Checkbox";
import { toast } from "@/hooks/useToast";
import { useState, useMemo, useEffect } from "react";
import { UniqueIdentifier } from "@dnd-kit/core";
import { DraggableTable } from "@/components/table/DraggableTable";
import {
deletePersona,
personaComparator,
togglePersonaFeatured,
togglePersonaVisibility,
} from "./lib";
import { FiEdit2 } from "react-icons/fi";
import { useUser } from "@/providers/UserProvider";
import { Button } from "@opal/components";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
import { SvgAlertCircle, SvgTrash } from "@opal/icons";
import type { Route } from "next";
function PersonaTypeDisplay({ persona }: { persona: Persona }) {
if (persona.builtin_persona) {
return <Text as="p">Built-In</Text>;
}
if (persona.is_featured) {
return <Text as="p">Featured</Text>;
}
if (persona.is_public) {
return <Text as="p">Public</Text>;
}
if (persona.groups.length > 0 || persona.users.length > 0) {
return <Text as="p">Shared</Text>;
}
return (
<Text as="p">Personal {persona.owner && <>({persona.owner.email})</>}</Text>
);
}
export function PersonasTable({
personas,
refreshPersonas,
currentPage,
pageSize,
}: {
personas: Persona[];
refreshPersonas: () => void;
currentPage: number;
pageSize: number;
}) {
const router = useRouter();
const { refreshUser, isAdmin } = useUser();
const editablePersonas = useMemo(() => {
return personas.filter((p) => !p.builtin_persona);
}, [personas]);
const editablePersonaIds = useMemo(() => {
return new Set(editablePersonas.map((p) => p.id.toString()));
}, [editablePersonas]);
const [finalPersonas, setFinalPersonas] = useState<Persona[]>([]);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [personaToDelete, setPersonaToDelete] = useState<Persona | null>(null);
const [defaultModalOpen, setDefaultModalOpen] = useState(false);
const [personaToToggleDefault, setPersonaToToggleDefault] =
useState<Persona | null>(null);
useEffect(() => {
const editable = editablePersonas.sort(personaComparator);
const nonEditable = personas
.filter((p) => !editablePersonaIds.has(p.id.toString()))
.sort(personaComparator);
setFinalPersonas([...editable, ...nonEditable]);
}, [editablePersonas, personas, editablePersonaIds]);
const updatePersonaOrder = async (orderedPersonaIds: UniqueIdentifier[]) => {
const reorderedPersonas = orderedPersonaIds.map(
(id) => personas.find((persona) => persona.id.toString() === id)!
);
setFinalPersonas(reorderedPersonas);
// Calculate display_priority based on current page.
// Page 1 (items 0-9): priorities 0-9
// Page 2 (items 10-19): priorities 10-19, etc.
const pageStartIndex = (currentPage - 1) * pageSize;
const displayPriorityMap = new Map<UniqueIdentifier, number>();
orderedPersonaIds.forEach((personaId, ind) => {
displayPriorityMap.set(personaId, pageStartIndex + ind);
});
const response = await fetch("/api/admin/agents/display-priorities", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
display_priority_map: Object.fromEntries(displayPriorityMap),
}),
});
if (!response.ok) {
toast.error(`Failed to update persona order - ${await response.text()}`);
setFinalPersonas(personas);
await refreshPersonas();
return;
}
await refreshPersonas();
await refreshUser();
};
const openDeleteModal = (persona: Persona) => {
setPersonaToDelete(persona);
setDeleteModalOpen(true);
};
const closeDeleteModal = () => {
setDeleteModalOpen(false);
setPersonaToDelete(null);
};
const handleDeletePersona = async () => {
if (personaToDelete) {
const response = await deletePersona(personaToDelete.id);
if (response.ok) {
refreshPersonas();
closeDeleteModal();
} else {
toast.error(`Failed to delete persona - ${await response.text()}`);
}
}
};
const openDefaultModal = (persona: Persona) => {
setPersonaToToggleDefault(persona);
setDefaultModalOpen(true);
};
const closeDefaultModal = () => {
setDefaultModalOpen(false);
setPersonaToToggleDefault(null);
};
const handleToggleDefault = async () => {
if (personaToToggleDefault) {
const response = await togglePersonaFeatured(
personaToToggleDefault.id,
personaToToggleDefault.is_featured
);
if (response.ok) {
refreshPersonas();
closeDefaultModal();
} else {
toast.error(`Failed to update persona - ${await response.text()}`);
}
}
};
return (
<div>
{deleteModalOpen && personaToDelete && (
<ConfirmationModalLayout
icon={SvgAlertCircle}
title="Delete Agent"
onClose={closeDeleteModal}
submit={<Button onClick={handleDeletePersona}>Delete</Button>}
>
{`Are you sure you want to delete ${personaToDelete.name}?`}
</ConfirmationModalLayout>
)}
{defaultModalOpen &&
personaToToggleDefault &&
(() => {
const isDefault = personaToToggleDefault.is_featured;
const title = isDefault
? "Remove Featured Agent"
: "Set Featured Agent";
const buttonText = isDefault ? "Remove Feature" : "Set as Featured";
const text = isDefault
? `Are you sure you want to remove the featured status of ${personaToToggleDefault.name}?`
: `Are you sure you want to set the featured status of ${personaToToggleDefault.name}?`;
const additionalText = isDefault
? `Removing "${personaToToggleDefault.name}" as a featured agent will not affect its visibility or accessibility.`
: `Setting "${personaToToggleDefault.name}" as a featured agent will make it public and visible to all users. This action cannot be undone.`;
return (
<ConfirmationModalLayout
icon={SvgAlertCircle}
title={title}
onClose={closeDefaultModal}
submit={
<Button onClick={handleToggleDefault}>{buttonText}</Button>
}
>
<div className="flex flex-col gap-2">
<Text as="p">{text}</Text>
<Text as="p" text03>
{additionalText}
</Text>
</div>
</ConfirmationModalLayout>
);
})()}
<DraggableTable
headers={[
"Name",
"Description",
"Type",
"Featured Agent",
"Is Visible",
"Delete",
]}
isAdmin={isAdmin}
rows={finalPersonas.map((persona) => {
const isEditable = editablePersonas.includes(persona);
return {
id: persona.id.toString(),
cells: [
<div key="name" className="flex">
{!persona.builtin_persona && (
<FiEdit2
className="mr-1 my-auto cursor-pointer"
onClick={() =>
router.push(
`/app/agents/edit/${
persona.id
}?u=${Date.now()}&admin=true` as Route
)
}
/>
)}
<p className="text font-medium whitespace-normal break-none">
{persona.name}
</p>
</div>,
<p
key="description"
className="whitespace-normal break-all max-w-2xl"
>
{persona.description}
</p>,
<PersonaTypeDisplay key={persona.id} persona={persona} />,
<div
key="featured"
onClick={() => {
openDefaultModal(persona);
}}
className={`
px-1 py-0.5 rounded flex hover:bg-accent-background-hovered cursor-pointer select-none w-fit items-center gap-2
`}
>
<div className="my-auto flex-none w-22">
{!persona.is_featured ? (
<div className="text-error">Not Featured</div>
) : (
"Featured"
)}
</div>
<Checkbox checked={persona.is_featured} />
</div>,
<div
key="is_visible"
onClick={async () => {
const response = await togglePersonaVisibility(
persona.id,
persona.is_listed
);
if (response.ok) {
refreshPersonas();
} else {
toast.error(
`Failed to update persona - ${await response.text()}`
);
}
}}
className={`
px-1 py-0.5 rounded flex hover:bg-accent-background-hovered cursor-pointer select-none w-fit items-center gap-2
`}
>
<div className="my-auto w-fit">
{!persona.is_listed ? (
<div className="text-error">Hidden</div>
) : (
"Visible"
)}
</div>
<Checkbox checked={persona.is_listed} />
</div>,
<div key="edit" className="flex">
<div className="mr-auto my-auto">
{!persona.builtin_persona && isEditable ? (
<Button
icon={SvgTrash}
prominence="tertiary"
onClick={() => openDeleteModal(persona)}
/>
) : (
<Text as="p">-</Text>
)}
</div>
</div>,
],
staticModifiers: [[1, "lg:w-[250px] xl:w-[400px] 2xl:w-[550px]"]],
};
})}
setRows={updatePersonaOrder}
/>
</div>
);
}

View File

@@ -1 +1,160 @@
export { default } from "@/refresh-pages/admin/AgentsPage";
"use client";
import { PersonasTable } from "./PersonaTable";
import Text from "@/components/ui/text";
import Title from "@/components/ui/title";
import Separator from "@/refresh-components/Separator";
import { SubLabel } from "@/components/Field";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import CreateButton from "@/refresh-components/buttons/CreateButton";
import { useAdminPersonas } from "@/hooks/useAdminPersonas";
import { Persona } from "./interfaces";
import { ThreeDotsLoader } from "@/components/Loading";
import { ErrorCallout } from "@/components/ErrorCallout";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { useState, useEffect } from "react";
import { Pagination } from "@opal/components";
const route = ADMIN_ROUTES.AGENTS;
const PAGE_SIZE = 20;
function MainContent({
personas,
totalItems,
currentPage,
onPageChange,
refreshPersonas,
}: {
personas: Persona[];
totalItems: number;
currentPage: number;
onPageChange: (page: number) => void;
refreshPersonas: () => void;
}) {
// Filter out default/unified assistants.
// NOTE: The backend should already exclude them if includeDefault = false is
// provided. That change was made with the introduction of pagination; we keep
// this filter here for now for backwards compatibility.
const customPersonas = personas.filter((persona) => !persona.builtin_persona);
const totalPages = Math.ceil(totalItems / PAGE_SIZE);
// Clamp currentPage when totalItems shrinks (e.g., deleting the last item on a page)
useEffect(() => {
if (currentPage > totalPages && totalPages > 0) {
onPageChange(totalPages);
}
}, [currentPage, totalPages, onPageChange]);
return (
<div>
<Text className="mb-2">
Agents are a way to build custom search/question-answering experiences
for different use cases.
</Text>
<Text className="mt-2">They allow you to customize:</Text>
<div className="text-sm">
<ul className="list-disc mt-2 ml-4">
<li>
The prompt used by your LLM of choice to respond to the user query
</li>
<li>The documents that are used as context</li>
</ul>
</div>
<div>
<Separator />
<Title>Create an Agent</Title>
<CreateButton href="/app/agents/create?admin=true">
New Agent
</CreateButton>
<Separator />
<Title>Existing Agents</Title>
{totalItems > 0 ? (
<>
<SubLabel>
Agents will be displayed as options on the Chat / Search
interfaces in the order they are displayed below. Agents marked as
hidden will not be displayed. Editable agents are shown at the
top.
</SubLabel>
<PersonasTable
personas={customPersonas}
refreshPersonas={refreshPersonas}
currentPage={currentPage}
pageSize={PAGE_SIZE}
/>
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onChange={onPageChange}
/>
)}
</>
) : (
<div className="mt-6 p-8 border border-border rounded-lg bg-background-weak text-center">
<Text className="text-lg font-medium mb-2">
No custom agents yet
</Text>
<Text className="text-subtle mb-3">
Create your first agent to:
</Text>
<ul className="text-subtle text-sm list-disc text-left inline-block mb-3">
<li>Build department-specific knowledge bases</li>
<li>Create specialized research agents</li>
<li>Set up compliance and policy advisors</li>
</ul>
<Text className="text-subtle text-sm mb-4">
...and so much more!
</Text>
<CreateButton href="/app/agents/create?admin=true">
Create Your First Agent
</CreateButton>
</div>
)}
</div>
</div>
);
}
export default function Page() {
const [currentPage, setCurrentPage] = useState(1);
const { personas, totalItems, isLoading, error, refresh } = useAdminPersonas({
pageNum: currentPage - 1, // Backend uses 0-indexed pages
pageSize: PAGE_SIZE,
});
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />
<SettingsLayouts.Body>
{isLoading && <ThreeDotsLoader />}
{error && (
<ErrorCallout
errorTitle="Failed to load agents"
errorMsg={
error?.info?.message ||
error?.info?.detail ||
"An unknown error occurred"
}
/>
)}
{!isLoading && !error && (
<MainContent
personas={personas}
totalItems={totalItems}
currentPage={currentPage}
onPageChange={setCurrentPage}
refreshPersonas={refresh}
/>
)}
</SettingsLayouts.Body>
</SettingsLayouts.Root>
);
}

View File

@@ -395,7 +395,7 @@ function SeatsCard({
<InputLayouts.Vertical title="Seats">
<InputNumber
value={newSeatCount}
onChange={(v) => setNewSeatCount(v ?? 1)}
onChange={setNewSeatCount}
min={1}
defaultValue={totalSeats}
showReset

View File

@@ -230,7 +230,7 @@ export default function CheckoutView({ onAdjustPlan }: CheckoutViewProps) {
>
<InputNumber
value={seats}
onChange={(v) => setSeats(v ?? minRequiredSeats)}
onChange={setSeats}
min={minRequiredSeats}
defaultValue={minRequiredSeats}
showReset

View File

@@ -260,7 +260,7 @@ export default function VoiceProviderSetupModal({
<SvgArrowExchange className="size-3 text-text-04" />
</div>
<div className="flex items-center justify-center size-7 p-0.5 shrink-0 overflow-clip">
<SvgOnyxLogo size={24} className="shrink-0" />
<SvgOnyxLogo size={24} className="text-text-04 shrink-0" />
</div>
</div>
);

View File

@@ -69,7 +69,7 @@ export const WebProviderSetupModal = memo(
<SvgArrowExchange className="size-3 text-text-04" />
</div>
<div className="flex items-center justify-center size-7 p-0.5 shrink-0 overflow-clip">
<SvgOnyxLogo size={24} className="shrink-0" />
<SvgOnyxLogo size={24} className="text-text-04 shrink-0" />
</div>
</div>
);

View File

@@ -1372,7 +1372,7 @@ export default function Page() {
} logo`,
fallback:
selectedContentProviderType === "onyx_web_crawler" ? (
<SvgOnyxLogo size={24} />
<SvgOnyxLogo size={24} className="text-text-05" />
) : undefined,
size: 24,
containerSize: 28,

View File

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

View File

@@ -28,12 +28,7 @@ export default function Layout({ children }: LayoutProps) {
<SettingsLayouts.Header icon={SvgSliders} title="Settings" separator />
<SettingsLayouts.Body>
<Section
flexDirection="row"
justifyContent="start"
alignItems="start"
gap={1.5}
>
<Section flexDirection="row" alignItems="start" gap={1.5}>
{/* Left: Tab Navigation */}
<div
data-testid="settings-left-tab-navigation"

View File

@@ -7,11 +7,8 @@ import { processRawChatHistory } from "@/app/app/services/lib";
import { getLatestMessageChain } from "@/app/app/services/messageTree";
import HumanMessage from "@/app/app/message/HumanMessage";
import AgentMessage from "@/app/app/message/messageComponents/AgentMessage";
import { Callout } from "@/components/ui/callout";
import OnyxInitializingLoader from "@/components/OnyxInitializingLoader";
import { Section } from "@/layouts/general-layouts";
import { IllustrationContent } from "@opal/layouts";
import SvgNotFound from "@opal/illustrations/not-found";
import { Button } from "@opal/components";
import { Persona } from "@/app/admin/agents/interfaces";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import PreviewModal from "@/sections/modals/PreviewModal";
@@ -36,17 +33,12 @@ export default function SharedChatDisplay({
if (!chatSession) {
return (
<div className="h-full w-full flex flex-col items-center justify-center">
<Section flexDirection="column" alignItems="center" gap={1}>
<IllustrationContent
illustration={SvgNotFound}
title="Shared chat not found"
description="Did not find a shared chat with the specified ID."
/>
<Button href="/app" prominence="secondary">
Start a new chat
</Button>
</Section>
<div className="min-h-full w-full">
<div className="mx-auto w-fit pt-8">
<Callout type="danger" title="Shared Chat Not Found">
Did not find a shared chat with the specified ID.
</Callout>
</div>
</div>
);
}
@@ -59,17 +51,12 @@ export default function SharedChatDisplay({
if (firstMessage === undefined) {
return (
<div className="h-full w-full flex flex-col items-center justify-center">
<Section flexDirection="column" alignItems="center" gap={1}>
<IllustrationContent
illustration={SvgNotFound}
title="Shared chat not found"
description="No messages found in shared chat."
/>
<Button href="/app" prominence="secondary">
Start a new chat
</Button>
</Section>
<div className="min-h-full w-full">
<div className="mx-auto w-fit pt-8">
<Callout type="danger" title="Shared Chat Not Found">
No messages found in shared chat.
</Callout>
</div>
</div>
);
}

View File

@@ -13,7 +13,6 @@ import {
type KeyboardEvent,
} from "react";
import { useRouter } from "next/navigation";
import { getPastedFilesIfNoText } from "@/lib/clipboard";
import { cn, isImageFile } from "@/lib/utils";
import { Disabled } from "@opal/core";
import {
@@ -231,11 +230,21 @@ const InputBar = memo(
const handlePaste = useCallback(
(event: ClipboardEvent) => {
const pastedFiles = getPastedFilesIfNoText(event.clipboardData);
if (pastedFiles.length > 0) {
event.preventDefault();
// Context handles session binding internally
uploadFiles(pastedFiles);
const items = event.clipboardData?.items;
if (items) {
const pastedFiles: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item && item.kind === "file") {
const file = item.getAsFile();
if (file) pastedFiles.push(file);
}
}
if (pastedFiles.length > 0) {
event.preventDefault();
// Context handles session binding internally
uploadFiles(pastedFiles);
}
}
},
[uploadFiles]

View File

@@ -413,7 +413,7 @@ const MemoizedBuildSidebarInner = memo(
return (
<SidebarWrapper folded={folded} onFoldClick={onFoldClick}>
<SidebarBody
pinnedContent={
actionButtons={
<div className="flex flex-col gap-0.5">
{newBuildButton}
{buildConfigurePanel}

View File

@@ -2,9 +2,7 @@
/* Base layers */
--z-base: 0;
--z-content: 1;
/* Settings header must sit above sticky table headers (--z-sticky: 10) so
the page header scrolls over pinned columns without being obscured. */
--z-settings-header: 11;
--z-settings-header: 8;
--z-app-layout: 9;
--z-sticky: 10;

View File

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

View File

@@ -17,7 +17,6 @@ import StatsOverlayLoader from "@/components/dev/StatsOverlayLoader";
import AppHealthBanner from "@/sections/AppHealthBanner";
import CustomAnalyticsScript from "@/providers/CustomAnalyticsScript";
import ProductGatingWrapper from "@/providers/ProductGatingWrapper";
import SWRConfigProvider from "@/providers/SWRConfigProvider";
const hankenGrotesk = Hanken_Grotesk({
subsets: ["latin"],
@@ -80,23 +79,21 @@ export default function RootLayout({
<div className="text-text min-h-screen bg-background">
<TooltipProvider>
<PHProvider>
<SWRConfigProvider>
<AppHealthBanner />
<AppProvider>
<DynamicMetadata />
<CustomAnalyticsScript />
<Suspense fallback={null}>
<PostHogPageView />
</Suspense>
<div id={MODAL_ROOT_ID} className="h-screen w-screen">
<ProductGatingWrapper>{children}</ProductGatingWrapper>
</div>
{process.env.NEXT_PUBLIC_POSTHOG_KEY && <WebVitals />}
{process.env.NEXT_PUBLIC_ENABLE_STATS === "true" && (
<StatsOverlayLoader />
)}
</AppProvider>
</SWRConfigProvider>
<AppHealthBanner />
<AppProvider>
<DynamicMetadata />
<CustomAnalyticsScript />
<Suspense fallback={null}>
<PostHogPageView />
</Suspense>
<div id={MODAL_ROOT_ID} className="h-screen w-screen">
<ProductGatingWrapper>{children}</ProductGatingWrapper>
</div>
{process.env.NEXT_PUBLIC_POSTHOG_KEY && <WebVitals />}
{process.env.NEXT_PUBLIC_ENABLE_STATS === "true" && (
<StatsOverlayLoader />
)}
</AppProvider>
</PHProvider>
</TooltipProvider>
</div>

View File

@@ -21,7 +21,7 @@ import Text from "@/refresh-components/texts/Text";
import { Section } from "@/layouts/general-layouts";
import Popover, { PopoverMenu } from "@/refresh-components/Popover";
import { SvgCheck, SvgClock, SvgTag } from "@opal/icons";
import { FilterButton } from "@opal/components";
import FilterButton from "@/refresh-components/buttons/FilterButton";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import useFilter from "@/hooks/useFilter";
import { LineItemButton } from "@opal/components";
@@ -217,7 +217,7 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
<Popover open={timeFilterOpen} onOpenChange={setTimeFilterOpen}>
<Popover.Trigger asChild>
<FilterButton
icon={SvgClock}
leftIcon={SvgClock}
active={!!timeFilter}
onClear={() => {
setTimeFilter(null);
@@ -253,7 +253,7 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
<Popover open={tagFilterOpen} onOpenChange={setTagFilterOpen}>
<Popover.Trigger asChild>
<FilterButton
icon={SvgTag}
leftIcon={SvgTag}
active={selectedTags.length > 0}
onClear={() => {
setSelectedTags([]);

View File

@@ -1,15 +1,7 @@
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { AuthType, NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
interface AuthTypeAPIResponse {
auth_type: string;
requires_verification: boolean;
anonymous_user_enabled: boolean | null;
password_min_length: number;
has_users: boolean;
oauth_enabled: boolean;
}
export interface AuthTypeMetadata {
authType: AuthType;
autoRedirect: boolean;
@@ -30,24 +22,6 @@ const DEFAULT_AUTH_TYPE_METADATA: AuthTypeMetadata = {
oauthEnabled: false,
};
async function fetchAuthTypeMetadata(url: string): Promise<AuthTypeMetadata> {
const res = await fetch(url);
if (!res.ok) throw new Error("Failed to fetch auth type metadata");
const data: AuthTypeAPIResponse = await res.json();
const authType = NEXT_PUBLIC_CLOUD_ENABLED
? AuthType.CLOUD
: (data.auth_type as AuthType);
return {
authType,
autoRedirect: authType === AuthType.OIDC || authType === AuthType.SAML,
requiresVerification: data.requires_verification,
anonymousUserEnabled: data.anonymous_user_enabled,
passwordMinLength: data.password_min_length,
hasUsers: data.has_users,
oauthEnabled: data.oauth_enabled,
};
}
export function useAuthTypeMetadata(): {
authTypeMetadata: AuthTypeMetadata;
isLoading: boolean;
@@ -55,7 +29,7 @@ export function useAuthTypeMetadata(): {
} {
const { data, error, isLoading } = useSWR<AuthTypeMetadata>(
"/api/auth/type",
fetchAuthTypeMetadata,
errorHandlingFetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
@@ -63,6 +37,14 @@ export function useAuthTypeMetadata(): {
}
);
if (NEXT_PUBLIC_CLOUD_ENABLED && data) {
return {
authTypeMetadata: { ...data, authType: AuthType.CLOUD },
isLoading,
error,
};
}
return {
authTypeMetadata: data ?? DEFAULT_AUTH_TYPE_METADATA,
isLoading,

View File

@@ -61,11 +61,6 @@ interface UseChatSessionControllerProps {
}) => Promise<void>;
}
export type SessionFetchError = {
type: "not_found" | "access_denied" | "unknown";
detail: string;
} | null;
export default function useChatSessionController({
existingChatSessionId,
searchParams,
@@ -85,8 +80,6 @@ export default function useChatSessionController({
const [currentSessionFileTokenCount, setCurrentSessionFileTokenCount] =
useState<number>(0);
const [projectFiles, setProjectFiles] = useState<ProjectFile[]>([]);
const [sessionFetchError, setSessionFetchError] =
useState<SessionFetchError>(null);
// Store actions
const updateSessionAndMessageTree = useChatSessionStore(
(state) => state.updateSessionAndMessageTree
@@ -158,8 +151,6 @@ export default function useChatSessionController({
}
async function initialSessionFetch() {
setSessionFetchError(null);
if (existingChatSessionId === null) {
// Clear the current session in the store to show intro messages
setCurrentSession(null);
@@ -187,42 +178,9 @@ export default function useChatSessionController({
setCurrentSession(existingChatSessionId);
setIsFetchingChatMessages(existingChatSessionId, true);
let response: Response;
try {
response = await fetch(
`/api/chat/get-chat-session/${existingChatSessionId}`
);
} catch (error) {
setIsFetchingChatMessages(existingChatSessionId, false);
console.error("Failed to fetch chat session", {
chatSessionId: existingChatSessionId,
error,
});
setSessionFetchError({
type: "unknown",
detail: "Failed to load chat session. Please check your connection.",
});
return;
}
if (!response.ok) {
setIsFetchingChatMessages(existingChatSessionId, false);
let detail = "An unexpected error occurred.";
try {
const errorBody = await response.json();
detail = errorBody.detail || detail;
} catch {
// ignore parse errors
}
const type =
response.status === 404
? "not_found"
: response.status === 403
? "access_denied"
: "unknown";
setSessionFetchError({ type, detail });
return;
}
const response = await fetch(
`/api/chat/get-chat-session/${existingChatSessionId}`
);
const session = await response.json();
const chatSession = session as BackendChatSession;
@@ -398,6 +356,5 @@ export default function useChatSessionController({
currentSessionFileTokenCount,
onMessageSelection,
projectFiles,
sessionFetchError,
};
}

View File

@@ -36,11 +36,7 @@ export function useMemoryManager({
setLocalMemories((prev) => {
const emptyNewItems = prev.filter((m) => m.isNew && !m.content.trim());
const availableSlots = MAX_MEMORY_COUNT - existingMemories.length;
return [
...emptyNewItems.slice(0, Math.max(0, availableSlots)),
...existingMemories,
];
return [...emptyNewItems, ...existingMemories];
});
initialMemoriesRef.current = memories;
}, [memories]);

View File

@@ -123,9 +123,6 @@ export interface LLMProviderFormProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** The current default model name for this provider (from the global default). */
defaultModelName?: string;
// Onboarding-specific (only when variant === "onboarding")
onboardingState?: OnboardingState;
onboardingActions?: OnboardingActions;

View File

@@ -1,89 +0,0 @@
import { getPastedFilesIfNoText } from "./clipboard";
type MockClipboardData = Parameters<typeof getPastedFilesIfNoText>[0];
function makeClipboardData({
textPlain = "",
text = "",
files = [],
}: {
textPlain?: string;
text?: string;
files?: File[];
}): MockClipboardData {
return {
items: files.map((file) => ({
kind: "file",
getAsFile: () => file,
})),
getData: (format: string) => {
if (format === "text/plain") {
return textPlain;
}
if (format === "text") {
return text;
}
return "";
},
};
}
describe("getPastedFilesIfNoText", () => {
it("prefers plain text over pasted files when both are present", () => {
const imageFile = new File(["slide preview"], "slide.png", {
type: "image/png",
});
expect(
getPastedFilesIfNoText(
makeClipboardData({
textPlain: "Welcome to PowerPoint for Mac",
files: [imageFile],
})
)
).toEqual([]);
});
it("falls back to text data when text/plain is empty", () => {
const imageFile = new File(["slide preview"], "slide.png", {
type: "image/png",
});
expect(
getPastedFilesIfNoText(
makeClipboardData({
text: "Welcome to PowerPoint for Mac",
files: [imageFile],
})
)
).toEqual([]);
});
it("still returns files for image-only pastes", () => {
const imageFile = new File(["slide preview"], "slide.png", {
type: "image/png",
});
expect(
getPastedFilesIfNoText(makeClipboardData({ files: [imageFile] }))
).toEqual([imageFile]);
});
it("ignores whitespace-only text and keeps file pastes working", () => {
const imageFile = new File(["slide preview"], "slide.png", {
type: "image/png",
});
expect(
getPastedFilesIfNoText(
makeClipboardData({
textPlain: " ",
text: "\n",
files: [imageFile],
})
)
).toEqual([imageFile]);
});
});

View File

@@ -1,52 +0,0 @@
type ClipboardFileItem = {
kind: string;
getAsFile: () => File | null;
};
type ClipboardDataLike = {
items?: ArrayLike<ClipboardFileItem> | null;
getData: (format: string) => string;
};
function getClipboardText(
clipboardData: ClipboardDataLike,
format: "text/plain" | "text"
): string {
try {
return clipboardData.getData(format);
} catch {
return "";
}
}
export function getPastedFilesIfNoText(
clipboardData?: ClipboardDataLike | null
): File[] {
if (!clipboardData) {
return [];
}
const plainText = getClipboardText(clipboardData, "text/plain").trim();
const fallbackText = getClipboardText(clipboardData, "text").trim();
// Apps like PowerPoint on macOS can place both rendered image data and the
// original text on the clipboard. Prefer letting the textarea consume text.
if (plainText || fallbackText || !clipboardData.items) {
return [];
}
const pastedFiles: File[] = [];
for (let i = 0; i < clipboardData.items.length; i++) {
const item = clipboardData.items[i];
if (item?.kind !== "file") {
continue;
}
const file = item.getAsFile();
if (file) {
pastedFiles.push(file);
}
}
return pastedFiles;
}

View File

@@ -127,7 +127,8 @@ export const DESKTOP_SMALL_BREAKPOINT_PX = 912;
export const DESKTOP_MEDIUM_BREAKPOINT_PX = 1232;
export const DEFAULT_AVATAR_SIZE_PX = 18;
export const HORIZON_DISTANCE_PX = 800;
export const DEFAULT_LOGO_SIZE_PX = 24;
export const LOGO_FOLDED_SIZE_PX = 24;
export const LOGO_UNFOLDED_SIZE_PX = 88;
export const DEFAULT_CONTEXT_TOKENS = 120_000;
export const MAX_CHUNKS_FED_TO_CHAT = 25;

View File

@@ -19,29 +19,6 @@ const DEFAULT_AUTH_ERROR_MSG =
const DEFAULT_ERROR_MSG = "An error occurred while fetching the data.";
/**
* SWR `onErrorRetry` callback that suppresses automatic retries for
* authentication errors (401/403). Pass this to any SWR hook whose endpoint
* requires auth so that unauthenticated pages don't spam the backend.
*/
export const skipRetryOnAuthError: NonNullable<
import("swr").SWRConfiguration["onErrorRetry"]
> = (error, _key, _config, revalidate, { retryCount }) => {
if (
error instanceof FetchError &&
(error.status === 401 || error.status === 403)
)
return;
// For non-auth errors, retry with exponential backoff
if (
_config.errorRetryCount !== undefined &&
retryCount >= _config.errorRetryCount
)
return;
const delay = Math.min(2000 * 2 ** retryCount, 30000);
setTimeout(() => revalidate({ retryCount }), delay);
};
export const errorHandlingFetcher = async <T>(url: string): Promise<T> => {
const res = await fetch(url);

View File

@@ -1,16 +0,0 @@
"use client";
import { SWRConfig } from "swr";
import { skipRetryOnAuthError } from "@/lib/fetcher";
export default function SWRConfigProvider({
children,
}: {
children: React.ReactNode;
}) {
return (
<SWRConfig value={{ onErrorRetry: skipRetryOnAuthError }}>
{children}
</SWRConfig>
);
}

View File

@@ -49,9 +49,7 @@ export function SettingsProvider({
const [isMobile, setIsMobile] = useState<boolean | undefined>();
const settingsLoading = coreSettingsLoading || enterpriseSettingsLoading;
const vectorDbEnabled =
!coreSettingsLoading &&
!settingsError &&
settings.vector_db_enabled !== false;
!coreSettingsLoading && settings.vector_db_enabled !== false;
const { ccPairs } = useCCPairs(vectorDbEnabled);
useEffect(() => {

View File

@@ -1,15 +1,16 @@
"use client";
import { OnyxIcon, OnyxLogoTypeIcon } from "@/components/icons/icons";
import { useSettingsContext } from "@/providers/SettingsProvider";
import {
DEFAULT_LOGO_SIZE_PX,
LOGO_FOLDED_SIZE_PX,
LOGO_UNFOLDED_SIZE_PX,
NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED,
} from "@/lib/constants";
import { cn } from "@/lib/utils";
import Text from "@/refresh-components/texts/Text";
import Truncated from "@/refresh-components/texts/Truncated";
import { useMemo } from "react";
import { SvgOnyxLogo, SvgOnyxLogoTyped } from "@opal/icons";
export interface LogoProps {
folded?: boolean;
@@ -18,7 +19,8 @@ export interface LogoProps {
}
export default function Logo({ folded, size, className }: LogoProps) {
const resolvedSize = size ?? DEFAULT_LOGO_SIZE_PX;
const foldedSize = size ?? LOGO_FOLDED_SIZE_PX;
const unfoldedSize = size ?? LOGO_UNFOLDED_SIZE_PX;
const settings = useSettingsContext();
const logoDisplayStyle = settings.enterpriseSettings?.logo_display_style;
const applicationName = settings.enterpriseSettings?.application_name;
@@ -40,7 +42,7 @@ export default function Logo({ folded, size, className }: LogoProps) {
"aspect-square rounded-full overflow-hidden relative flex-shrink-0",
className
)}
style={{ height: resolvedSize }}
style={{ height: foldedSize, width: foldedSize }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
@@ -50,10 +52,7 @@ export default function Logo({ folded, size, className }: LogoProps) {
/>
</div>
) : (
<SvgOnyxLogo
size={resolvedSize}
className={cn("flex-shrink-0", className)}
/>
<OnyxIcon size={foldedSize} className={cn("flex-shrink-0", className)} />
);
const renderNameAndPoweredBy = (opts: {
@@ -99,11 +98,8 @@ export default function Logo({ folded, size, className }: LogoProps) {
return applicationName ? (
renderNameAndPoweredBy({ includeLogo: true, includeName: true })
) : folded ? (
<SvgOnyxLogo
size={resolvedSize}
className={cn("flex-shrink-0", className)}
/>
<OnyxIcon size={foldedSize} className={cn("flex-shrink-0", className)} />
) : (
<SvgOnyxLogoTyped size={resolvedSize} className={className} />
<OnyxLogoTypeIcon size={unfoldedSize} className={className} />
);
}

View File

@@ -120,10 +120,6 @@ export interface ModalContentProps
> {
width?: keyof typeof widthClasses;
height?: keyof typeof heightClasses;
/** Vertical placement of the modal. `"center"` (default) centers in the
* viewport/container. `"top"` pins the modal near the top of the viewport,
* matching the position used by CommandMenu. */
position?: "center" | "top";
preventAccidentalClose?: boolean;
skipOverlay?: boolean;
background?: "default" | "gray";
@@ -140,7 +136,6 @@ const ModalContent = React.forwardRef<
children,
width = "md",
height = "fit",
position = "center",
preventAccidentalClose = true,
skipOverlay = false,
background = "default",
@@ -272,39 +267,27 @@ const ModalContent = React.forwardRef<
const { centerX, centerY, hasContainerCenter } = useContainerCenter();
const isTop = position === "top";
const animationClasses = cn(
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
"data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
!isTop &&
"data-[state=open]:slide-in-from-top-1/2 data-[state=closed]:slide-out-to-top-1/2",
"data-[state=open]:slide-in-from-top-1/2 data-[state=closed]:slide-out-to-top-1/2",
"duration-200"
);
const containerStyle: React.CSSProperties | undefined =
hasContainerCenter && !isTop
? ({
left: centerX,
top: centerY,
"--tw-enter-translate-x": "-50%",
"--tw-exit-translate-x": "-50%",
"--tw-enter-translate-y": "-50%",
"--tw-exit-translate-y": "-50%",
} as React.CSSProperties)
: hasContainerCenter && isTop
? ({
left: centerX,
"--tw-enter-translate-x": "-50%",
"--tw-exit-translate-x": "-50%",
} as React.CSSProperties)
: undefined;
const containerStyle: React.CSSProperties | undefined = hasContainerCenter
? ({
left: centerX,
top: centerY,
"--tw-enter-translate-x": "-50%",
"--tw-exit-translate-x": "-50%",
"--tw-enter-translate-y": "-50%",
"--tw-exit-translate-y": "-50%",
} as React.CSSProperties)
: undefined;
const positionClasses = cn(
"fixed -translate-x-1/2",
isTop
? cn("top-[72px]", !hasContainerCenter && "left-1/2")
: cn("-translate-y-1/2", !hasContainerCenter && "left-1/2 top-1/2")
"fixed -translate-x-1/2 -translate-y-1/2",
!hasContainerCenter && "left-1/2 top-1/2"
);
const dialogEventHandlers = {

View File

@@ -0,0 +1,74 @@
import type { Meta, StoryObj } from "@storybook/react";
import FilterButton from "./FilterButton";
import { SvgFilter, SvgCalendar, SvgUser } from "@opal/icons";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
const meta: Meta<typeof FilterButton> = {
title: "refresh-components/buttons/FilterButton",
component: FilterButton,
tags: ["autodocs"],
decorators: [
(Story) => (
<TooltipPrimitive.Provider>
<Story />
</TooltipPrimitive.Provider>
),
],
};
export default meta;
type Story = StoryObj<typeof FilterButton>;
export const Default: Story = {
args: {
leftIcon: SvgFilter,
children: "Filter",
},
};
export const Active: Story = {
args: {
leftIcon: SvgFilter,
active: true,
children: "Source: Google Drive",
onClear: () => {},
},
};
export const Transient: Story = {
args: {
leftIcon: SvgCalendar,
transient: true,
children: "Date Range",
},
};
export const WithoutLabel: Story = {
args: {
leftIcon: SvgFilter,
},
};
export const AllStates: Story = {
render: () => (
<div
style={{
display: "flex",
gap: 12,
alignItems: "center",
flexWrap: "wrap",
}}
>
<FilterButton leftIcon={SvgFilter}>Inactive</FilterButton>
<FilterButton leftIcon={SvgFilter} transient>
Transient
</FilterButton>
<FilterButton leftIcon={SvgCalendar} active onClear={() => {}}>
Active Filter
</FilterButton>
<FilterButton leftIcon={SvgUser} active onClear={() => {}}>
Author: John Doe
</FilterButton>
</div>
),
};

View File

@@ -0,0 +1,113 @@
"use client";
import React, { useState } from "react";
import Text from "@/refresh-components/texts/Text";
import { cn, noProp } from "@/lib/utils";
import type { IconProps } from "@opal/types";
import IconButton from "./IconButton";
import { SvgChevronDownSmall, SvgX } from "@opal/icons";
const buttonClasses = (transient?: boolean) =>
({
active: [
"bg-background-tint-inverted-03",
"hover:bg-background-tint-inverted-04",
transient && "bg-background-tint-inverted-04",
"active:bg-background-tint-inverted-02",
],
inactive: [
"bg-background-tint-01",
"hover:bg-background-tint-02",
transient && "bg-background-tint-02",
"active:bg-background-tint-00",
],
}) as const;
const textClasses = (transient?: boolean) => ({
active: ["text-text-inverted-05"],
inactive: [
"text-text-03",
"group-hover/FilterButton:text-text-04",
transient && "text-text-04",
"group-active/FilterButton:text-text-05",
],
});
const iconClasses = (transient?: boolean) =>
({
active: ["stroke-text-inverted-05"],
inactive: [
"stroke-text-03",
"group-hover/FilterButton:stroke-text-04",
transient && "stroke-text-04",
"group-active/FilterButton:stroke-text-05",
],
}) as const;
export interface FilterButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
// Button states:
active?: boolean;
transient?: boolean;
leftIcon: React.FunctionComponent<IconProps>;
onClear?: () => void;
children?: string;
}
export default function FilterButton({
active,
transient,
leftIcon: LeftIcon,
onClick,
onClear,
children,
className,
...props
}: FilterButtonProps) {
const state = active ? "active" : "inactive";
return (
<button
className={cn(
"p-2 h-fit rounded-12 group/FilterButton flex flex-row items-center justify-center gap-1 w-fit",
buttonClasses(transient)[state],
className
)}
onClick={onClick}
{...props}
>
<div className="pr-0.5">
<LeftIcon
className={cn("w-[1rem] h-[1rem]", iconClasses(transient)[state])}
/>
</div>
<Text as="p" nowrap className={cn(textClasses(transient)[state])}>
{children}
</Text>
<div className="pl-0">
{active ? (
<IconButton
icon={SvgX}
onClick={noProp(onClear)}
secondary
className="!p-0 !rounded-04"
/>
) : (
<div className="w-[1rem] h-[1rem]">
<SvgChevronDownSmall
className={cn(
"w-[1rem] h-[1rem] transition-transform duration-200 ease-in-out",
iconClasses(transient)[state],
transient && "-rotate-180"
)}
/>
</div>
)}
</div>
</button>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import React from "react";
import type { ButtonType, IconFunctionComponent, IconProps } from "@opal/types";
import type { IconFunctionComponent, IconProps } from "@opal/types";
import type { Route } from "next";
import { Interactive } from "@opal/core";
import { ContentAction } from "@opal/layouts";
@@ -18,7 +18,6 @@ export interface SidebarTabProps {
// Button properties:
onClick?: React.MouseEventHandler<HTMLElement>;
href?: string;
type?: ButtonType;
icon?: React.FunctionComponent<IconProps>;
children?: React.ReactNode;
rightChildren?: React.ReactNode;
@@ -32,7 +31,6 @@ export default function SidebarTab({
onClick,
href,
type,
icon,
rightChildren,
children,
@@ -60,14 +58,12 @@ export default function SidebarTab({
variant="sidebar"
state={selected ? "selected" : "empty"}
onClick={onClick}
type="button"
group="group/SidebarTab"
>
<Interactive.Container
roundingVariant="compact"
heightVariant="lg"
widthVariant="full"
type={type}
>
{href && (
<Link

View File

@@ -44,8 +44,8 @@ import { SvgChevronUp, SvgChevronDown, SvgRevert } from "@opal/icons";
* ```
*/
export interface InputNumberProps {
value: number | null;
onChange: (value: number | null) => void;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
@@ -54,7 +54,6 @@ export interface InputNumberProps {
variant?: Variants;
disabled?: boolean;
className?: string;
placeholder?: string;
}
export default function InputNumber({
@@ -68,36 +67,31 @@ export default function InputNumber({
variant = "primary",
disabled = false,
className,
placeholder,
}: InputNumberProps) {
const inputRef = React.useRef<HTMLInputElement | null>(null);
const [inputValue, setInputValue] = React.useState(
value === null ? "" : String(value)
);
const [inputValue, setInputValue] = React.useState(String(value));
const isDisabled = disabled || variant === "disabled";
// Sync input value when external value changes (e.g., from stepper buttons or reset)
React.useEffect(() => {
setInputValue(value === null ? "" : String(value));
setInputValue(String(value));
}, [value]);
const effectiveValue = value ?? 0;
const canIncrement = max === undefined || effectiveValue < max;
const canDecrement =
value !== null && (min === undefined || effectiveValue > min);
const canIncrement = max === undefined || value < max;
const canDecrement = min === undefined || value > min;
const canReset =
showReset && defaultValue !== undefined && value !== defaultValue;
const handleIncrement = () => {
if (canIncrement) {
const newValue = effectiveValue + step;
const newValue = value + step;
onChange(max !== undefined ? Math.min(newValue, max) : newValue);
}
};
const handleDecrement = () => {
if (canDecrement) {
const newValue = effectiveValue - step;
const newValue = value - step;
onChange(min !== undefined ? Math.max(newValue, min) : newValue);
}
};
@@ -109,11 +103,14 @@ export default function InputNumber({
};
const handleBlur = () => {
// On blur, if empty, keep as null so placeholder shows
// On blur, if empty, set fallback value; otherwise sync display with actual value
if (inputValue.trim() === "") {
onChange(null);
let fallback = min ?? 0;
if (max !== undefined) fallback = Math.min(fallback, max);
setInputValue(String(fallback));
onChange(fallback);
} else {
setInputValue(value === null ? "" : String(value));
setInputValue(String(value));
}
};
@@ -155,7 +152,6 @@ export default function InputNumber({
pattern="[0-9]*"
disabled={isDisabled}
value={inputValue}
placeholder={placeholder}
onChange={handleInputChange}
onBlur={handleBlur}
className={cn(

View File

@@ -101,19 +101,7 @@ function MemoryItem({
setIsFocused(false);
void onBlur(originalIndex);
}}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!e.shiftKey &&
!e.nativeEvent.isComposing
) {
e.preventDefault();
textareaRef.current?.blur();
}
}}
rows={1}
autoResize
maxRows={3}
rows={3}
maxLength={MAX_MEMORY_LENGTH}
resizable={false}
className="bg-background-tint-01 hover:bg-background-tint-00 focus-within:bg-background-tint-00"
@@ -161,7 +149,6 @@ interface MemoriesModalProps {
initialTargetMemoryId?: number | null;
initialTargetIndex?: number | null;
highlightOnOpen?: boolean;
focusNewLine?: boolean;
}
export default function MemoriesModal({
@@ -171,7 +158,6 @@ export default function MemoriesModal({
initialTargetMemoryId,
initialTargetIndex,
highlightOnOpen = false,
focusNewLine = false,
}: MemoriesModalProps) {
const close = useModalClose(onClose);
const [focusMemoryId, setFocusMemoryId] = useState<number | null>(null);
@@ -245,19 +231,6 @@ export default function MemoriesModal({
onNotify: (message, type) => toast[type](message),
});
// Always start with an empty card; optionally focus it (View/Add button)
const hasAddedEmptyRef = useRef(false);
useEffect(() => {
if (hasAddedEmptyRef.current) return;
hasAddedEmptyRef.current = true;
const id = handleAddMemory();
if (id !== null && focusNewLine) {
setFocusMemoryId(id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onAddLine = () => {
const id = handleAddMemory();
if (id !== null) {
@@ -267,7 +240,7 @@ export default function MemoriesModal({
return (
<Modal open onOpenChange={(open) => !open && close?.()}>
<Modal.Content width="sm" height="lg" position="top">
<Modal.Content width="sm" height="lg">
<Modal.Header
icon={SvgAddLines}
title="Memory"

View File

@@ -11,7 +11,7 @@ import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import TextSeparator from "@/refresh-components/TextSeparator";
import Tabs from "@/refresh-components/Tabs";
import { FilterButton } from "@opal/components";
import FilterButton from "@/refresh-components/buttons/FilterButton";
import Popover, { PopoverMenu } from "@/refresh-components/Popover";
import LineItem from "@/refresh-components/buttons/LineItem";
import { Button } from "@opal/components";
@@ -465,8 +465,9 @@ export default function AgentsNavigationPage() {
>
<Popover.Trigger asChild>
<FilterButton
icon={SvgUser}
leftIcon={SvgUser}
active={selectedCreatorIds.size > 0}
transient={creatorFilterOpen}
onClear={() => setSelectedCreatorIds(new Set())}
>
{creatorFilterButtonText}
@@ -536,7 +537,8 @@ export default function AgentsNavigationPage() {
>
<Popover.Trigger asChild>
<FilterButton
icon={SvgActions}
leftIcon={SvgActions}
transient={actionsFilterOpen}
active={
selectedActionIds.size > 0 || selectedMcpServerIds.size > 0
}

View File

@@ -5,7 +5,6 @@ import { personaIncludesRetrieval } from "@/app/app/services/lib";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast, useToastFromQuery } from "@/hooks/useToast";
import { SEARCH_PARAM_NAMES } from "@/app/app/services/searchParams";
import { Section } from "@/layouts/general-layouts";
import { useFederatedConnectors, useFilters, useLlmManager } from "@/lib/hooks";
import { useForcedTools } from "@/lib/hooks/useForcedTools";
import OnyxInitializingLoader from "@/components/OnyxInitializingLoader";
@@ -63,9 +62,6 @@ import { useShowOnboarding } from "@/hooks/useShowOnboarding";
import * as AppLayouts from "@/layouts/app-layouts";
import { SvgChevronDown, SvgFileText } from "@opal/icons";
import { Button } from "@opal/components";
import { IllustrationContent } from "@opal/layouts";
import SvgNotFound from "@opal/illustrations/not-found";
import SvgNoAccess from "@opal/illustrations/no-access";
import Spacer from "@/refresh-components/Spacer";
import useAppFocus from "@/hooks/useAppFocus";
import { useQueryController } from "@/providers/QueryControllerProvider";
@@ -385,26 +381,23 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
setSelectedAgentFromId,
});
const {
onMessageSelection,
currentSessionFileTokenCount,
sessionFetchError,
} = useChatSessionController({
existingChatSessionId: currentChatSessionId,
searchParams,
filterManager,
firstMessage,
setSelectedAgentFromId,
setSelectedDocuments,
setCurrentMessageFiles,
chatSessionIdRef,
loadedIdSessionRef,
chatInputBarRef,
isInitialLoad,
submitOnLoadPerformed,
refreshChatSessions,
onSubmit,
});
const { onMessageSelection, currentSessionFileTokenCount } =
useChatSessionController({
existingChatSessionId: currentChatSessionId,
searchParams,
filterManager,
firstMessage,
setSelectedAgentFromId,
setSelectedDocuments,
setCurrentMessageFiles,
chatSessionIdRef,
loadedIdSessionRef,
chatInputBarRef,
isInitialLoad,
submitOnLoadPerformed,
refreshChatSessions,
onSubmit,
});
useSendMessageToParent();
@@ -686,10 +679,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
{/* ChatUI */}
<Fade
show={
appFocus.isChat() &&
!!currentChatSessionId &&
!!liveAgent &&
!sessionFetchError
appFocus.isChat() && !!currentChatSessionId && !!liveAgent
}
className="h-full w-full flex flex-col items-center"
>
@@ -718,45 +708,6 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
</ChatScrollContainer>
</Fade>
{/* Session fetch error (404 / 403) */}
<Fade
show={appFocus.isChat() && sessionFetchError !== null}
className="h-full w-full flex flex-col items-center justify-center"
>
{sessionFetchError && (
<Section
flexDirection="column"
alignItems="center"
gap={1}
>
<IllustrationContent
illustration={
sessionFetchError.type === "access_denied"
? SvgNoAccess
: SvgNotFound
}
title={
sessionFetchError.type === "not_found"
? "Chat not found"
: sessionFetchError.type === "access_denied"
? "Access denied"
: "Something went wrong"
}
description={
sessionFetchError.type === "not_found"
? "This chat session doesn't exist or has been deleted."
: sessionFetchError.type === "access_denied"
? "You don't have permission to view this chat session."
: sessionFetchError.detail
}
/>
<Button href="/app" prominence="secondary">
Start a new chat
</Button>
</Section>
)}
</Fade>
{/* ProjectUI */}
{appFocus.isProject() && (
<div className="w-full max-h-[50vh] overflow-y-auto overscroll-y-none">
@@ -785,12 +736,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
</div>
{/* ── Middle-center: AppInputBar ── */}
<div
className={cn(
"row-start-2 flex flex-col items-center px-4",
sessionFetchError && "hidden"
)}
>
<div className="row-start-2 flex flex-col items-center px-4">
<div className="relative w-full max-w-[var(--app-page-main-content-width)] flex flex-col">
{/* Scroll to bottom button - positioned absolutely above AppInputBar */}
{appFocus.isChat() && showScrollButton && (

View File

@@ -1,32 +0,0 @@
"use client";
import { SvgOnyxOctagon, SvgPlus } from "@opal/icons";
import { Button } from "@opal/components";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import Link from "next/link";
import AgentsTable from "./AgentsPage/AgentsTable";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function AgentsPage() {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
title="Agents"
description="Customize AI behavior and knowledge with agents. Manage agents in your organization."
icon={SvgOnyxOctagon}
rightChildren={
<Link href="/app/agents/create?admin=true">
<Button icon={SvgPlus}>New Agent</Button>
</Link>
}
/>
<SettingsLayouts.Body>
<AgentsTable />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
);
}

View File

@@ -1,378 +0,0 @@
"use client";
import { useCallback, useState } from "react";
import { Button } from "@opal/components";
// TODO(@raunakab): migrate to Opal LineItemButton once it supports danger variant
import LineItem from "@/refresh-components/buttons/LineItem";
import { Disabled } from "@opal/core";
import { cn } from "@opal/utils";
import {
SvgMoreHorizontal,
SvgEdit,
SvgEye,
SvgEyeOff,
SvgStar,
SvgStarOff,
SvgShare,
SvgBarChart,
SvgTrash,
} from "@opal/icons";
import Popover, { PopoverMenu } from "@/refresh-components/Popover";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
import Text from "@/refresh-components/texts/Text";
import { toast } from "@/hooks/useToast";
import { useRouter } from "next/navigation";
import {
deleteAgent,
toggleAgentFeatured,
toggleAgentListed,
} from "@/refresh-pages/admin/AgentsPage/svc";
import type { AgentRow } from "@/refresh-pages/admin/AgentsPage/interfaces";
import type { Route } from "next";
import ShareAgentModal from "@/sections/modals/ShareAgentModal";
import { useCreateModal } from "@/refresh-components/contexts/ModalContext";
import { useAgent } from "@/hooks/useAgents";
import {
updateAgentSharedStatus,
updateAgentFeaturedStatus,
} from "@/lib/agents";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { useUser } from "@/providers/UserProvider";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface AgentRowActionsProps {
agent: AgentRow;
onMutate: () => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function AgentRowActions({
agent,
onMutate,
}: AgentRowActionsProps) {
const router = useRouter();
const { isAdmin, isCurator } = useUser();
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
const canUpdateFeaturedStatus = isAdmin || isCurator;
const { agent: fullAgent, refresh: refreshAgent } = useAgent(agent.id);
const shareModal = useCreateModal();
const [popoverOpen, setPopoverOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [featuredOpen, setFeaturedOpen] = useState(false);
const [unlistOpen, setUnlistOpen] = useState(false);
async function handleAction(action: () => Promise<void>, close: () => void) {
setIsSubmitting(true);
try {
await action();
onMutate();
toast.success(`${agent.name} updated successfully.`);
close();
} catch (err) {
toast.error(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsSubmitting(false);
}
}
const handleShare = useCallback(
async (
userIds: string[],
groupIds: number[],
isPublic: boolean,
isFeatured: boolean,
labelIds: number[]
) => {
const shareError = await updateAgentSharedStatus(
agent.id,
userIds,
groupIds,
isPublic,
isPaidEnterpriseFeaturesEnabled,
labelIds
);
if (shareError) {
toast.error(`Failed to share agent: ${shareError}`);
return;
}
if (canUpdateFeaturedStatus) {
const featuredError = await updateAgentFeaturedStatus(
agent.id,
isFeatured
);
if (featuredError) {
toast.error(`Failed to update featured status: ${featuredError}`);
refreshAgent();
return;
}
}
refreshAgent();
onMutate();
shareModal.toggle(false);
},
[
agent.id,
isPaidEnterpriseFeaturesEnabled,
canUpdateFeaturedStatus,
refreshAgent,
onMutate,
]
);
return (
<>
<shareModal.Provider>
<ShareAgentModal
agentId={agent.id}
userIds={fullAgent?.users?.map((u) => u.id) ?? []}
groupIds={fullAgent?.groups ?? []}
isPublic={fullAgent?.is_public ?? false}
isFeatured={fullAgent?.is_featured ?? false}
labelIds={fullAgent?.labels?.map((l) => l.id) ?? []}
onShare={handleShare}
/>
</shareModal.Provider>
<div className="flex items-center gap-0.5">
{/* TODO(@raunakab): abstract a more standardized way of doing this
opacity-on-hover animation. Making Hoverable more extensible
(e.g. supporting table row groups) would let us use it here
instead of raw Tailwind group-hover. */}
{!agent.builtin_persona && (
<div className="opacity-0 group-hover/row:opacity-100 transition-opacity">
<Button
prominence="tertiary"
icon={SvgEdit}
tooltip="Edit Agent"
onClick={() =>
router.push(
`/app/agents/edit/${
agent.id
}?u=${Date.now()}&admin=true` as Route
)
}
/>
</div>
)}
{!agent.is_listed ? (
<Button
prominence="tertiary"
icon={SvgEyeOff}
tooltip="Re-list Agent"
onClick={() =>
handleAction(
() => toggleAgentListed(agent.id, agent.is_listed),
() => {}
)
}
/>
) : (
<div
className={cn(
!agent.is_featured &&
"opacity-0 group-hover/row:opacity-100 transition-opacity"
)}
>
<Button
prominence="tertiary"
icon={SvgStar}
interaction={featuredOpen ? "hover" : "rest"}
tooltip={
agent.is_featured ? "Remove Featured" : "Set as Featured"
}
onClick={() => {
setPopoverOpen(false);
setFeaturedOpen(true);
}}
/>
</div>
)}
{/* Overflow menu */}
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<div
className={cn(
!popoverOpen &&
"opacity-0 group-hover/row:opacity-100 transition-opacity"
)}
>
<Popover.Trigger asChild>
<Button prominence="tertiary" icon={SvgMoreHorizontal} />
</Popover.Trigger>
</div>
<Popover.Content align="end" width="sm">
<PopoverMenu>
{[
<LineItem
key="visibility"
icon={agent.is_listed ? SvgEyeOff : SvgEye}
onClick={() => {
setPopoverOpen(false);
if (agent.is_listed) {
setUnlistOpen(true);
} else {
handleAction(
() => toggleAgentListed(agent.id, agent.is_listed),
() => {}
);
}
}}
>
{agent.is_listed ? "Unlist Agent" : "List Agent"}
</LineItem>,
<LineItem
key="share"
icon={SvgShare}
onClick={() => {
setPopoverOpen(false);
shareModal.toggle(true);
}}
>
Share
</LineItem>,
isPaidEnterpriseFeaturesEnabled ? (
<LineItem
key="stats"
icon={SvgBarChart}
onClick={() => {
setPopoverOpen(false);
router.push(`/ee/agents/stats/${agent.id}` as Route);
}}
>
Stats
</LineItem>
) : undefined,
!agent.builtin_persona ? null : undefined,
!agent.builtin_persona ? (
<LineItem
key="delete"
icon={SvgTrash}
danger
onClick={() => {
setPopoverOpen(false);
setDeleteOpen(true);
}}
>
Delete
</LineItem>
) : undefined,
]}
</PopoverMenu>
</Popover.Content>
</Popover>
</div>
{deleteOpen && (
<ConfirmationModalLayout
icon={SvgTrash}
title="Delete Agent"
onClose={isSubmitting ? undefined : () => setDeleteOpen(false)}
submit={
<Disabled disabled={isSubmitting}>
<Button
variant="danger"
onClick={() => {
handleAction(
() => deleteAgent(agent.id),
() => setDeleteOpen(false)
);
}}
>
Delete
</Button>
</Disabled>
}
>
<Text as="p" text03>
Are you sure you want to delete{" "}
<Text as="span" text05>
{agent.name}
</Text>
? This action cannot be undone.
</Text>
</ConfirmationModalLayout>
)}
{featuredOpen && (
<ConfirmationModalLayout
icon={agent.is_featured ? SvgStarOff : SvgStar}
title={
agent.is_featured
? `Remove ${agent.name} from Featured`
: `Feature ${agent.name}`
}
onClose={isSubmitting ? undefined : () => setFeaturedOpen(false)}
submit={
<Disabled disabled={isSubmitting}>
<Button
onClick={() => {
handleAction(
() => toggleAgentFeatured(agent.id, agent.is_featured),
() => setFeaturedOpen(false)
);
}}
>
{agent.is_featured ? "Unfeature" : "Feature"}
</Button>
</Disabled>
}
>
<div className="flex flex-col gap-2">
<Text as="p" text03>
{agent.is_featured
? `This will remove ${agent.name} from the featured section on top of the explore agents list. New users will no longer see it pinned to their sidebar, but existing pins are unaffected.`
: "Featured agents appear at the top of the explore agents list and are automatically pinned to the sidebar for new users with access. Use this to highlight recommended agents across your organization."}
</Text>
<Text as="p" text03>
This does not change who can access this agent.
</Text>
</div>
</ConfirmationModalLayout>
)}
{unlistOpen && (
<ConfirmationModalLayout
icon={SvgEyeOff}
title={`Unlist ${agent.name}`}
onClose={isSubmitting ? undefined : () => setUnlistOpen(false)}
submit={
<Disabled disabled={isSubmitting}>
<Button
onClick={() => {
handleAction(
() => toggleAgentListed(agent.id, agent.is_listed),
() => setUnlistOpen(false)
);
}}
>
Unlist
</Button>
</Disabled>
}
>
<div className="flex flex-col gap-2">
<Text as="p" text03>
Unlisted agents don&apos;t appear in the explore agents list but
remain accessible via direct link, and to users who have
previously used or pinned them.
</Text>
<Text as="p" text03>
This does not change who can access this agent.
</Text>
</div>
</ConfirmationModalLayout>
)}
</>
);
}

View File

@@ -1,210 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { Table, createTableColumns } from "@opal/components";
import { Content, IllustrationContent } from "@opal/layouts";
import SvgNoResult from "@opal/illustrations/no-result";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import type { MinimalUserSnapshot } from "@/lib/types";
import AgentAvatar from "@/refresh-components/avatars/AgentAvatar";
import type { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces";
import { useAdminPersonas } from "@/hooks/useAdminPersonas";
import { toast } from "@/hooks/useToast";
import AgentRowActions from "@/refresh-pages/admin/AgentsPage/AgentRowActions";
import { updateAgentDisplayPriorities } from "@/refresh-pages/admin/AgentsPage/svc";
import type { AgentRow } from "@/refresh-pages/admin/AgentsPage/interfaces";
import type { Persona } from "@/app/admin/agents/interfaces";
import { SvgUser } from "@opal/icons";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function toAgentRow(persona: Persona): AgentRow {
return {
id: persona.id,
name: persona.name,
description: persona.description,
is_public: persona.is_public,
is_listed: persona.is_listed,
is_featured: persona.is_featured,
builtin_persona: persona.builtin_persona,
display_priority: persona.display_priority,
owner: persona.owner,
groups: persona.groups,
users: persona.users,
uploaded_image_id: persona.uploaded_image_id,
icon_name: persona.icon_name,
};
}
// ---------------------------------------------------------------------------
// Column renderers
// ---------------------------------------------------------------------------
function renderCreatedByColumn(
_value: MinimalUserSnapshot | null,
row: AgentRow
) {
return (
<Content
sizePreset="main-ui"
variant="section"
icon={SvgUser}
title={row.builtin_persona ? "System" : row.owner?.email ?? "\u2014"}
/>
);
}
function getAccessTitle(row: AgentRow): string {
if (row.is_public) return "Public";
if (row.groups.length > 0 || row.users.length > 0) return "Shared";
return "Private";
}
function renderAccessColumn(_isPublic: boolean, row: AgentRow) {
return (
<Content
sizePreset="main-ui"
variant="section"
title={getAccessTitle(row)}
description={
!row.is_listed ? "Unlisted" : row.is_featured ? "Featured" : undefined
}
/>
);
}
// ---------------------------------------------------------------------------
// Columns
// ---------------------------------------------------------------------------
const tc = createTableColumns<AgentRow>();
function buildColumns(onMutate: () => void) {
return [
tc.qualifier({
content: "icon",
background: true,
getContent: (row) => (props) => (
<AgentAvatar
agent={row as unknown as MinimalPersonaSnapshot}
size={props.size}
/>
),
}),
tc.column("name", {
header: "Name",
weight: 25,
cell: (value) => (
<Text as="span" mainUiBody text05>
{value}
</Text>
),
}),
tc.column("description", {
header: "Description",
weight: 35,
cell: (value) => (
<Text as="span" mainUiBody text03>
{value || "\u2014"}
</Text>
),
}),
tc.column("owner", {
header: "Created By",
weight: 20,
cell: renderCreatedByColumn,
}),
tc.column("is_public", {
header: "Access",
weight: 12,
cell: renderAccessColumn,
}),
tc.actions({
cell: (row) => <AgentRowActions agent={row} onMutate={onMutate} />,
}),
];
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const PAGE_SIZE = 10;
export default function AgentsTable() {
const [searchTerm, setSearchTerm] = useState("");
const { personas, isLoading, error, refresh } = useAdminPersonas();
const columns = useMemo(() => buildColumns(refresh), [refresh]);
const agentRows: AgentRow[] = useMemo(
() => personas.filter((p) => !p.builtin_persona).map(toAgentRow),
[personas]
);
const handleReorder = async (
_orderedIds: string[],
changedOrders: Record<string, number>
) => {
try {
await updateAgentDisplayPriorities(changedOrders);
refresh();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to update agent order"
);
refresh();
}
};
if (isLoading) {
return (
<div className="flex justify-center py-12">
<SimpleLoader className="h-6 w-6" />
</div>
);
}
if (error) {
console.error("Failed to load agents:", error);
return (
<Text as="p" secondaryBody text03>
Failed to load agents. Please try refreshing the page.
</Text>
);
}
return (
<div className="flex flex-col gap-3">
<InputTypeIn
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search agents..."
leftSearchIcon
/>
<Table
data={agentRows}
columns={columns}
getRowId={(row) => String(row.id)}
pageSize={PAGE_SIZE}
searchTerm={searchTerm}
draggable={{
onReorder: handleReorder,
}}
emptyState={
<IllustrationContent
illustration={SvgNoResult}
title="No agents found"
description="No agents match the current search."
/>
}
footer={{}}
/>
</div>
);
}

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