Compare commits

..

12 Commits

Author SHA1 Message Date
Bo-Onyx
b20a5ebf69 feat(hook): Add frontend feature control and admin hook page (#9575) 2026-03-25 00:37:37 +00:00
Bo-Onyx
8645adb807 fix(width): UI update model width definition. (#9613) 2026-03-25 00:11:32 +00:00
Nikolas Garza
2425bd4d8d feat(groups): add shared resources and token limit sections (#9538) 2026-03-24 23:44:44 +00:00
Raunak Bhagat
333b2b19cb refactor: fix sidebar layout (#9601)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-24 23:22:00 +00:00
Jamison Lahman
44895b3bd6 fix(ux): disable MCP Tools toggle if needs authenticated (#9607) 2026-03-24 22:45:23 +00:00
Raunak Bhagat
78c2ecf99f refactor(opal): restructure Onyx logo icons into composable parts (#9606)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-24 22:26:28 +00:00
Ciaran Sweet
e3e0e04edc fix: update values.yaml comment for opensearch admin password secretKeyRef (#9595) 2026-03-24 21:54:03 +00:00
Justin Tahara
a19fe03bd8 fix(ui): Text focused paste from PowerPoint (#9603) 2026-03-24 21:23:58 +00:00
Nikolas Garza
415c05b5f8 feat(groups): add create group page (#9515)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:55:18 +00:00
Nikolas Garza
352fd19f0a feat(admin): inline group renaming (#9491) 2026-03-24 20:12:17 +00:00
Raunak Bhagat
41ae039bfa refactor(opal): cleanup button types in Opal (#9598) 2026-03-24 20:06:39 +00:00
Bo-Onyx
782c734287 feat(hook): integrate query processing hook point (#9533) 2026-03-24 19:47:17 +00:00
109 changed files with 2834 additions and 1533 deletions

34
.vscode/launch.json vendored
View File

@@ -15,10 +15,10 @@
{
"name": "Run All Onyx Services",
"configurations": [
// "Web Server",
"Web Server",
"Model Server",
"API Server",
// "MCP Server",
"MCP Server",
"Slack Bot",
"Celery primary",
"Celery light",
@@ -95,7 +95,7 @@
"LOG_LEVEL": "DEBUG",
"PYTHONUNBUFFERED": "1"
},
"args": ["model_server.main:app", "--reload", "--port", "9010"],
"args": ["model_server.main:app", "--reload", "--port", "9000"],
"presentation": {
"group": "2"
},
@@ -113,7 +113,7 @@
"LOG_LEVEL": "DEBUG",
"PYTHONUNBUFFERED": "1"
},
"args": ["onyx.main:app", "--reload", "--port", "8090"],
"args": ["onyx.main:app", "--reload", "--port", "8080"],
"presentation": {
"group": "2"
},
@@ -165,7 +165,7 @@
"envFile": "${workspaceFolder}/.vscode/.env",
"env": {
"MCP_SERVER_ENABLED": "true",
"MCP_SERVER_PORT": "8100",
"MCP_SERVER_PORT": "8090",
"MCP_SERVER_CORS_ORIGINS": "http://localhost:*",
"LOG_LEVEL": "DEBUG",
"PYTHONUNBUFFERED": "1"
@@ -174,7 +174,7 @@
"onyx.mcp_server.api:mcp_app",
"--reload",
"--port",
"8100",
"8090",
"--timeout-graceful-shutdown",
"0"
],
@@ -526,7 +526,10 @@
"type": "node",
"request": "launch",
"runtimeExecutable": "uv",
"runtimeArgs": ["sync", "--all-extras"],
"runtimeArgs": [
"sync",
"--all-extras"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"presentation": {
@@ -650,7 +653,14 @@
"type": "node",
"request": "launch",
"runtimeExecutable": "uv",
"runtimeArgs": ["run", "--with", "onyx-devtools", "ods", "db", "upgrade"],
"runtimeArgs": [
"run",
"--with",
"onyx-devtools",
"ods",
"db",
"upgrade"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"presentation": {
@@ -669,11 +679,7 @@
"PYTHONUNBUFFERED": "1",
"PYTHONPATH": "backend"
},
"args": [
"--filename",
"backend/generated/openapi.json",
"--generate-python-client"
]
"args": ["--filename", "backend/generated/openapi.json", "--generate-python-client"]
},
{
// script to debug multi tenant db issues
@@ -702,7 +708,7 @@
"name": "Debug React Web App in Chrome",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3010",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/web"
}
]

View File

@@ -115,14 +115,8 @@ def fetch_user_group_token_rate_limits_for_user(
ordered: bool = True,
get_editable: bool = True,
) -> Sequence[TokenRateLimit]:
stmt = (
select(TokenRateLimit)
.join(
TokenRateLimit__UserGroup,
TokenRateLimit.id == TokenRateLimit__UserGroup.rate_limit_id,
)
.where(TokenRateLimit__UserGroup.user_group_id == group_id)
)
stmt = select(TokenRateLimit)
stmt = stmt.where(User__UserGroup.user_group_id == group_id)
stmt = _add_user_filters(stmt, user, get_editable)
if enabled_only:

View File

@@ -4,6 +4,7 @@ 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
@@ -17,6 +18,7 @@ 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
@@ -29,6 +31,7 @@ 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
@@ -191,3 +194,38 @@ 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

@@ -112,3 +112,8 @@ class UserGroupRename(BaseModel):
class SetCuratorRequest(BaseModel):
user_id: UUID
is_curator: bool
class UpdateGroupAgentsRequest(BaseModel):
added_agent_ids: list[int]
removed_agent_ids: list[int]

View File

@@ -59,6 +59,7 @@ 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
@@ -68,11 +69,19 @@ 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
@@ -424,6 +433,28 @@ 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,
@@ -491,6 +522,7 @@ def handle_stream_message_objects(
persona = chat_session.persona
message_text = new_msg_req.message
user_identity = LLMUserIdentity(
user_id=llm_user_identifier, session_id=str(chat_session.id)
)
@@ -582,6 +614,28 @@ 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,
@@ -921,6 +975,17 @@ 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

@@ -44,6 +44,7 @@ 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,6 +5,7 @@ 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):
@@ -14,7 +15,7 @@ Usage (Celery tasks and FastAPI handlers):
# hook failed but fail strategy is SOFT — continue with original behavior
...
else:
# result is the response payload dict from the customer's endpoint
# result is a validated Pydantic model instance (spec.response_model)
...
is_reachable update policy
@@ -53,9 +54,11 @@ 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
@@ -81,6 +84,9 @@ class HookSoftFailed:
"""Hook was called but failed with SOFT fail strategy — continuing."""
T = TypeVar("T", bound=BaseModel)
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
@@ -268,22 +274,21 @@ def _persist_result(
# ---------------------------------------------------------------------------
def execute_hook(
*,
db_session: Session,
hook_point: HookPoint,
def _execute_hook_inner(
hook: Hook,
payload: dict[str, Any],
) -> 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
response_type: type[T],
) -> T | HookSoftFailed:
"""Make the HTTP call, validate the response, and return a typed model.
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 — "
@@ -300,13 +305,36 @@ def execute_hook(
headers: dict[str, str] = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
with httpx.Client(timeout=timeout) as client:
with httpx.Client(
timeout=timeout, follow_redirects=False
) as client: # SSRF guard: never follow redirects
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:
@@ -323,8 +351,41 @@ def execute_hook(
f"Hook execution failed (soft fail) for hook_id={hook_id}: {outcome.error_message}"
)
return HookSoftFailed()
if outcome.response_payload is None:
raise ValueError(
f"response_payload is None for successful hook call (hook_id={hook_id})"
if validated_model is None:
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
f"validated_model is None for successful hook call (hook_id={hook_id})",
)
return outcome.response_payload
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

View File

@@ -51,13 +51,12 @@ class HookPointSpec:
output_schema: ClassVar[dict[str, Any]]
def __init_subclass__(cls, **kwargs: object) -> None:
"""Enforce that every concrete subclass declares all required class attributes.
"""Enforce that every subclass declares all required class attributes.
Called automatically by Python whenever a class inherits from HookPointSpec.
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.
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.
"""
super().__init_subclass__(**kwargs)
missing = [attr for attr in _REQUIRED_ATTRS if not hasattr(cls, attr)]

View File

@@ -26,6 +26,8 @@ class DocumentIngestionSpec(HookPointSpec):
default_timeout_seconds = 30.0
fail_hard_description = "The document will not be indexed."
default_fail_strategy = HookFailStrategy.HARD
# TODO(Bo-Onyx): update later
docs_url = "https://docs.google.com/document/d/1pGhB8Wcnhhj8rS4baEJL6CX05yFhuIDNk1gbBRiWu94/edit?tab=t.ue263ual5vdi"
payload_model = DocumentIngestionPayload
response_model = DocumentIngestionResponse

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. Always present — the session is guaranteed to exist by the time this hook fires."
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."
)
@@ -25,7 +25,7 @@ class QueryProcessingResponse(BaseModel):
default=None,
description=(
"The query to use in the pipeline. "
"Null, empty string, or absent = reject the query."
"Null, empty string, whitespace-only, or absent = reject the query."
),
)
rejection_message: str | None = Field(
@@ -65,6 +65,8 @@ class QueryProcessingSpec(HookPointSpec):
"The query will be blocked and the user will see an error message."
)
default_fail_strategy = HookFailStrategy.HARD
# TODO(Bo-Onyx): update later
docs_url = "https://docs.google.com/document/d/1pGhB8Wcnhhj8rS4baEJL6CX05yFhuIDNk1gbBRiWu94/edit?tab=t.g2r1a1699u87"
payload_model = QueryProcessingPayload
response_model = QueryProcessingResponse

View File

@@ -17,6 +17,7 @@ from onyx.db.models import User
from onyx.db.notification import dismiss_all_notifications
from onyx.db.notification import get_notifications
from onyx.db.notification import update_notification_last_shown
from onyx.hooks.utils import HOOKS_AVAILABLE
from onyx.key_value_store.factory import get_kv_store
from onyx.key_value_store.interface import KvKeyNotFoundError
from onyx.server.features.build.utils import is_onyx_craft_enabled
@@ -80,6 +81,7 @@ def fetch_settings(
needs_reindexing=needs_reindexing,
onyx_craft_enabled=onyx_craft_enabled_for_user,
vector_db_enabled=not DISABLE_VECTOR_DB,
hooks_enabled=HOOKS_AVAILABLE,
version=onyx_version,
)

View File

@@ -104,5 +104,7 @@ class UserSettings(Settings):
# False when DISABLE_VECTOR_DB is set — connectors, RAG search, and
# document sets are unavailable.
vector_db_enabled: bool = True
# True when hooks are available: single-tenant deployment with HOOK_ENABLED=true.
hooks_enabled: bool = False
# Application version, read from the ONYX_VERSION env var at startup.
version: str | None = None

View File

@@ -1,4 +1,12 @@
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:
@@ -32,3 +40,81 @@ 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,6 +7,7 @@ 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
@@ -15,13 +16,15 @@ 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"}
_RESPONSE_PAYLOAD: dict[str, Any] = {"rewritten_query": "better test"}
# A valid QueryProcessingResponse payload — used by success-path tests.
_RESPONSE_PAYLOAD: dict[str, Any] = {"query": "better test"}
def _make_hook(
@@ -33,6 +36,7 @@ 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
@@ -42,6 +46,7 @@ def _make_hook(
hook.id = hook_id
hook.fail_strategy = fail_strategy
hook.is_reachable = is_reachable
hook.hook_point = hook_point
return hook
@@ -140,6 +145,7 @@ 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)
@@ -152,7 +158,9 @@ def test_early_exit_returns_skipped_with_no_db_writes(
# ---------------------------------------------------------------------------
def test_success_returns_payload_and_sets_reachable(db_session: MagicMock) -> None:
def test_success_returns_validated_model_and_sets_reachable(
db_session: MagicMock,
) -> None:
hook = _make_hook()
with (
@@ -171,9 +179,11 @@ def test_success_returns_payload_and_sets_reachable(db_session: MagicMock) -> No
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
response_type=QueryProcessingResponse,
)
assert result == _RESPONSE_PAYLOAD
assert isinstance(result, QueryProcessingResponse)
assert result.query == _RESPONSE_PAYLOAD["query"]
_, update_kwargs = mock_update.call_args
assert update_kwargs["is_reachable"] is True
mock_log.assert_not_called()
@@ -200,9 +210,11 @@ 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 result == _RESPONSE_PAYLOAD
assert isinstance(result, QueryProcessingResponse)
assert result.query == _RESPONSE_PAYLOAD["query"]
mock_update.assert_not_called()
@@ -230,6 +242,7 @@ 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)
@@ -265,6 +278,7 @@ 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)
@@ -388,6 +402,7 @@ 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:
@@ -395,6 +410,7 @@ def test_http_failure_paths(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
response_type=QueryProcessingResponse,
)
assert isinstance(result, expected_type)
@@ -442,6 +458,7 @@ 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
@@ -457,16 +474,16 @@ def test_authorization_header(
@pytest.mark.parametrize(
"http_exception,expected_result",
"http_exception,expect_onyx_error",
[
pytest.param(None, _RESPONSE_PAYLOAD, id="success_path"),
pytest.param(httpx.ConnectError("refused"), OnyxError, id="hard_fail_path"),
pytest.param(None, False, id="success_path"),
pytest.param(httpx.ConnectError("refused"), True, id="hard_fail_path"),
],
)
def test_persist_session_failure_is_swallowed(
db_session: MagicMock,
http_exception: Exception | None,
expected_result: Any,
expect_onyx_error: bool,
) -> None:
"""DB session failure in _persist_result must not mask the real return value or OnyxError."""
hook = _make_hook(fail_strategy=HookFailStrategy.HARD)
@@ -489,12 +506,13 @@ def test_persist_session_failure_is_swallowed(
side_effect=http_exception,
)
if expected_result is OnyxError:
if expect_onyx_error:
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:
@@ -502,8 +520,131 @@ def test_persist_session_failure_is_swallowed(
db_session=db_session,
hook_point=HookPoint.QUERY_PROCESSING,
payload=_PAYLOAD,
response_type=QueryProcessingResponse,
)
assert result == expected_result
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,
)
def test_is_reachable_failure_does_not_prevent_log(db_session: MagicMock) -> None:
@@ -535,6 +676,7 @@ 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

@@ -103,7 +103,7 @@ opensearch:
- name: OPENSEARCH_INITIAL_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: onyx-opensearch # Must match auth.opensearch.secretName.
name: onyx-opensearch # Must match auth.opensearch.secretName or auth.opensearch.existingSecret if defined.
key: opensearch_admin_password # Must match auth.opensearch.secretKeys value.
resources:

View File

@@ -36,9 +36,6 @@ 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;

View File

@@ -1,4 +1,3 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { FilterButton } from "@opal/components";
import { Disabled as DisabledProvider } from "@opal/core";

View File

@@ -1,7 +1,5 @@
import {
Interactive,
type InteractiveStatefulState,
type InteractiveStatefulInteraction,
type InteractiveStatefulProps,
InteractiveContainerRoundingVariant,
} from "@opal/core";
@@ -21,40 +19,26 @@ type ContentPassthroughProps = DistributiveOmit<
"paddingVariant" | "widthVariant" | "ref" | "withInteractive"
>;
type LineItemButtonOwnProps = {
type LineItemButtonOwnProps = Pick<
InteractiveStatefulProps,
| "state"
| "interaction"
| "onClick"
| "href"
| "target"
| "group"
| "ref"
| "type"
> & {
/** 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;
@@ -78,11 +62,11 @@ function LineItemButton({
target,
group,
ref,
type = "button",
// Sizing
roundingVariant = "default",
width = "full",
type = "button",
tooltip,
tooltipSide = "top",

View File

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

View File

@@ -49,9 +49,6 @@ 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

@@ -145,8 +145,6 @@ export function Table<TData>(props: DataTableProps<TData>) {
pageSize,
initialSorting,
initialColumnVisibility,
initialRowSelection,
initialViewSelected,
draggable,
footer,
size = "lg",
@@ -223,8 +221,6 @@ export function Table<TData>(props: DataTableProps<TData>) {
pageSize: effectivePageSize,
initialSorting,
initialColumnVisibility,
initialRowSelection,
initialViewSelected,
getRowId,
onSelectionChange,
searchTerm,

View File

@@ -103,10 +103,6 @@ interface UseDataTableOptions<TData extends RowData> {
initialSorting?: SortingState;
/** Initial column visibility state. @default {} */
initialColumnVisibility?: VisibilityState;
/** Initial row selection state. Keys are row IDs (from `getRowId`), values are `true`. @default {} */
initialRowSelection?: RowSelectionState;
/** When true AND `initialRowSelection` is non-empty, start in view-selected mode (filtered to selected rows). @default false */
initialViewSelected?: boolean;
/** Called whenever the set of selected row IDs changes. */
onSelectionChange?: (selectedIds: string[]) => void;
/** Search term for global text filtering. Rows are filtered to those containing
@@ -199,8 +195,6 @@ export default function useDataTable<TData extends RowData>(
columnResizeMode = "onChange",
initialSorting = [],
initialColumnVisibility = {},
initialRowSelection = {},
initialViewSelected = false,
getRowId,
onSelectionChange,
searchTerm,
@@ -212,8 +206,7 @@ export default function useDataTable<TData extends RowData>(
// ---- internal state -----------------------------------------------------
const [sorting, setSorting] = useState<SortingState>(initialSorting);
const [rowSelection, setRowSelection] =
useState<RowSelectionState>(initialRowSelection);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
initialColumnVisibility
@@ -223,12 +216,8 @@ export default function useDataTable<TData extends RowData>(
pageSize: pageSizeOption,
});
/** Combined global filter: view-mode (selected IDs) + text search. */
const initialSelectedIds =
initialViewSelected && Object.keys(initialRowSelection).length > 0
? new Set(Object.keys(initialRowSelection))
: null;
const [globalFilter, setGlobalFilter] = useState<GlobalFilterValue>({
selectedIds: initialSelectedIds,
selectedIds: null,
searchTerm: "",
});
@@ -395,31 +384,6 @@ export default function useDataTable<TData extends RowData>(
: data.length;
const isPaginated = isFinite(pagination.pageSize);
// ---- keep view-mode filter in sync with selection ----------------------
// When in view-selected mode, deselecting a row should remove it from
// the visible set so it disappears immediately.
useEffect(() => {
if (isServerSide) return;
if (globalFilter.selectedIds == null) return;
const currentIds = new Set(Object.keys(rowSelection));
// Remove any ID from the filter that is no longer selected
let changed = false;
const next = new Set<string>();
globalFilter.selectedIds.forEach((id) => {
if (currentIds.has(id)) {
next.add(id);
} else {
changed = true;
}
});
if (changed) {
setGlobalFilter((prev) => ({ ...prev, selectedIds: next }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- only react to
// selection changes while in view mode
}, [rowSelection, isServerSide]);
// ---- selection change callback ------------------------------------------
const isFirstRenderRef = useRef(true);
const onSelectionChangeRef = useRef(onSelectionChange);
@@ -428,10 +392,6 @@ export default function useDataTable<TData extends RowData>(
useEffect(() => {
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
// Still fire the callback on first render if there's an initial selection
if (selectedRowIds.length > 0) {
onSelectionChangeRef.current?.(selectedRowIds);
}
return;
}
onSelectionChangeRef.current?.(selectedRowIds);

View File

@@ -146,10 +146,6 @@ export interface DataTableProps<TData> {
initialSorting?: SortingState;
/** Initial column visibility state. */
initialColumnVisibility?: VisibilityState;
/** Initial row selection state. Keys are row IDs (from `getRowId`), values are `true`. */
initialRowSelection?: Record<string, boolean>;
/** When true AND `initialRowSelection` is non-empty, start in view-selected mode. @default false */
initialViewSelected?: boolean;
/** Enable drag-and-drop row reordering. */
draggable?: DataTableDraggableConfig;
/** Footer configuration. */

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 { WithoutStyles } from "@opal/types";
import type { ButtonType, WithoutStyles } from "@opal/types";
import {
containerSizeVariants,
type ContainerSizeVariants,
@@ -52,7 +52,7 @@ interface InteractiveContainerProps
*
* Mutually exclusive with `href`.
*/
type?: "submit" | "button" | "reset";
type?: ButtonType;
/**
* When `true`, applies a 1px border using the theme's border color.

View File

@@ -0,0 +1,21 @@
import type { IconProps } from "@opal/types";
const SvgFileBroadcast = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M6.1875 2.25003H2.625C1.808 2.25003 1.125 2.93303 1.125 3.75003L1.125 14.25C1.125 15.067 1.808 15.75 2.625 15.75L9.37125 15.75C10.1883 15.75 10.8713 15.067 10.8713 14.25L10.8713 6.94128M6.1875 2.25003L10.8713 6.94128M6.1875 2.25003V6.94128H10.8713M10.3069 2.25L13.216 5.15914C13.6379 5.5811 13.875 6.15339 13.875 6.75013V13.875C13.875 14.5212 13.737 15.2081 13.4392 15.7538M16.4391 15.7538C16.737 15.2081 16.875 14.5213 16.875 13.8751L16.875 7.02481C16.875 5.53418 16.2833 4.10451 15.23 3.04982L14.4301 2.25003"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgFileBroadcast;

View File

@@ -0,0 +1,21 @@
import type { IconProps } from "@opal/types";
const SvgHookNodes = ({ 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="M10.0002 4C10.0002 3.99708 10.0002 3.99415 10.0001 3.99123C9.99542 2.8907 9.10181 2 8.00016 2C6.89559 2 6.00016 2.89543 6.00016 4C6.00016 4.73701 6.39882 5.38092 6.99226 5.72784L4.67276 9.70412M11.6589 13.7278C11.9549 13.9009 12.2993 14 12.6668 14C13.7714 14 14.6668 13.1046 14.6668 12C14.6668 10.8954 13.7714 10 12.6668 10C12.2993 10 11.9549 10.0991 11.6589 10.2722L9.33943 6.29588M2.33316 10.2678C1.73555 10.6136 1.3335 11.2599 1.3335 12C1.3335 13.1046 2.22893 14 3.3335 14C4.43807 14 5.3335 13.1046 5.3335 12H10.0002"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgHookNodes;

View File

@@ -69,8 +69,9 @@ 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 SvgFileBroadcast } from "@opal/icons/file-broadcast";
export { default as SvgFiles } from "@opal/icons/files";
export { default as SvgFileChartPie } from "@opal/icons/file-chart-pie";
export { default as SvgFileSmall } from "@opal/icons/file-small";
export { default as SvgFileText } from "@opal/icons/file-text";
@@ -90,6 +91,7 @@ export { default as SvgHashSmall } from "@opal/icons/hash-small";
export { default as SvgHash } from "@opal/icons/hash";
export { default as SvgHeadsetMic } from "@opal/icons/headset-mic";
export { default as SvgHistory } from "@opal/icons/history";
export { default as SvgHookNodes } from "@opal/icons/hook-nodes";
export { default as SvgHourglass } from "@opal/icons/hourglass";
export { default as SvgImage } from "@opal/icons/image";
export { default as SvgImageSmall } from "@opal/icons/image-small";
@@ -121,7 +123,9 @@ 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

@@ -0,0 +1,27 @@
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,19 +1,27 @@
import type { IconProps } from "@opal/types";
const SvgOnyxLogo = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 56 56"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
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"
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)"
/>
</svg>
);

View File

@@ -0,0 +1,28 @@
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

@@ -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="text-text-04 shrink-0" />
<SvgOnyxLogo size={24} className="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="text-text-04 shrink-0" />
<SvgOnyxLogo size={24} className="shrink-0" />
</div>
</div>
);

View File

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

View File

@@ -98,7 +98,7 @@ export default function IndexAttemptErrorsModal({
return (
<Modal open onOpenChange={onClose}>
<Modal.Content width="lg" height="full">
<Modal.Content width="full" height="full">
<Modal.Header
icon={SvgAlertTriangle}
title="Indexing Errors"

View File

@@ -1,13 +0,0 @@
"use client";
import { use } from "react";
import EditGroupPage from "@/refresh-pages/admin/GroupsPage/EditGroupPage";
export default function EditGroupRoute({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return <EditGroupPage groupId={Number(id)} />;
}

View File

@@ -1,17 +0,0 @@
"use client";
import { use } from "react";
import EditGroupPage from "@/refresh-pages/admin/GroupsPage/EditGroupPage";
export default function EditGroupRoute({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const groupId = Number(id);
if (Number.isNaN(groupId)) {
return null;
}
return <EditGroupPage groupId={groupId} />;
}

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ 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 {
@@ -230,21 +231,11 @@ const InputBar = memo(
const handlePaste = useCallback(
(event: ClipboardEvent) => {
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);
}
const pastedFiles = getPastedFilesIfNoText(event.clipboardData);
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
actionButtons={
pinnedContent={
<div className="flex flex-col gap-0.5">
{newBuildButton}
{buildConfigurePanel}

View File

@@ -151,7 +151,7 @@ export default function ConfigureConnectorModal({
return (
<>
<Modal open={open} onOpenChange={onClose}>
<Modal.Content width="md" height="fit">
<Modal.Content width="xl" height="fit">
<Modal.Header
icon={SvgPlug}
title={getStepTitle()}

View File

@@ -263,7 +263,7 @@ export default function CredentialStep({
open
onOpenChange={() => setCreateCredentialFormToggle(false)}
>
<Modal.Content width="md" height="fit">
<Modal.Content width="xl" height="fit">
<Modal.Header
icon={SvgKey}
title={`Create a ${getSourceDisplayName(

View File

@@ -215,7 +215,7 @@ export default function UserLibraryModal({
return (
<>
<Modal open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<Modal.Content width="md" height="fit">
<Modal.Content width="xl" height="fit">
<Modal.Header
icon={SvgFileText}
title="Your Files"

View File

@@ -2,6 +2,8 @@
/* 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-app-layout: 9;
--z-sticky: 10;

View File

@@ -0,0 +1,31 @@
import { ConnectorStatus } from "@/lib/types";
import { ConnectorMultiSelect } from "@/components/ConnectorMultiSelect";
interface ConnectorEditorProps {
selectedCCPairIds: number[];
setSetCCPairIds: (ccPairId: number[]) => void;
allCCPairs: ConnectorStatus<any, any>[];
}
export const ConnectorEditor = ({
selectedCCPairIds,
setSetCCPairIds,
allCCPairs,
}: ConnectorEditorProps) => {
// Filter out public docs, since they don't make sense as part of a group
const privateCCPairs = allCCPairs.filter(
(ccPair) => ccPair.access_type === "private"
);
return (
<ConnectorMultiSelect
name="connectors"
label="Connectors"
connectors={privateCCPairs}
selectedIds={selectedCCPairIds}
onChange={setSetCCPairIds}
placeholder="Search for connectors..."
showError={true}
/>
);
};

View File

@@ -0,0 +1,87 @@
import { User } from "@/lib/types";
import { FiX } from "react-icons/fi";
import InputComboBox from "@/refresh-components/inputs/InputComboBox/InputComboBox";
import Button from "@/refresh-components/buttons/Button";
interface UserEditorProps {
selectedUserIds: string[];
setSelectedUserIds: (userIds: string[]) => void;
allUsers: User[];
existingUsers: User[];
onSubmit?: (users: User[]) => void;
}
export const UserEditor = ({
selectedUserIds,
setSelectedUserIds,
allUsers,
existingUsers,
onSubmit,
}: UserEditorProps) => {
const selectedUsers = allUsers.filter((user) =>
selectedUserIds.includes(user.id)
);
return (
<>
<div className="mb-2 flex flex-wrap gap-x-2">
{selectedUsers.length > 0 &&
selectedUsers.map((selectedUser) => (
<div
key={selectedUser.id}
onClick={() => {
setSelectedUserIds(
selectedUserIds.filter((userId) => userId !== selectedUser.id)
);
}}
className={`
flex
rounded-lg
px-2
py-1
border
border-border
hover:bg-accent-background
cursor-pointer`}
>
{selectedUser.email} <FiX className="ml-1 my-auto" />
</div>
))}
</div>
<div className="flex">
<InputComboBox
placeholder="Search..."
value=""
onChange={() => {}}
onValueChange={(selectedValue) => {
setSelectedUserIds([
...Array.from(new Set([...selectedUserIds, selectedValue])),
]);
}}
options={allUsers
.filter(
(user) =>
!selectedUserIds.includes(user.id) &&
!existingUsers.map((user) => user.id).includes(user.id)
)
.map((user) => ({
label: user.email,
value: user.id,
}))}
strict
leftSearchIcon
/>
{onSubmit && (
// TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved
<Button
className="ml-3 flex-nowrap w-32"
onClick={() => onSubmit(selectedUsers)}
>
Add Users
</Button>
)}
</div>
</>
);
};

View File

@@ -0,0 +1,153 @@
import { Form, Formik } from "formik";
import * as Yup from "yup";
import { toast } from "@/hooks/useToast";
import { ConnectorStatus, User, UserGroup } from "@/lib/types";
import { TextFormField } from "@/components/Field";
import { createUserGroup } from "./lib";
import { UserEditor } from "./UserEditor";
import { ConnectorEditor } from "./ConnectorEditor";
import Modal from "@/refresh-components/Modal";
import Button from "@/refresh-components/buttons/Button";
import Separator from "@/refresh-components/Separator";
import Text from "@/refresh-components/texts/Text";
import { SvgUsers } from "@opal/icons";
import { useVectorDbEnabled } from "@/providers/SettingsProvider";
export interface UserGroupCreationFormProps {
onClose: () => void;
users: User[];
ccPairs: ConnectorStatus<any, any>[];
existingUserGroup?: UserGroup;
}
export default function UserGroupCreationForm({
onClose,
users,
ccPairs,
existingUserGroup,
}: UserGroupCreationFormProps) {
const isUpdate = existingUserGroup !== undefined;
const vectorDbEnabled = useVectorDbEnabled();
const privateCcPairs = ccPairs.filter(
(ccPair) => ccPair.access_type === "private"
);
return (
<Modal open onOpenChange={onClose}>
<Modal.Content>
<Modal.Header
icon={SvgUsers}
title={isUpdate ? "Update a User Group" : "Create a new User Group"}
onClose={onClose}
/>
<Modal.Body>
<Separator />
<Formik
initialValues={{
name: existingUserGroup ? existingUserGroup.name : "",
user_ids: [] as string[],
cc_pair_ids: [] as number[],
}}
validationSchema={Yup.object().shape({
name: Yup.string().required("Please enter a name for the group"),
user_ids: Yup.array().of(Yup.string().required()),
cc_pair_ids: Yup.array().of(Yup.number().required()),
})}
onSubmit={async (values, formikHelpers) => {
formikHelpers.setSubmitting(true);
let response;
response = await createUserGroup(values);
formikHelpers.setSubmitting(false);
if (response.ok) {
toast.success(
isUpdate
? "Successfully updated user group!"
: "Successfully created user group!"
);
onClose();
} else {
const responseJson = await response.json();
const errorMsg = responseJson.detail || responseJson.message;
toast.error(
isUpdate
? `Error updating user group - ${errorMsg}`
: `Error creating user group - ${errorMsg}`
);
}
}}
>
{({ isSubmitting, values, setFieldValue }) => (
<Form>
<TextFormField
name="name"
label="Name:"
placeholder="A name for the User Group"
disabled={isUpdate}
/>
<Separator />
{vectorDbEnabled ? (
<>
<Text as="p" className="font-medium">
Select which private connectors this group has access to:
</Text>
<Text as="p" text02>
All documents indexed by the selected connectors will be
visible to users in this group.
</Text>
<ConnectorEditor
allCCPairs={privateCcPairs}
selectedCCPairIds={values.cc_pair_ids}
setSetCCPairIds={(ccPairsIds) =>
setFieldValue("cc_pair_ids", ccPairsIds)
}
/>
</>
) : (
<Text as="p" text03>
Connectors are not available in Onyx Lite. Redeploy Onyx
with DISABLE_VECTOR_DB=false to index knowledge via
connectors.
</Text>
)}
<Separator />
<Text as="p" className="font-medium">
Select which Users should be a part of this Group.
</Text>
<Text as="p" text02>
All selected users will be able to search through all
documents indexed by the selected connectors.
</Text>
<div className="mb-3 gap-2">
<UserEditor
selectedUserIds={values.user_ids}
setSelectedUserIds={(userIds) =>
setFieldValue("user_ids", userIds)
}
allUsers={users}
existingUsers={[]}
/>
</div>
<div className="flex">
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
<Button
type="submit"
disabled={isSubmitting}
className="mx-auto w-64"
>
{isUpdate ? "Update!" : "Create!"}
</Button>
</div>
</Form>
)}
</Formik>
</Modal.Body>
</Modal.Content>
</Modal>
);
}

View File

@@ -0,0 +1,177 @@
"use client";
import {
Table,
TableHead,
TableRow,
TableBody,
TableCell,
} from "@/components/ui/table";
import { toast } from "@/hooks/useToast";
import { LoadingAnimation } from "@/components/Loading";
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
import { deleteUserGroup } from "./lib";
import { useRouter } from "next/navigation";
import { FiEdit2, FiUser } from "react-icons/fi";
import { User, UserGroup } from "@/lib/types";
import Link from "next/link";
import { DeleteButton } from "@/components/DeleteButton";
import { TableHeader } from "@/components/ui/table";
import Button from "@/refresh-components/buttons/Button";
import { SvgEdit } from "@opal/icons";
const MAX_USERS_TO_DISPLAY = 6;
const SimpleUserDisplay = ({ user }: { user: User }) => {
return (
<div className="flex my-0.5">
<FiUser className="mr-2 my-auto" /> {user.email}
</div>
);
};
interface UserGroupsTableProps {
userGroups: UserGroup[];
refresh: () => void;
}
export const UserGroupsTable = ({
userGroups,
refresh,
}: UserGroupsTableProps) => {
const router = useRouter();
// sort by name for consistent ordering
userGroups.sort((a, b) => {
if (a.name < b.name) {
return -1;
} else if (a.name > b.name) {
return 1;
} else {
return 0;
}
});
return (
<div>
<Table className="overflow-visible">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Connectors</TableHead>
<TableHead>Users</TableHead>
<TableHead>Status</TableHead>
<TableHead>Delete</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{userGroups
.filter((userGroup) => !userGroup.is_up_for_deletion)
.map((userGroup) => {
return (
<TableRow key={userGroup.id}>
<TableCell>
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
<Button
internal
leftIcon={SvgEdit}
href={`/admin/groups/${userGroup.id}`}
className="truncate"
>
{userGroup.name}
</Button>
</TableCell>
<TableCell>
{userGroup.cc_pairs.length > 0 ? (
<div>
{userGroup.cc_pairs.map((ccPairDescriptor, ind) => {
return (
<div
className={
ind !== userGroup.cc_pairs.length - 1
? "mb-3"
: ""
}
key={ccPairDescriptor.id}
>
<ConnectorTitle
connector={ccPairDescriptor.connector}
ccPairId={ccPairDescriptor.id}
ccPairName={ccPairDescriptor.name}
showMetadata={false}
/>
</div>
);
})}
</div>
) : (
"-"
)}
</TableCell>
<TableCell>
{userGroup.users.length > 0 ? (
<div>
{userGroup.users.length <= MAX_USERS_TO_DISPLAY ? (
userGroup.users.map((user) => {
return (
<SimpleUserDisplay key={user.id} user={user} />
);
})
) : (
<div>
{userGroup.users
.slice(0, MAX_USERS_TO_DISPLAY)
.map((user) => {
return (
<SimpleUserDisplay
key={user.id}
user={user}
/>
);
})}
<div>
+ {userGroup.users.length - MAX_USERS_TO_DISPLAY}{" "}
more
</div>
</div>
)}
</div>
) : (
"-"
)}
</TableCell>
<TableCell>
{userGroup.is_up_to_date ? (
<div className="text-success">Up to date!</div>
) : (
<div className="w-10">
<LoadingAnimation text="Syncing" />
</div>
)}
</TableCell>
<TableCell>
<DeleteButton
onClick={async (event) => {
event.stopPropagation();
const response = await deleteUserGroup(userGroup.id);
if (response.ok) {
toast.success(
`User Group "${userGroup.name}" deleted`
);
} else {
const errorMsg = (await response.json()).detail;
toast.error(
`Failed to delete User Group - ${errorMsg}`
);
}
refresh();
}}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
};

View File

@@ -0,0 +1,83 @@
import { Button } from "@opal/components";
import Modal from "@/refresh-components/Modal";
import { useState } from "react";
import { updateUserGroup } from "./lib";
import { toast } from "@/hooks/useToast";
import { ConnectorStatus, UserGroup } from "@/lib/types";
import { ConnectorMultiSelect } from "@/components/ConnectorMultiSelect";
import { SvgPlus } from "@opal/icons";
export interface AddConnectorFormProps {
ccPairs: ConnectorStatus<any, any>[];
userGroup: UserGroup;
onClose: () => void;
}
export default function AddConnectorForm({
ccPairs,
userGroup,
onClose,
}: AddConnectorFormProps) {
const [selectedCCPairIds, setSelectedCCPairIds] = useState<number[]>([]);
// Filter out ccPairs that are already in the user group and are not private
const availableCCPairs = ccPairs
.filter(
(ccPair) =>
!userGroup.cc_pairs
.map((userGroupCCPair) => userGroupCCPair.id)
.includes(ccPair.cc_pair_id)
)
.filter((ccPair) => ccPair.access_type === "private");
return (
<Modal open onOpenChange={onClose}>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgPlus}
title="Add New Connector"
onClose={onClose}
/>
<Modal.Body>
<ConnectorMultiSelect
name="connectors"
label="Select Connectors"
connectors={availableCCPairs}
selectedIds={selectedCCPairIds}
onChange={setSelectedCCPairIds}
placeholder="Search for connectors to add..."
showError={false}
/>
<Button
onClick={async () => {
const newCCPairIds = [
...Array.from(
new Set(
userGroup.cc_pairs
.map((ccPair) => ccPair.id)
.concat(selectedCCPairIds)
)
),
];
const response = await updateUserGroup(userGroup.id, {
user_ids: userGroup.users.map((user) => user.id),
cc_pair_ids: newCCPairIds,
});
if (response.ok) {
toast.success("Successfully added connectors to group");
onClose();
} else {
const responseJson = await response.json();
const errorMsg = responseJson.detail || responseJson.message;
toast.error(`Failed to add connectors to group - ${errorMsg}`);
onClose();
}
}}
>
Add Connectors
</Button>
</Modal.Body>
</Modal.Content>
</Modal>
);
}

View File

@@ -0,0 +1,64 @@
import Modal from "@/refresh-components/Modal";
import { updateUserGroup } from "./lib";
import { toast } from "@/hooks/useToast";
import { User, UserGroup } from "@/lib/types";
import { UserEditor } from "../UserEditor";
import { useState } from "react";
import { SvgUserPlus } from "@opal/icons";
export interface AddMemberFormProps {
users: User[];
userGroup: UserGroup;
onClose: () => void;
}
export default function AddMemberForm({
users,
userGroup,
onClose,
}: AddMemberFormProps) {
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
return (
<Modal open onOpenChange={onClose}>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgUserPlus}
title="Add New User"
onClose={onClose}
/>
<Modal.Body>
<UserEditor
selectedUserIds={selectedUserIds}
setSelectedUserIds={setSelectedUserIds}
allUsers={users}
existingUsers={userGroup.users}
onSubmit={async (selectedUsers) => {
const newUserIds = [
...Array.from(
new Set(
userGroup.users
.map((user) => user.id)
.concat(selectedUsers.map((user) => user.id))
)
),
];
const response = await updateUserGroup(userGroup.id, {
user_ids: newUserIds,
cc_pair_ids: userGroup.cc_pairs.map((ccPair) => ccPair.id),
});
if (response.ok) {
toast.success("Successfully added users to group");
onClose();
} else {
const responseJson = await response.json();
const errorMsg = responseJson.detail || responseJson.message;
toast.error(`Failed to add users to group - ${errorMsg}`);
onClose();
}
}}
/>
</Modal.Body>
</Modal.Content>
</Modal>
);
}

View File

@@ -0,0 +1,57 @@
import { toast } from "@/hooks/useToast";
import CreateRateLimitModal from "../../../../admin/token-rate-limits/CreateRateLimitModal";
import { Scope } from "../../../../admin/token-rate-limits/types";
import { insertGroupTokenRateLimit } from "../../../../admin/token-rate-limits/lib";
import { mutate } from "swr";
interface AddMemberFormProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
userGroupId: number;
}
const handleCreateGroupTokenRateLimit = async (
period_hours: number,
token_budget: number,
group_id: number = -1
) => {
const tokenRateLimitArgs = {
enabled: true,
token_budget: token_budget,
period_hours: period_hours,
};
return await insertGroupTokenRateLimit(tokenRateLimitArgs, group_id);
};
export const AddTokenRateLimitForm: React.FC<AddMemberFormProps> = ({
isOpen,
setIsOpen,
userGroupId,
}) => {
const handleSubmit = (
_: Scope,
period_hours: number,
token_budget: number,
group_id: number = -1
) => {
handleCreateGroupTokenRateLimit(period_hours, token_budget, group_id)
.then(() => {
setIsOpen(false);
toast.success("Token rate limit created!");
mutate(`/api/admin/token-rate-limits/user-group/${userGroupId}`);
})
.catch((error) => {
toast.error(error.message);
});
};
return (
<CreateRateLimitModal
isOpen={isOpen}
setIsOpen={setIsOpen}
onSubmit={handleSubmit}
forSpecificScope={Scope.USER_GROUP}
forSpecificUserGroup={userGroupId}
/>
);
};

View File

@@ -0,0 +1,483 @@
"use client";
import { toast } from "@/hooks/useToast";
import { useState } from "react";
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
import AddMemberForm from "./AddMemberForm";
import { updateUserGroup, updateCuratorStatus } from "./lib";
import { LoadingAnimation } from "@/components/Loading";
import {
User,
UserGroup,
UserRole,
USER_ROLE_LABELS,
ConnectorStatus,
} from "@/lib/types";
import AddConnectorForm from "./AddConnectorForm";
import Separator from "@/refresh-components/Separator";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import Text from "@/components/ui/text";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { DeleteButton } from "@/components/DeleteButton";
import { Bubble } from "@/components/Bubble";
import { BookmarkIcon, RobotIcon } from "@/components/icons/icons";
import { AddTokenRateLimitForm } from "./AddTokenRateLimitForm";
import { GenericTokenRateLimitTable } from "@/app/admin/token-rate-limits/TokenRateLimitTables";
import { useUser } from "@/providers/UserProvider";
import GenericConfirmModal from "@/components/modals/GenericConfirmModal";
import Spacer from "@/refresh-components/Spacer";
interface GroupDisplayProps {
users: User[];
ccPairs: ConnectorStatus<any, any>[];
userGroup: UserGroup;
refreshUserGroup: () => void;
}
const UserRoleDropdown = ({
user,
group,
onSuccess,
onError,
isAdmin,
}: {
user: User;
group: UserGroup;
onSuccess: () => void;
onError: (message: string) => void;
isAdmin: boolean;
}) => {
const [localRole, setLocalRole] = useState(() => {
if (user.role === UserRole.CURATOR) {
return group.curator_ids.includes(user.id)
? UserRole.CURATOR
: UserRole.BASIC;
}
return user.role;
});
const [isSettingRole, setIsSettingRole] = useState(false);
const [showDemoteConfirm, setShowDemoteConfirm] = useState(false);
const [pendingRoleChange, setPendingRoleChange] = useState<string | null>(
null
);
const { user: currentUser } = useUser();
const applyRoleChange = async (value: string) => {
if (value === localRole) return;
if (value === UserRole.BASIC || value === UserRole.CURATOR) {
setIsSettingRole(true);
setLocalRole(value);
try {
const response = await updateCuratorStatus(group.id, {
user_id: user.id,
is_curator: value === UserRole.CURATOR,
});
if (response.ok) {
onSuccess();
user.role = value;
} else {
const errorData = await response.json();
throw new Error(errorData.detail || "Failed to update user role");
}
} catch (error: any) {
onError(error.message);
setLocalRole(user.role);
} finally {
setIsSettingRole(false);
}
}
};
const handleChange = (value: string) => {
if (value === UserRole.BASIC && user.id === currentUser?.id) {
setPendingRoleChange(value);
setShowDemoteConfirm(true);
} else {
applyRoleChange(value);
}
};
const isEditable =
user.role === UserRole.BASIC || user.role === UserRole.CURATOR;
return (
<>
{/* Confirmation modal - only shown when users try to demote themselves */}
{showDemoteConfirm && pendingRoleChange && (
<GenericConfirmModal
title="Remove Yourself as a Curator for this Group?"
message="Are you sure you want to change your role to Basic? This will remove your ability to curate this group."
confirmText="Yes, set me to Basic"
onClose={() => {
// Cancel the role change if user dismisses modal
setShowDemoteConfirm(false);
setPendingRoleChange(null);
}}
onConfirm={() => {
// Apply the role change if user confirms
setShowDemoteConfirm(false);
applyRoleChange(pendingRoleChange);
setPendingRoleChange(null);
}}
/>
)}
{isEditable ? (
<InputSelect
value={localRole}
onValueChange={handleChange}
disabled={isSettingRole}
>
<InputSelect.Trigger placeholder="Select role" />
<InputSelect.Content>
<InputSelect.Item value={UserRole.BASIC}>Basic</InputSelect.Item>
<InputSelect.Item value={UserRole.CURATOR}>
Curator
</InputSelect.Item>
</InputSelect.Content>
</InputSelect>
) : (
<div>{USER_ROLE_LABELS[localRole]}</div>
)}
</>
);
};
export const GroupDisplay = ({
users,
ccPairs,
userGroup,
refreshUserGroup,
}: GroupDisplayProps) => {
const [addMemberFormVisible, setAddMemberFormVisible] = useState(false);
const [addConnectorFormVisible, setAddConnectorFormVisible] = useState(false);
const [addRateLimitFormVisible, setAddRateLimitFormVisible] = useState(false);
const { isAdmin } = useUser();
const onRoleChangeSuccess = () =>
toast.success("User role updated successfully!");
const onRoleChangeError = (errorMsg: string) =>
toast.error(`Unable to update user role - ${errorMsg}`);
return (
<div>
<div className="text-sm mb-3 flex">
<Text className="mr-1">Status:</Text>{" "}
{userGroup.is_up_to_date ? (
<div className="text-success font-bold">Up to date</div>
) : (
<div className="text-accent font-bold">
<LoadingAnimation text="Syncing" />
</div>
)}
</div>
<Separator />
<div className="flex w-full">
<h2 className="text-xl font-bold">Users</h2>
</div>
<div className="mt-2">
{userGroup.users.length > 0 ? (
<>
<Table className="overflow-visible">
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead className="flex w-full">
<div className="ml-auto">Remove User</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{userGroup.users.map((groupMember) => {
return (
<TableRow key={groupMember.id}>
<TableCell className="whitespace-normal break-all">
{groupMember.email}
</TableCell>
<TableCell>
<UserRoleDropdown
user={groupMember}
group={userGroup}
onSuccess={onRoleChangeSuccess}
onError={onRoleChangeError}
isAdmin={isAdmin}
/>
</TableCell>
<TableCell>
<div className="flex w-full">
<div className="ml-auto m-2">
{(isAdmin ||
!userGroup.curator_ids.includes(
groupMember.id
)) && (
<DeleteButton
onClick={async () => {
const response = await updateUserGroup(
userGroup.id,
{
user_ids: userGroup.users
.filter(
(userGroupUser) =>
userGroupUser.id !== groupMember.id
)
.map(
(userGroupUser) => userGroupUser.id
),
cc_pair_ids: userGroup.cc_pairs.map(
(ccPair) => ccPair.id
),
}
);
if (response.ok) {
toast.success(
"Successfully removed user from group"
);
} else {
const responseJson = await response.json();
const errorMsg =
responseJson.detail ||
responseJson.message;
toast.error(
`Error removing user from group - ${errorMsg}`
);
}
refreshUserGroup();
}}
/>
)}
</div>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</>
) : (
<div className="text-sm">No users in this group...</div>
)}
</div>
<SimpleTooltip
tooltip="Cannot update group while sync is occurring"
disabled={userGroup.is_up_to_date}
>
<Disabled disabled={!userGroup.is_up_to_date}>
<Button
onClick={() => {
if (userGroup.is_up_to_date) {
setAddMemberFormVisible(true);
}
}}
>
Add Users
</Button>
</Disabled>
</SimpleTooltip>
{addMemberFormVisible && (
<AddMemberForm
users={users}
userGroup={userGroup}
onClose={() => {
setAddMemberFormVisible(false);
refreshUserGroup();
}}
/>
)}
<Separator />
<h2 className="text-xl font-bold mt-8">Connectors</h2>
<div className="mt-2">
{userGroup.cc_pairs.length > 0 ? (
<>
<Table className="overflow-visible">
<TableHeader>
<TableRow>
<TableHead>Connector</TableHead>
<TableHead className="flex w-full">
<div className="ml-auto">Remove Connector</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{userGroup.cc_pairs.map((ccPair) => {
return (
<TableRow key={ccPair.id}>
<TableCell className="whitespace-normal break-all">
<ConnectorTitle
connector={ccPair.connector}
ccPairId={ccPair.id}
ccPairName={ccPair.name}
/>
</TableCell>
<TableCell>
<div className="flex w-full">
<div className="ml-auto m-2">
<DeleteButton
onClick={async () => {
const response = await updateUserGroup(
userGroup.id,
{
user_ids: userGroup.users.map(
(userGroupUser) => userGroupUser.id
),
cc_pair_ids: userGroup.cc_pairs
.filter(
(userGroupCCPair) =>
userGroupCCPair.id != ccPair.id
)
.map((ccPair) => ccPair.id),
}
);
if (response.ok) {
toast.success(
"Successfully removed connector from group"
);
} else {
const responseJson = await response.json();
const errorMsg =
responseJson.detail || responseJson.message;
toast.error(
`Error removing connector from group - ${errorMsg}`
);
}
refreshUserGroup();
}}
/>
</div>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</>
) : (
<div className="text-sm">No connectors in this group...</div>
)}
</div>
<SimpleTooltip
tooltip="Cannot update group while sync is occurring"
disabled={userGroup.is_up_to_date}
>
<Disabled disabled={!userGroup.is_up_to_date}>
<Button
onClick={() => {
if (userGroup.is_up_to_date) {
setAddConnectorFormVisible(true);
}
}}
>
Add Connectors
</Button>
</Disabled>
</SimpleTooltip>
{addConnectorFormVisible && (
<AddConnectorForm
ccPairs={ccPairs}
userGroup={userGroup}
onClose={() => {
setAddConnectorFormVisible(false);
refreshUserGroup();
}}
/>
)}
<Separator />
<h2 className="text-xl font-bold mt-8 mb-2">Document Sets</h2>
<div>
{userGroup.document_sets.length > 0 ? (
<div className="flex flex-wrap gap-2">
{userGroup.document_sets.map((documentSet) => {
return (
<Bubble isSelected key={documentSet.id}>
<div className="flex">
<BookmarkIcon />
<Text className="ml-1">{documentSet.name}</Text>
</div>
</Bubble>
);
})}
</div>
) : (
<>
<Text>No document sets in this group...</Text>
</>
)}
</div>
<Separator />
<h2 className="text-xl font-bold mt-8 mb-2">Agents</h2>
<div>
{userGroup.document_sets.length > 0 ? (
<div className="flex flex-wrap gap-2">
{userGroup.personas.map((persona) => {
return (
<Bubble isSelected key={persona.id}>
<div className="flex">
<RobotIcon />
<Text className="ml-1">{persona.name}</Text>
</div>
</Bubble>
);
})}
</div>
) : (
<>
<Text>No Agents in this group...</Text>
</>
)}
</div>
<Separator />
<h2 className="text-xl font-bold mt-8 mb-2">Token Rate Limits</h2>
<AddTokenRateLimitForm
isOpen={addRateLimitFormVisible}
setIsOpen={setAddRateLimitFormVisible}
userGroupId={userGroup.id}
/>
<GenericTokenRateLimitTable
fetchUrl={`/api/admin/token-rate-limits/user-group/${userGroup.id}`}
hideHeading
isAdmin={isAdmin}
/>
{isAdmin && (
<>
<Spacer rem={0.75} />
<Button onClick={() => setAddRateLimitFormVisible(true)}>
Create a Token Rate Limit
</Button>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,12 @@
import { useUserGroups } from "@/lib/hooks";
export const useSpecificUserGroup = (groupId: string) => {
const { data, isLoading, error, refreshUserGroups } = useUserGroups();
const userGroup = data?.find((group) => group.id.toString() === groupId);
return {
userGroup,
isLoading,
error,
refreshUserGroup: refreshUserGroups,
};
};

View File

@@ -0,0 +1,29 @@
import { UserGroupUpdate, SetCuratorRequest } from "../types";
export const updateUserGroup = async (
groupId: number,
userGroup: UserGroupUpdate
) => {
const url = `/api/manage/admin/user-group/${groupId}`;
return await fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(userGroup),
});
};
export const updateCuratorStatus = async (
groupId: number,
curatorRequest: SetCuratorRequest
) => {
const url = `/api/manage/admin/user-group/${groupId}/set-curator`;
return await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(curatorRequest),
});
};

View File

@@ -0,0 +1,87 @@
"use client";
import { use } from "react";
import { GroupDisplay } from "./GroupDisplay";
import { useSpecificUserGroup } from "./hook";
import { ThreeDotsLoader } from "@/components/Loading";
import { useConnectorStatus } from "@/lib/hooks";
import useUsers from "@/hooks/useUsers";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { useVectorDbEnabled } from "@/providers/SettingsProvider";
const route = ADMIN_ROUTES.GROUPS;
function Main({ groupId }: { groupId: string }) {
const vectorDbEnabled = useVectorDbEnabled();
const {
userGroup,
isLoading: userGroupIsLoading,
error: userGroupError,
refreshUserGroup,
} = useSpecificUserGroup(groupId);
const {
data: users,
isLoading: userIsLoading,
error: usersError,
} = useUsers({ includeApiKeys: true });
const {
data: ccPairs,
isLoading: isCCPairsLoading,
error: ccPairsError,
} = useConnectorStatus(30000, vectorDbEnabled);
if (
userGroupIsLoading ||
userIsLoading ||
(vectorDbEnabled && isCCPairsLoading)
) {
return (
<div className="h-full">
<div className="my-auto">
<ThreeDotsLoader />
</div>
</div>
);
}
if (!userGroup || userGroupError) {
return <div>Error loading user group</div>;
}
if (!users || usersError) {
return <div>Error loading users</div>;
}
if (vectorDbEnabled && (!ccPairs || ccPairsError)) {
return <div>Error loading connectors</div>;
}
return (
<>
<SettingsLayouts.Header
icon={route.icon}
title={userGroup.name || "Unknown"}
separator
backButton
/>
<SettingsLayouts.Body>
<GroupDisplay
users={users.accepted}
ccPairs={ccPairs ?? []}
userGroup={userGroup}
refreshUserGroup={refreshUserGroup}
/>
</SettingsLayouts.Body>
</>
);
}
export default function Page(props: { params: Promise<{ groupId: string }> }) {
const params = use(props.params);
return (
<SettingsLayouts.Root>
<Main groupId={params.groupId} />
</SettingsLayouts.Root>
);
}

View File

@@ -1,13 +0,0 @@
"use client";
import { use } from "react";
import EditGroupPage from "@/refresh-pages/admin/GroupsPage/EditGroupPage";
export default function EditGroupRoute({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return <EditGroupPage groupId={Number(id)} />;
}

View File

@@ -0,0 +1,17 @@
import { UserGroupCreation } from "./types";
export const createUserGroup = async (userGroup: UserGroupCreation) => {
return fetch("/api/manage/admin/user-group", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(userGroup),
});
};
export const deleteUserGroup = async (userGroupId: number) => {
return fetch(`/api/manage/admin/user-group/${userGroupId}`, {
method: "DELETE",
});
};

View File

@@ -1 +1,89 @@
export { default } from "@/refresh-pages/admin/GroupsPage";
"use client";
import { UserGroupsTable } from "./UserGroupsTable";
import UserGroupCreationForm from "./UserGroupCreationForm";
import { useState } from "react";
import { ThreeDotsLoader } from "@/components/Loading";
import { useConnectorStatus, useUserGroups } from "@/lib/hooks";
import useUsers from "@/hooks/useUsers";
import { useUser } from "@/providers/UserProvider";
import CreateButton from "@/refresh-components/buttons/CreateButton";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { useVectorDbEnabled } from "@/providers/SettingsProvider";
const route = ADMIN_ROUTES.GROUPS;
function Main() {
const [showForm, setShowForm] = useState(false);
const vectorDbEnabled = useVectorDbEnabled();
const { data, isLoading, error, refreshUserGroups } = useUserGroups();
const {
data: ccPairs,
isLoading: isCCPairsLoading,
error: ccPairsError,
} = useConnectorStatus(30000, vectorDbEnabled);
const {
data: users,
isLoading: userIsLoading,
error: usersError,
} = useUsers({ includeApiKeys: true });
const { isAdmin } = useUser();
if (isLoading || (vectorDbEnabled && isCCPairsLoading) || userIsLoading) {
return <ThreeDotsLoader />;
}
if (error || !data) {
return <div className="text-red-600">Error loading users</div>;
}
if (vectorDbEnabled && (ccPairsError || !ccPairs)) {
return <div className="text-red-600">Error loading connectors</div>;
}
if (usersError || !users) {
return <div className="text-red-600">Error loading users</div>;
}
return (
<>
{isAdmin && (
<CreateButton onClick={() => setShowForm(true)}>
Create New User Group
</CreateButton>
)}
{data.length > 0 && (
<div className="mt-2">
<UserGroupsTable userGroups={data} refresh={refreshUserGroups} />
</div>
)}
{showForm && (
<UserGroupCreationForm
onClose={() => {
refreshUserGroups();
setShowForm(false);
}}
users={users.accepted}
ccPairs={ccPairs ?? []}
/>
)}
</>
);
}
export default function Page() {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header icon={route.icon} title={route.title} separator />
<SettingsLayouts.Body>
<Main />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
);
}

View File

@@ -0,0 +1,15 @@
export interface UserGroupUpdate {
user_ids: string[];
cc_pair_ids: number[];
}
export interface SetCuratorRequest {
user_id: string;
is_curator: boolean;
}
export interface UserGroupCreation {
name: string;
user_ids: string[];
cc_pair_ids: number[];
}

View File

@@ -178,7 +178,7 @@ function PreviousQueryHistoryExportsModal({
return (
<Modal open onOpenChange={() => setShowModal(false)}>
<Modal.Content width="lg" height="full">
<Modal.Content width="full" height="full">
<Modal.Header
icon={SvgFileText}
title="Previous Query History Exports"

View File

@@ -0,0 +1,15 @@
"use client";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { HookPointMeta } from "@/refresh-pages/admin/HooksPage/interfaces";
export function useHookSpecs() {
const { data, isLoading, error } = useSWR<HookPointMeta[]>(
"/api/admin/hooks/specs",
errorHandlingFetcher,
{ revalidateOnFocus: false }
);
return { specs: data, isLoading, error };
}

View File

@@ -63,6 +63,9 @@ export interface Settings {
// are unavailable.
vector_db_enabled?: boolean;
// True when hooks are available: single-tenant deployment with HOOK_ENABLED=true.
hooks_enabled?: boolean;
// Application version from the ONYX_VERSION env var on the server.
version?: string | null;
}

View File

@@ -4,6 +4,7 @@ import {
SvgActivity,
SvgArrowExchange,
SvgAudio,
SvgHookNodes,
SvgBarChart,
SvgBookOpen,
SvgBubbleText,
@@ -227,6 +228,12 @@ export const ADMIN_ROUTES = {
title: "Document Index Migration",
sidebarLabel: "Document Index Migration",
},
HOOKS: {
path: "/admin/hooks",
icon: SvgHookNodes,
title: "Hook Extensions",
sidebarLabel: "Hook Extensions",
},
SCIM: {
path: "/admin/scim",
icon: SvgUserSync,

View File

@@ -0,0 +1,89 @@
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]);
});
});

52
web/src/lib/clipboard.ts Normal file
View File

@@ -0,0 +1,52 @@
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,8 +127,7 @@ 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 LOGO_FOLDED_SIZE_PX = 24;
export const LOGO_UNFOLDED_SIZE_PX = 88;
export const DEFAULT_LOGO_SIZE_PX = 24;
export const DEFAULT_CONTEXT_TOKENS = 120_000;
export const MAX_CHUNKS_FED_TO_CHAT = 25;

View File

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

View File

@@ -67,7 +67,7 @@ function LargeModalDemo() {
<div style={{ padding: 32 }}>
<Button onClick={() => setOpen(true)}>Open Large Modal</Button>
<Modal open={open} onOpenChange={setOpen}>
<Modal.Content width="lg" height="full">
<Modal.Content width="full" height="full">
<Modal.Header
icon={SvgInfoSmall}
title="Large Modal"

View File

@@ -76,10 +76,11 @@ const useModalContext = () => {
};
const widthClasses = {
lg: "w-[80dvw]",
md: "w-[60rem]",
"md-sm": "w-[50rem]",
sm: "w-[32rem]",
full: "w-[80dvw]",
xl: "w-[60rem]",
lg: "w-[50rem]",
md: "w-[40rem]",
sm: "w-[30rem]",
};
const heightClasses = {
@@ -97,20 +98,20 @@ const heightClasses = {
* @example
* ```tsx
* // Using width and height props
* <Modal.Content width="lg" height="full">
* {/* Large modal: w-[80dvw] h-[80dvh] *\/}
* <Modal.Content width="full" height="full">
* {/* Full modal: w-[80dvw] h-[80dvh] *\/}
* </Modal.Content>
*
* <Modal.Content width="md" height="fit">
* {/* Medium modal: w-[60rem] h-fit *\/}
* <Modal.Content width="xl" height="fit">
* {/* XL modal: w-[60rem] h-fit *\/}
* </Modal.Content>
*
* <Modal.Content width="sm" height="sm">
* {/* Small modal: w-[32rem] max-h-[30rem] *\/}
* {/* Small modal: w-[30rem] max-h-[30rem] *\/}
* </Modal.Content>
*
* <Modal.Content width="sm" height="lg">
* {/* Tall modal: w-[32rem] max-h-[calc(100dvh-4rem)] *\/}
* {/* Tall modal: w-[30rem] max-h-[calc(100dvh-4rem)] *\/}
* </Modal.Content>
* ```
*/
@@ -138,7 +139,7 @@ const ModalContent = React.forwardRef<
(
{
children,
width = "md",
width = "xl",
height = "fit",
position = "center",
preventAccidentalClose = true,

View File

@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import InputSearch from "./InputSearch";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
const meta: Meta<typeof InputSearch> = {
title: "refresh-components/inputs/InputSearch",
component: InputSearch,
tags: ["autodocs"],
decorators: [
(Story) => (
<TooltipPrimitive.Provider>
<div style={{ width: 320 }}>
<Story />
</div>
</TooltipPrimitive.Provider>
),
],
};
export default meta;
type Story = StoryObj<typeof InputSearch>;
export const Default: Story = {
render: function DefaultStory() {
const [value, setValue] = React.useState("");
return (
<InputSearch
placeholder="Search..."
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
},
};
export const WithValue: Story = {
render: function WithValueStory() {
const [value, setValue] = React.useState("Search Value");
return (
<InputSearch
placeholder="Search..."
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
},
};
export const Disabled: Story = {
render: function DisabledStory() {
return (
<InputSearch
placeholder="Search..."
value=""
onChange={() => {}}
disabled
/>
);
},
};

View File

@@ -0,0 +1,70 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import InputTypeIn, {
InputTypeInProps,
} from "@/refresh-components/inputs/InputTypeIn";
/**
* InputSearch Component
*
* A subtle search input that follows the "Subtle Input Styles" spec:
* no border by default, border appears on hover/focus/active.
*
* @example
* ```tsx
* // Basic usage
* <InputSearch
* placeholder="Search..."
* value={search}
* onChange={(e) => setSearch(e.target.value)}
* />
*
* // Disabled state
* <InputSearch
* disabled
* placeholder="Search..."
* value=""
* onChange={() => {}}
* />
* ```
*/
export interface InputSearchProps
extends Omit<InputTypeInProps, "variant" | "leftSearchIcon"> {
/**
* Ref to the underlying input element.
*/
ref?: React.Ref<HTMLInputElement>;
/**
* Whether the input is disabled.
*/
disabled?: boolean;
}
export default function InputSearch({
ref,
disabled,
className,
...props
}: InputSearchProps) {
return (
<InputTypeIn
ref={ref}
variant={disabled ? "disabled" : "internal"}
leftSearchIcon
className={cn(
"[&_input]:font-main-ui-muted [&_input]:text-text-02 [&_input]:placeholder:text-text-02",
!disabled && [
"border border-transparent",
"hover:border-border-03",
"active:border-border-05",
"focus-within:shadow-[0px_0px_0px_2px_var(--background-tint-04)]",
"focus-within:hover:border-border-03",
],
className
)}
{...props}
/>
);
}

View File

@@ -321,7 +321,7 @@ export default function ExpandableTextDisplay({
{/* Expanded Modal */}
<Modal open={isModalOpen} onOpenChange={setIsModalOpen}>
<Modal.Content height="lg" width="md-sm" preventAccidentalClose={false}>
<Modal.Content height="lg" width="lg" preventAccidentalClose={false}>
{/* Header */}
<div className="flex items-start justify-between px-4 py-3">
<div className="flex flex-col">

View File

@@ -114,6 +114,10 @@ function MCPServerCard({
const allToolIds = tools.map((t) => t.id);
const serverEnabled =
tools.length > 0 && tools.some((t) => isToolEnabled(t.id));
const needsAuth = !server.is_authenticated;
const authTooltip = needsAuth
? "Authenticate this MCP server before enabling its tools."
: undefined;
return (
<ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
@@ -122,10 +126,13 @@ function MCPServerCard({
description={server.description}
icon={getActionIcon(server.server_url, server.name)}
rightChildren={
<Switch
checked={serverEnabled}
onCheckedChange={(checked) => onToggleTools(allToolIds, checked)}
/>
<SimpleTooltip tooltip={authTooltip} side="top">
<Switch
checked={serverEnabled}
onCheckedChange={(checked) => onToggleTools(allToolIds, checked)}
disabled={needsAuth}
/>
</SimpleTooltip>
}
>
{tools.length > 0 && (
@@ -158,12 +165,15 @@ function MCPServerCard({
description={tool.description}
icon={tool.icon}
rightChildren={
<Switch
checked={isToolEnabled(tool.id)}
onCheckedChange={(checked) =>
onToggleTool(tool.id, checked)
}
/>
<SimpleTooltip tooltip={authTooltip} side="top">
<Switch
checked={isToolEnabled(tool.id)}
onCheckedChange={(checked) =>
onToggleTool(tool.id, checked)
}
disabled={needsAuth}
/>
</SimpleTooltip>
}
/>
))}
@@ -757,7 +767,7 @@ function ChatPreferencesForm() {
open={systemPromptModalOpen}
onOpenChange={setSystemPromptModalOpen}
>
<Modal.Content width="md" height="fit">
<Modal.Content width="xl" height="fit">
<Modal.Header
icon={SvgAddLines}
title="System Prompt"

View File

@@ -16,14 +16,8 @@ import Separator from "@/refresh-components/Separator";
import { toast } from "@/hooks/useToast";
import { errorHandlingFetcher } from "@/lib/fetcher";
import useAdminUsers from "@/hooks/useAdminUsers";
import type { ApiKeyDescriptor } from "./interfaces";
import {
createGroup,
deleteGroup,
updateAgentGroupSharing,
updateDocSetGroupSharing,
saveTokenLimits,
} from "./svc";
import type { ApiKeyDescriptor, MemberRow } from "./interfaces";
import { createGroup } from "./svc";
import { apiKeyToMemberRow, memberTableColumns, PAGE_SIZE } from "./shared";
import SharedGroupResources from "@/refresh-pages/admin/GroupsPage/SharedGroupResources";
import TokenLimitSection from "./TokenLimitSection";
@@ -53,7 +47,7 @@ function CreateGroupPage() {
const isLoading = usersLoading || apiKeysLoading;
const error = usersError ?? apiKeysError;
const allRows = useMemo(() => {
const allRows: MemberRow[] = useMemo(() => {
const activeUsers = users.filter((u) => u.is_active);
const serviceAccountRows = (apiKeys ?? []).map(apiKeyToMemberRow);
return [...activeUsers, ...serviceAccountRows];
@@ -67,27 +61,11 @@ function CreateGroupPage() {
}
setIsSubmitting(true);
let groupId: number | null = null;
try {
groupId = await createGroup(trimmed, selectedUserIds, selectedCcPairIds);
// Associate agents, doc sets, and token limits with the new group.
// If any of these fail, we roll back by deleting the group.
if (selectedAgentIds.length > 0) {
await updateAgentGroupSharing(groupId, [], selectedAgentIds);
}
if (selectedDocSetIds.length > 0) {
await updateDocSetGroupSharing(groupId, [], selectedDocSetIds);
}
await saveTokenLimits(groupId, tokenLimits, []);
await createGroup(trimmed, selectedUserIds, selectedCcPairIds);
toast.success(`Group "${trimmed}" created`);
router.push("/admin/groups");
} catch (e) {
// Roll back: delete the partially-created group so the user can retry
if (groupId != null) {
await deleteGroup(groupId).catch(() => {});
}
toast.error(e instanceof Error ? e.message : "Failed to create group");
} finally {
setIsSubmitting(false);
@@ -166,7 +144,6 @@ function CreateGroupPage() {
data={allRows}
columns={memberTableColumns}
getRowId={(row) => row.id ?? row.email}
qualifier="avatar"
pageSize={PAGE_SIZE}
searchTerm={searchTerm}
selectionBehavior="multi-select"

View File

@@ -1,513 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import useSWR, { useSWRConfig } from "swr";
import { Table, Button } from "@opal/components";
import { IllustrationContent } from "@opal/layouts";
import { SvgUsers, SvgTrash, SvgMinusCircle, SvgPlusCircle } from "@opal/icons";
import IconButton from "@/refresh-components/buttons/IconButton";
import Card from "@/refresh-components/cards/Card";
import * as InputLayouts from "@/layouts/input-layouts";
import SvgNoResult from "@opal/illustrations/no-result";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { Section } from "@/layouts/general-layouts";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import Text from "@/refresh-components/texts/Text";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
import Separator from "@/refresh-components/Separator";
import { toast } from "@/hooks/useToast";
import { errorHandlingFetcher } from "@/lib/fetcher";
import useAdminUsers from "@/hooks/useAdminUsers";
import type { UserGroup } from "@/lib/types";
import type {
ApiKeyDescriptor,
MemberRow,
TokenRateLimitDisplay,
} from "./interfaces";
import {
apiKeyToMemberRow,
baseColumns,
memberTableColumns,
tc,
PAGE_SIZE,
} from "./shared";
import {
USER_GROUP_URL,
renameGroup,
updateGroup,
deleteGroup,
updateAgentGroupSharing,
updateDocSetGroupSharing,
saveTokenLimits,
} from "./svc";
import SharedGroupResources from "@/refresh-pages/admin/GroupsPage/SharedGroupResources";
import TokenLimitSection from "./TokenLimitSection";
import type { TokenLimit } from "./TokenLimitSection";
const addModeColumns = memberTableColumns;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface EditGroupPageProps {
groupId: number;
}
function EditGroupPage({ groupId }: EditGroupPageProps) {
const router = useRouter();
const { mutate } = useSWRConfig();
// Fetch the group data — poll every 5s while syncing so the UI updates
// automatically when the backend finishes processing the previous edit.
const {
data: groups,
isLoading: groupLoading,
error: groupError,
} = useSWR<UserGroup[]>(USER_GROUP_URL, errorHandlingFetcher, {
refreshInterval: (latestData) => {
const g = latestData?.find((g) => g.id === groupId);
return g && !g.is_up_to_date ? 5000 : 0;
},
});
const group = useMemo(
() => groups?.find((g) => g.id === groupId) ?? null,
[groups, groupId]
);
const isSyncing = group != null && !group.is_up_to_date;
// Fetch token rate limits for this group
const { data: tokenRateLimits, isLoading: tokenLimitsLoading } = useSWR<
TokenRateLimitDisplay[]
>(`/api/admin/token-rate-limits/user-group/${groupId}`, errorHandlingFetcher);
// Form state
const [groupName, setGroupName] = useState("");
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const isSubmittingRef = useRef(false);
const [selectedCcPairIds, setSelectedCcPairIds] = useState<number[]>([]);
const [selectedDocSetIds, setSelectedDocSetIds] = useState<number[]>([]);
const [selectedAgentIds, setSelectedAgentIds] = useState<number[]>([]);
const [tokenLimits, setTokenLimits] = useState<TokenLimit[]>([
{ tokenBudget: null, periodHours: null },
]);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [initialized, setInitialized] = useState(false);
const [isAddingMembers, setIsAddingMembers] = useState(false);
const initialAgentIdsRef = useRef<number[]>([]);
const initialDocSetIdsRef = useRef<number[]>([]);
// Users and API keys
const { users, isLoading: usersLoading, error: usersError } = useAdminUsers();
const {
data: apiKeys,
isLoading: apiKeysLoading,
error: apiKeysError,
} = useSWR<ApiKeyDescriptor[]>("/api/admin/api-key", errorHandlingFetcher);
const isLoading =
groupLoading || usersLoading || apiKeysLoading || tokenLimitsLoading;
const error = groupError ?? usersError ?? apiKeysError;
// Pre-populate form when group data loads
useEffect(() => {
if (group && !initialized) {
setGroupName(group.name);
setSelectedUserIds(group.users.map((u) => u.id));
setSelectedCcPairIds(group.cc_pairs.map((cc) => cc.id));
const docSetIds = group.document_sets.map((ds) => ds.id);
setSelectedDocSetIds(docSetIds);
initialDocSetIdsRef.current = docSetIds;
const agentIds = group.personas.map((p) => p.id);
setSelectedAgentIds(agentIds);
initialAgentIdsRef.current = agentIds;
setInitialized(true);
}
}, [group, initialized]);
// Pre-populate token limits when fetched
useEffect(() => {
if (tokenRateLimits && tokenRateLimits.length > 0) {
setTokenLimits(
tokenRateLimits.map((trl) => ({
tokenBudget: trl.token_budget,
periodHours: trl.period_hours,
}))
);
}
}, [tokenRateLimits]);
const allRows = useMemo(() => {
const activeUsers = users.filter((u) => u.is_active);
const serviceAccountRows = (apiKeys ?? []).map(apiKeyToMemberRow);
return [...activeUsers, ...serviceAccountRows];
}, [users, apiKeys]);
const memberRows = useMemo(() => {
const selected = new Set(selectedUserIds);
return allRows.filter((r) => selected.has(r.id ?? r.email));
}, [allRows, selectedUserIds]);
const currentRowSelection = useMemo(() => {
const sel: Record<string, boolean> = {};
for (const id of selectedUserIds) sel[id] = true;
return sel;
}, [selectedUserIds]);
const handleRemoveMember = useCallback((userId: string) => {
setSelectedUserIds((prev) => prev.filter((id) => id !== userId));
}, []);
const memberColumns = useMemo(
() => [
...baseColumns,
tc.actions({
showSorting: false,
showColumnVisibility: false,
cell: (row: MemberRow) => (
<IconButton
icon={SvgMinusCircle}
tertiary
onClick={(e) => {
e.stopPropagation();
handleRemoveMember(row.id ?? row.email);
}}
/>
),
}),
],
[handleRemoveMember]
);
// IDs of members not visible in the add-mode table (e.g. inactive users).
// We preserve these so they aren't silently removed when the table fires
// onSelectionChange with only the visible rows.
const hiddenMemberIds = useMemo(() => {
const visibleIds = new Set(allRows.map((r) => r.id ?? r.email));
return selectedUserIds.filter((id) => !visibleIds.has(id));
}, [allRows, selectedUserIds]);
// Guard onSelectionChange: ignore updates until the form is fully initialized.
// Without this, TanStack fires onSelectionChange before all rows are loaded,
// which overwrites selectedUserIds with a partial set.
const handleSelectionChange = useCallback(
(ids: string[]) => {
if (!initialized) return;
setSelectedUserIds([...ids, ...hiddenMemberIds]);
},
[initialized, hiddenMemberIds]
);
async function handleSave() {
if (isSubmittingRef.current) return;
const trimmed = groupName.trim();
if (!trimmed) {
toast.error("Group name is required");
return;
}
// Re-fetch group to check sync status before saving
const freshGroups = await fetch(USER_GROUP_URL).then((r) => r.json());
const freshGroup = freshGroups.find((g: UserGroup) => g.id === groupId);
if (freshGroup && !freshGroup.is_up_to_date) {
toast.error(
"This group is currently syncing. Please wait a moment and try again."
);
return;
}
isSubmittingRef.current = true;
setIsSubmitting(true);
try {
// Rename if name changed
if (group && trimmed !== group.name) {
await renameGroup(group.id, trimmed);
}
// Update members and cc_pairs
await updateGroup(groupId, selectedUserIds, selectedCcPairIds);
// Update agent sharing (add/remove this group from changed agents)
await updateAgentGroupSharing(
groupId,
initialAgentIdsRef.current,
selectedAgentIds
);
// Update document set sharing (add/remove this group from changed doc sets)
await updateDocSetGroupSharing(
groupId,
initialDocSetIdsRef.current,
selectedDocSetIds
);
// Save token rate limits (create/update/delete)
await saveTokenLimits(groupId, tokenLimits, tokenRateLimits ?? []);
// Update refs so subsequent saves diff correctly
initialAgentIdsRef.current = selectedAgentIds;
initialDocSetIdsRef.current = selectedDocSetIds;
mutate(USER_GROUP_URL);
mutate(`/api/admin/token-rate-limits/user-group/${groupId}`);
toast.success(`Group "${trimmed}" updated`);
router.push("/admin/groups");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update group");
} finally {
isSubmittingRef.current = false;
setIsSubmitting(false);
}
}
async function handleDelete() {
setIsDeleting(true);
try {
await deleteGroup(groupId);
mutate(USER_GROUP_URL);
toast.success(`Group "${group?.name}" deleted`);
router.push("/admin/groups");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to delete group");
} finally {
setIsDeleting(false);
setShowDeleteModal(false);
}
}
// 404 state
if (!isLoading && !error && !group) {
return (
<SettingsLayouts.Root width="sm">
<SettingsLayouts.Header
icon={SvgUsers}
title="Group Not Found"
separator
/>
<SettingsLayouts.Body>
<IllustrationContent
illustration={SvgNoResult}
title="Group not found"
description="This group doesn't exist or may have been deleted."
/>
</SettingsLayouts.Body>
</SettingsLayouts.Root>
);
}
const headerActions = (
<Section flexDirection="row" gap={0.5} width="auto" height="auto">
<Button
prominence="tertiary"
onClick={() => router.push("/admin/groups")}
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!groupName.trim() || isSubmitting || isSyncing}
>
{isSubmitting ? "Saving..." : isSyncing ? "Syncing..." : "Save Changes"}
</Button>
</Section>
);
return (
<>
<SettingsLayouts.Root width="sm">
<SettingsLayouts.Header
icon={SvgUsers}
title="Edit Group"
separator
rightChildren={headerActions}
/>
<SettingsLayouts.Body>
{isLoading && <SimpleLoader />}
{error && (
<Text as="p" secondaryBody text03>
Failed to load group data.
</Text>
)}
{!isLoading && !error && group && (
<>
{/* Group Name */}
<Section
gap={0.5}
height="auto"
alignItems="stretch"
justifyContent="start"
>
<Text mainUiBody text04>
Group Name
</Text>
<InputTypeIn
placeholder="Name your group"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
/>
</Section>
<Separator noPadding />
{/* Members table */}
<Section
gap={0.75}
height="auto"
alignItems="stretch"
justifyContent="start"
>
<Section
flexDirection="row"
gap={0.5}
height="auto"
alignItems="center"
justifyContent="start"
>
<InputTypeIn
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={
isAddingMembers
? "Search users and accounts..."
: "Search members..."
}
leftSearchIcon
className="flex-1"
/>
{isAddingMembers ? (
<Button
prominence="secondary"
onClick={() => setIsAddingMembers(false)}
>
Done
</Button>
) : (
<Button
prominence="tertiary"
icon={SvgPlusCircle}
onClick={() => setIsAddingMembers(true)}
>
Add
</Button>
)}
</Section>
{isAddingMembers ? (
<Table
key="add-members"
data={allRows}
columns={addModeColumns}
getRowId={(row) => row.id ?? row.email}
qualifier="avatar"
pageSize={PAGE_SIZE}
searchTerm={searchTerm}
selectionBehavior="multi-select"
initialRowSelection={currentRowSelection}
onSelectionChange={handleSelectionChange}
footer={{}}
emptyState={
<IllustrationContent
illustration={SvgNoResult}
title="No users found"
description="No users match your search."
/>
}
/>
) : (
<Table
data={memberRows}
columns={memberColumns}
getRowId={(row) => row.id ?? row.email}
qualifier="avatar"
pageSize={PAGE_SIZE}
searchTerm={searchTerm}
footer={{}}
emptyState={
<IllustrationContent
illustration={SvgNoResult}
title="No members"
description="Add members to this group."
/>
}
/>
)}
</Section>
<SharedGroupResources
selectedCcPairIds={selectedCcPairIds}
onCcPairIdsChange={setSelectedCcPairIds}
selectedDocSetIds={selectedDocSetIds}
onDocSetIdsChange={setSelectedDocSetIds}
selectedAgentIds={selectedAgentIds}
onAgentIdsChange={setSelectedAgentIds}
/>
<TokenLimitSection
limits={tokenLimits}
onLimitsChange={setTokenLimits}
/>
{/* Delete This Group */}
<Card>
<InputLayouts.Horizontal
title="Delete This Group"
description="Members will lose access to any resources shared with this group."
center
>
<Button
variant="danger"
prominence="secondary"
icon={SvgTrash}
onClick={() => setShowDeleteModal(true)}
>
Delete Group
</Button>
</InputLayouts.Horizontal>
</Card>
</>
)}
</SettingsLayouts.Body>
</SettingsLayouts.Root>
{showDeleteModal && (
<ConfirmationModalLayout
icon={SvgTrash}
title="Delete Group"
onClose={() => setShowDeleteModal(false)}
submit={
<Button
variant="danger"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
}
>
<Text as="p" text03>
Members of group{" "}
<Text as="span" text05>
{group?.name}
</Text>{" "}
will lose access to any resources shared with this group, unless
they have been granted access directly. Deletion cannot be undone.
</Text>
</ConfirmationModalLayout>
)}
</>
);
}
export default EditGroupPage;

View File

@@ -1,6 +1,5 @@
"use client";
import { useRouter } from "next/navigation";
import type { UserGroup } from "@/lib/types";
import { SvgChevronRight, SvgUserManage, SvgUsers } from "@opal/icons";
import { ContentAction } from "@opal/layouts";
@@ -22,7 +21,6 @@ interface GroupCardProps {
}
function GroupCard({ group }: GroupCardProps) {
const router = useRouter();
const { mutate } = useSWRConfig();
const builtIn = isBuiltInGroup(group);
const isAdmin = group.name === "Admin";
@@ -41,7 +39,7 @@ function GroupCard({ group }: GroupCardProps) {
}
return (
<Card padding={0.5} data-card>
<Card padding={0.5}>
<ContentAction
icon={isAdmin ? SvgUserManage : SvgUsers}
title={group.name}
@@ -55,16 +53,13 @@ function GroupCard({ group }: GroupCardProps) {
<Section flexDirection="row" alignItems="start" gap={0}>
<div className="py-1">
<Text mainUiBody text03>
{formatMemberCount(
group.users.filter((u) => u.is_active).length
)}
{formatMemberCount(group.users.length)}
</Text>
</div>
<Button
icon={SvgChevronRight}
prominence="tertiary"
tooltip="View group"
onClick={() => router.push(`/admin/groups/${group.id}`)}
/>
</Section>
}

View File

@@ -8,14 +8,8 @@ import Popover from "@/refresh-components/Popover";
import Separator from "@/refresh-components/Separator";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import Text from "@/refresh-components/texts/Text";
import type { PopoverSection } from "./interfaces";
interface ResourcePopoverProps {
placeholder: string;
searchValue: string;
onSearchChange: (value: string) => void;
sections: PopoverSection[];
}
import { cn } from "@/lib/utils";
import type { ResourcePopoverProps } from "@/refresh-pages/admin/GroupsPage/SharedGroupResources/interfaces";
function ResourcePopover({
placeholder,
@@ -89,11 +83,12 @@ function ResourcePopover({
{section.items.map((item) => (
<div
key={item.key}
className={
className={cn(
"rounded-08 cursor-pointer",
item.disabled
? "rounded-08 bg-background-tint-02 cursor-pointer"
: "rounded-08 cursor-pointer hover:bg-background-tint-02 transition-colors"
}
? "bg-background-tint-02"
: "hover:bg-background-tint-02 transition-colors"
)}
onClick={() => {
item.onSelect();
}}

View File

@@ -40,11 +40,7 @@ function SharedBadge() {
);
}
interface SourceIconStackProps {
sources: { source: ValidSources }[];
}
function SourceIconStack({ sources }: SourceIconStackProps) {
function SourceIconStack({ sources }: { sources: { source: ValidSources }[] }) {
if (sources.length === 0) return null;
const unique = Array.from(
@@ -52,7 +48,13 @@ function SourceIconStack({ sources }: SourceIconStackProps) {
).slice(0, 3);
return (
<div className="flex items-center shrink-0 px-0.5">
<Section
flexDirection="row"
alignItems="center"
height="auto"
gap={0}
className="shrink-0 px-0.5"
>
{unique.map((s, i) => {
const Icon = getSourceMetadata(s.source).icon;
return (
@@ -65,7 +67,7 @@ function SourceIconStack({ sources }: SourceIconStackProps) {
</div>
);
})}
</div>
</Section>
);
}

View File

@@ -10,3 +10,10 @@ export interface PopoverSection {
label?: string;
items: PopoverItem[];
}
export interface ResourcePopoverProps {
placeholder: string;
searchValue: string;
onSearchChange: (value: string) => void;
sections: PopoverSection[];
}

View File

@@ -32,6 +32,16 @@ function TokenLimitSection({ limits, onLimitsChange }: TokenLimitSectionProps) {
const nextKeyRef = useRef(limits.length);
const keysRef = useRef<number[]>(limits.map((_, i) => i));
// Sync keys if the parent provides a different number of limits externally
// (e.g. loaded from server after initial mount).
if (keysRef.current.length < limits.length) {
while (keysRef.current.length < limits.length) {
keysRef.current.push(nextKeyRef.current++);
}
} else if (keysRef.current.length > limits.length) {
keysRef.current = keysRef.current.slice(0, limits.length);
}
function addLimit() {
const emptyIndex = limits.findIndex(
(l) => l.tokenBudget === null && l.periodHours === null
@@ -107,7 +117,7 @@ function TokenLimitSection({ limits, onLimitsChange }: TokenLimitSectionProps) {
<InputNumber
value={limit.periodHours}
onChange={(v) => updateLimit(i, "periodHours", v)}
min={0}
min={1}
placeholder="24"
/>
</div>

View File

@@ -13,10 +13,3 @@ export interface ApiKeyDescriptor {
export interface MemberRow extends UserRow {
api_key_display?: string;
}
export interface TokenRateLimitDisplay {
token_id: number;
enabled: boolean;
token_budget: number;
period_hours: number;
}

View File

@@ -4,7 +4,6 @@ import { SvgUser, SvgUserManage, SvgGlobe, SvgSlack } from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import Text from "@/refresh-components/texts/Text";
import { UserRole, UserStatus, USER_ROLE_LABELS } from "@/lib/types";
import { getInitials } from "@/refresh-pages/admin/UsersPage/utils";
import type { ApiKeyDescriptor, MemberRow } from "./interfaces";
// ---------------------------------------------------------------------------
@@ -77,21 +76,15 @@ function renderAccountTypeColumn(_value: unknown, row: MemberRow) {
const tc = createTableColumns<MemberRow>();
export const baseColumns = [
tc.qualifier({
content: "avatar-user",
getInitials: (row) => getInitials(row.personal_name, row.email),
selectable: true,
}),
tc.qualifier(),
tc.column("email", {
header: "Name",
weight: 25,
minWidth: 180,
cell: renderNameColumn,
}),
tc.column("api_key_display", {
header: "",
weight: 15,
minWidth: 100,
enableSorting: false,
cell: (value) =>
value ? (
@@ -103,7 +96,6 @@ export const baseColumns = [
tc.column("role", {
header: "Account Type",
weight: 15,
minWidth: 140,
cell: renderAccountTypeColumn,
}),
];
@@ -112,5 +104,3 @@ export const memberTableColumns = [
...baseColumns,
tc.actions({ showSorting: false }),
];
export { tc };

View File

@@ -40,27 +40,6 @@ async function createGroup(
return group.id;
}
async function updateGroup(
groupId: number,
userIds: string[],
ccPairIds: number[]
): Promise<void> {
const res = await fetch(`${USER_GROUP_URL}/${groupId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_ids: userIds,
cc_pair_ids: ccPairIds,
}),
});
if (!res.ok) {
const detail = await res.json().catch(() => null);
throw new Error(
detail?.detail ?? `Failed to update group: ${res.statusText}`
);
}
}
async function deleteGroup(groupId: number): Promise<void> {
const res = await fetch(`${USER_GROUP_URL}/${groupId}`, {
method: "DELETE",
@@ -85,48 +64,21 @@ async function updateAgentGroupSharing(
const initialSet = new Set(initialAgentIds);
const currentSet = new Set(currentAgentIds);
const added = currentAgentIds.filter((id) => !initialSet.has(id));
const removed = initialAgentIds.filter((id) => !currentSet.has(id));
const added_agent_ids = currentAgentIds.filter((id) => !initialSet.has(id));
const removed_agent_ids = initialAgentIds.filter((id) => !currentSet.has(id));
// For each added agent, add this group to its group_ids
for (const agentId of added) {
// Fetch current agent to get existing group associations
const agentRes = await fetch(`/api/persona/${agentId}`);
if (!agentRes.ok) {
throw new Error(`Failed to fetch agent ${agentId}`);
}
const agent = await agentRes.json();
const existingGroupIds: number[] = (agent.groups ?? []) as number[];
if (!existingGroupIds.includes(groupId)) {
existingGroupIds.push(groupId);
}
const shareRes = await fetch(`/api/persona/${agentId}/share`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ group_ids: existingGroupIds }),
});
if (!shareRes.ok) {
throw new Error(`Failed to share agent ${agentId} with group`);
}
}
if (added_agent_ids.length === 0 && removed_agent_ids.length === 0) return;
// For each removed agent, remove this group from its group_ids
for (const agentId of removed) {
const agentRes = await fetch(`/api/persona/${agentId}`);
if (!agentRes.ok) {
throw new Error(`Failed to fetch agent ${agentId}`);
}
const agent = await agentRes.json();
const existingGroupIds: number[] = (agent.groups ?? []) as number[];
const filtered = existingGroupIds.filter((id) => id !== groupId);
const shareRes = await fetch(`/api/persona/${agentId}/share`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ group_ids: filtered }),
});
if (!shareRes.ok) {
throw new Error(`Failed to update agent ${agentId} sharing`);
}
const res = await fetch(`${USER_GROUP_URL}/${groupId}/agents`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ added_agent_ids, removed_agent_ids }),
});
if (!res.ok) {
const detail = await res.json().catch(() => null);
throw new Error(
detail?.detail ?? `Failed to update agent sharing: ${res.statusText}`
);
}
}
@@ -138,6 +90,7 @@ interface DocumentSetSummary {
id: number;
description: string;
cc_pair_summaries: { id: number }[];
federated_connector_summaries: { id: number }[];
is_public: boolean;
users: string[];
groups: number[];
@@ -179,6 +132,9 @@ async function updateDocSetGroupSharing(
id: ds.id,
description: ds.description,
cc_pair_ids: ds.cc_pair_summaries.map((cc) => cc.id),
federated_connectors: ds.federated_connector_summaries.map((fc) => ({
federated_connector_id: fc.id,
})),
is_public: ds.is_public,
users: ds.users,
groups: updatedGroups,
@@ -202,6 +158,9 @@ async function updateDocSetGroupSharing(
id: ds.id,
description: ds.description,
cc_pair_ids: ds.cc_pair_summaries.map((cc) => cc.id),
federated_connectors: ds.federated_connector_summaries.map((fc) => ({
federated_connector_id: fc.id,
})),
is_public: ds.is_public,
users: ds.users,
groups: updatedGroups,
@@ -303,7 +262,6 @@ export {
USER_GROUP_URL,
renameGroup,
createGroup,
updateGroup,
deleteGroup,
updateAgentGroupSharing,
updateDocSetGroupSharing,

View File

@@ -0,0 +1,117 @@
"use client";
import { useState, useEffect } from "react";
import { toast } from "@/hooks/useToast";
import { useHookSpecs } from "@/hooks/useHookSpecs";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { ContentAction } from "@opal/layouts";
import { Button } from "@opal/components";
import InputSearch from "@/refresh-components/inputs/InputSearch";
import Card from "@/refresh-components/cards/Card";
import Text from "@/refresh-components/texts/Text";
import {
SvgArrowExchange,
SvgBubbleText,
SvgExternalLink,
SvgFileBroadcast,
SvgHookNodes,
} from "@opal/icons";
import { IconFunctionComponent } from "@opal/types";
const HOOK_POINT_ICONS: Record<string, IconFunctionComponent> = {
document_ingestion: SvgFileBroadcast,
query_processing: SvgBubbleText,
};
function getHookPointIcon(hookPoint: string): IconFunctionComponent {
return HOOK_POINT_ICONS[hookPoint] ?? SvgHookNodes;
}
export default function HooksContent() {
const [search, setSearch] = useState("");
const { specs, isLoading, error } = useHookSpecs();
useEffect(() => {
if (error) {
toast.error("Failed to load hook specifications.");
}
}, [error]);
if (isLoading) {
return <SimpleLoader />;
}
if (error) {
return (
<Text text03 secondaryBody>
Failed to load hook specifications. Please refresh the page.
</Text>
);
}
const filtered = (specs ?? []).filter(
(spec) =>
spec.display_name.toLowerCase().includes(search.toLowerCase()) ||
spec.description.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="flex flex-col gap-6">
<InputSearch
placeholder="Search hooks..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex flex-col gap-2">
{filtered.length === 0 ? (
<Text text03 secondaryBody>
{search
? "No hooks match your search."
: "No hook points are available."}
</Text>
) : (
filtered.map((spec) => (
<Card
key={spec.hook_point}
variant="secondary"
padding={0.5}
gap={0}
>
<ContentAction
icon={getHookPointIcon(spec.hook_point)}
title={spec.display_name}
description={spec.description}
sizePreset="main-content"
variant="section"
paddingVariant="fit"
rightChildren={
// TODO(Bo-Onyx): wire up Connect — open modal to create/edit hook
<Button prominence="tertiary" rightIcon={SvgArrowExchange}>
Connect
</Button>
}
/>
{spec.docs_url && (
<div className="pl-7 pt-1">
<a
href={spec.docs_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 w-fit text-text-03"
>
<Text as="span" secondaryBody text03 className="underline">
Documentation
</Text>
<SvgExternalLink size={16} className="text-text-02" />
</a>
</div>
)}
</Card>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { useSettingsContext } from "@/providers/SettingsProvider";
import { toast } from "@/hooks/useToast";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import HooksContent from "./HooksContent";
const route = ADMIN_ROUTES.HOOKS;
export default function HooksPage() {
const router = useRouter();
const { settings, settingsLoading } = useSettingsContext();
useEffect(() => {
if (!settingsLoading && !settings.hooks_enabled) {
toast.info("Hook Extensions are not enabled for this deployment.");
router.replace("/");
}
}, [settingsLoading, settings.hooks_enabled, router]);
if (settingsLoading || !settings.hooks_enabled) {
return <SimpleLoader />;
}
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={route.icon}
title={route.title}
description="Extend Onyx pipelines by registering external API endpoints as callbacks at predefined hook points."
separator
/>
<SettingsLayouts.Body>
<HooksContent />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
);
}

View File

@@ -0,0 +1,63 @@
export type HookPoint = string;
export type HookFailStrategy = "hard" | "soft";
export interface HookPointMeta {
hook_point: HookPoint;
display_name: string;
description: string;
docs_url: string | null;
input_schema: Record<string, unknown>;
output_schema: Record<string, unknown>;
default_timeout_seconds: number;
default_fail_strategy: HookFailStrategy;
fail_hard_description: string;
}
export interface HookResponse {
id: number;
name: string;
hook_point: HookPoint;
endpoint_url: string | null;
fail_strategy: HookFailStrategy;
timeout_seconds: number;
is_active: boolean;
is_reachable: boolean | null;
creator_email: string | null;
created_at: string;
updated_at: string;
}
export interface HookCreateRequest {
name: string;
hook_point: HookPoint;
endpoint_url: string;
api_key?: string;
fail_strategy?: HookFailStrategy;
timeout_seconds?: number;
}
export interface HookUpdateRequest {
name?: string;
endpoint_url?: string;
api_key?: string | null;
fail_strategy?: HookFailStrategy;
timeout_seconds?: number;
}
export interface HookExecutionRecord {
error_message: string | null;
status_code: number | null;
duration_ms: number | null;
created_at: string;
}
export type HookValidateStatus =
| "passed"
| "auth_failed"
| "timeout"
| "cannot_connect";
export interface HookValidateResponse {
status: HookValidateStatus;
error_message: string | null;
}

View File

@@ -20,6 +20,7 @@ import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import { ChatState } from "@/app/app/interfaces";
import { useForcedTools } from "@/lib/hooks/useForcedTools";
import useAppFocus from "@/hooks/useAppFocus";
import { getPastedFilesIfNoText } from "@/lib/clipboard";
import { cn, isImageFile } from "@/lib/utils";
import { Disabled } from "@opal/core";
import { useUser } from "@/providers/UserProvider";
@@ -300,20 +301,10 @@ const AppInputBar = React.memo(
}, [showFiles, currentMessageFiles]);
function handlePaste(event: React.ClipboardEvent) {
const items = event.clipboardData?.items;
if (items) {
const pastedFiles = [];
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();
handleFileUpload(pastedFiles);
}
const pastedFiles = getPastedFilesIfNoText(event.clipboardData);
if (pastedFiles.length > 0) {
event.preventDefault();
handleFileUpload(pastedFiles);
}
}

View File

@@ -237,7 +237,7 @@ export default function AgentViewerModal({ agent }: AgentViewerModalProps) {
onOpenChange={agentViewerModal.toggle}
>
<Modal.Content
width="md-sm"
width="lg"
height="lg"
bottomSlot={<AgentChatInput agent={agent} onSubmit={handleStartChat} />}
>

View File

@@ -17,7 +17,7 @@ export default function ExceptionTraceModal({
}: ExceptionTraceModalProps) {
return (
<Modal open onOpenChange={onOutsideClick}>
<Modal.Content width="lg" height="full">
<Modal.Content width="full" height="full">
<Modal.Header
icon={SvgAlertTriangle}
title="Full Exception Trace"

View File

@@ -10,7 +10,7 @@ import {
export const codeVariant: PreviewVariant = {
matches: (name) => !!getCodeLanguage(name || ""),
width: "md",
width: "xl",
height: "lg",
needsTextContent: true,
codeBackground: true,

View File

@@ -31,7 +31,7 @@ function parseCsv(content: string): CsvData {
export const csvVariant: PreviewVariant = {
matches: (name, mime) =>
mime.startsWith("text/csv") || (name || "").toLowerCase().endsWith(".csv"),
width: "lg",
width: "full",
height: "full",
needsTextContent: true,
codeBackground: false,

View File

@@ -22,7 +22,7 @@ function formatContent(language: string, content: string): string {
export const dataVariant: PreviewVariant = {
matches: (name, mime) =>
!!getDataLanguage(name || "") || !!getLanguageByMime(mime),
width: "md",
width: "xl",
height: "lg",
needsTextContent: true,
codeBackground: true,

View File

@@ -127,7 +127,7 @@ export const docxVariant: PreviewVariant = {
const lower = (name || "").toLowerCase();
return lower.endsWith(".docx") || lower.endsWith(".doc");
},
width: "lg",
width: "full",
height: "full",
needsTextContent: false,
codeBackground: false,

View File

@@ -8,7 +8,7 @@ import {
export const imageVariant: PreviewVariant = {
matches: (_name, mime) => mime.startsWith("image/"),
width: "lg",
width: "full",
height: "full",
needsTextContent: false,
codeBackground: false,

View File

@@ -19,7 +19,7 @@ export const markdownVariant: PreviewVariant = {
if (MARKDOWN_MIMES.some((m) => mime.startsWith(m))) return true;
return isMarkdownFile(name || "");
},
width: "lg",
width: "full",
height: "full",
needsTextContent: true,
codeBackground: false,

View File

@@ -4,7 +4,7 @@ import { DownloadButton } from "@/sections/modals/PreviewModal/variants/shared";
export const pdfVariant: PreviewVariant = {
matches: (_name, mime) => mime === "application/pdf",
width: "lg",
width: "full",
height: "full",
needsTextContent: false,
codeBackground: false,

View File

@@ -25,7 +25,7 @@ export const textVariant: PreviewVariant = {
const lowerName = (name || "").toLowerCase();
return TEXT_EXTENSIONS.some((extension) => lowerName.endsWith(extension));
},
width: "md",
width: "xl",
height: "lg",
needsTextContent: true,
codeBackground: true,

View File

@@ -5,7 +5,7 @@ import { DownloadButton } from "@/sections/modals/PreviewModal/variants/shared";
export const unsupportedVariant: PreviewVariant = {
matches: () => true,
width: "md",
width: "xl",
height: "full",
needsTextContent: false,
codeBackground: false,

View File

@@ -633,7 +633,7 @@ export function LLMConfigurationModalWrapper({
return (
<Modal open onOpenChange={onClose}>
<Modal.Content width="md-sm" height="lg">
<Modal.Content width="lg" height="lg">
<Form className="flex flex-col h-full min-h-0">
<Modal.Header
icon={providerIcon}

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