Compare commits

..

1 Commits

Author SHA1 Message Date
Nik
6041773f60 refactor: replace HTTPException with OnyxError in model_server/encoders 2026-03-09 13:57:00 -07:00
45 changed files with 239 additions and 2283 deletions

View File

@@ -316,7 +316,6 @@ jobs:
# Base config shared by both editions
cat <<EOF > deployment/docker_compose/.env
COMPOSE_PROFILES=s3-filestore
OPENSEARCH_FOR_ONYX_ENABLED=false
AUTH_TYPE=basic
POSTGRES_POOL_PRE_PING=true
POSTGRES_USE_NULL_POOL=true
@@ -419,7 +418,6 @@ jobs:
-e POSTGRES_POOL_PRE_PING=true \
-e POSTGRES_USE_NULL_POOL=true \
-e VESPA_HOST=index \
-e ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=false \
-e REDIS_HOST=cache \
-e API_SERVER_HOST=api_server \
-e OPENAI_API_KEY=${OPENAI_API_KEY} \
@@ -639,7 +637,6 @@ jobs:
ONYX_BACKEND_IMAGE=${ECR_CACHE}:integration-test-backend-test-${RUN_ID} \
ONYX_MODEL_SERVER_IMAGE=${ECR_CACHE}:integration-test-model-server-test-${RUN_ID} \
DEV_MODE=true \
OPENSEARCH_FOR_ONYX_ENABLED=false \
docker compose -f docker-compose.multitenant-dev.yml up \
relational_db \
index \
@@ -694,7 +691,6 @@ jobs:
-e POSTGRES_DB=postgres \
-e POSTGRES_USE_NULL_POOL=true \
-e VESPA_HOST=index \
-e ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=false \
-e REDIS_HOST=cache \
-e API_SERVER_HOST=api_server \
-e OPENAI_API_KEY=${OPENAI_API_KEY} \

View File

@@ -46,9 +46,7 @@ RUN apt-get update && \
pkg-config \
gcc \
nano \
vim \
libjemalloc2 \
&& \
vim && \
rm -rf /var/lib/apt/lists/* && \
apt-get clean
@@ -167,13 +165,6 @@ ENV PYTHONPATH=/app
ARG ONYX_VERSION=0.0.0-dev
ENV ONYX_VERSION=${ONYX_VERSION}
# Use jemalloc instead of glibc malloc to reduce memory fragmentation
# in long-running Python processes (API server, Celery workers).
# The soname is architecture-independent; the dynamic linker resolves
# the correct path from standard library directories.
# Placed after all RUN steps so build-time processes are unaffected.
ENV LD_PRELOAD=libjemalloc.so.2
# Default command which does nothing
# This container is used by api server and background which specify their own CMD
CMD ["tail", "-f", "/dev/null"]

View File

@@ -1,4 +1,3 @@
import os
from datetime import datetime
import jwt
@@ -21,13 +20,7 @@ logger = setup_logger()
def verify_auth_setting() -> None:
# All the Auth flows are valid for EE version, but warn about deprecated 'disabled'
raw_auth_type = (os.environ.get("AUTH_TYPE") or "").lower()
if raw_auth_type == "disabled":
logger.warning(
"AUTH_TYPE='disabled' is no longer supported. "
"Using 'basic' instead. Please update your configuration."
)
# All the Auth flows are valid for EE version
logger.notice(f"Using Auth Type: {AUTH_TYPE.value}")

View File

@@ -18,7 +18,7 @@ from onyx.db.models import HierarchyNode
def _build_hierarchy_access_filter(
user_email: str,
user_email: str | None,
external_group_ids: list[str],
) -> ColumnElement[bool]:
"""Build SQLAlchemy filter for hierarchy node access.
@@ -43,7 +43,7 @@ def _build_hierarchy_access_filter(
def _get_accessible_hierarchy_nodes_for_source(
db_session: Session,
source: DocumentSource,
user_email: str,
user_email: str | None,
external_group_ids: list[str],
) -> list[HierarchyNode]:
"""

View File

@@ -4,10 +4,11 @@ from typing import Any
from typing import TYPE_CHECKING
from fastapi import APIRouter
from fastapi import HTTPException
from fastapi import Request
from model_server.utils import simple_log_function_time
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.utils.logger import setup_logger
from shared_configs.enums import EmbedTextType
from shared_configs.model_server_models import Embedding
@@ -188,7 +189,7 @@ async def process_embed_request(
)
if not embed_request.texts:
raise HTTPException(status_code=400, detail="No texts to be embedded")
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, "No texts to be embedded")
if not all(embed_request.texts):
raise ValueError("Empty strings are not allowed for embedding.")
@@ -211,14 +212,12 @@ async def process_embed_request(
)
return EmbedResponse(embeddings=embeddings)
except RateLimitError as e:
raise HTTPException(
status_code=429,
detail=str(e),
)
raise OnyxError(OnyxErrorCode.RATE_LIMITED, str(e))
except Exception as e:
logger.exception(
f"Error during embedding process: provider={embed_request.provider_type} model={embed_request.model_name}"
)
raise HTTPException(
status_code=500, detail=f"Error during embedding process: {e}"
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
f"Error during embedding process: {e}",
)

View File

@@ -1,5 +1,4 @@
import json
import os
import random
import secrets
import string
@@ -146,22 +145,10 @@ def is_user_admin(user: User) -> bool:
def verify_auth_setting() -> None:
"""Log warnings for AUTH_TYPE issues.
This only runs on app startup not during migrations/scripts.
"""
raw_auth_type = (os.environ.get("AUTH_TYPE") or "").lower()
if raw_auth_type == "cloud":
if AUTH_TYPE == AuthType.CLOUD:
raise ValueError(
"'cloud' is not a valid auth type for self-hosted deployments."
f"{AUTH_TYPE.value} is not a valid auth type for self-hosted deployments."
)
if raw_auth_type == "disabled":
logger.warning(
"AUTH_TYPE='disabled' is no longer supported. "
"Using 'basic' instead. Please update your configuration."
)
logger.notice(f"Using Auth Type: {AUTH_TYPE.value}")

View File

@@ -96,12 +96,19 @@ WEB_DOMAIN = os.environ.get("WEB_DOMAIN") or "http://localhost:3000"
#####
# Auth Configs
#####
# Silently default to basic - warnings/errors logged in verify_auth_setting()
# which only runs on app startup, not during migrations/scripts
_auth_type_str = (os.environ.get("AUTH_TYPE") or "").lower()
if _auth_type_str in [auth_type.value for auth_type in AuthType]:
# Upgrades users from disabled auth to basic auth and shows warning.
_auth_type_str = (os.environ.get("AUTH_TYPE") or "basic").lower()
if _auth_type_str == "disabled":
logger.warning(
"AUTH_TYPE='disabled' is no longer supported. "
"Defaulting to 'basic'. Please update your configuration. "
"Your existing data will be migrated automatically."
)
_auth_type_str = AuthType.BASIC.value
try:
AUTH_TYPE = AuthType(_auth_type_str)
else:
except ValueError:
logger.error(f"Invalid AUTH_TYPE: {_auth_type_str}. Defaulting to 'basic'.")
AUTH_TYPE = AuthType.BASIC
PASSWORD_MIN_LENGTH = int(os.getenv("PASSWORD_MIN_LENGTH", 8))
@@ -285,9 +292,8 @@ OPENSEARCH_TEXT_ANALYZER = os.environ.get("OPENSEARCH_TEXT_ANALYZER") or "englis
# environments we always want to be dual indexing into both OpenSearch and Vespa
# to stress test the new codepaths. Only enable this if there is some instance
# of OpenSearch running for the relevant Onyx instance.
# NOTE: Now enabled on by default, unless the env indicates otherwise.
ENABLE_OPENSEARCH_INDEXING_FOR_ONYX = (
os.environ.get("ENABLE_OPENSEARCH_INDEXING_FOR_ONYX", "true").lower() == "true"
os.environ.get("ENABLE_OPENSEARCH_INDEXING_FOR_ONYX", "").lower() == "true"
)
# NOTE: This effectively does nothing anymore, admins can now toggle whether
# retrieval is through OpenSearch. This value is only used as a final fallback

View File

@@ -1,5 +1,4 @@
import asyncio
from collections.abc import AsyncGenerator
from collections.abc import AsyncIterable
from collections.abc import Iterable
from datetime import datetime
@@ -205,7 +204,7 @@ def _manage_async_retrieval(
end_time: datetime | None = end
async def _async_fetch() -> AsyncGenerator[Document, None]:
async def _async_fetch() -> AsyncIterable[Document]:
intents = Intents.default()
intents.message_content = True
async with Client(intents=intents) as discord_client:
@@ -228,23 +227,22 @@ def _manage_async_retrieval(
def run_and_yield() -> Iterable[Document]:
loop = asyncio.new_event_loop()
async_gen = _async_fetch()
try:
# Get the async generator
async_gen = _async_fetch()
# Convert to AsyncIterator
async_iter = async_gen.__aiter__()
while True:
try:
doc = loop.run_until_complete(anext(async_gen))
# Create a coroutine by calling anext with the async iterator
next_coro = anext(async_iter)
# Run the coroutine to get the next document
doc = loop.run_until_complete(next_coro)
yield doc
except StopAsyncIteration:
break
finally:
# Must close the async generator before the loop so the Discord
# client's `async with` block can await its shutdown coroutine.
# The nested try/finally ensures the loop always closes even if
# aclose() raises (same pattern as cursor.close() before conn.close()).
try:
loop.run_until_complete(async_gen.aclose())
finally:
loop.close()
loop.close()
return run_and_yield()

View File

@@ -44,7 +44,6 @@ from onyx.connectors.google_utils.shared_constants import (
from onyx.db.credentials import update_credential_json
from onyx.db.models import User
from onyx.key_value_store.factory import get_kv_store
from onyx.key_value_store.interface import unwrap_str
from onyx.server.documents.models import CredentialBase
from onyx.server.documents.models import GoogleAppCredentials
from onyx.server.documents.models import GoogleServiceAccountKey
@@ -90,7 +89,7 @@ def _get_current_oauth_user(creds: OAuthCredentials, source: DocumentSource) ->
def verify_csrf(credential_id: int, state: str) -> None:
csrf = unwrap_str(get_kv_store().load(KV_CRED_KEY.format(str(credential_id))))
csrf = get_kv_store().load(KV_CRED_KEY.format(str(credential_id)))
if csrf != state:
raise PermissionError(
"State from Google Drive Connector callback does not match expected"
@@ -179,9 +178,7 @@ def get_auth_url(credential_id: int, source: DocumentSource) -> str:
params = parse_qs(parsed_url.query)
get_kv_store().store(
KV_CRED_KEY.format(credential_id),
{"value": params.get("state", [None])[0]},
encrypt=True,
KV_CRED_KEY.format(credential_id), params.get("state", [None])[0], encrypt=True
)
return str(auth_url)

View File

@@ -458,7 +458,7 @@ def get_all_hierarchy_nodes_for_source(
def _get_accessible_hierarchy_nodes_for_source(
db_session: Session,
source: DocumentSource,
user_email: str, # noqa: ARG001
user_email: str | None, # noqa: ARG001
external_group_ids: list[str], # noqa: ARG001
) -> list[HierarchyNode]:
"""
@@ -485,7 +485,7 @@ def _get_accessible_hierarchy_nodes_for_source(
def get_accessible_hierarchy_nodes_for_source(
db_session: Session,
source: DocumentSource,
user_email: str,
user_email: str | None,
external_group_ids: list[str],
) -> list[HierarchyNode]:
"""

View File

@@ -1,5 +1,4 @@
import abc
from typing import cast
from onyx.utils.special_types import JSON_ro
@@ -8,19 +7,6 @@ class KvKeyNotFoundError(Exception):
pass
def unwrap_str(val: JSON_ro) -> str:
"""Unwrap a string stored as {"value": str} in the encrypted KV store.
Also handles legacy plain-string values cached in Redis."""
if isinstance(val, dict):
try:
return cast(str, val["value"])
except KeyError:
raise ValueError(
f"Expected dict with 'value' key, got keys: {list(val.keys())}"
)
return cast(str, val)
class KeyValueStore:
# In the Multi Tenant case, the tenant context is picked up automatically, it does not need to be passed in
# It's read from the global thread level variable

View File

@@ -1905,7 +1905,7 @@ def get_connector_by_id(
@router.post("/connector-request")
def submit_connector_request(
request_data: ConnectorRequestSubmission,
user: User = Depends(current_user),
user: User | None = Depends(current_user),
) -> StatusResponse:
"""
Submit a connector request for Cloud deployments.
@@ -1918,7 +1918,7 @@ def submit_connector_request(
raise HTTPException(status_code=400, detail="Connector name cannot be empty")
# Get user identifier for telemetry
user_email = user.email
user_email = user.email if user else None
distinct_id = user_email or tenant_id
# Track connector request via PostHog telemetry (Cloud only)

View File

@@ -57,6 +57,9 @@ def list_messages(
db_session: Session = Depends(get_session),
) -> MessageListResponse:
"""Get all messages for a build session."""
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
session_manager = SessionManager(db_session)
messages = session_manager.list_messages(session_id, user.id)

View File

@@ -54,14 +54,18 @@ def _require_opensearch(db_session: Session) -> None:
)
def _get_user_access_info(user: User, db_session: Session) -> tuple[str, list[str]]:
def _get_user_access_info(
user: User | None, db_session: Session
) -> tuple[str | None, list[str]]:
if not user:
return None, []
return user.email, get_user_external_group_ids(db_session, user)
@router.get(HIERARCHY_NODES_LIST_PATH)
def list_accessible_hierarchy_nodes(
source: DocumentSource,
user: User = Depends(current_user),
user: User | None = Depends(current_user),
db_session: Session = Depends(get_session),
) -> HierarchyNodesResponse:
_require_opensearch(db_session)
@@ -88,7 +92,7 @@ def list_accessible_hierarchy_nodes(
@router.post(HIERARCHY_NODE_DOCUMENTS_PATH)
def list_accessible_hierarchy_node_documents(
documents_request: HierarchyNodeDocumentsRequest,
user: User = Depends(current_user),
user: User | None = Depends(current_user),
db_session: Session = Depends(get_session),
) -> HierarchyNodeDocumentsResponse:
_require_opensearch(db_session)

View File

@@ -1013,7 +1013,7 @@ def get_mcp_servers_for_assistant(
@router.get("/servers", response_model=MCPServersResponse)
def get_mcp_servers_for_user(
db: Session = Depends(get_session),
user: User = Depends(current_user),
user: User | None = Depends(current_user),
) -> MCPServersResponse:
"""List all MCP servers for use in agent configuration and chat UI.

View File

@@ -2,6 +2,7 @@ import contextvars
import threading
import uuid
from enum import Enum
from typing import cast
import requests
@@ -14,7 +15,6 @@ from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.models import User
from onyx.key_value_store.factory import get_kv_store
from onyx.key_value_store.interface import KvKeyNotFoundError
from onyx.key_value_store.interface import unwrap_str
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import (
fetch_versioned_implementation_with_fallback,
@@ -25,7 +25,6 @@ from shared_configs.contextvars import get_current_tenant_id
logger = setup_logger()
_DANSWER_TELEMETRY_ENDPOINT = "https://telemetry.onyx.app/anonymous_telemetry"
_CACHED_UUID: str | None = None
_CACHED_INSTANCE_DOMAIN: str | None = None
@@ -63,10 +62,10 @@ def get_or_generate_uuid() -> str:
kv_store = get_kv_store()
try:
_CACHED_UUID = unwrap_str(kv_store.load(KV_CUSTOMER_UUID_KEY))
_CACHED_UUID = cast(str, kv_store.load(KV_CUSTOMER_UUID_KEY))
except KvKeyNotFoundError:
_CACHED_UUID = str(uuid.uuid4())
kv_store.store(KV_CUSTOMER_UUID_KEY, {"value": _CACHED_UUID}, encrypt=True)
kv_store.store(KV_CUSTOMER_UUID_KEY, _CACHED_UUID, encrypt=True)
return _CACHED_UUID
@@ -80,16 +79,14 @@ def _get_or_generate_instance_domain() -> str | None: #
kv_store = get_kv_store()
try:
_CACHED_INSTANCE_DOMAIN = unwrap_str(kv_store.load(KV_INSTANCE_DOMAIN_KEY))
_CACHED_INSTANCE_DOMAIN = cast(str, kv_store.load(KV_INSTANCE_DOMAIN_KEY))
except KvKeyNotFoundError:
with get_session_with_current_tenant() as db_session:
first_user = db_session.query(User).first()
if first_user:
_CACHED_INSTANCE_DOMAIN = first_user.email.split("@")[-1]
kv_store.store(
KV_INSTANCE_DOMAIN_KEY,
{"value": _CACHED_INSTANCE_DOMAIN},
encrypt=True,
KV_INSTANCE_DOMAIN_KEY, _CACHED_INSTANCE_DOMAIN, encrypt=True
)
return _CACHED_INSTANCE_DOMAIN

View File

@@ -85,7 +85,7 @@ def test_group_overlap_filter(
results = _get_accessible_hierarchy_nodes_for_source(
db_session,
source=DocumentSource.GOOGLE_DRIVE,
user_email="",
user_email=None,
external_group_ids=["group_engineering"],
)
result_ids = {n.raw_node_id for n in results}
@@ -124,7 +124,7 @@ def test_no_credentials_returns_only_public(
results = _get_accessible_hierarchy_nodes_for_source(
db_session,
source=DocumentSource.GOOGLE_DRIVE,
user_email="",
user_email=None,
external_group_ids=[],
)
result_ids = {n.raw_node_id for n in results}

View File

@@ -1,54 +0,0 @@
from unittest.mock import MagicMock
import pytest
import onyx.auth.users as users
from onyx.auth.users import verify_auth_setting
from onyx.configs.constants import AuthType
def test_verify_auth_setting_raises_for_cloud(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Cloud auth type is not valid for self-hosted deployments."""
monkeypatch.setenv("AUTH_TYPE", "cloud")
with pytest.raises(ValueError, match="'cloud' is not a valid auth type"):
verify_auth_setting()
def test_verify_auth_setting_warns_for_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Disabled auth type logs a deprecation warning."""
monkeypatch.setenv("AUTH_TYPE", "disabled")
mock_logger = MagicMock()
monkeypatch.setattr(users, "logger", mock_logger)
monkeypatch.setattr(users, "AUTH_TYPE", AuthType.BASIC)
verify_auth_setting()
mock_logger.warning.assert_called_once()
assert "no longer supported" in mock_logger.warning.call_args[0][0]
@pytest.mark.parametrize(
"auth_type",
[AuthType.BASIC, AuthType.GOOGLE_OAUTH, AuthType.OIDC, AuthType.SAML],
)
def test_verify_auth_setting_valid_auth_types(
monkeypatch: pytest.MonkeyPatch,
auth_type: AuthType,
) -> None:
"""Valid auth types work without errors or warnings."""
monkeypatch.setenv("AUTH_TYPE", auth_type.value)
mock_logger = MagicMock()
monkeypatch.setattr(users, "logger", mock_logger)
monkeypatch.setattr(users, "AUTH_TYPE", auth_type)
verify_auth_setting()
mock_logger.warning.assert_not_called()
mock_logger.notice.assert_called_once_with(f"Using Auth Type: {auth_type.value}")

View File

@@ -1,43 +0,0 @@
"""Unit tests for _get_user_access_info helper function.
These tests mock all database operations and don't require a real database.
"""
from unittest.mock import MagicMock
from unittest.mock import patch
from sqlalchemy.orm import Session
from onyx.server.features.hierarchy.api import _get_user_access_info
def test_get_user_access_info_returns_email_and_groups() -> None:
"""_get_user_access_info returns the user's email and external group IDs."""
mock_user = MagicMock()
mock_user.email = "test@example.com"
mock_db_session = MagicMock(spec=Session)
with patch(
"onyx.server.features.hierarchy.api.get_user_external_group_ids",
return_value=["group1", "group2"],
):
email, groups = _get_user_access_info(mock_user, mock_db_session)
assert email == "test@example.com"
assert groups == ["group1", "group2"]
def test_get_user_access_info_with_no_groups() -> None:
"""User with no external groups returns empty list."""
mock_user = MagicMock()
mock_user.email = "solo@example.com"
mock_db_session = MagicMock(spec=Session)
with patch(
"onyx.server.features.hierarchy.api.get_user_external_group_ids",
return_value=[],
):
email, groups = _get_user_access_info(mock_user, mock_db_session)
assert email == "solo@example.com"
assert groups == []

View File

@@ -61,9 +61,6 @@ services:
- POSTGRES_HOST=relational_db
- POSTGRES_DEFAULT_SCHEMA=${POSTGRES_DEFAULT_SCHEMA:-}
- VESPA_HOST=index
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
- REDIS_HOST=cache
- WEB_DOMAIN=${WEB_DOMAIN:-}
# MinIO configuration
@@ -80,7 +77,6 @@ services:
- DISABLE_RERANK_FOR_STREAMING=${DISABLE_RERANK_FOR_STREAMING:-}
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
- MODEL_SERVER_PORT=${MODEL_SERVER_PORT:-}
- CODE_INTERPRETER_BASE_URL=${CODE_INTERPRETER_BASE_URL:-http://code-interpreter:8000}
- LOG_ONYX_MODEL_INTERACTIONS=${LOG_ONYX_MODEL_INTERACTIONS:-}
- LOG_VESPA_TIMING_INFORMATION=${LOG_VESPA_TIMING_INFORMATION:-}
- LOG_ENDPOINT_LATENCY=${LOG_ENDPOINT_LATENCY:-}
@@ -172,9 +168,6 @@ services:
- POSTGRES_DB=${POSTGRES_DB:-}
- POSTGRES_DEFAULT_SCHEMA=${POSTGRES_DEFAULT_SCHEMA:-}
- VESPA_HOST=index
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
- REDIS_HOST=cache
- WEB_DOMAIN=${WEB_DOMAIN:-}
# MinIO configuration
@@ -431,50 +424,6 @@ services:
max-size: "50m"
max-file: "6"
opensearch:
image: opensearchproject/opensearch:3.4.0
restart: unless-stopped
# Controls whether this service runs. In order to enable it, add
# opensearch-enabled to COMPOSE_PROFILES in the environment for this
# docker-compose.
# NOTE: Now enabled on by default. To explicitly disable this service,
# uncomment this profile and ensure COMPOSE_PROFILES in your env does not
# list the profile, or when running docker compose, include all desired
# service names but this one. Additionally set
# OPENSEARCH_FOR_ONYX_ENABLED=false in your env.
# profiles: ["opensearch-enabled"]
environment:
# We need discovery.type=single-node so that OpenSearch doesn't try
# forming a cluster and waiting for other nodes to become live.
- discovery.type=single-node
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
# This and the JVM config below come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
# We do this to avoid unstable performance from page swaps.
- bootstrap.memory_lock=true # Disable JVM heap memory swapping.
# Java heap should be ~50% of memory limit. For now we assume a limit of
# 4g although in practice the container can request more than this.
# See https://opster.com/guides/opensearch/opensearch-basics/opensearch-heap-size-usage-and-jvm-garbage-collection/
# Xms is the starting size, Xmx is the maximum size. These should be the
# same.
- "OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g"
volumes:
- opensearch-data:/usr/share/opensearch/data
# These come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
ulimits:
# Similarly to bootstrap.memory_lock, we don't want to impose limits on
# how much memory a process can lock from being swapped.
memlock:
soft: -1 # Set memlock to unlimited (no soft or hard limit).
hard: -1
nofile:
soft: 65536 # Maximum number of open files for the opensearch user - set to at least 65536.
hard: 65536
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
nginx:
image: nginx:1.25.5-alpine
restart: unless-stopped
@@ -559,5 +508,3 @@ volumes:
model_cache_huggingface:
indexing_huggingface_model_cache:
# mcp_server_logs:
# Persistent data for OpenSearch.
opensearch-data:

View File

@@ -21,9 +21,6 @@ services:
- AUTH_TYPE=${AUTH_TYPE:-oidc}
- POSTGRES_HOST=relational_db
- VESPA_HOST=index
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
- REDIS_HOST=cache
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
# MinIO configuration
@@ -58,9 +55,6 @@ services:
- AUTH_TYPE=${AUTH_TYPE:-oidc}
- POSTGRES_HOST=relational_db
- VESPA_HOST=index
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
- REDIS_HOST=cache
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
- INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server}
@@ -234,50 +228,6 @@ services:
max-size: "50m"
max-file: "6"
opensearch:
image: opensearchproject/opensearch:3.4.0
restart: unless-stopped
# Controls whether this service runs. In order to enable it, add
# opensearch-enabled to COMPOSE_PROFILES in the environment for this
# docker-compose.
# NOTE: Now enabled on by default. To explicitly disable this service,
# uncomment this profile and ensure COMPOSE_PROFILES in your env does not
# list the profile, or when running docker compose, include all desired
# service names but this one. Additionally set
# OPENSEARCH_FOR_ONYX_ENABLED=false in your env.
# profiles: ["opensearch-enabled"]
environment:
# We need discovery.type=single-node so that OpenSearch doesn't try
# forming a cluster and waiting for other nodes to become live.
- discovery.type=single-node
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
# This and the JVM config below come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
# We do this to avoid unstable performance from page swaps.
- bootstrap.memory_lock=true # Disable JVM heap memory swapping.
# Java heap should be ~50% of memory limit. For now we assume a limit of
# 4g although in practice the container can request more than this.
# See https://opster.com/guides/opensearch/opensearch-basics/opensearch-heap-size-usage-and-jvm-garbage-collection/
# Xms is the starting size, Xmx is the maximum size. These should be the
# same.
- "OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g"
volumes:
- opensearch-data:/usr/share/opensearch/data
# These come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
ulimits:
# Similarly to bootstrap.memory_lock, we don't want to impose limits on
# how much memory a process can lock from being swapped.
memlock:
soft: -1 # Set memlock to unlimited (no soft or hard limit).
hard: -1
nofile:
soft: 65536 # Maximum number of open files for the opensearch user - set to at least 65536.
hard: 65536
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
nginx:
image: nginx:1.25.5-alpine
restart: unless-stopped
@@ -365,5 +315,3 @@ volumes:
model_cache_huggingface:
indexing_huggingface_model_cache:
# mcp_server_logs:
# Persistent data for OpenSearch.
opensearch-data:

View File

@@ -21,12 +21,8 @@ services:
- AUTH_TYPE=${AUTH_TYPE:-oidc}
- POSTGRES_HOST=relational_db
- VESPA_HOST=index
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
- REDIS_HOST=cache
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
- CODE_INTERPRETER_BASE_URL=${CODE_INTERPRETER_BASE_URL:-http://code-interpreter:8000}
- USE_IAM_AUTH=${USE_IAM_AUTH}
- AWS_REGION_NAME=${AWS_REGION_NAME-}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID-}
@@ -72,9 +68,6 @@ services:
- AUTH_TYPE=${AUTH_TYPE:-oidc}
- POSTGRES_HOST=relational_db
- VESPA_HOST=index
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
- REDIS_HOST=cache
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
- INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server}
@@ -258,50 +251,6 @@ services:
max-size: "50m"
max-file: "6"
opensearch:
image: opensearchproject/opensearch:3.4.0
restart: unless-stopped
# Controls whether this service runs. In order to enable it, add
# opensearch-enabled to COMPOSE_PROFILES in the environment for this
# docker-compose.
# NOTE: Now enabled on by default. To explicitly disable this service,
# uncomment this profile and ensure COMPOSE_PROFILES in your env does not
# list the profile, or when running docker compose, include all desired
# service names but this one. Additionally set
# OPENSEARCH_FOR_ONYX_ENABLED=false in your env.
# profiles: ["opensearch-enabled"]
environment:
# We need discovery.type=single-node so that OpenSearch doesn't try
# forming a cluster and waiting for other nodes to become live.
- discovery.type=single-node
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
# This and the JVM config below come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
# We do this to avoid unstable performance from page swaps.
- bootstrap.memory_lock=true # Disable JVM heap memory swapping.
# Java heap should be ~50% of memory limit. For now we assume a limit of
# 4g although in practice the container can request more than this.
# See https://opster.com/guides/opensearch/opensearch-basics/opensearch-heap-size-usage-and-jvm-garbage-collection/
# Xms is the starting size, Xmx is the maximum size. These should be the
# same.
- "OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g"
volumes:
- opensearch-data:/usr/share/opensearch/data
# These come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
ulimits:
# Similarly to bootstrap.memory_lock, we don't want to impose limits on
# how much memory a process can lock from being swapped.
memlock:
soft: -1 # Set memlock to unlimited (no soft or hard limit).
hard: -1
nofile:
soft: 65536 # Maximum number of open files for the opensearch user - set to at least 65536.
hard: 65536
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
nginx:
image: nginx:1.25.5-alpine
restart: unless-stopped
@@ -394,5 +343,3 @@ volumes:
# mcp_server_logs:
# Shared volume for persistent document storage (Craft file-system mode)
file-system:
# Persistent data for OpenSearch.
opensearch-data:

View File

@@ -22,12 +22,8 @@ services:
- AUTH_TYPE=${AUTH_TYPE:-oidc}
- POSTGRES_HOST=relational_db
- VESPA_HOST=index
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
- REDIS_HOST=cache
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
- CODE_INTERPRETER_BASE_URL=${CODE_INTERPRETER_BASE_URL:-http://code-interpreter:8000}
- USE_IAM_AUTH=${USE_IAM_AUTH}
- AWS_REGION_NAME=${AWS_REGION_NAME-}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID-}
@@ -77,9 +73,6 @@ services:
- AUTH_TYPE=${AUTH_TYPE:-oidc}
- POSTGRES_HOST=relational_db
- VESPA_HOST=index
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
- REDIS_HOST=cache
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
- INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server}
@@ -277,50 +270,6 @@ services:
max-size: "50m"
max-file: "6"
opensearch:
image: opensearchproject/opensearch:3.4.0
restart: unless-stopped
# Controls whether this service runs. In order to enable it, add
# opensearch-enabled to COMPOSE_PROFILES in the environment for this
# docker-compose.
# NOTE: Now enabled on by default. To explicitly disable this service,
# uncomment this profile and ensure COMPOSE_PROFILES in your env does not
# list the profile, or when running docker compose, include all desired
# service names but this one. Additionally set
# OPENSEARCH_FOR_ONYX_ENABLED=false in your env.
# profiles: ["opensearch-enabled"]
environment:
# We need discovery.type=single-node so that OpenSearch doesn't try
# forming a cluster and waiting for other nodes to become live.
- discovery.type=single-node
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
# This and the JVM config below come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
# We do this to avoid unstable performance from page swaps.
- bootstrap.memory_lock=true # Disable JVM heap memory swapping.
# Java heap should be ~50% of memory limit. For now we assume a limit of
# 4g although in practice the container can request more than this.
# See https://opster.com/guides/opensearch/opensearch-basics/opensearch-heap-size-usage-and-jvm-garbage-collection/
# Xms is the starting size, Xmx is the maximum size. These should be the
# same.
- "OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g"
volumes:
- opensearch-data:/usr/share/opensearch/data
# These come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
ulimits:
# Similarly to bootstrap.memory_lock, we don't want to impose limits on
# how much memory a process can lock from being swapped.
memlock:
soft: -1 # Set memlock to unlimited (no soft or hard limit).
hard: -1
nofile:
soft: 65536 # Maximum number of open files for the opensearch user - set to at least 65536.
hard: 65536
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
nginx:
image: nginx:1.25.5-alpine
restart: unless-stopped
@@ -431,5 +380,3 @@ volumes:
# mcp_server_logs:
# Shared volume for persistent document storage (Craft file-system mode)
file-system:
# Persistent data for OpenSearch.
opensearch-data:

View File

@@ -57,9 +57,6 @@ services:
condition: service_started
index:
condition: service_started
opensearch:
condition: service_started
required: false
cache:
condition: service_started
inference_model_server:
@@ -81,10 +78,9 @@ services:
- VESPA_HOST=${VESPA_HOST:-index}
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-false}
- REDIS_HOST=${REDIS_HOST:-cache}
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
- CODE_INTERPRETER_BASE_URL=${CODE_INTERPRETER_BASE_URL:-http://code-interpreter:8000}
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
- S3_AWS_ACCESS_KEY_ID=${S3_AWS_ACCESS_KEY_ID:-minioadmin}
- S3_AWS_SECRET_ACCESS_KEY=${S3_AWS_SECRET_ACCESS_KEY:-minioadmin}
@@ -143,19 +139,11 @@ services:
- path: .env
required: false
depends_on:
relational_db:
condition: service_started
index:
condition: service_started
opensearch:
condition: service_started
required: false
cache:
condition: service_started
inference_model_server:
condition: service_started
indexing_model_server:
condition: service_started
- relational_db
- index
- cache
- inference_model_server
- indexing_model_server
restart: unless-stopped
environment:
- FILE_STORE_BACKEND=${FILE_STORE_BACKEND:-s3}
@@ -163,7 +151,7 @@ services:
- VESPA_HOST=${VESPA_HOST:-index}
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-false}
- REDIS_HOST=${REDIS_HOST:-cache}
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
- INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server}
@@ -418,12 +406,7 @@ services:
# Controls whether this service runs. In order to enable it, add
# opensearch-enabled to COMPOSE_PROFILES in the environment for this
# docker-compose.
# NOTE: Now enabled on by default. To explicitly disable this service,
# uncomment this profile and ensure COMPOSE_PROFILES in your env does not
# list the profile, or when running docker compose, include all desired
# service names but this one. Additionally set
# OPENSEARCH_FOR_ONYX_ENABLED=false in your env.
# profiles: ["opensearch-enabled"]
profiles: ["opensearch-enabled"]
environment:
# We need discovery.type=single-node so that OpenSearch doesn't try
# forming a cluster and waiting for other nodes to become live.
@@ -433,11 +416,11 @@ services:
# We do this to avoid unstable performance from page swaps.
- bootstrap.memory_lock=true # Disable JVM heap memory swapping.
# Java heap should be ~50% of memory limit. For now we assume a limit of
# 4g although in practice the container can request more than this.
# 2g although in practice the container can request more than this.
# See https://opster.com/guides/opensearch/opensearch-basics/opensearch-heap-size-usage-and-jvm-garbage-collection/
# Xms is the starting size, Xmx is the maximum size. These should be the
# same.
- "OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g"
- "OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g"
volumes:
- opensearch-data:/usr/share/opensearch/data
# These come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/

View File

@@ -67,8 +67,10 @@ POSTGRES_PASSWORD=password
## remove s3-filestore from COMPOSE_PROFILES and set FILE_STORE_BACKEND=postgres.
COMPOSE_PROFILES=s3-filestore
FILE_STORE_BACKEND=s3
## Setting for enabling OpenSearch.
OPENSEARCH_FOR_ONYX_ENABLED=true
## Settings for enabling OpenSearch. Uncomment these and comment out
## COMPOSE_PROFILES above.
# COMPOSE_PROFILES=s3-filestore,opensearch-enabled
# OPENSEARCH_FOR_ONYX_ENABLED=true
## MinIO/S3 Configuration (only needed when FILE_STORE_BACKEND=s3)
S3_ENDPOINT_URL=http://minio:9000

View File

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

View File

@@ -76,10 +76,7 @@ vespa:
memory: 32000Mi
opensearch:
# Enabled by default. Override to false and set the appropriate env vars in
# the instance-specific values yaml if using AWS-managed OpenSearch, or simply
# override to false to entirely disable.
enabled: true
enabled: false
# These values are passed to the opensearch subchart.
# See https://github.com/opensearch-project/helm-charts/blob/main/charts/opensearch/values.yaml
@@ -1161,10 +1158,8 @@ auth:
opensearch:
# Enable or disable this secret entirely. Will remove from env var
# configurations and remove any created secrets.
# Enabled by default. Override to false and set the appropriate env vars in
# the instance-specific values yaml if using AWS-managed OpenSearch, or
# simply override to false to entirely disable.
enabled: true
# Set to true when opensearch.enabled is true.
enabled: false
# Overwrite the default secret name, ignored if existingSecret is defined.
secretName: 'onyx-opensearch'
# Use a secret specified elsewhere.

3
web/.gitignore vendored
View File

@@ -47,6 +47,3 @@ next-env.d.ts
# generated clients ... in particular, the API to the Onyx backend itself!
/src/lib/generated
.jest-cache
# storybook
storybook-static

View File

@@ -1,80 +0,0 @@
# Onyx Storybook
Storybook is an isolated development environment for UI components. It renders each component in a standalone "story" outside of the main app, so you can visually verify appearance, interact with props, and catch regressions without navigating through the full application.
The Onyx Storybook covers the full component library — from low-level `@opal/core` primitives up through `refresh-components` — giving designers and engineers a shared reference for every visual state.
**Production:** [onyx-storybook.vercel.app](https://onyx-storybook.vercel.app)
## Running Locally
```bash
cd web
npm run storybook # dev server on http://localhost:6006
npm run storybook:build # static build to storybook-static/
```
The dev server hot-reloads when you edit a component or story file.
## Writing Stories
Stories are **co-located** next to their component source:
```
lib/opal/src/core/interactive/
├── components.tsx ← the component
├── Interactive.stories.tsx ← the story
└── styles.css
src/refresh-components/buttons/
├── Button.tsx
└── Button.stories.tsx
```
### Minimal Template
```tsx
import type { Meta, StoryObj } from "@storybook/react";
import { MyComponent } from "./MyComponent";
const meta: Meta<typeof MyComponent> = {
title: "Category/MyComponent", // sidebar path
component: MyComponent,
tags: ["autodocs"], // generates a docs page from props
};
export default meta;
type Story = StoryObj<typeof MyComponent>;
export const Default: Story = {
args: { label: "Hello" },
};
```
### Conventions
- **Title format:** `Core/Name`, `Components/Name`, `Layouts/Name`, or `refresh-components/category/Name`
- **Tags:** Add `tags: ["autodocs"]` to auto-generate a props docs page
- **Decorators:** Components that use Radix tooltips need a `TooltipPrimitive.Provider` decorator
- **Layout:** Use `parameters: { layout: "fullscreen" }` for modals/popovers that use portals
## Dark Mode
Use the theme toggle (paint roller icon) in the Storybook toolbar to switch between light and dark modes. This adds/removes the `dark` class on the preview body, matching the app's `darkMode: "class"` Tailwind config. All color tokens from `colors.css` adapt automatically.
## Deployment
The production Storybook is deployed as a static site on Vercel. The build runs `npm run storybook:build` which outputs to `storybook-static/`, and Vercel serves that directory.
Deploys are triggered on merges to `main` when files in `web/lib/opal/`, `web/src/refresh-components/`, or `web/.storybook/` change.
## Component Layers
The sidebar organizes components by their layer in the design system:
| Layer | Path | Examples |
|-------|------|----------|
| **Core** | `lib/opal/src/core/` | Interactive, Hoverable |
| **Components** | `lib/opal/src/components/` | Button, OpenButton, Tag |
| **Layouts** | `lib/opal/src/layouts/` | Content, ContentAction, IllustrationContent |
| **refresh-components** | `src/refresh-components/` | Inputs, tables, modals, text, cards, tiles, etc. |

View File

@@ -1,42 +0,0 @@
import type { StorybookConfig } from "@storybook/react-vite";
import path from "path";
const config: StorybookConfig = {
stories: [
"../lib/opal/src/**/*.stories.@(ts|tsx)",
"../src/refresh-components/**/*.stories.@(ts|tsx)",
],
addons: ["@storybook/addon-essentials", "@storybook/addon-themes"],
framework: {
name: "@storybook/react-vite",
options: {},
},
staticDirs: ["../public"],
docs: {
autodocs: "tag",
},
typescript: {
reactDocgen: "react-docgen-typescript",
},
viteFinal: async (config) => {
config.resolve = config.resolve ?? {};
config.resolve.alias = {
...config.resolve.alias,
"@": path.resolve(__dirname, "../src"),
"@opal": path.resolve(__dirname, "../lib/opal/src"),
"@public": path.resolve(__dirname, "../public"),
// Next.js module stubs for Vite
"next/link": path.resolve(__dirname, "mocks/next-link.tsx"),
"next/navigation": path.resolve(__dirname, "mocks/next-navigation.tsx"),
"next/image": path.resolve(__dirname, "mocks/next-image.tsx"),
};
// Process CSS with Tailwind via PostCSS
config.css = config.css ?? {};
config.css.postcss = path.resolve(__dirname, "..");
return config;
},
};
export default config;

View File

@@ -1,28 +0,0 @@
import React from "react";
interface ImageProps {
src: string;
alt: string;
width?: number;
height?: number;
fill?: boolean;
[key: string]: unknown;
}
function Image({ src, alt, width, height, fill, ...props }: ImageProps) {
const fillStyle: React.CSSProperties = fill
? { position: "absolute", inset: 0, width: "100%", height: "100%" }
: {};
return (
<img
{...(props as React.ImgHTMLAttributes<HTMLImageElement>)}
src={src}
alt={alt}
width={fill ? undefined : width}
height={fill ? undefined : height}
style={{ ...(props.style as React.CSSProperties), ...fillStyle }}
/>
);
}
export default Image;

View File

@@ -1,28 +0,0 @@
import React from "react";
interface LinkProps {
href: string;
children: React.ReactNode;
[key: string]: unknown;
}
function Link({
href,
children,
prefetch: _prefetch,
scroll: _scroll,
shallow: _shallow,
replace: _replace,
passHref: _passHref,
locale: _locale,
legacyBehavior: _legacyBehavior,
...props
}: LinkProps) {
return (
<a href={href} {...props}>
{children}
</a>
);
}
export default Link;

View File

@@ -1,30 +0,0 @@
export function useRouter() {
return {
push: (_url: string) => {},
replace: (_url: string) => {},
back: () => {},
forward: () => {},
refresh: () => {},
prefetch: (_url: string) => Promise.resolve(),
};
}
export function usePathname() {
return "/";
}
export function useSearchParams() {
return new URLSearchParams() as ReadonlyURLSearchParams;
}
export function useParams() {
return {};
}
export function redirect(_url: string): never {
throw new Error("redirect() called in Storybook");
}
export function notFound(): never {
throw new Error("notFound() called in Storybook");
}

View File

@@ -1,11 +0,0 @@
<!-- Preconnect for fonts loaded via globals.css @import -->
<link
rel="preconnect"
href="https://fonts.googleapis.com"
crossorigin="anonymous"
/>
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossorigin="anonymous"
/>

View File

@@ -1,27 +0,0 @@
import type { Preview } from "@storybook/react";
import { withThemeByClassName } from "@storybook/addon-themes";
import "../src/app/globals.css";
const preview: Preview = {
parameters: {
layout: "centered",
backgrounds: { disable: true },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
decorators: [
withThemeByClassName({
themes: {
light: "",
dark: "dark",
},
defaultTheme: "light",
}),
],
};
export default preview;

View File

@@ -1,47 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Tag } from "@opal/components";
import { SvgAlertCircle } from "@opal/icons";
const TAG_COLORS = ["green", "purple", "blue", "gray", "amber"] as const;
const meta: Meta<typeof Tag> = {
title: "opal/components/Tag",
component: Tag,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof Tag>;
export const Default: Story = {
args: {
title: "Label",
},
};
export const AllColors: Story = {
render: () => (
<div className="flex items-center gap-2">
{TAG_COLORS.map((color) => (
<Tag key={color} title={color} color={color} />
))}
</div>
),
};
export const WithIcon: Story = {
args: {
title: "Alert",
icon: SvgAlertCircle,
},
};
export const AllColorsWithIcon: Story = {
render: () => (
<div className="flex items-center gap-2">
{TAG_COLORS.map((color) => (
<Tag key={color} title={color} color={color} icon={SvgAlertCircle} />
))}
</div>
),
};

1571
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,9 +24,7 @@
"test:ci": "jest --ci --maxWorkers=2 --silent --bail",
"test:changed": "jest --onlyChanged",
"test:diff": "jest --changedSince=main",
"test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build -o storybook-static"
"test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
@@ -112,11 +110,6 @@
},
"devDependencies": {
"@playwright/test": "^1.39.0",
"@storybook/addon-essentials": "^8.6.18",
"@storybook/addon-themes": "^8.6.18",
"@storybook/blocks": "^8.6.18",
"@storybook/react": "^8.6.18",
"@storybook/react-vite": "^8.6.18",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
@@ -142,7 +135,6 @@
"jest-environment-jsdom": "^30.2.0",
"prettier": "3.1.0",
"stats.js": "^0.17.0",
"storybook": "^8.6.18",
"tailwindcss": "^3.4.17",
"ts-jest": "^29.2.5",
"ts-unused-exports": "^11.0.1",

View File

@@ -85,6 +85,23 @@ export default function LoginPage({
{authTypeMetadata?.authType === AuthType.BASIC && (
<div className="flex flex-col w-full gap-6">
<LoginText />
{authTypeMetadata?.oauthEnabled && authUrl && (
<>
<SignInButton
authorizeUrl={authUrl}
authType={AuthType.GOOGLE_OAUTH}
/>
<div className="flex flex-row items-center w-full gap-2">
<div className="flex-1 border-t border-text-01" />
<Text as="p" text03 mainUiMuted>
or
</Text>
<div className="flex-1 border-t border-text-01" />
</div>
</>
)}
<EmailPasswordForm nextUrl={effectiveNextUrl} />
</div>
)}

View File

@@ -72,7 +72,15 @@ export default async function Page(props: PageProps) {
let authUrl: string | null = null;
if (authTypeMetadata) {
try {
authUrl = await getAuthUrlSS(authTypeMetadata.authType, nextUrl);
// For BASIC auth with OAuth enabled, fetch the OAuth URL
if (
authTypeMetadata.authType === AuthType.BASIC &&
authTypeMetadata.oauthEnabled
) {
authUrl = await getAuthUrlSS(AuthType.GOOGLE_OAUTH, nextUrl);
} else {
authUrl = await getAuthUrlSS(authTypeMetadata.authType, nextUrl);
}
} catch (e) {
console.log(`Some fetch failed for the login page - ${e}`);
}

View File

@@ -198,15 +198,16 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
const showEmpty = !error && results.length === 0;
return (
<div className="flex-1 min-h-0 w-full flex flex-col gap-3">
{/* ── Top row: Filters + Result count ── */}
<div className="flex-shrink-0 flex flex-row gap-x-4">
<div
className={cn(
"flex flex-col justify-end gap-3",
showEmpty ? "flex-1" : "flex-[3]"
)}
>
<>
<div
className="flex-1 min-h-0 w-full grid gap-x-4"
style={{
gridTemplateColumns: showEmpty ? "1fr" : "3fr 1fr",
gridTemplateRows: "auto 1fr auto",
}}
>
{/* Top-left: Search filters */}
<div className="row-start-1 col-start-1 flex flex-col justify-end gap-3">
<div className="flex flex-row gap-2">
{/* Time filter */}
<Popover open={timeFilterOpen} onOpenChange={setTimeFilterOpen}>
@@ -306,8 +307,9 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
<Separator noPadding />
</div>
{/* Top-right: Number of results */}
{!showEmpty && (
<div className="flex-1 flex flex-col justify-end gap-3">
<div className="row-start-1 col-start-2 flex flex-col justify-end gap-3">
<Section alignItems="start">
<Text text03 mainUiMuted>
{results.length} Results
@@ -317,14 +319,12 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
<Separator noPadding />
</div>
)}
</div>
{/* ── Middle row: Results + Source filter ── */}
<div className="flex-1 min-h-0 flex flex-row gap-x-4">
{/* Bottom-left: Search results */}
<div
className={cn(
"min-h-0 overflow-y-scroll flex flex-col gap-2",
showEmpty ? "flex-1 justify-center" : "flex-[3]"
"row-start-2 col-start-1 min-h-0 overflow-y-scroll py-3 flex flex-col gap-2",
showEmpty && "justify-center"
)}
>
{error ? (
@@ -332,16 +332,12 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
) : paginatedResults.length > 0 ? (
<>
{paginatedResults.map((doc) => (
<div
<SearchCard
key={`${doc.document_id}-${doc.chunk_ind}`}
className="flex-shrink-0"
>
<SearchCard
document={doc}
isLlmSelected={llmSelectedSet.has(doc.document_id)}
onDocumentClick={onDocumentClick}
/>
</div>
document={doc}
isLlmSelected={llmSelectedSet.has(doc.document_id)}
onDocumentClick={onDocumentClick}
/>
))}
</>
) : (
@@ -353,8 +349,20 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
)}
</div>
{/* Pagination */}
{!showEmpty && (
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-4 px-1">
<div className="row-start-3 col-start-1 col-span-2 pt-3">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
{/* Bottom-right: Source filter */}
{!showEmpty && (
<div className="row-start-2 col-start-2 min-h-0 overflow-y-auto flex flex-col gap-4 px-1 py-3">
<Section gap={0.25} height="fit">
{sourcesWithMeta.map(({ source, meta, count }) => (
<LineItem
@@ -378,15 +386,6 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
</div>
)}
</div>
{/* ── Bottom row: Pagination ── */}
{!showEmpty && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
)}
</div>
</>
);
}

View File

@@ -66,7 +66,7 @@ export default function Pagination({
const pageNumbers = getPageNumbers();
return (
<Section flexDirection="row" height="auto" gap={0.25}>
<Section flexDirection="row" gap={0.25}>
{/* Previous button */}
<Disabled disabled={currentPage === 1}>
<Button
@@ -77,7 +77,7 @@ export default function Pagination({
</Disabled>
{/* Page numbers */}
<Section flexDirection="row" height="auto" gap={0} width="fit">
<Section flexDirection="row" gap={0} width="fit">
{pageNumbers.map((page, index) => {
if (page === "...") {
return (

View File

@@ -29,7 +29,6 @@ import { SourceMetadata } from "@/lib/search/interfaces";
import { SourceIcon } from "@/components/SourceIcon";
import { useAvailableTools } from "@/hooks/useAvailableTools";
import useCCPairs from "@/hooks/useCCPairs";
import { useLLMProviders } from "@/hooks/useLLMProviders";
import { useVectorDbEnabled } from "@/providers/SettingsProvider";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import { useToolOAuthStatus } from "@/lib/hooks/useToolOAuthStatus";
@@ -179,10 +178,6 @@ export default function ActionsPopover({
// const [showTopShadow, setShowTopShadow] = useState(false);
const { selectedSources, setSelectedSources } = filterManager;
const [mcpServers, setMcpServers] = useState<MCPServer[]>([]);
const { llmProviders, isLoading: isLLMLoading } = useLLMProviders(
selectedAgent.id
);
const hasAnyProvider = !isLLMLoading && (llmProviders?.length ?? 0) > 0;
// Use the OAuth hook
const { getToolAuthStatus, authenticateTool } = useToolOAuthStatus(
@@ -498,8 +493,7 @@ export default function ActionsPopover({
// Fetch MCP servers for the agent on mount
useEffect(() => {
if (selectedAgent == null || selectedAgent.id == null || !hasAnyProvider)
return;
if (selectedAgent == null || selectedAgent.id == null) return;
const abortController = new AbortController();
@@ -540,7 +534,7 @@ export default function ActionsPopover({
return () => {
abortController.abort();
};
}, [selectedAgent?.id, hasAnyProvider]);
}, [selectedAgent?.id]);
// No separate MCP tool loading; tools already exist in selectedAgent.tools

View File

@@ -676,7 +676,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
>
{/* Main content grid — 3 rows, animated */}
<div
className="flex-1 w-full grid min-h-0 transition-[grid-template-rows] duration-150 ease-in-out"
className="flex-1 w-full grid min-h-0 px-4 transition-[grid-template-rows] duration-150 ease-in-out"
style={gridStyle}
>
{/* ── Top row: ChatUI / WelcomeMessage / ProjectUI ── */}
@@ -741,7 +741,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
</div>
{/* ── Middle-center: AppInputBar ── */}
<div className="row-start-2 flex flex-col items-center px-4">
<div className="row-start-2 flex flex-col items-center">
<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 && (
@@ -841,7 +841,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
</div>
{/* ── Bottom: SearchResults + SourceFilter / Suggestions / ProjectChatList ── */}
<div className="row-start-3 min-h-0 overflow-hidden flex flex-col items-center w-full px-4">
<div className="row-start-3 min-h-0 overflow-hidden flex flex-col items-center w-full">
{/* Agent description below input */}
{(appFocus.isNewSession() || appFocus.isAgent()) &&
!isDefaultAgent && (

View File

@@ -366,7 +366,7 @@ const ChatScrollContainer = React.memo(
>
<div
ref={contentWrapperRef}
className="w-full flex-1 flex flex-col items-center px-4"
className="w-full flex-1 flex flex-col items-center"
data-scroll-ready={isScrollReady}
style={{
visibility: isScrollReady ? "visible" : "hidden",