mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-07 16:02:45 +00:00
Compare commits
26 Commits
cli/v0.2.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c31338e9b7 | ||
|
|
1c32a83dc2 | ||
|
|
4a2ff7e0ef | ||
|
|
c3f8fad729 | ||
|
|
d50a5e0e27 | ||
|
|
697a679409 | ||
|
|
0c95650176 | ||
|
|
0d3a6b255b | ||
|
|
01748efe6a | ||
|
|
de6c4f4a51 | ||
|
|
689f61ce08 | ||
|
|
dec836a172 | ||
|
|
b6e623ef5c | ||
|
|
ec9e340656 | ||
|
|
885006cb7a | ||
|
|
472073cac0 | ||
|
|
5e61659e3a | ||
|
|
7b18949b63 | ||
|
|
efe51c108e | ||
|
|
c092d16c01 | ||
|
|
da715eaa58 | ||
|
|
bb18d39765 | ||
|
|
abc2cd5572 | ||
|
|
a704acbf73 | ||
|
|
8737122133 | ||
|
|
c5d7cfa896 |
2
.github/workflows/deployment.yml
vendored
2
.github/workflows/deployment.yml
vendored
@@ -228,7 +228,7 @@ jobs:
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: create-release
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # ratchet:softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # ratchet:softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.release-tag.outputs.tag }}
|
||||
name: ${{ steps.release-tag.outputs.tag }}
|
||||
|
||||
2
.github/workflows/helm-chart-releases.yml
vendored
2
.github/workflows/helm-chart-releases.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Helm CLI
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # ratchet:azure/setup-helm@v4
|
||||
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # ratchet:azure/setup-helm@v5.0.0
|
||||
with:
|
||||
version: v3.12.1
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # ratchet:actions/stale@v10
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # ratchet:actions/stale@v10
|
||||
with:
|
||||
stale-issue-message: 'This issue is stale because it has been open 75 days with no activity. Remove stale label or comment or this will be closed in 15 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open 75 days with no activity. Remove stale label or comment or this will be closed in 15 days.'
|
||||
|
||||
2
.github/workflows/pr-helm-chart-testing.yml
vendored
2
.github/workflows/pr-helm-chart-testing.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # ratchet:azure/setup-helm@v4.3.1
|
||||
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # ratchet:azure/setup-helm@v5.0.0
|
||||
with:
|
||||
version: v3.19.0
|
||||
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -59,3 +59,6 @@ node_modules
|
||||
|
||||
# plans
|
||||
plans/
|
||||
|
||||
# Added context for LLMs
|
||||
onyx-llm-context/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
from onyx.db.engine.iam_auth import get_iam_auth_token
|
||||
from onyx.configs.app_configs import USE_IAM_AUTH
|
||||
from onyx.configs.app_configs import POSTGRES_HOST
|
||||
@@ -19,7 +19,6 @@ from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.sql.schema import SchemaItem
|
||||
from onyx.configs.constants import SSL_CERT_FILE
|
||||
from shared_configs.configs import (
|
||||
MULTI_TENANT,
|
||||
@@ -45,8 +44,6 @@ if config.config_file_name is not None and config.attributes.get(
|
||||
|
||||
target_metadata = [Base.metadata, ResultModelBase.metadata]
|
||||
|
||||
EXCLUDE_TABLES = {"kombu_queue", "kombu_message"}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ssl_context: ssl.SSLContext | None = None
|
||||
@@ -56,25 +53,6 @@ if USE_IAM_AUTH:
|
||||
ssl_context = ssl.create_default_context(cafile=SSL_CERT_FILE)
|
||||
|
||||
|
||||
def include_object(
|
||||
object: SchemaItem, # noqa: ARG001
|
||||
name: str | None,
|
||||
type_: Literal[
|
||||
"schema",
|
||||
"table",
|
||||
"column",
|
||||
"index",
|
||||
"unique_constraint",
|
||||
"foreign_key_constraint",
|
||||
],
|
||||
reflected: bool, # noqa: ARG001
|
||||
compare_to: SchemaItem | None, # noqa: ARG001
|
||||
) -> bool:
|
||||
if type_ == "table" and name in EXCLUDE_TABLES:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def filter_tenants_by_range(
|
||||
tenant_ids: list[str], start_range: int | None = None, end_range: int | None = None
|
||||
) -> list[str]:
|
||||
@@ -231,7 +209,6 @@ def do_run_migrations(
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata, # type: ignore
|
||||
include_object=include_object,
|
||||
version_table_schema=schema_name,
|
||||
include_schemas=True,
|
||||
compare_type=True,
|
||||
@@ -405,7 +382,6 @@ def run_migrations_offline() -> None:
|
||||
url=url,
|
||||
target_metadata=target_metadata, # type: ignore
|
||||
literal_binds=True,
|
||||
include_object=include_object,
|
||||
version_table_schema=schema,
|
||||
include_schemas=True,
|
||||
script_location=config.get_main_option("script_location"),
|
||||
@@ -447,7 +423,6 @@ def run_migrations_offline() -> None:
|
||||
url=url,
|
||||
target_metadata=target_metadata, # type: ignore
|
||||
literal_binds=True,
|
||||
include_object=include_object,
|
||||
version_table_schema=schema,
|
||||
include_schemas=True,
|
||||
script_location=config.get_main_option("script_location"),
|
||||
@@ -490,7 +465,6 @@ def run_migrations_online() -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata, # type: ignore
|
||||
include_object=include_object,
|
||||
version_table_schema=schema_name,
|
||||
include_schemas=True,
|
||||
compare_type=True,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.schema import SchemaItem
|
||||
|
||||
from alembic import context
|
||||
from onyx.db.engine.sql_engine import build_connection_string
|
||||
@@ -35,27 +33,6 @@ target_metadata = [PublicBase.metadata]
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
EXCLUDE_TABLES = {"kombu_queue", "kombu_message"}
|
||||
|
||||
|
||||
def include_object(
|
||||
object: SchemaItem, # noqa: ARG001
|
||||
name: str | None,
|
||||
type_: Literal[
|
||||
"schema",
|
||||
"table",
|
||||
"column",
|
||||
"index",
|
||||
"unique_constraint",
|
||||
"foreign_key_constraint",
|
||||
],
|
||||
reflected: bool, # noqa: ARG001
|
||||
compare_to: SchemaItem | None, # noqa: ARG001
|
||||
) -> bool:
|
||||
if type_ == "table" and name in EXCLUDE_TABLES:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
@@ -85,7 +62,6 @@ def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata, # type: ignore[arg-type]
|
||||
include_object=include_object,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
|
||||
@@ -56,7 +56,6 @@ Then it cycles through its tasks as scheduled by Celery Beat:
|
||||
| `check_for_user_file_processing` | 20s | Checks for user uploads → dispatches to `USER_FILE_PROCESSING` queue |
|
||||
| `check_for_checkpoint_cleanup` | 1h | Cleans up old indexing checkpoints |
|
||||
| `check_for_index_attempt_cleanup` | 30m | Cleans up old index attempts |
|
||||
| `kombu_message_cleanup_task` | periodic | Cleans orphaned Kombu messages from DB (Kombu being the messaging framework used by Celery) |
|
||||
| `celery_beat_heartbeat` | 1m | Heartbeat for Beat watchdog |
|
||||
|
||||
Watchdog is a separate Python process managed by supervisord which runs alongside celery workers. It checks the ONYX_CELERY_BEAT_HEARTBEAT_KEY in
|
||||
|
||||
@@ -317,7 +317,6 @@ celery_app.autodiscover_tasks(
|
||||
"onyx.background.celery.tasks.docprocessing",
|
||||
"onyx.background.celery.tasks.evals",
|
||||
"onyx.background.celery.tasks.hierarchyfetching",
|
||||
"onyx.background.celery.tasks.periodic",
|
||||
"onyx.background.celery.tasks.pruning",
|
||||
"onyx.background.celery.tasks.shared",
|
||||
"onyx.background.celery.tasks.vespa",
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
#####
|
||||
# Periodic Tasks
|
||||
#####
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from celery import shared_task
|
||||
from celery.contrib.abortable import AbortableTask # type: ignore
|
||||
from celery.exceptions import TaskRevokedError
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.background.celery.apps.app_base import task_logger
|
||||
from onyx.configs.app_configs import JOB_TIMEOUT
|
||||
from onyx.configs.constants import OnyxCeleryTask
|
||||
from onyx.configs.constants import PostgresAdvisoryLocks
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
|
||||
|
||||
@shared_task(
|
||||
name=OnyxCeleryTask.KOMBU_MESSAGE_CLEANUP_TASK,
|
||||
soft_time_limit=JOB_TIMEOUT,
|
||||
bind=True,
|
||||
base=AbortableTask,
|
||||
)
|
||||
def kombu_message_cleanup_task(self: Any, tenant_id: str) -> int: # noqa: ARG001
|
||||
"""Runs periodically to clean up the kombu_message table"""
|
||||
|
||||
# we will select messages older than this amount to clean up
|
||||
KOMBU_MESSAGE_CLEANUP_AGE = 7 # days
|
||||
KOMBU_MESSAGE_CLEANUP_PAGE_LIMIT = 1000
|
||||
|
||||
ctx = {}
|
||||
ctx["last_processed_id"] = 0
|
||||
ctx["deleted"] = 0
|
||||
ctx["cleanup_age"] = KOMBU_MESSAGE_CLEANUP_AGE
|
||||
ctx["page_limit"] = KOMBU_MESSAGE_CLEANUP_PAGE_LIMIT
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
# Exit the task if we can't take the advisory lock
|
||||
result = db_session.execute(
|
||||
text("SELECT pg_try_advisory_lock(:id)"),
|
||||
{"id": PostgresAdvisoryLocks.KOMBU_MESSAGE_CLEANUP_LOCK_ID.value},
|
||||
).scalar()
|
||||
if not result:
|
||||
return 0
|
||||
|
||||
while True:
|
||||
if self.is_aborted():
|
||||
raise TaskRevokedError("kombu_message_cleanup_task was aborted.")
|
||||
|
||||
b = kombu_message_cleanup_task_helper(ctx, db_session)
|
||||
if not b:
|
||||
break
|
||||
|
||||
db_session.commit()
|
||||
|
||||
if ctx["deleted"] > 0:
|
||||
task_logger.info(
|
||||
f"Deleted {ctx['deleted']} orphaned messages from kombu_message."
|
||||
)
|
||||
|
||||
return ctx["deleted"]
|
||||
|
||||
|
||||
def kombu_message_cleanup_task_helper(ctx: dict, db_session: Session) -> bool:
|
||||
"""
|
||||
Helper function to clean up old messages from the `kombu_message` table that are no longer relevant.
|
||||
|
||||
This function retrieves messages from the `kombu_message` table that are no longer visible and
|
||||
older than a specified interval. It checks if the corresponding task_id exists in the
|
||||
`celery_taskmeta` table. If the task_id does not exist, the message is deleted.
|
||||
|
||||
Args:
|
||||
ctx (dict): A context dictionary containing configuration parameters such as:
|
||||
- 'cleanup_age' (int): The age in days after which messages are considered old.
|
||||
- 'page_limit' (int): The maximum number of messages to process in one batch.
|
||||
- 'last_processed_id' (int): The ID of the last processed message to handle pagination.
|
||||
- 'deleted' (int): A counter to track the number of deleted messages.
|
||||
db_session (Session): The SQLAlchemy database session for executing queries.
|
||||
|
||||
Returns:
|
||||
bool: Returns True if there are more rows to process, False if not.
|
||||
"""
|
||||
|
||||
inspector = inspect(db_session.bind)
|
||||
if not inspector:
|
||||
return False
|
||||
|
||||
# With the move to redis as celery's broker and backend, kombu tables may not even exist.
|
||||
# We can fail silently.
|
||||
if not inspector.has_table("kombu_message"):
|
||||
return False
|
||||
|
||||
query = text(
|
||||
"""
|
||||
SELECT id, timestamp, payload
|
||||
FROM kombu_message WHERE visible = 'false'
|
||||
AND timestamp < CURRENT_TIMESTAMP - INTERVAL :interval_days
|
||||
AND id > :last_processed_id
|
||||
ORDER BY id
|
||||
LIMIT :page_limit
|
||||
"""
|
||||
)
|
||||
kombu_messages = db_session.execute(
|
||||
query,
|
||||
{
|
||||
"interval_days": f"{ctx['cleanup_age']} days",
|
||||
"page_limit": ctx["page_limit"],
|
||||
"last_processed_id": ctx["last_processed_id"],
|
||||
},
|
||||
).fetchall()
|
||||
|
||||
if len(kombu_messages) == 0:
|
||||
return False
|
||||
|
||||
for msg in kombu_messages:
|
||||
payload = json.loads(msg[2])
|
||||
task_id = payload["headers"]["id"]
|
||||
|
||||
# Check if task_id exists in celery_taskmeta
|
||||
task_exists = db_session.execute(
|
||||
text("SELECT 1 FROM celery_taskmeta WHERE task_id = :task_id"),
|
||||
{"task_id": task_id},
|
||||
).fetchone()
|
||||
|
||||
# If task_id does not exist, delete the message
|
||||
if not task_exists:
|
||||
result = db_session.execute(
|
||||
text("DELETE FROM kombu_message WHERE id = :message_id"),
|
||||
{"message_id": msg[0]},
|
||||
)
|
||||
if result.rowcount > 0: # type: ignore
|
||||
ctx["deleted"] += 1
|
||||
|
||||
ctx["last_processed_id"] = msg[0]
|
||||
|
||||
return True
|
||||
@@ -379,6 +379,14 @@ POSTGRES_HOST = os.environ.get("POSTGRES_HOST") or "127.0.0.1"
|
||||
POSTGRES_PORT = os.environ.get("POSTGRES_PORT") or "5432"
|
||||
POSTGRES_DB = os.environ.get("POSTGRES_DB") or "postgres"
|
||||
AWS_REGION_NAME = os.environ.get("AWS_REGION_NAME") or "us-east-2"
|
||||
# Comma-separated replica / multi-host list. If unset, defaults to POSTGRES_HOST
|
||||
# only.
|
||||
_POSTGRES_HOSTS_STR = os.environ.get("POSTGRES_HOSTS", "").strip()
|
||||
POSTGRES_HOSTS: list[str] = (
|
||||
[h.strip() for h in _POSTGRES_HOSTS_STR.split(",") if h.strip()]
|
||||
if _POSTGRES_HOSTS_STR
|
||||
else [POSTGRES_HOST]
|
||||
)
|
||||
|
||||
POSTGRES_API_SERVER_POOL_SIZE = int(
|
||||
os.environ.get("POSTGRES_API_SERVER_POOL_SIZE") or 40
|
||||
|
||||
@@ -12,6 +12,11 @@ SLACK_USER_TOKEN_PREFIX = "xoxp-"
|
||||
SLACK_BOT_TOKEN_PREFIX = "xoxb-"
|
||||
ONYX_EMAILABLE_LOGO_MAX_DIM = 512
|
||||
|
||||
# The mask_string() function in encryption.py uses "•" (U+2022 BULLET) to mask secrets.
|
||||
MASK_CREDENTIAL_CHAR = "\u2022"
|
||||
# Pattern produced by mask_string for strings >= 14 chars: "abcd...wxyz" (exactly 11 chars)
|
||||
MASK_CREDENTIAL_LONG_RE = re.compile(r"^.{4}\.{3}.{4}$")
|
||||
|
||||
SOURCE_TYPE = "source_type"
|
||||
# stored in the `metadata` of a chunk. Used to signify that this chunk should
|
||||
# not be used for QA. For example, Google Drive file types which can't be parsed
|
||||
@@ -391,10 +396,6 @@ class MilestoneRecordType(str, Enum):
|
||||
REQUESTED_CONNECTOR = "requested_connector"
|
||||
|
||||
|
||||
class PostgresAdvisoryLocks(Enum):
|
||||
KOMBU_MESSAGE_CLEANUP_LOCK_ID = auto()
|
||||
|
||||
|
||||
class OnyxCeleryQueues:
|
||||
# "celery" is the default queue defined by celery and also the queue
|
||||
# we are running in the primary worker to run system tasks
|
||||
@@ -577,7 +578,6 @@ class OnyxCeleryTask:
|
||||
MONITOR_PROCESS_MEMORY = "monitor_process_memory"
|
||||
CELERY_BEAT_HEARTBEAT = "celery_beat_heartbeat"
|
||||
|
||||
KOMBU_MESSAGE_CLEANUP_TASK = "kombu_message_cleanup_task"
|
||||
CONNECTOR_PERMISSION_SYNC_GENERATOR_TASK = (
|
||||
"connector_permission_sync_generator_task"
|
||||
)
|
||||
|
||||
@@ -8,6 +8,8 @@ from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.configs.constants import FederatedConnectorSource
|
||||
from onyx.configs.constants import MASK_CREDENTIAL_CHAR
|
||||
from onyx.configs.constants import MASK_CREDENTIAL_LONG_RE
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.db.models import DocumentSet
|
||||
from onyx.db.models import FederatedConnector
|
||||
@@ -45,6 +47,23 @@ def fetch_all_federated_connectors_parallel() -> list[FederatedConnector]:
|
||||
return fetch_all_federated_connectors(db_session)
|
||||
|
||||
|
||||
def _reject_masked_credentials(credentials: dict[str, Any]) -> None:
|
||||
"""Raise if any credential string value contains mask placeholder characters.
|
||||
|
||||
mask_string() has two output formats:
|
||||
- Short strings (< 14 chars): "••••••••••••" (U+2022 BULLET)
|
||||
- Long strings (>= 14 chars): "abcd...wxyz" (first4 + "..." + last4)
|
||||
Both must be rejected.
|
||||
"""
|
||||
for key, val in credentials.items():
|
||||
if isinstance(val, str) and (
|
||||
MASK_CREDENTIAL_CHAR in val or MASK_CREDENTIAL_LONG_RE.match(val)
|
||||
):
|
||||
raise ValueError(
|
||||
f"Credential field '{key}' contains masked placeholder characters. Please provide the actual credential value."
|
||||
)
|
||||
|
||||
|
||||
def validate_federated_connector_credentials(
|
||||
source: FederatedConnectorSource,
|
||||
credentials: dict[str, Any],
|
||||
@@ -66,6 +85,8 @@ def create_federated_connector(
|
||||
config: dict[str, Any] | None = None,
|
||||
) -> FederatedConnector:
|
||||
"""Create a new federated connector with credential and config validation."""
|
||||
_reject_masked_credentials(credentials)
|
||||
|
||||
# Validate credentials before creating
|
||||
if not validate_federated_connector_credentials(source, credentials):
|
||||
raise ValueError(
|
||||
@@ -277,6 +298,8 @@ def update_federated_connector(
|
||||
)
|
||||
|
||||
if credentials is not None:
|
||||
_reject_masked_credentials(credentials)
|
||||
|
||||
# Validate credentials before updating
|
||||
if not validate_federated_connector_credentials(
|
||||
federated_connector.source, credentials
|
||||
|
||||
@@ -236,14 +236,15 @@ def upsert_llm_provider(
|
||||
db_session.add(existing_llm_provider)
|
||||
|
||||
# Filter out empty strings and None values from custom_config to allow
|
||||
# providers like Bedrock to fall back to IAM roles when credentials are not provided
|
||||
# providers like Bedrock to fall back to IAM roles when credentials are not provided.
|
||||
# NOTE: An empty dict ({}) is preserved as-is — it signals that the provider was
|
||||
# created via the custom modal and must be reopened with CustomModal, not a
|
||||
# provider-specific modal. Only None means "no custom config at all".
|
||||
custom_config = llm_provider_upsert_request.custom_config
|
||||
if custom_config:
|
||||
custom_config = {
|
||||
k: v for k, v in custom_config.items() if v is not None and v.strip() != ""
|
||||
}
|
||||
# Set to None if the dict is empty after filtering
|
||||
custom_config = custom_config or None
|
||||
|
||||
api_base = llm_provider_upsert_request.api_base or None
|
||||
existing_llm_provider.provider = llm_provider_upsert_request.provider
|
||||
@@ -303,16 +304,7 @@ def upsert_llm_provider(
|
||||
).delete(synchronize_session="fetch")
|
||||
db_session.flush()
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from onyx.llm.utils import get_max_input_tokens
|
||||
|
||||
for model_config in llm_provider_upsert_request.model_configurations:
|
||||
max_input_tokens = model_config.max_input_tokens
|
||||
if max_input_tokens is None:
|
||||
max_input_tokens = get_max_input_tokens(
|
||||
model_name=model_config.name,
|
||||
model_provider=llm_provider_upsert_request.provider,
|
||||
)
|
||||
|
||||
supported_flows = [LLMModelFlowType.CHAT]
|
||||
if model_config.supports_image_input:
|
||||
@@ -325,7 +317,7 @@ def upsert_llm_provider(
|
||||
model_configuration_id=existing.id,
|
||||
supported_flows=supported_flows,
|
||||
is_visible=model_config.is_visible,
|
||||
max_input_tokens=max_input_tokens,
|
||||
max_input_tokens=model_config.max_input_tokens,
|
||||
display_name=model_config.display_name,
|
||||
)
|
||||
else:
|
||||
@@ -335,7 +327,7 @@ def upsert_llm_provider(
|
||||
model_name=model_config.name,
|
||||
supported_flows=supported_flows,
|
||||
is_visible=model_config.is_visible,
|
||||
max_input_tokens=max_input_tokens,
|
||||
max_input_tokens=model_config.max_input_tokens,
|
||||
display_name=model_config.display_name,
|
||||
)
|
||||
|
||||
|
||||
@@ -205,18 +205,26 @@ def read_pdf_file(
|
||||
try:
|
||||
pdf_reader = PdfReader(file)
|
||||
|
||||
if pdf_reader.is_encrypted and pdf_pass is not None:
|
||||
if pdf_reader.is_encrypted:
|
||||
# Try the explicit password first, then fall back to an empty
|
||||
# string. Owner-password-only PDFs (permission restrictions but
|
||||
# no open password) decrypt successfully with "".
|
||||
# See https://github.com/onyx-dot-app/onyx/issues/9754
|
||||
passwords = [p for p in [pdf_pass, ""] if p is not None]
|
||||
decrypt_success = False
|
||||
try:
|
||||
decrypt_success = pdf_reader.decrypt(pdf_pass) != 0
|
||||
except Exception:
|
||||
logger.error("Unable to decrypt pdf")
|
||||
for pw in passwords:
|
||||
try:
|
||||
if pdf_reader.decrypt(pw) != 0:
|
||||
decrypt_success = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not decrypt_success:
|
||||
logger.error(
|
||||
"Encrypted PDF could not be decrypted, returning empty text."
|
||||
)
|
||||
return "", metadata, []
|
||||
elif pdf_reader.is_encrypted:
|
||||
logger.warning("No Password for an encrypted PDF, returning empty text.")
|
||||
return "", metadata, []
|
||||
|
||||
# Basic PDF metadata
|
||||
if pdf_reader.metadata is not None:
|
||||
|
||||
@@ -33,8 +33,20 @@ def is_pdf_protected(file: IO[Any]) -> bool:
|
||||
|
||||
with preserve_position(file):
|
||||
reader = PdfReader(file)
|
||||
if not reader.is_encrypted:
|
||||
return False
|
||||
|
||||
return bool(reader.is_encrypted)
|
||||
# PDFs with only an owner password (permission restrictions like
|
||||
# print/copy disabled) use an empty user password — any viewer can open
|
||||
# them without prompting. decrypt("") returns 0 only when a real user
|
||||
# password is required. See https://github.com/onyx-dot-app/onyx/issues/9754
|
||||
try:
|
||||
return reader.decrypt("") == 0
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to evaluate PDF encryption; treating as password protected"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def is_docx_protected(file: IO[Any]) -> bool:
|
||||
|
||||
@@ -6,6 +6,7 @@ from onyx.configs.app_configs import MCP_SERVER_ENABLED
|
||||
from onyx.configs.app_configs import MCP_SERVER_HOST
|
||||
from onyx.configs.app_configs import MCP_SERVER_PORT
|
||||
from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.variable_functionality import set_is_ee_based_on_env_variable
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
@@ -16,6 +17,7 @@ def main() -> None:
|
||||
logger.info("MCP server is disabled (MCP_SERVER_ENABLED=false)")
|
||||
return
|
||||
|
||||
set_is_ee_based_on_env_variable()
|
||||
logger.info(f"Starting MCP server on {MCP_SERVER_HOST}:{MCP_SERVER_PORT}")
|
||||
|
||||
from onyx.mcp_server.api import mcp_app
|
||||
|
||||
@@ -79,7 +79,9 @@ class LLMProviderDescriptor(BaseModel):
|
||||
provider=provider,
|
||||
provider_display_name=get_provider_display_name(provider),
|
||||
model_configurations=filter_model_configurations(
|
||||
llm_provider_model.model_configurations, provider
|
||||
llm_provider_model.model_configurations,
|
||||
provider,
|
||||
use_stored_display_name=llm_provider_model.custom_config is not None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -156,7 +158,9 @@ class LLMProviderView(LLMProvider):
|
||||
personas=personas,
|
||||
deployment_name=llm_provider_model.deployment_name,
|
||||
model_configurations=filter_model_configurations(
|
||||
llm_provider_model.model_configurations, provider
|
||||
llm_provider_model.model_configurations,
|
||||
provider,
|
||||
use_stored_display_name=llm_provider_model.custom_config is not None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -198,13 +202,13 @@ class ModelConfigurationView(BaseModel):
|
||||
cls,
|
||||
model_configuration_model: "ModelConfigurationModel",
|
||||
provider_name: str,
|
||||
use_stored_display_name: bool = False,
|
||||
) -> "ModelConfigurationView":
|
||||
# For dynamic providers (OpenRouter, Bedrock, Ollama), use the display_name
|
||||
# stored in DB from the source API. Skip LiteLLM parsing entirely.
|
||||
# For dynamic providers (OpenRouter, Bedrock, Ollama) and custom-config
|
||||
# providers, use the display_name stored in DB. Skip LiteLLM parsing.
|
||||
if (
|
||||
provider_name in DYNAMIC_LLM_PROVIDERS
|
||||
and model_configuration_model.display_name
|
||||
):
|
||||
provider_name in DYNAMIC_LLM_PROVIDERS or use_stored_display_name
|
||||
) and model_configuration_model.display_name:
|
||||
# Extract vendor from model name for grouping (e.g., "Anthropic", "OpenAI")
|
||||
vendor = extract_vendor_from_model_name(
|
||||
model_configuration_model.name, provider_name
|
||||
|
||||
@@ -308,12 +308,15 @@ def should_filter_as_dated_duplicate(
|
||||
def filter_model_configurations(
|
||||
model_configurations: list,
|
||||
provider: str,
|
||||
use_stored_display_name: bool = False,
|
||||
) -> list:
|
||||
"""Filter out obsolete and dated duplicate models from configurations.
|
||||
|
||||
Args:
|
||||
model_configurations: List of ModelConfiguration DB models
|
||||
provider: The provider name (e.g., "openai", "anthropic")
|
||||
use_stored_display_name: If True, prefer the display_name stored in the
|
||||
DB over LiteLLM enrichments. Set for custom-config providers.
|
||||
|
||||
Returns:
|
||||
List of ModelConfigurationView objects with obsolete/duplicate models removed
|
||||
@@ -333,7 +336,9 @@ def filter_model_configurations(
|
||||
if should_filter_as_dated_duplicate(model_configuration.name, all_model_names):
|
||||
continue
|
||||
filtered_configs.append(
|
||||
ModelConfigurationView.from_model(model_configuration, provider)
|
||||
ModelConfigurationView.from_model(
|
||||
model_configuration, provider, use_stored_display_name
|
||||
)
|
||||
)
|
||||
|
||||
return filtered_configs
|
||||
|
||||
@@ -461,7 +461,7 @@ lazy-imports==1.0.1
|
||||
# via onyx
|
||||
legacy-cgi==2.6.4 ; python_full_version >= '3.13'
|
||||
# via ddtrace
|
||||
litellm==1.83.0
|
||||
litellm==1.81.6
|
||||
# via onyx
|
||||
locket==1.0.0
|
||||
# via
|
||||
|
||||
@@ -219,7 +219,7 @@ kiwisolver==1.4.9
|
||||
# via matplotlib
|
||||
kubernetes==31.0.0
|
||||
# via onyx
|
||||
litellm==1.83.0
|
||||
litellm==1.81.6
|
||||
# via onyx
|
||||
mako==1.2.4
|
||||
# via alembic
|
||||
|
||||
@@ -154,7 +154,7 @@ jsonschema-specifications==2025.9.1
|
||||
# via jsonschema
|
||||
kubernetes==31.0.0
|
||||
# via onyx
|
||||
litellm==1.83.0
|
||||
litellm==1.81.6
|
||||
# via onyx
|
||||
markupsafe==3.0.3
|
||||
# via jinja2
|
||||
|
||||
@@ -189,7 +189,7 @@ kombu==5.5.4
|
||||
# via celery
|
||||
kubernetes==31.0.0
|
||||
# via onyx
|
||||
litellm==1.83.0
|
||||
litellm==1.81.6
|
||||
# via onyx
|
||||
markupsafe==3.0.3
|
||||
# via jinja2
|
||||
|
||||
@@ -186,7 +186,7 @@ class TestDocumentIndexNew:
|
||||
)
|
||||
document_index.index(chunks=[pre_chunk], indexing_metadata=pre_metadata)
|
||||
|
||||
time.sleep(1)
|
||||
time.sleep(2)
|
||||
|
||||
# Now index a batch with the existing doc and a new doc.
|
||||
chunks = [
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import pytest
|
||||
|
||||
from onyx.configs.constants import MASK_CREDENTIAL_CHAR
|
||||
from onyx.db.federated import _reject_masked_credentials
|
||||
|
||||
|
||||
class TestRejectMaskedCredentials:
|
||||
"""Verify that masked credential values are never accepted for DB writes.
|
||||
|
||||
mask_string() has two output formats:
|
||||
- Short strings (< 14 chars): "••••••••••••" (U+2022 BULLET)
|
||||
- Long strings (>= 14 chars): "abcd...wxyz" (first4 + "..." + last4)
|
||||
_reject_masked_credentials must catch both.
|
||||
"""
|
||||
|
||||
def test_rejects_fully_masked_value(self) -> None:
|
||||
masked = MASK_CREDENTIAL_CHAR * 12 # "••••••••••••"
|
||||
with pytest.raises(ValueError, match="masked placeholder"):
|
||||
_reject_masked_credentials({"client_id": masked})
|
||||
|
||||
def test_rejects_long_string_masked_value(self) -> None:
|
||||
"""mask_string returns 'first4...last4' for long strings — the real
|
||||
format used for OAuth credentials like client_id and client_secret."""
|
||||
with pytest.raises(ValueError, match="masked placeholder"):
|
||||
_reject_masked_credentials({"client_id": "1234...7890"})
|
||||
|
||||
def test_rejects_when_any_field_is_masked(self) -> None:
|
||||
"""Even if client_id is real, a masked client_secret must be caught."""
|
||||
with pytest.raises(ValueError, match="client_secret"):
|
||||
_reject_masked_credentials(
|
||||
{
|
||||
"client_id": "1234567890.1234567890",
|
||||
"client_secret": MASK_CREDENTIAL_CHAR * 12,
|
||||
}
|
||||
)
|
||||
|
||||
def test_accepts_real_credentials(self) -> None:
|
||||
# Should not raise
|
||||
_reject_masked_credentials(
|
||||
{
|
||||
"client_id": "1234567890.1234567890",
|
||||
"client_secret": "test_client_secret_value",
|
||||
}
|
||||
)
|
||||
|
||||
def test_accepts_empty_dict(self) -> None:
|
||||
# Should not raise — empty credentials are handled elsewhere
|
||||
_reject_masked_credentials({})
|
||||
|
||||
def test_ignores_non_string_values(self) -> None:
|
||||
# Non-string values (None, bool, int) should pass through
|
||||
_reject_masked_credentials(
|
||||
{
|
||||
"client_id": "real_value",
|
||||
"redirect_uri": None,
|
||||
"some_flag": True,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
%PDF-1.3
|
||||
%<25><><EFBFBD><EFBFBD>
|
||||
1 0 obj
|
||||
<<
|
||||
/Producer <1083d595b1>
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Count 1
|
||||
/Kids [ 4 0 R ]
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Pages 2 0 R
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 <<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
/MediaBox [ 0.0 0.0 200 200 ]
|
||||
/Contents 5 0 R
|
||||
/Parent 2 0 R
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Length 42
|
||||
>>
|
||||
stream
|
||||
,N<><6~<7E>)<29><><EFBFBD><EFBFBD><EFBFBD>u<EFBFBD><0C><><EFBFBD>Zc'<27><>>8g<38><67><EFBFBD>n<EFBFBD><6E><EFBFBD><EFBFBD><EFBFBD>9"
|
||||
endstream
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/V 2
|
||||
/R 3
|
||||
/Length 128
|
||||
/P 4294967292
|
||||
/Filter /Standard
|
||||
/O <6a340a292629053da84a6d8b19a5d505953b8b3fdac3d2d389fde0e354528d44>
|
||||
/U <d6f0dc91c7b9de264a8d708515468e6528bf4e5e4e758a4164004e56fffa0108>
|
||||
>>
|
||||
endobj
|
||||
xref
|
||||
0 7
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n
|
||||
0000000059 00000 n
|
||||
0000000118 00000 n
|
||||
0000000167 00000 n
|
||||
0000000348 00000 n
|
||||
0000000440 00000 n
|
||||
trailer
|
||||
<<
|
||||
/Size 7
|
||||
/Root 3 0 R
|
||||
/Info 1 0 R
|
||||
/ID [ <6364336635356135633239323638353039306635656133623165313637366430> <6364336635356135633239323638353039306635656133623165313637366430> ]
|
||||
/Encrypt 6 0 R
|
||||
>>
|
||||
startxref
|
||||
655
|
||||
%%EOF
|
||||
@@ -54,6 +54,12 @@ class TestReadPdfFile:
|
||||
text, _, _ = read_pdf_file(_load("encrypted.pdf"), pdf_pass="wrong")
|
||||
assert text == ""
|
||||
|
||||
def test_owner_password_only_pdf_extracts_text(self) -> None:
|
||||
"""A PDF encrypted with only an owner password (no user password)
|
||||
should still yield its text content. Regression for #9754."""
|
||||
text, _, _ = read_pdf_file(_load("owner_protected.pdf"))
|
||||
assert "Hello World" in text
|
||||
|
||||
def test_empty_pdf(self) -> None:
|
||||
text, _, _ = read_pdf_file(_load("empty.pdf"))
|
||||
assert text.strip() == ""
|
||||
@@ -117,6 +123,12 @@ class TestIsPdfProtected:
|
||||
def test_protected_pdf(self) -> None:
|
||||
assert is_pdf_protected(_load("encrypted.pdf")) is True
|
||||
|
||||
def test_owner_password_only_is_not_protected(self) -> None:
|
||||
"""A PDF with only an owner password (permission restrictions) but no
|
||||
user password should NOT be considered protected — any viewer can open
|
||||
it without prompting for a password."""
|
||||
assert is_pdf_protected(_load("owner_protected.pdf")) is False
|
||||
|
||||
def test_preserves_file_position(self) -> None:
|
||||
pdf = _load("simple.pdf")
|
||||
pdf.seek(42)
|
||||
|
||||
@@ -19,6 +19,6 @@ dependencies:
|
||||
version: 5.4.0
|
||||
- name: code-interpreter
|
||||
repository: https://onyx-dot-app.github.io/python-sandbox/
|
||||
version: 0.3.1
|
||||
digest: sha256:4965b6ea3674c37163832a2192cd3bc8004f2228729fca170af0b9f457e8f987
|
||||
generated: "2026-03-02T15:29:39.632344-08:00"
|
||||
version: 0.3.2
|
||||
digest: sha256:74908ea45ace2b4be913ff762772e6d87e40bab64e92c6662aa51730eaeb9d87
|
||||
generated: "2026-04-06T15:34:02.597166-07:00"
|
||||
|
||||
@@ -5,7 +5,7 @@ home: https://www.onyx.app/
|
||||
sources:
|
||||
- "https://github.com/onyx-dot-app/onyx"
|
||||
type: application
|
||||
version: 0.4.39
|
||||
version: 0.4.40
|
||||
appVersion: latest
|
||||
annotations:
|
||||
category: Productivity
|
||||
@@ -45,6 +45,6 @@ dependencies:
|
||||
repository: https://charts.min.io/
|
||||
condition: minio.enabled
|
||||
- name: code-interpreter
|
||||
version: 0.3.1
|
||||
version: 0.3.2
|
||||
repository: https://onyx-dot-app.github.io/python-sandbox/
|
||||
condition: codeInterpreter.enabled
|
||||
|
||||
@@ -67,6 +67,9 @@ spec:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
{{- if .Values.api.runUpdateCaCertificates }}
|
||||
update-ca-certificates &&
|
||||
{{- end }}
|
||||
alembic upgrade head &&
|
||||
echo "Starting Onyx Api Server" &&
|
||||
uvicorn onyx.main:app --host {{ .Values.global.host }} --port {{ .Values.api.containerPorts.server }}
|
||||
|
||||
@@ -504,6 +504,18 @@ api:
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
||||
# Run update-ca-certificates before starting the server.
|
||||
# Useful when mounting custom CA certificates via volumes/volumeMounts.
|
||||
# NOTE: Requires the container to run as root (runAsUser: 0).
|
||||
# CA certificate files must be mounted under /usr/local/share/ca-certificates/
|
||||
# with a .crt extension (e.g. /usr/local/share/ca-certificates/my-ca.crt).
|
||||
# NOTE: Python HTTP clients (requests, httpx) use certifi's bundle by default
|
||||
# and will not pick up the system CA store automatically. Set the following
|
||||
# environment variables via configMap values (loaded through envFrom) to make them use the updated system bundle:
|
||||
# REQUESTS_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt
|
||||
# SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
|
||||
runUpdateCaCertificates: false
|
||||
|
||||
|
||||
######################################################################
|
||||
#
|
||||
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"cohere==5.6.1",
|
||||
"fastapi==0.133.1",
|
||||
"google-genai==1.52.0",
|
||||
"litellm==1.83.0",
|
||||
"litellm==1.81.6",
|
||||
"openai==2.14.0",
|
||||
"pydantic==2.11.7",
|
||||
"prometheus_client>=0.21.1",
|
||||
|
||||
8
uv.lock
generated
8
uv.lock
generated
@@ -3134,7 +3134,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.83.0"
|
||||
version = "1.81.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -3150,9 +3150,9 @@ dependencies = [
|
||||
{ name = "tiktoken" },
|
||||
{ name = "tokenizers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/92/6ce9737554994ca8e536e5f4f6a87cc7c4774b656c9eb9add071caf7d54b/litellm-1.83.0.tar.gz", hash = "sha256:860bebc76c4bb27b4cf90b4a77acd66dba25aced37e3db98750de8a1766bfb7a", size = 17333062, upload-time = "2026-03-31T05:08:25.331Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/f3/194a2dca6cb3eddb89f4bc2920cf5e27542256af907c23be13c61fe7e021/litellm-1.81.6.tar.gz", hash = "sha256:f02b503dfb7d66d1c939f82e4db21aeec1d6e2ed1fe3f5cd02aaec3f792bc4ae", size = 13878107, upload-time = "2026-02-01T04:02:27.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2c/a670cc050fcd6f45c6199eb99e259c73aea92edba8d5c2fc1b3686d36217/litellm-1.83.0-py3-none-any.whl", hash = "sha256:88c536d339248f3987571493015784671ba3f193a328e1ea6780dbebaa2094a8", size = 15610306, upload-time = "2026-03-31T05:08:21.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/05/3516cc7386b220d388aa0bd833308c677e94eceb82b2756dd95e06f6a13f/litellm-1.81.6-py3-none-any.whl", hash = "sha256:573206ba194d49a1691370ba33f781671609ac77c35347f8a0411d852cf6341a", size = 12224343, upload-time = "2026-02-01T04:02:23.704Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4443,7 +4443,7 @@ requires-dist = [
|
||||
{ name = "langchain-core", marker = "extra == 'backend'", specifier = "==1.2.22" },
|
||||
{ name = "langfuse", marker = "extra == 'backend'", specifier = "==3.10.0" },
|
||||
{ name = "lazy-imports", marker = "extra == 'backend'", specifier = "==1.0.1" },
|
||||
{ name = "litellm", specifier = "==1.83.0" },
|
||||
{ name = "litellm", specifier = "==1.81.6" },
|
||||
{ name = "lxml", marker = "extra == 'backend'", specifier = "==5.3.0" },
|
||||
{ name = "mako", marker = "extra == 'backend'", specifier = "==1.2.4" },
|
||||
{ name = "manygo", marker = "extra == 'dev'", specifier = "==0.2.0" },
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { Card } from "@opal/components/cards/card/components";
|
||||
import { Content } from "@opal/layouts";
|
||||
import { Content, SizePreset } from "@opal/layouts";
|
||||
import { SvgEmpty } from "@opal/icons";
|
||||
import type { IconFunctionComponent, PaddingVariants } from "@opal/types";
|
||||
import type {
|
||||
IconFunctionComponent,
|
||||
PaddingVariants,
|
||||
RichStr,
|
||||
} from "@opal/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type EmptyMessageCardProps = {
|
||||
type EmptyMessageCardBaseProps = {
|
||||
/** Icon displayed alongside the title. */
|
||||
icon?: IconFunctionComponent;
|
||||
|
||||
/** Primary message text. */
|
||||
title: string;
|
||||
title: string | RichStr;
|
||||
|
||||
/** Padding preset for the card. @default "md" */
|
||||
padding?: PaddingVariants;
|
||||
@@ -21,16 +25,30 @@ type EmptyMessageCardProps = {
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
};
|
||||
|
||||
type EmptyMessageCardProps =
|
||||
| (EmptyMessageCardBaseProps & {
|
||||
/** @default "secondary" */
|
||||
sizePreset?: "secondary";
|
||||
})
|
||||
| (EmptyMessageCardBaseProps & {
|
||||
sizePreset: "main-ui";
|
||||
/** Description text. Only supported when `sizePreset` is `"main-ui"`. */
|
||||
description?: string | RichStr;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EmptyMessageCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EmptyMessageCard({
|
||||
icon = SvgEmpty,
|
||||
title,
|
||||
padding = "md",
|
||||
ref,
|
||||
}: EmptyMessageCardProps) {
|
||||
function EmptyMessageCard(props: EmptyMessageCardProps) {
|
||||
const {
|
||||
sizePreset = "secondary",
|
||||
icon = SvgEmpty,
|
||||
title,
|
||||
padding = "md",
|
||||
ref,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
@@ -39,13 +57,23 @@ function EmptyMessageCard({
|
||||
padding={padding}
|
||||
rounding="md"
|
||||
>
|
||||
<Content
|
||||
icon={icon}
|
||||
title={title}
|
||||
sizePreset="secondary"
|
||||
variant="body"
|
||||
prominence="muted"
|
||||
/>
|
||||
{sizePreset === "secondary" ? (
|
||||
<Content
|
||||
icon={icon}
|
||||
title={title}
|
||||
sizePreset="secondary"
|
||||
variant="body"
|
||||
prominence="muted"
|
||||
/>
|
||||
) : (
|
||||
<Content
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={"description" in props ? props.description : undefined}
|
||||
sizePreset={sizePreset}
|
||||
variant="section"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import "@opal/core/animations/styles.css";
|
||||
import React, { createContext, useContext, useState, useCallback } from "react";
|
||||
import React from "react";
|
||||
import { cn } from "@opal/utils";
|
||||
import type { WithoutStyles, ExtremaSizeVariants } from "@opal/types";
|
||||
import { widthVariants } from "@opal/shared";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context-per-group registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lazily-created map of group names to React contexts.
|
||||
*
|
||||
* Each group gets its own `React.Context<boolean | null>` so that a
|
||||
* `Hoverable.Item` only re-renders when its *own* group's hover state
|
||||
* changes — not when any unrelated group changes.
|
||||
*
|
||||
* The default value is `null` (no provider found), which lets
|
||||
* `Hoverable.Item` distinguish "no Root ancestor" from "Root says
|
||||
* not hovered" and throw when `group` was explicitly specified.
|
||||
*/
|
||||
const contextMap = new Map<string, React.Context<boolean | null>>();
|
||||
|
||||
function getOrCreateContext(group: string): React.Context<boolean | null> {
|
||||
let ctx = contextMap.get(group);
|
||||
if (!ctx) {
|
||||
ctx = createContext<boolean | null>(null);
|
||||
ctx.displayName = `HoverableContext(${group})`;
|
||||
contextMap.set(group, ctx);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type HoverableInteraction = "rest" | "hover";
|
||||
|
||||
interface HoverableRootProps
|
||||
extends WithoutStyles<React.HTMLAttributes<HTMLDivElement>> {
|
||||
children: React.ReactNode;
|
||||
group: string;
|
||||
/** Width preset. @default "auto" */
|
||||
widthVariant?: ExtremaSizeVariants;
|
||||
/**
|
||||
* JS-controllable interaction state override.
|
||||
*
|
||||
* - `"rest"` (default): items are shown/hidden by CSS `:hover`.
|
||||
* - `"hover"`: forces items visible regardless of hover state. Useful when
|
||||
* a hoverable action opens a modal — set `interaction="hover"` while the
|
||||
* modal is open so the user can see which element they're interacting with.
|
||||
*
|
||||
* @default "rest"
|
||||
*/
|
||||
interaction?: HoverableInteraction;
|
||||
/** Ref forwarded to the root `<div>`. */
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
@@ -65,12 +51,10 @@ interface HoverableItemProps
|
||||
/**
|
||||
* Hover-tracking container for a named group.
|
||||
*
|
||||
* Wraps children in a `<div>` that tracks mouse-enter / mouse-leave and
|
||||
* provides the hover state via a per-group React context.
|
||||
*
|
||||
* Nesting works because each `Hoverable.Root` creates a **new** context
|
||||
* provider that shadows the parent — so an inner `Hoverable.Item group="b"`
|
||||
* reads from the inner provider, not the outer `group="a"` provider.
|
||||
* Uses a `data-hover-group` attribute and CSS `:hover` to control
|
||||
* descendant `Hoverable.Item` visibility. No React state or context —
|
||||
* the browser natively removes `:hover` when modals/portals steal
|
||||
* pointer events, preventing stale hover state.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
@@ -87,70 +71,20 @@ function HoverableRoot({
|
||||
group,
|
||||
children,
|
||||
widthVariant = "full",
|
||||
interaction = "rest",
|
||||
ref,
|
||||
onMouseEnter: consumerMouseEnter,
|
||||
onMouseLeave: consumerMouseLeave,
|
||||
onFocusCapture: consumerFocusCapture,
|
||||
onBlurCapture: consumerBlurCapture,
|
||||
...props
|
||||
}: HoverableRootProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
const onMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setHovered(true);
|
||||
consumerMouseEnter?.(e);
|
||||
},
|
||||
[consumerMouseEnter]
|
||||
);
|
||||
|
||||
const onMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setHovered(false);
|
||||
consumerMouseLeave?.(e);
|
||||
},
|
||||
[consumerMouseLeave]
|
||||
);
|
||||
|
||||
const onFocusCapture = useCallback(
|
||||
(e: React.FocusEvent<HTMLDivElement>) => {
|
||||
setFocused(true);
|
||||
consumerFocusCapture?.(e);
|
||||
},
|
||||
[consumerFocusCapture]
|
||||
);
|
||||
|
||||
const onBlurCapture = useCallback(
|
||||
(e: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!(e.relatedTarget instanceof Node) ||
|
||||
!e.currentTarget.contains(e.relatedTarget)
|
||||
) {
|
||||
setFocused(false);
|
||||
}
|
||||
consumerBlurCapture?.(e);
|
||||
},
|
||||
[consumerBlurCapture]
|
||||
);
|
||||
|
||||
const active = hovered || focused;
|
||||
const GroupContext = getOrCreateContext(group);
|
||||
|
||||
return (
|
||||
<GroupContext.Provider value={active}>
|
||||
<div
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cn(widthVariants[widthVariant])}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onFocusCapture={onFocusCapture}
|
||||
onBlurCapture={onBlurCapture}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</GroupContext.Provider>
|
||||
<div
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cn(widthVariants[widthVariant])}
|
||||
data-hover-group={group}
|
||||
data-interaction={interaction !== "rest" ? interaction : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,13 +96,10 @@ function HoverableRoot({
|
||||
* An element whose visibility is controlled by hover state.
|
||||
*
|
||||
* **Local mode** (`group` omitted): the item handles hover on its own
|
||||
* element via CSS `:hover`. This is the core abstraction.
|
||||
* element via CSS `:hover`.
|
||||
*
|
||||
* **Group mode** (`group` provided): visibility is driven by a matching
|
||||
* `Hoverable.Root` ancestor's hover state via React context. If no
|
||||
* matching Root is found, an error is thrown.
|
||||
*
|
||||
* Uses data-attributes for variant styling (see `styles.css`).
|
||||
* **Group mode** (`group` provided): visibility is driven by CSS `:hover`
|
||||
* on the nearest `Hoverable.Root` ancestor via `[data-hover-group]:hover`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
@@ -184,8 +115,6 @@ function HoverableRoot({
|
||||
* </Hoverable.Item>
|
||||
* </Hoverable.Root>
|
||||
* ```
|
||||
*
|
||||
* @throws If `group` is specified but no matching `Hoverable.Root` ancestor exists.
|
||||
*/
|
||||
function HoverableItem({
|
||||
group,
|
||||
@@ -194,17 +123,6 @@ function HoverableItem({
|
||||
ref,
|
||||
...props
|
||||
}: HoverableItemProps) {
|
||||
const contextValue = useContext(
|
||||
group ? getOrCreateContext(group) : NOOP_CONTEXT
|
||||
);
|
||||
|
||||
if (group && contextValue === null) {
|
||||
throw new Error(
|
||||
`Hoverable.Item group="${group}" has no matching Hoverable.Root ancestor. ` +
|
||||
`Either wrap it in <Hoverable.Root group="${group}"> or remove the group prop for local hover.`
|
||||
);
|
||||
}
|
||||
|
||||
const isLocal = group === undefined;
|
||||
|
||||
return (
|
||||
@@ -213,9 +131,6 @@ function HoverableItem({
|
||||
ref={ref}
|
||||
className={cn("hoverable-item")}
|
||||
data-hoverable-variant={variant}
|
||||
data-hoverable-active={
|
||||
isLocal ? undefined : contextValue ? "true" : undefined
|
||||
}
|
||||
data-hoverable-local={isLocal ? "true" : undefined}
|
||||
>
|
||||
{children}
|
||||
@@ -223,9 +138,6 @@ function HoverableItem({
|
||||
);
|
||||
}
|
||||
|
||||
/** Stable context used when no group is specified (local mode). */
|
||||
const NOOP_CONTEXT = createContext<boolean | null>(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compound export
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -233,18 +145,16 @@ const NOOP_CONTEXT = createContext<boolean | null>(null);
|
||||
/**
|
||||
* Hoverable compound component for hover-to-reveal patterns.
|
||||
*
|
||||
* Provides two sub-components:
|
||||
* Entirely CSS-driven — no React state or context. The browser's native
|
||||
* `:hover` pseudo-class handles all state, which means hover is
|
||||
* automatically cleared when modals/portals steal pointer events.
|
||||
*
|
||||
* - `Hoverable.Root` — A container that tracks hover state for a named group
|
||||
* and provides it via React context.
|
||||
* - `Hoverable.Root` — Container with `data-hover-group`. CSS `:hover`
|
||||
* on this element reveals descendant `Hoverable.Item` elements.
|
||||
*
|
||||
* - `Hoverable.Item` — The core abstraction. On its own (no `group`), it
|
||||
* applies local CSS `:hover` for the variant effect. When `group` is
|
||||
* specified, it reads hover state from the nearest matching
|
||||
* `Hoverable.Root` — and throws if no matching Root is found.
|
||||
*
|
||||
* Supports nesting: a child `Hoverable.Root` shadows the parent's context,
|
||||
* so each group's items only respond to their own root's hover.
|
||||
* - `Hoverable.Item` — Hidden by default. In group mode, revealed when
|
||||
* the ancestor Root is hovered. In local mode (no `group`), revealed
|
||||
* when the item itself is hovered.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
@@ -276,4 +186,5 @@ export {
|
||||
type HoverableRootProps,
|
||||
type HoverableItemProps,
|
||||
type HoverableItemVariant,
|
||||
type HoverableInteraction,
|
||||
};
|
||||
|
||||
@@ -7,8 +7,20 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Group mode — Root controls visibility via React context */
|
||||
.hoverable-item[data-hoverable-variant="opacity-on-hover"][data-hoverable-active="true"] {
|
||||
/* Group mode — Root :hover controls descendant item visibility via CSS.
|
||||
Exclude local-mode items so they aren't revealed by an ancestor root. */
|
||||
[data-hover-group]:hover
|
||||
.hoverable-item[data-hoverable-variant="opacity-on-hover"]:not(
|
||||
[data-hoverable-local]
|
||||
) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Interaction override — force items visible via JS */
|
||||
[data-hover-group][data-interaction="hover"]
|
||||
.hoverable-item[data-hoverable-variant="opacity-on-hover"]:not(
|
||||
[data-hoverable-local]
|
||||
) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -17,7 +29,16 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Focus — item (or a focusable descendant) receives keyboard focus */
|
||||
/* Group focus — any focusable descendant of the Root receives keyboard focus,
|
||||
revealing all group items (same behavior as hover). */
|
||||
[data-hover-group]:focus-within
|
||||
.hoverable-item[data-hoverable-variant="opacity-on-hover"]:not(
|
||||
[data-hoverable-local]
|
||||
) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Local focus — item (or a focusable descendant) receives keyboard focus */
|
||||
.hoverable-item[data-hoverable-variant="opacity-on-hover"]:has(:focus-visible) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { CardHeaderLayout } from "@opal/layouts";
|
||||
import { Card } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
@@ -18,14 +18,14 @@ const withTooltipProvider: Decorator = (Story) => (
|
||||
);
|
||||
|
||||
const meta = {
|
||||
title: "Layouts/CardHeaderLayout",
|
||||
component: CardHeaderLayout,
|
||||
title: "Layouts/Card.Header",
|
||||
component: Card.Header,
|
||||
tags: ["autodocs"],
|
||||
decorators: [withTooltipProvider],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
} satisfies Meta<typeof CardHeaderLayout>;
|
||||
} satisfies Meta<typeof Card.Header>;
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -38,7 +38,7 @@ type Story = StoryObj<typeof meta>;
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<div className="w-[28rem] border rounded-16">
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
@@ -57,7 +57,7 @@ export const Default: Story = {
|
||||
export const WithBothSlots: Story = {
|
||||
render: () => (
|
||||
<div className="w-[28rem] border rounded-16">
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
@@ -92,7 +92,7 @@ export const WithBothSlots: Story = {
|
||||
export const RightChildrenOnly: Story = {
|
||||
render: () => (
|
||||
<div className="w-[28rem] border rounded-16">
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
@@ -111,7 +111,7 @@ export const RightChildrenOnly: Story = {
|
||||
export const NoRightChildren: Story = {
|
||||
render: () => (
|
||||
<div className="w-[28rem] border rounded-16">
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
@@ -125,7 +125,7 @@ export const NoRightChildren: Story = {
|
||||
export const LongContent: Story = {
|
||||
render: () => (
|
||||
<div className="w-[28rem] border rounded-16">
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
116
web/lib/opal/src/layouts/cards/README.md
Normal file
116
web/lib/opal/src/layouts/cards/README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Card
|
||||
|
||||
**Import:** `import { Card } from "@opal/layouts";`
|
||||
|
||||
A namespace of card layout primitives. Each sub-component handles a specific region of a card.
|
||||
|
||||
## Card.Header
|
||||
|
||||
A card header layout that pairs a [`Content`](../content/README.md) block with a right-side column and an optional full-width children slot.
|
||||
|
||||
### Why Card.Header?
|
||||
|
||||
[`ContentAction`](../content-action/README.md) provides a single `rightChildren` slot. Card headers typically need two distinct right-side regions — a primary action on top and secondary actions on the bottom. `Card.Header` provides this with `rightChildren` and `bottomRightChildren` slots, plus a `children` slot for full-width content below the header row (e.g., search bars, expandable tool lists).
|
||||
|
||||
### Props
|
||||
|
||||
Inherits **all** props from [`Content`](../content/README.md) (icon, title, description, sizePreset, variant, editable, onTitleChange, suffix, etc.) plus:
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `rightChildren` | `ReactNode` | `undefined` | Content rendered to the right of the Content block (top of right column). |
|
||||
| `bottomRightChildren` | `ReactNode` | `undefined` | Content rendered below `rightChildren` in the same column. Laid out as `flex flex-row`. |
|
||||
| `children` | `ReactNode` | `undefined` | Content rendered below the full header row, spanning the entire width. |
|
||||
|
||||
### Layout Structure
|
||||
|
||||
```
|
||||
+---------------------------------------------------------+
|
||||
| [Content (p-2, self-start)] [rightChildren] |
|
||||
| icon + title + description [bottomRightChildren] |
|
||||
+---------------------------------------------------------+
|
||||
| [children — full width] |
|
||||
+---------------------------------------------------------+
|
||||
```
|
||||
|
||||
- Outer wrapper: `flex flex-col w-full`
|
||||
- Header row: `flex flex-row items-stretch w-full`
|
||||
- Content area: `flex-1 min-w-0 self-start p-2` — top-aligned with fixed padding
|
||||
- Right column: `flex flex-col items-end shrink-0` — no padding, no gap
|
||||
- `bottomRightChildren` wrapper: `flex flex-row` — lays children out horizontally
|
||||
- `children` wrapper: `w-full` — only rendered when children are provided
|
||||
|
||||
### Usage
|
||||
|
||||
#### Card with primary and secondary actions
|
||||
|
||||
```tsx
|
||||
import { Card } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgGlobe, SvgSettings, SvgUnplug, SvgCheckSquare } from "@opal/icons";
|
||||
|
||||
<Card.Header
|
||||
icon={SvgGlobe}
|
||||
title="Google Search"
|
||||
description="Web search provider"
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
rightChildren={
|
||||
<Button icon={SvgCheckSquare} variant="action" prominence="tertiary">
|
||||
Current Default
|
||||
</Button>
|
||||
}
|
||||
bottomRightChildren={
|
||||
<>
|
||||
<Button icon={SvgUnplug} size="sm" prominence="tertiary" tooltip="Disconnect" />
|
||||
<Button icon={SvgSettings} size="sm" prominence="tertiary" tooltip="Edit" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
#### Card with only a connect action
|
||||
|
||||
```tsx
|
||||
<Card.Header
|
||||
icon={SvgCloud}
|
||||
title="OpenAI"
|
||||
description="Not configured"
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
rightChildren={
|
||||
<Button rightIcon={SvgArrowExchange} prominence="tertiary">
|
||||
Connect
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
#### Card with expandable children
|
||||
|
||||
```tsx
|
||||
<Card.Header
|
||||
icon={SvgServer}
|
||||
title="MCP Server"
|
||||
description="12 tools available"
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
rightChildren={<Button icon={SvgSettings} prominence="tertiary" />}
|
||||
>
|
||||
<SearchBar placeholder="Search tools..." />
|
||||
</Card.Header>
|
||||
```
|
||||
|
||||
#### No right children
|
||||
|
||||
```tsx
|
||||
<Card.Header
|
||||
icon={SvgInfo}
|
||||
title="Section Header"
|
||||
description="Description text"
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
/>
|
||||
```
|
||||
|
||||
When both `rightChildren` and `bottomRightChildren` are omitted and no `children` are provided, the component renders only the padded `Content`.
|
||||
@@ -4,16 +4,23 @@ import { Content, type ContentProps } from "@opal/layouts/content/components";
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CardHeaderLayoutProps = ContentProps & {
|
||||
type CardHeaderProps = ContentProps & {
|
||||
/** Content rendered to the right of the Content block. */
|
||||
rightChildren?: React.ReactNode;
|
||||
|
||||
/** Content rendered below `rightChildren` in the same column. */
|
||||
bottomRightChildren?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Content rendered below the header row, full-width.
|
||||
* Use for expandable sections, search bars, or any content
|
||||
* that should appear beneath the icon/title/actions row.
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CardHeaderLayout
|
||||
// Card.Header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -24,9 +31,12 @@ type CardHeaderLayoutProps = ContentProps & {
|
||||
* `rightChildren` on top, `bottomRightChildren` below — with no
|
||||
* padding or gap between them.
|
||||
*
|
||||
* The optional `children` slot renders below the full header row,
|
||||
* spanning the entire width.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <CardHeaderLayout
|
||||
* <Card.Header
|
||||
* icon={SvgGlobe}
|
||||
* title="Google"
|
||||
* description="Search engine"
|
||||
@@ -42,32 +52,42 @@ type CardHeaderLayoutProps = ContentProps & {
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
function CardHeaderLayout({
|
||||
function Header({
|
||||
rightChildren,
|
||||
bottomRightChildren,
|
||||
children,
|
||||
...contentProps
|
||||
}: CardHeaderLayoutProps) {
|
||||
}: CardHeaderProps) {
|
||||
const hasRight = rightChildren || bottomRightChildren;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-stretch w-full">
|
||||
<div className="flex-1 min-w-0 self-start p-2">
|
||||
<Content {...contentProps} />
|
||||
</div>
|
||||
{hasRight && (
|
||||
<div className="flex flex-col items-end shrink-0">
|
||||
{rightChildren && <div className="flex-1">{rightChildren}</div>}
|
||||
{bottomRightChildren && (
|
||||
<div className="flex flex-row">{bottomRightChildren}</div>
|
||||
)}
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-row items-stretch w-full">
|
||||
<div className="flex-1 min-w-0 self-start p-2">
|
||||
<Content {...contentProps} />
|
||||
</div>
|
||||
)}
|
||||
{hasRight && (
|
||||
<div className="flex flex-col items-end shrink-0">
|
||||
{rightChildren && <div className="flex-1">{rightChildren}</div>}
|
||||
{bottomRightChildren && (
|
||||
<div className="flex flex-row">{bottomRightChildren}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{children && <div className="w-full">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card namespace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Card = { Header };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { CardHeaderLayout, type CardHeaderLayoutProps };
|
||||
export { Card, type CardHeaderProps };
|
||||
@@ -1,94 +0,0 @@
|
||||
# CardHeaderLayout
|
||||
|
||||
**Import:** `import { CardHeaderLayout, type CardHeaderLayoutProps } from "@opal/layouts";`
|
||||
|
||||
A card header layout that pairs a [`Content`](../../content/README.md) block with a right-side column of vertically stacked children.
|
||||
|
||||
## Why CardHeaderLayout?
|
||||
|
||||
[`ContentAction`](../../content-action/README.md) provides a single `rightChildren` slot. Card headers typically need two distinct right-side regions — a primary action on top and secondary actions on the bottom. `CardHeaderLayout` provides this with `rightChildren` and `bottomRightChildren` slots, with no padding or gap between them so the caller has full control over spacing.
|
||||
|
||||
## Props
|
||||
|
||||
Inherits **all** props from [`Content`](../../content/README.md) (icon, title, description, sizePreset, variant, etc.) plus:
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `rightChildren` | `ReactNode` | `undefined` | Content rendered to the right of the Content block (top of right column). |
|
||||
| `bottomRightChildren` | `ReactNode` | `undefined` | Content rendered below `rightChildren` in the same column. Laid out as `flex flex-row`. |
|
||||
|
||||
## Layout Structure
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ [Content (p-2, self-start)] [rightChildren] │
|
||||
│ icon + title + description [bottomRightChildren] │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Outer wrapper: `flex flex-row items-stretch w-full`
|
||||
- Content area: `flex-1 min-w-0 self-start p-2` — top-aligned with fixed padding
|
||||
- Right column: `flex flex-col items-end justify-between shrink-0` — no padding, no gap
|
||||
- `bottomRightChildren` wrapper: `flex flex-row` — lays children out horizontally
|
||||
|
||||
The right column uses `justify-between` so when both slots are present, `rightChildren` sits at the top and `bottomRightChildren` at the bottom.
|
||||
|
||||
## Usage
|
||||
|
||||
### Card with primary and secondary actions
|
||||
|
||||
```tsx
|
||||
import { CardHeaderLayout } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgGlobe, SvgSettings, SvgUnplug, SvgCheckSquare } from "@opal/icons";
|
||||
|
||||
<CardHeaderLayout
|
||||
icon={SvgGlobe}
|
||||
title="Google Search"
|
||||
description="Web search provider"
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
rightChildren={
|
||||
<Button icon={SvgCheckSquare} variant="action" prominence="tertiary">
|
||||
Current Default
|
||||
</Button>
|
||||
}
|
||||
bottomRightChildren={
|
||||
<>
|
||||
<Button icon={SvgUnplug} size="sm" prominence="tertiary" tooltip="Disconnect" />
|
||||
<Button icon={SvgSettings} size="sm" prominence="tertiary" tooltip="Edit" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
### Card with only a connect action
|
||||
|
||||
```tsx
|
||||
<CardHeaderLayout
|
||||
icon={SvgCloud}
|
||||
title="OpenAI"
|
||||
description="Not configured"
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
rightChildren={
|
||||
<Button rightIcon={SvgArrowExchange} prominence="tertiary">
|
||||
Connect
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
### No right children
|
||||
|
||||
```tsx
|
||||
<CardHeaderLayout
|
||||
icon={SvgInfo}
|
||||
title="Section Header"
|
||||
description="Description text"
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
/>
|
||||
```
|
||||
|
||||
When both `rightChildren` and `bottomRightChildren` are omitted, the component renders only the padded `Content`.
|
||||
@@ -12,11 +12,8 @@ export {
|
||||
type ContentActionProps,
|
||||
} from "@opal/layouts/content-action/components";
|
||||
|
||||
/* CardHeaderLayout */
|
||||
export {
|
||||
CardHeaderLayout,
|
||||
type CardHeaderLayoutProps,
|
||||
} from "@opal/layouts/cards/header-layout/components";
|
||||
/* Card */
|
||||
export { Card, type CardHeaderProps } from "@opal/layouts/cards/components";
|
||||
|
||||
/* IllustrationContent */
|
||||
export {
|
||||
|
||||
6
web/package-lock.json
generated
6
web/package-lock.json
generated
@@ -18122,9 +18122,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
|
||||
@@ -16,7 +16,7 @@ import Text from "@/refresh-components/texts/Text";
|
||||
import SidebarWrapper from "@/sections/sidebar/SidebarWrapper";
|
||||
import SidebarBody from "@/sections/sidebar/SidebarBody";
|
||||
import SidebarSection from "@/sections/sidebar/SidebarSection";
|
||||
import UserAvatarPopover from "@/sections/sidebar/UserAvatarPopover";
|
||||
import AccountPopover from "@/sections/sidebar/AccountPopover";
|
||||
import Popover, { PopoverMenu } from "@/refresh-components/Popover";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import ButtonRenaming from "@/refresh-components/buttons/ButtonRenaming";
|
||||
@@ -398,7 +398,7 @@ const MemoizedBuildSidebarInner = memo(
|
||||
() => (
|
||||
<div>
|
||||
{backToChatButton}
|
||||
<UserAvatarPopover folded={folded} />
|
||||
<AccountPopover folded={folded} />
|
||||
</div>
|
||||
),
|
||||
[folded, backToChatButton]
|
||||
|
||||
@@ -133,7 +133,7 @@ async function createFederatedConnector(
|
||||
|
||||
async function updateFederatedConnector(
|
||||
id: number,
|
||||
credentials: CredentialForm,
|
||||
credentials: CredentialForm | null,
|
||||
config?: ConfigForm
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
@@ -143,7 +143,7 @@ async function updateFederatedConnector(
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentials,
|
||||
credentials: credentials ?? undefined,
|
||||
config: config || {},
|
||||
}),
|
||||
});
|
||||
@@ -201,7 +201,9 @@ export function FederatedConnectorForm({
|
||||
const isEditMode = connectorId !== undefined;
|
||||
|
||||
const [formState, setFormState] = useState<FormState>({
|
||||
credentials: preloadedConnectorData?.credentials || {},
|
||||
// In edit mode, don't populate credentials with masked values from the API.
|
||||
// Masked values (e.g. "••••••••••••") would be saved back and corrupt the real credentials.
|
||||
credentials: isEditMode ? {} : preloadedConnectorData?.credentials || {},
|
||||
config: preloadedConnectorData?.config || {},
|
||||
schema: preloadedCredentialSchema?.credentials || null,
|
||||
configurationSchema: null,
|
||||
@@ -209,6 +211,7 @@ export function FederatedConnectorForm({
|
||||
configurationSchemaError: null,
|
||||
connectorError: null,
|
||||
});
|
||||
const [credentialsModified, setCredentialsModified] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitMessage, setSubmitMessage] = useState<string | null>(null);
|
||||
const [submitSuccess, setSubmitSuccess] = useState<boolean | null>(null);
|
||||
@@ -333,6 +336,7 @@ export function FederatedConnectorForm({
|
||||
}
|
||||
|
||||
const handleCredentialChange = (key: string, value: string) => {
|
||||
setCredentialsModified(true);
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
credentials: {
|
||||
@@ -354,6 +358,11 @@ export function FederatedConnectorForm({
|
||||
|
||||
const handleValidateCredentials = async () => {
|
||||
if (!formState.schema) return;
|
||||
if (isEditMode && !credentialsModified) {
|
||||
setSubmitMessage("Enter new credential values before validating.");
|
||||
setSubmitSuccess(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsValidating(true);
|
||||
setSubmitMessage(null);
|
||||
@@ -411,8 +420,10 @@ export function FederatedConnectorForm({
|
||||
setSubmitSuccess(null);
|
||||
|
||||
try {
|
||||
// Validate required fields
|
||||
if (formState.schema) {
|
||||
const shouldValidateCredentials = !isEditMode || credentialsModified;
|
||||
|
||||
// Validate required fields (skip for credentials in edit mode when unchanged)
|
||||
if (formState.schema && shouldValidateCredentials) {
|
||||
const missingRequired = Object.entries(formState.schema)
|
||||
.filter(
|
||||
([key, field]) => field.required && !formState.credentials[key]
|
||||
@@ -442,16 +453,20 @@ export function FederatedConnectorForm({
|
||||
}
|
||||
setConfigValidationErrors({});
|
||||
|
||||
// Validate credentials before creating/updating
|
||||
const validation = await validateCredentials(
|
||||
connector,
|
||||
formState.credentials
|
||||
);
|
||||
if (!validation.success) {
|
||||
setSubmitMessage(`Credential validation failed: ${validation.message}`);
|
||||
setSubmitSuccess(false);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
// Validate credentials before creating/updating (skip in edit mode when unchanged)
|
||||
if (shouldValidateCredentials) {
|
||||
const validation = await validateCredentials(
|
||||
connector,
|
||||
formState.credentials
|
||||
);
|
||||
if (!validation.success) {
|
||||
setSubmitMessage(
|
||||
`Credential validation failed: ${validation.message}`
|
||||
);
|
||||
setSubmitSuccess(false);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create or update the connector
|
||||
@@ -459,7 +474,7 @@ export function FederatedConnectorForm({
|
||||
isEditMode && connectorId
|
||||
? await updateFederatedConnector(
|
||||
connectorId,
|
||||
formState.credentials,
|
||||
credentialsModified ? formState.credentials : null,
|
||||
formState.config
|
||||
)
|
||||
: await createFederatedConnector(
|
||||
@@ -538,14 +553,16 @@ export function FederatedConnectorForm({
|
||||
id={fieldKey}
|
||||
type={fieldSpec.secret ? "password" : "text"}
|
||||
placeholder={
|
||||
fieldSpec.example
|
||||
? String(fieldSpec.example)
|
||||
: fieldSpec.description
|
||||
isEditMode && !credentialsModified
|
||||
? "•••••••• (leave blank to keep current value)"
|
||||
: fieldSpec.example
|
||||
? String(fieldSpec.example)
|
||||
: fieldSpec.description
|
||||
}
|
||||
value={formState.credentials[fieldKey] || ""}
|
||||
onChange={(e) => handleCredentialChange(fieldKey, e.target.value)}
|
||||
className="w-96"
|
||||
required={fieldSpec.required}
|
||||
required={!isEditMode && fieldSpec.required}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
"use client";
|
||||
import {
|
||||
WellKnownLLMProviderDescriptor,
|
||||
LLMProviderDescriptor,
|
||||
} from "@/interfaces/llm";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import { LLMProviderDescriptor } from "@/interfaces/llm";
|
||||
import React, { createContext, useContext, useCallback } from "react";
|
||||
import { useLLMProviders } from "@/hooks/useLLMProviders";
|
||||
import { useLLMProviderOptions } from "@/lib/hooks/useLLMProviderOptions";
|
||||
import { testDefaultProvider as testDefaultProviderSvc } from "@/lib/llmConfig/svc";
|
||||
|
||||
interface ProviderContextType {
|
||||
shouldShowConfigurationNeeded: boolean;
|
||||
providerOptions: WellKnownLLMProviderDescriptor[];
|
||||
refreshProviderInfo: () => Promise<void>;
|
||||
// Expose configured provider instances for components that need it (e.g., onboarding)
|
||||
llmProviders: LLMProviderDescriptor[] | undefined;
|
||||
isLoadingProviders: boolean;
|
||||
hasProviders: boolean;
|
||||
@@ -29,79 +14,26 @@ const ProviderContext = createContext<ProviderContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const DEFAULT_LLM_PROVIDER_TEST_COMPLETE_KEY = "defaultLlmProviderTestComplete";
|
||||
|
||||
function checkDefaultLLMProviderTestComplete() {
|
||||
if (typeof window === "undefined") return true;
|
||||
return (
|
||||
localStorage.getItem(DEFAULT_LLM_PROVIDER_TEST_COMPLETE_KEY) === "true"
|
||||
);
|
||||
}
|
||||
|
||||
function setDefaultLLMProviderTestComplete() {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(DEFAULT_LLM_PROVIDER_TEST_COMPLETE_KEY, "true");
|
||||
}
|
||||
|
||||
export function ProviderContextProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { user } = useUser();
|
||||
|
||||
// Use SWR hooks instead of raw fetch
|
||||
const {
|
||||
llmProviders,
|
||||
isLoading: isLoadingProviders,
|
||||
refetch: refetchProviders,
|
||||
} = useLLMProviders();
|
||||
const { llmProviderOptions: providerOptions, refetch: refetchOptions } =
|
||||
useLLMProviderOptions();
|
||||
|
||||
const [defaultCheckSuccessful, setDefaultCheckSuccessful] =
|
||||
useState<boolean>(true);
|
||||
|
||||
// Test the default provider - only runs if test hasn't passed yet
|
||||
const testDefaultProvider = useCallback(async () => {
|
||||
const shouldCheck =
|
||||
!checkDefaultLLMProviderTestComplete() &&
|
||||
(!user || user.role === "admin");
|
||||
|
||||
if (shouldCheck) {
|
||||
const success = await testDefaultProviderSvc();
|
||||
setDefaultCheckSuccessful(success);
|
||||
if (success) {
|
||||
setDefaultLLMProviderTestComplete();
|
||||
}
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Test default provider on mount
|
||||
useEffect(() => {
|
||||
testDefaultProvider();
|
||||
}, [testDefaultProvider]);
|
||||
|
||||
const hasProviders = (llmProviders?.length ?? 0) > 0;
|
||||
const validProviderExists = hasProviders && defaultCheckSuccessful;
|
||||
|
||||
const shouldShowConfigurationNeeded =
|
||||
!validProviderExists && (providerOptions?.length ?? 0) > 0;
|
||||
|
||||
const refreshProviderInfo = useCallback(async () => {
|
||||
// Refetch provider lists and re-test default provider if needed
|
||||
await Promise.all([
|
||||
refetchProviders(),
|
||||
refetchOptions(),
|
||||
testDefaultProvider(),
|
||||
]);
|
||||
}, [refetchProviders, refetchOptions, testDefaultProvider]);
|
||||
await refetchProviders();
|
||||
}, [refetchProviders]);
|
||||
|
||||
return (
|
||||
<ProviderContext.Provider
|
||||
value={{
|
||||
shouldShowConfigurationNeeded,
|
||||
providerOptions: providerOptions ?? [],
|
||||
refreshProviderInfo,
|
||||
llmProviders,
|
||||
isLoadingProviders,
|
||||
|
||||
@@ -17,7 +17,6 @@ const mockProviderStatus = {
|
||||
llmProviders: [] as unknown[],
|
||||
isLoadingProviders: false,
|
||||
hasProviders: false,
|
||||
providerOptions: [],
|
||||
refreshProviderInfo: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -71,7 +70,6 @@ describe("useShowOnboarding", () => {
|
||||
mockProviderStatus.llmProviders = [];
|
||||
mockProviderStatus.isLoadingProviders = false;
|
||||
mockProviderStatus.hasProviders = false;
|
||||
mockProviderStatus.providerOptions = [];
|
||||
});
|
||||
|
||||
it("returns showOnboarding=false while providers are loading", () => {
|
||||
@@ -198,7 +196,6 @@ describe("useShowOnboarding", () => {
|
||||
OnboardingStep.Welcome
|
||||
);
|
||||
expect(result.current.onboardingActions).toBeDefined();
|
||||
expect(result.current.llmDescriptors).toEqual([]);
|
||||
});
|
||||
|
||||
describe("localStorage persistence", () => {
|
||||
|
||||
192
web/src/hooks/useMultiModelChat.ts
Normal file
192
web/src/hooks/useMultiModelChat.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
MAX_MODELS,
|
||||
SelectedModel,
|
||||
} from "@/refresh-components/popovers/ModelSelector";
|
||||
import { LLMOverride } from "@/app/app/services/lib";
|
||||
import { LlmManager } from "@/lib/hooks";
|
||||
import { buildLlmOptions } from "@/refresh-components/popovers/LLMPopover";
|
||||
|
||||
export interface UseMultiModelChatReturn {
|
||||
/** Currently selected models for multi-model comparison. */
|
||||
selectedModels: SelectedModel[];
|
||||
/** Whether multi-model mode is active (>1 model selected). */
|
||||
isMultiModelActive: boolean;
|
||||
/** Add a model to the selection. */
|
||||
addModel: (model: SelectedModel) => void;
|
||||
/** Remove a model by index. */
|
||||
removeModel: (index: number) => void;
|
||||
/** Replace a model at a specific index with a new one. */
|
||||
replaceModel: (index: number, model: SelectedModel) => void;
|
||||
/** Clear all selected models. */
|
||||
clearModels: () => void;
|
||||
/** Build the LLMOverride[] array from selectedModels. */
|
||||
buildLlmOverrides: () => LLMOverride[];
|
||||
/**
|
||||
* Restore multi-model selection from model version strings (e.g. from chat history).
|
||||
* Matches against available llmOptions to reconstruct full SelectedModel objects.
|
||||
*/
|
||||
restoreFromModelNames: (modelNames: string[]) => void;
|
||||
/**
|
||||
* Switch to a single model by name (after user picks a preferred response).
|
||||
* Matches against llmOptions to find the full SelectedModel.
|
||||
*/
|
||||
selectSingleModel: (modelName: string) => void;
|
||||
}
|
||||
|
||||
export default function useMultiModelChat(
|
||||
llmManager: LlmManager
|
||||
): UseMultiModelChatReturn {
|
||||
const [selectedModels, setSelectedModels] = useState<SelectedModel[]>([]);
|
||||
const [defaultInitialized, setDefaultInitialized] = useState(false);
|
||||
|
||||
// Initialize with the default model from llmManager once providers load
|
||||
const llmOptions = useMemo(
|
||||
() =>
|
||||
llmManager.llmProviders ? buildLlmOptions(llmManager.llmProviders) : [],
|
||||
[llmManager.llmProviders]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultInitialized) return;
|
||||
if (llmOptions.length === 0) return;
|
||||
const { currentLlm } = llmManager;
|
||||
// Don't initialize if currentLlm hasn't loaded yet
|
||||
if (!currentLlm.modelName) return;
|
||||
const match = llmOptions.find(
|
||||
(opt) =>
|
||||
opt.provider === currentLlm.provider &&
|
||||
opt.modelName === currentLlm.modelName
|
||||
);
|
||||
if (match) {
|
||||
setSelectedModels([
|
||||
{
|
||||
name: match.name,
|
||||
provider: match.provider,
|
||||
modelName: match.modelName,
|
||||
displayName: match.displayName,
|
||||
},
|
||||
]);
|
||||
setDefaultInitialized(true);
|
||||
}
|
||||
}, [llmOptions, llmManager.currentLlm, defaultInitialized]);
|
||||
|
||||
const isMultiModelActive = selectedModels.length > 1;
|
||||
|
||||
const addModel = useCallback((model: SelectedModel) => {
|
||||
setSelectedModels((prev) => {
|
||||
if (prev.length >= MAX_MODELS) return prev;
|
||||
if (
|
||||
prev.some(
|
||||
(m) =>
|
||||
m.provider === model.provider && m.modelName === model.modelName
|
||||
)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, model];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeModel = useCallback((index: number) => {
|
||||
setSelectedModels((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const replaceModel = useCallback((index: number, model: SelectedModel) => {
|
||||
setSelectedModels((prev) => {
|
||||
// Don't replace with a model that's already selected elsewhere
|
||||
if (
|
||||
prev.some(
|
||||
(m, i) =>
|
||||
i !== index &&
|
||||
m.provider === model.provider &&
|
||||
m.modelName === model.modelName
|
||||
)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev];
|
||||
next[index] = model;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearModels = useCallback(() => {
|
||||
setSelectedModels([]);
|
||||
}, []);
|
||||
|
||||
const restoreFromModelNames = useCallback(
|
||||
(modelNames: string[]) => {
|
||||
if (modelNames.length < 2 || llmOptions.length === 0) return;
|
||||
const restored: SelectedModel[] = [];
|
||||
for (const name of modelNames) {
|
||||
// Try matching by modelName (raw version string like "claude-opus-4-6")
|
||||
// or by displayName (friendly name like "Claude Opus 4.6")
|
||||
const match = llmOptions.find(
|
||||
(opt) =>
|
||||
opt.modelName === name ||
|
||||
opt.displayName === name ||
|
||||
opt.name === name
|
||||
);
|
||||
if (match) {
|
||||
restored.push({
|
||||
name: match.name,
|
||||
provider: match.provider,
|
||||
modelName: match.modelName,
|
||||
displayName: match.displayName,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (restored.length >= 2) {
|
||||
setSelectedModels(restored.slice(0, MAX_MODELS));
|
||||
setDefaultInitialized(true);
|
||||
}
|
||||
},
|
||||
[llmOptions]
|
||||
);
|
||||
|
||||
const selectSingleModel = useCallback(
|
||||
(modelName: string) => {
|
||||
if (llmOptions.length === 0) return;
|
||||
const match = llmOptions.find(
|
||||
(opt) =>
|
||||
opt.modelName === modelName ||
|
||||
opt.displayName === modelName ||
|
||||
opt.name === modelName
|
||||
);
|
||||
if (match) {
|
||||
setSelectedModels([
|
||||
{
|
||||
name: match.name,
|
||||
provider: match.provider,
|
||||
modelName: match.modelName,
|
||||
displayName: match.displayName,
|
||||
},
|
||||
]);
|
||||
}
|
||||
},
|
||||
[llmOptions]
|
||||
);
|
||||
|
||||
const buildLlmOverrides = useCallback((): LLMOverride[] => {
|
||||
return selectedModels.map((m) => ({
|
||||
model_provider: m.provider,
|
||||
model_version: m.modelName,
|
||||
display_name: m.displayName,
|
||||
}));
|
||||
}, [selectedModels]);
|
||||
|
||||
return {
|
||||
selectedModels,
|
||||
isMultiModelActive,
|
||||
addModel,
|
||||
removeModel,
|
||||
replaceModel,
|
||||
clearModels,
|
||||
buildLlmOverrides,
|
||||
restoreFromModelNames,
|
||||
selectSingleModel,
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
OnboardingState,
|
||||
OnboardingStep,
|
||||
} from "@/interfaces/onboarding";
|
||||
import { WellKnownLLMProviderDescriptor } from "@/interfaces/llm";
|
||||
import { updateUserPersonalization } from "@/lib/userSettings";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces";
|
||||
@@ -22,7 +21,6 @@ function getOnboardingCompletedKey(userId: string): string {
|
||||
|
||||
function useOnboardingState(liveAgent?: MinimalPersonaSnapshot): {
|
||||
state: OnboardingState;
|
||||
llmDescriptors: WellKnownLLMProviderDescriptor[];
|
||||
actions: OnboardingActions;
|
||||
isLoading: boolean;
|
||||
hasProviders: boolean;
|
||||
@@ -35,7 +33,6 @@ function useOnboardingState(liveAgent?: MinimalPersonaSnapshot): {
|
||||
llmProviders,
|
||||
isLoadingProviders,
|
||||
hasProviders: hasLlmProviders,
|
||||
providerOptions,
|
||||
refreshProviderInfo,
|
||||
} = useProviderStatus();
|
||||
|
||||
@@ -43,7 +40,6 @@ function useOnboardingState(liveAgent?: MinimalPersonaSnapshot): {
|
||||
const { refetch: refreshPersonaProviders } = useLLMProviders(liveAgent?.id);
|
||||
|
||||
const userName = user?.personalization?.name;
|
||||
const llmDescriptors = providerOptions;
|
||||
|
||||
const nameUpdateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null
|
||||
@@ -235,7 +231,6 @@ function useOnboardingState(liveAgent?: MinimalPersonaSnapshot): {
|
||||
|
||||
return {
|
||||
state,
|
||||
llmDescriptors,
|
||||
actions: {
|
||||
nextStep,
|
||||
prevStep,
|
||||
@@ -280,7 +275,6 @@ export function useShowOnboarding({
|
||||
const {
|
||||
state: onboardingState,
|
||||
actions: onboardingActions,
|
||||
llmDescriptors,
|
||||
isLoading: isLoadingOnboarding,
|
||||
hasProviders: hasAnyProvider,
|
||||
} = useOnboardingState(liveAgent);
|
||||
@@ -350,7 +344,6 @@ export function useShowOnboarding({
|
||||
onboardingDismissed,
|
||||
onboardingState,
|
||||
onboardingActions,
|
||||
llmDescriptors,
|
||||
isLoadingOnboarding,
|
||||
hideOnboarding,
|
||||
finishOnboarding,
|
||||
|
||||
@@ -122,7 +122,6 @@ export interface LLMProviderFormProps {
|
||||
variant?: LLMModalVariant;
|
||||
existingLlmProvider?: LLMProviderView;
|
||||
shouldMarkAsDefault?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
|
||||
/** The current default model name for this provider (from the global default). */
|
||||
|
||||
@@ -89,18 +89,6 @@ export const KeyWideLayout: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<KeyValueInput
|
||||
keyTitle="Key"
|
||||
valueTitle="Value"
|
||||
items={[{ key: "LOCKED", value: "cannot-edit" }]}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const EmptyLineMode: Story = {
|
||||
render: function EmptyStory() {
|
||||
const [items, setItems] = React.useState<KeyValue[]>([]);
|
||||
|
||||
@@ -68,21 +68,13 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useId,
|
||||
useRef,
|
||||
} from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import InputTypeIn from "./InputTypeIn";
|
||||
import { Button, EmptyMessageCard } from "@opal/components";
|
||||
import type { WithoutStyles } from "@opal/types";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { FieldContext } from "../form/FieldContext";
|
||||
import { FieldMessage } from "../messages/FieldMessage";
|
||||
import { ErrorTextLayout } from "@/layouts/input-layouts";
|
||||
import { SvgMinusCircle, SvgPlusCircle } from "@opal/icons";
|
||||
|
||||
export type KeyValue = { key: string; value: string };
|
||||
@@ -107,82 +99,50 @@ const GRID_COLS = {
|
||||
interface KeyValueInputItemProps {
|
||||
item: KeyValue;
|
||||
onChange: (next: KeyValue) => void;
|
||||
disabled?: boolean;
|
||||
onRemove: () => void;
|
||||
keyPlaceholder?: string;
|
||||
valuePlaceholder?: string;
|
||||
error?: KeyValueError;
|
||||
canRemove: boolean;
|
||||
index: number;
|
||||
fieldId: string;
|
||||
}
|
||||
|
||||
function KeyValueInputItem({
|
||||
item,
|
||||
onChange,
|
||||
disabled,
|
||||
onRemove,
|
||||
keyPlaceholder,
|
||||
valuePlaceholder,
|
||||
error,
|
||||
canRemove,
|
||||
index,
|
||||
fieldId,
|
||||
}: KeyValueInputItemProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<InputTypeIn
|
||||
placeholder={keyPlaceholder || "Key"}
|
||||
placeholder={keyPlaceholder}
|
||||
value={item.key}
|
||||
onChange={(e) => onChange({ ...item, key: e.target.value })}
|
||||
aria-label={`${keyPlaceholder || "Key"} ${index + 1}`}
|
||||
aria-invalid={!!error?.key}
|
||||
aria-describedby={
|
||||
error?.key ? `${fieldId}-key-error-${index}` : undefined
|
||||
}
|
||||
variant={disabled ? "disabled" : undefined}
|
||||
showClearButton={false}
|
||||
/>
|
||||
{error?.key && (
|
||||
<FieldMessage variant="error" className="ml-0.5">
|
||||
<FieldMessage.Content
|
||||
id={`${fieldId}-key-error-${index}`}
|
||||
role="alert"
|
||||
className="ml-0.5"
|
||||
>
|
||||
{error.key}
|
||||
</FieldMessage.Content>
|
||||
</FieldMessage>
|
||||
)}
|
||||
{error?.key && <ErrorTextLayout>{error.key}</ErrorTextLayout>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<InputTypeIn
|
||||
placeholder={valuePlaceholder || "Value"}
|
||||
placeholder={valuePlaceholder}
|
||||
value={item.value}
|
||||
onChange={(e) => onChange({ ...item, value: e.target.value })}
|
||||
aria-label={`${valuePlaceholder || "Value"} ${index + 1}`}
|
||||
aria-invalid={!!error?.value}
|
||||
aria-describedby={
|
||||
error?.value ? `${fieldId}-value-error-${index}` : undefined
|
||||
}
|
||||
variant={disabled ? "disabled" : undefined}
|
||||
showClearButton={false}
|
||||
/>
|
||||
{error?.value && (
|
||||
<FieldMessage variant="error" className="ml-0.5">
|
||||
<FieldMessage.Content
|
||||
id={`${fieldId}-value-error-${index}`}
|
||||
role="alert"
|
||||
className="ml-0.5"
|
||||
>
|
||||
{error.value}
|
||||
</FieldMessage.Content>
|
||||
</FieldMessage>
|
||||
)}
|
||||
{error?.value && <ErrorTextLayout>{error.value}</ErrorTextLayout>}
|
||||
</div>
|
||||
<Button
|
||||
disabled={disabled || !canRemove}
|
||||
disabled={!canRemove}
|
||||
prominence="tertiary"
|
||||
icon={SvgMinusCircle}
|
||||
onClick={onRemove}
|
||||
@@ -198,46 +158,31 @@ export interface KeyValueInputProps
|
||||
> {
|
||||
/** Title for the key column */
|
||||
keyTitle?: string;
|
||||
|
||||
/** Title for the value column */
|
||||
valueTitle?: string;
|
||||
|
||||
/** Placeholder for the key input */
|
||||
keyPlaceholder?: string;
|
||||
|
||||
/** Placeholder for the value input */
|
||||
valuePlaceholder?: string;
|
||||
|
||||
/** Array of key-value pairs */
|
||||
items: KeyValue[];
|
||||
|
||||
/** Callback when items change */
|
||||
onChange: (nextItems: KeyValue[]) => void;
|
||||
/** Custom add handler */
|
||||
onAdd?: () => void;
|
||||
/** Custom remove handler */
|
||||
onRemove?: (index: number) => void;
|
||||
/** Disabled state */
|
||||
disabled?: boolean;
|
||||
|
||||
/** Mode: 'line' allows removing all items, 'fixed-line' requires at least one item */
|
||||
mode?: "line" | "fixed-line";
|
||||
|
||||
/** Layout: 'equal' - both inputs same width, 'key-wide' - key input is wider (60/40 split) */
|
||||
layout?: "equal" | "key-wide";
|
||||
/** Callback when validation state changes */
|
||||
onValidationChange?: (isValid: boolean, errors: KeyValueError[]) => void;
|
||||
|
||||
/** Callback to handle validation errors - integrates with Formik or custom error handling. Called with error message when invalid, null when valid */
|
||||
onValidationError?: (errorMessage: string | null) => void;
|
||||
/** Optional custom validator for the key field. Return { isValid, message } */
|
||||
onKeyValidate?: (
|
||||
key: string,
|
||||
index: number,
|
||||
item: KeyValue,
|
||||
items: KeyValue[]
|
||||
) => { isValid: boolean; message?: string };
|
||||
/** Optional custom validator for the value field. Return { isValid, message } */
|
||||
onValueValidate?: (
|
||||
value: string,
|
||||
index: number,
|
||||
item: KeyValue,
|
||||
items: KeyValue[]
|
||||
) => { isValid: boolean; message?: string };
|
||||
/** Whether to validate for duplicate keys */
|
||||
validateDuplicateKeys?: boolean;
|
||||
/** Whether to validate for empty keys */
|
||||
validateEmptyKeys?: boolean;
|
||||
/** Optional name for the field (for accessibility) */
|
||||
name?: string;
|
||||
|
||||
/** Custom label for the add button (defaults to "Add Line") */
|
||||
addButtonLabel?: string;
|
||||
}
|
||||
@@ -245,26 +190,16 @@ export interface KeyValueInputProps
|
||||
export default function KeyValueInput({
|
||||
keyTitle = "Key",
|
||||
valueTitle = "Value",
|
||||
keyPlaceholder,
|
||||
valuePlaceholder,
|
||||
items = [],
|
||||
onChange,
|
||||
onAdd,
|
||||
onRemove,
|
||||
disabled = false,
|
||||
mode = "line",
|
||||
layout = "equal",
|
||||
onValidationChange,
|
||||
onValidationError,
|
||||
onKeyValidate,
|
||||
onValueValidate,
|
||||
validateDuplicateKeys = true,
|
||||
validateEmptyKeys = true,
|
||||
name,
|
||||
addButtonLabel = "Add Line",
|
||||
...rest
|
||||
}: KeyValueInputProps) {
|
||||
// Try to get field context if used within FormField (safe access)
|
||||
const fieldContext = useContext(FieldContext);
|
||||
|
||||
// Validation logic
|
||||
const errors = useMemo((): KeyValueError[] => {
|
||||
if (!items || items.length === 0) return [];
|
||||
@@ -273,12 +208,8 @@ export default function KeyValueInput({
|
||||
const keyCount = new Map<string, number[]>();
|
||||
|
||||
items.forEach((item, index) => {
|
||||
// Validate empty keys - only if value is filled (user is actively working on this row)
|
||||
if (
|
||||
validateEmptyKeys &&
|
||||
item.key.trim() === "" &&
|
||||
item.value.trim() !== ""
|
||||
) {
|
||||
// Validate empty keys
|
||||
if (item.key.trim() === "" && item.value.trim() !== "") {
|
||||
const error = errorsList[index];
|
||||
if (error) {
|
||||
error.key = "Key cannot be empty";
|
||||
@@ -291,56 +222,22 @@ export default function KeyValueInput({
|
||||
existing.push(index);
|
||||
keyCount.set(item.key, existing);
|
||||
}
|
||||
|
||||
// Custom key validation
|
||||
if (onKeyValidate) {
|
||||
const result = onKeyValidate(item.key, index, item, items);
|
||||
if (result && result.isValid === false) {
|
||||
const error = errorsList[index];
|
||||
if (error) {
|
||||
error.key = result.message || "Invalid key";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom value validation
|
||||
if (onValueValidate) {
|
||||
const result = onValueValidate(item.value, index, item, items);
|
||||
if (result && result.isValid === false) {
|
||||
const error = errorsList[index];
|
||||
if (error) {
|
||||
error.value = result.message || "Invalid value";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Validate duplicate keys
|
||||
if (validateDuplicateKeys) {
|
||||
keyCount.forEach((indices, key) => {
|
||||
if (indices.length > 1) {
|
||||
indices.forEach((index) => {
|
||||
const error = errorsList[index];
|
||||
if (error) {
|
||||
error.key = "Duplicate key";
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
keyCount.forEach((indices, key) => {
|
||||
if (indices.length > 1) {
|
||||
indices.forEach((index) => {
|
||||
const error = errorsList[index];
|
||||
if (error) {
|
||||
error.key = "Duplicate key";
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return errorsList;
|
||||
}, [
|
||||
items,
|
||||
validateDuplicateKeys,
|
||||
validateEmptyKeys,
|
||||
onKeyValidate,
|
||||
onValueValidate,
|
||||
]);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
return errors.every((error) => !error.key && !error.value);
|
||||
}, [errors]);
|
||||
}, [items]);
|
||||
|
||||
const hasAnyError = useMemo(() => {
|
||||
return errors.some((error) => error.key || error.value);
|
||||
@@ -371,21 +268,12 @@ export default function KeyValueInput({
|
||||
}, [hasAnyError, errors]);
|
||||
|
||||
// Notify parent of validation changes
|
||||
const onValidationChangeRef = useRef(onValidationChange);
|
||||
const onValidationErrorRef = useRef(onValidationError);
|
||||
|
||||
useEffect(() => {
|
||||
onValidationChangeRef.current = onValidationChange;
|
||||
}, [onValidationChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onValidationErrorRef.current = onValidationError;
|
||||
}, [onValidationError]);
|
||||
|
||||
useEffect(() => {
|
||||
onValidationChangeRef.current?.(isValid, errors);
|
||||
}, [isValid, errors]);
|
||||
|
||||
// Notify parent of error state for form library integration
|
||||
useEffect(() => {
|
||||
onValidationErrorRef.current?.(errorMessage);
|
||||
@@ -394,25 +282,17 @@ export default function KeyValueInput({
|
||||
const canRemoveItems = mode === "line" || items.length > 1;
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
if (onAdd) {
|
||||
onAdd();
|
||||
return;
|
||||
}
|
||||
onChange([...(items || []), { key: "", value: "" }]);
|
||||
}, [onAdd, onChange, items]);
|
||||
}, [onChange, items]);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(index: number) => {
|
||||
if (!canRemoveItems && items.length === 1) return;
|
||||
|
||||
if (onRemove) {
|
||||
onRemove(index);
|
||||
return;
|
||||
}
|
||||
const next = (items || []).filter((_, i) => i !== index);
|
||||
onChange(next);
|
||||
},
|
||||
[canRemoveItems, items, onRemove, onChange]
|
||||
[canRemoveItems, items, onChange]
|
||||
);
|
||||
|
||||
const handleItemChange = useCallback(
|
||||
@@ -431,8 +311,6 @@ export default function KeyValueInput({
|
||||
}
|
||||
}, [mode]); // Only run on mode change
|
||||
|
||||
const autoId = useId();
|
||||
const fieldId = fieldContext?.baseId || name || `key-value-input-${autoId}`;
|
||||
const gridCols = GRID_COLS[layout];
|
||||
|
||||
return (
|
||||
@@ -460,23 +338,24 @@ export default function KeyValueInput({
|
||||
key={index}
|
||||
item={item}
|
||||
onChange={(next) => handleItemChange(index, next)}
|
||||
disabled={disabled}
|
||||
onRemove={() => handleRemove(index)}
|
||||
keyPlaceholder={keyTitle}
|
||||
valuePlaceholder={valueTitle}
|
||||
keyPlaceholder={keyPlaceholder}
|
||||
valuePlaceholder={valuePlaceholder}
|
||||
error={errors[index]}
|
||||
canRemove={canRemoveItems}
|
||||
index={index}
|
||||
fieldId={fieldId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyMessageCard title="No items added yet." />
|
||||
<EmptyMessageCard
|
||||
title="No items added yet."
|
||||
padding="sm"
|
||||
sizePreset="secondary"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
disabled={disabled}
|
||||
prominence="secondary"
|
||||
onClick={handleAdd}
|
||||
icon={SvgPlusCircle}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import Popover, { PopoverMenu } from "@/refresh-components/Popover";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import { LlmDescriptor, LlmManager } from "@/lib/hooks";
|
||||
import { structureValue } from "@/lib/llmConfig/utils";
|
||||
import {
|
||||
@@ -11,25 +11,11 @@ import {
|
||||
import { LLMProviderDescriptor } from "@/interfaces/llm";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import {
|
||||
SvgCheck,
|
||||
SvgChevronDown,
|
||||
SvgChevronRight,
|
||||
SvgRefreshCw,
|
||||
} from "@opal/icons";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { SvgRefreshCw } from "@opal/icons";
|
||||
import { OpenButton } from "@opal/components";
|
||||
import { LLMOption, LLMOptionGroup } from "./interfaces";
|
||||
import ModelListContent from "./ModelListContent";
|
||||
|
||||
export interface LLMPopoverProps {
|
||||
llmManager: LlmManager;
|
||||
@@ -150,7 +136,6 @@ export default function LLMPopover({
|
||||
const isLoadingProviders = llmManager.isLoadingProviders;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const { user } = useUser();
|
||||
|
||||
const [localTemperature, setLocalTemperature] = useState(
|
||||
@@ -161,9 +146,7 @@ export default function LLMPopover({
|
||||
setLocalTemperature(llmManager.temperature ?? 0.5);
|
||||
}, [llmManager.temperature]);
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const selectedItemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleGlobalTemperatureChange = useCallback((value: number[]) => {
|
||||
const value_0 = value[0];
|
||||
@@ -182,39 +165,28 @@ export default function LLMPopover({
|
||||
[llmManager]
|
||||
);
|
||||
|
||||
const llmOptions = useMemo(
|
||||
() => buildLlmOptions(llmProviders, currentModelName),
|
||||
[llmProviders, currentModelName]
|
||||
const isSelected = useCallback(
|
||||
(option: LLMOption) =>
|
||||
option.modelName === llmManager.currentLlm.modelName &&
|
||||
option.provider === llmManager.currentLlm.provider,
|
||||
[llmManager.currentLlm.modelName, llmManager.currentLlm.provider]
|
||||
);
|
||||
|
||||
// Filter options by vision capability (when images are uploaded) and search query
|
||||
const filteredOptions = useMemo(() => {
|
||||
let result = llmOptions;
|
||||
if (requiresImageInput) {
|
||||
result = result.filter((opt) => opt.supportsImageInput);
|
||||
}
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(opt) =>
|
||||
opt.displayName.toLowerCase().includes(query) ||
|
||||
opt.modelName.toLowerCase().includes(query) ||
|
||||
(opt.vendor && opt.vendor.toLowerCase().includes(query))
|
||||
const handleSelectModel = useCallback(
|
||||
(option: LLMOption) => {
|
||||
llmManager.updateCurrentLlm({
|
||||
modelName: option.modelName,
|
||||
provider: option.provider,
|
||||
name: option.name,
|
||||
} as LlmDescriptor);
|
||||
onSelect?.(
|
||||
structureValue(option.name, option.provider, option.modelName)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [llmOptions, searchQuery, requiresImageInput]);
|
||||
|
||||
// Group options by provider using backend-provided display names and ordering
|
||||
// For aggregator providers (bedrock, openrouter, vertex_ai), flatten to "Provider/Vendor" format
|
||||
const groupedOptions = useMemo(
|
||||
() => groupLlmOptions(filteredOptions),
|
||||
[filteredOptions]
|
||||
setOpen(false);
|
||||
},
|
||||
[llmManager, onSelect]
|
||||
);
|
||||
|
||||
// Get display name for the model to show in the button
|
||||
// Use currentModelName prop if provided (e.g., for regenerate showing the model used),
|
||||
// otherwise fall back to the globally selected model
|
||||
const currentLlmDisplayName = useMemo(() => {
|
||||
// Only use currentModelName if it's a non-empty string
|
||||
const currentModel =
|
||||
@@ -234,122 +206,30 @@ export default function LLMPopover({
|
||||
return currentModel;
|
||||
}, [llmProviders, currentModelName, llmManager.currentLlm.modelName]);
|
||||
|
||||
// Determine which group the current model belongs to (for auto-expand)
|
||||
const currentGroupKey = useMemo(() => {
|
||||
const currentModel = llmManager.currentLlm.modelName;
|
||||
const currentProvider = llmManager.currentLlm.provider;
|
||||
// Match by both modelName AND provider to handle same model name across providers
|
||||
const option = llmOptions.find(
|
||||
(o) => o.modelName === currentModel && o.provider === currentProvider
|
||||
);
|
||||
if (!option) return "openai";
|
||||
|
||||
const provider = option.provider.toLowerCase();
|
||||
const isAggregator = AGGREGATOR_PROVIDERS.has(provider);
|
||||
|
||||
if (isAggregator && option.vendor) {
|
||||
return `${provider}/${option.vendor.toLowerCase()}`;
|
||||
}
|
||||
return provider;
|
||||
}, [
|
||||
llmOptions,
|
||||
llmManager.currentLlm.modelName,
|
||||
llmManager.currentLlm.provider,
|
||||
]);
|
||||
|
||||
// Track expanded groups - initialize with current model's group
|
||||
const [expandedGroups, setExpandedGroups] = useState<string[]>([
|
||||
currentGroupKey,
|
||||
]);
|
||||
|
||||
// Reset state when popover closes/opens
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearchQuery("");
|
||||
} else {
|
||||
// Reset expanded groups to only show the selected model's group
|
||||
setExpandedGroups([currentGroupKey]);
|
||||
}
|
||||
}, [open, currentGroupKey]);
|
||||
|
||||
// Auto-scroll to selected model when popover opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Small delay to let accordion content render
|
||||
const timer = setTimeout(() => {
|
||||
selectedItemRef.current?.scrollIntoView({
|
||||
behavior: "instant",
|
||||
block: "center",
|
||||
});
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const isSearching = searchQuery.trim().length > 0;
|
||||
|
||||
// Compute final expanded groups
|
||||
const effectiveExpandedGroups = useMemo(() => {
|
||||
if (isSearching) {
|
||||
// Force expand all when searching
|
||||
return groupedOptions.map((g) => g.key);
|
||||
}
|
||||
return expandedGroups;
|
||||
}, [isSearching, groupedOptions, expandedGroups]);
|
||||
|
||||
// Handler for accordion changes
|
||||
const handleAccordionChange = (value: string[]) => {
|
||||
// Only update state when not searching (force-expanding)
|
||||
if (!isSearching) {
|
||||
setExpandedGroups(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectModel = (option: LLMOption) => {
|
||||
llmManager.updateCurrentLlm({
|
||||
modelName: option.modelName,
|
||||
provider: option.provider,
|
||||
name: option.name,
|
||||
} as LlmDescriptor);
|
||||
onSelect?.(structureValue(option.name, option.provider, option.modelName));
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const renderModelItem = (option: LLMOption) => {
|
||||
const isSelected =
|
||||
option.modelName === llmManager.currentLlm.modelName &&
|
||||
option.provider === llmManager.currentLlm.provider;
|
||||
|
||||
const capabilities: string[] = [];
|
||||
if (option.supportsReasoning) {
|
||||
capabilities.push("Reasoning");
|
||||
}
|
||||
if (option.supportsImageInput) {
|
||||
capabilities.push("Vision");
|
||||
}
|
||||
const description =
|
||||
capabilities.length > 0 ? capabilities.join(", ") : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${option.name}-${option.modelName}`}
|
||||
ref={isSelected ? selectedItemRef : undefined}
|
||||
>
|
||||
<LineItem
|
||||
selected={isSelected}
|
||||
description={description}
|
||||
onClick={() => handleSelectModel(option)}
|
||||
rightChildren={
|
||||
isSelected ? (
|
||||
<SvgCheck className="h-4 w-4 stroke-action-link-05 shrink-0" />
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{option.displayName}
|
||||
</LineItem>
|
||||
const temperatureFooter = user?.preferences?.temperature_override_enabled ? (
|
||||
<>
|
||||
<div className="border-t border-border-02 mx-2" />
|
||||
<div className="flex flex-col w-full py-2 gap-2">
|
||||
<Slider
|
||||
value={[localTemperature]}
|
||||
max={llmManager.maxTemperature}
|
||||
min={0}
|
||||
step={0.01}
|
||||
onValueChange={handleGlobalTemperatureChange}
|
||||
onValueCommit={handleGlobalTemperatureCommit}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<Text secondaryBody text03>
|
||||
Temperature (creativity)
|
||||
</Text>
|
||||
<Text secondaryBody text03>
|
||||
{localTemperature.toFixed(1)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -373,129 +253,16 @@ export default function LLMPopover({
|
||||
</div>
|
||||
|
||||
<Popover.Content side="top" align="end" width="xl">
|
||||
<Section gap={0.5}>
|
||||
{/* Search Input */}
|
||||
<InputTypeIn
|
||||
ref={searchInputRef}
|
||||
leftSearchIcon
|
||||
variant="internal"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search models..."
|
||||
/>
|
||||
|
||||
{/* Model List with Vendor Groups */}
|
||||
<PopoverMenu scrollContainerRef={scrollContainerRef}>
|
||||
{isLoadingProviders
|
||||
? [
|
||||
<div key="loading" className="flex items-center gap-2 py-3">
|
||||
<SimpleLoader />
|
||||
<Text secondaryBody text03>
|
||||
Loading models...
|
||||
</Text>
|
||||
</div>,
|
||||
]
|
||||
: groupedOptions.length === 0
|
||||
? [
|
||||
<div key="empty" className="py-3">
|
||||
<Text secondaryBody text03>
|
||||
No models found
|
||||
</Text>
|
||||
</div>,
|
||||
]
|
||||
: groupedOptions.length === 1
|
||||
? // Single provider - show models directly without accordion
|
||||
[
|
||||
<div
|
||||
key="single-provider"
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
{groupedOptions[0]!.options.map(renderModelItem)}
|
||||
</div>,
|
||||
]
|
||||
: // Multiple providers - show accordion with groups
|
||||
[
|
||||
<Accordion
|
||||
key="accordion"
|
||||
type="multiple"
|
||||
value={effectiveExpandedGroups}
|
||||
onValueChange={handleAccordionChange}
|
||||
className="w-full flex flex-col"
|
||||
>
|
||||
{groupedOptions.map((group) => {
|
||||
const isExpanded = effectiveExpandedGroups.includes(
|
||||
group.key
|
||||
);
|
||||
return (
|
||||
<AccordionItem
|
||||
key={group.key}
|
||||
value={group.key}
|
||||
className="border-none pt-1"
|
||||
>
|
||||
{/* Group Header */}
|
||||
<AccordionTrigger className="flex items-center rounded-08 hover:no-underline hover:bg-background-tint-02 group [&>svg]:hidden w-full py-1">
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<div className="flex items-center justify-center size-5 shrink-0">
|
||||
<group.Icon size={16} />
|
||||
</div>
|
||||
<Text
|
||||
secondaryBody
|
||||
text03
|
||||
nowrap
|
||||
className="px-0.5"
|
||||
>
|
||||
{group.displayName}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center justify-center size-6 shrink-0">
|
||||
{isExpanded ? (
|
||||
<SvgChevronDown className="h-4 w-4 stroke-text-04 shrink-0" />
|
||||
) : (
|
||||
<SvgChevronRight className="h-4 w-4 stroke-text-04 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
{/* Model Items - full width highlight */}
|
||||
<AccordionContent className="pb-0 pt-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
{group.options.map(renderModelItem)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>,
|
||||
]}
|
||||
</PopoverMenu>
|
||||
|
||||
{/* Global Temperature Slider (shown if enabled in user prefs) */}
|
||||
{user?.preferences?.temperature_override_enabled && (
|
||||
<>
|
||||
<div className="border-t border-border-02 mx-2" />
|
||||
<div className="flex flex-col w-full py-2 gap-2">
|
||||
<Slider
|
||||
value={[localTemperature]}
|
||||
max={llmManager.maxTemperature}
|
||||
min={0}
|
||||
step={0.01}
|
||||
onValueChange={handleGlobalTemperatureChange}
|
||||
onValueCommit={handleGlobalTemperatureCommit}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<Text secondaryBody text03>
|
||||
Temperature (creativity)
|
||||
</Text>
|
||||
<Text secondaryBody text03>
|
||||
{localTemperature.toFixed(1)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
<ModelListContent
|
||||
llmProviders={llmProviders}
|
||||
currentModelName={currentModelName}
|
||||
requiresImageInput={requiresImageInput}
|
||||
isLoading={isLoadingProviders}
|
||||
onSelect={handleSelectModel}
|
||||
isSelected={isSelected}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
footer={temperatureFooter}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
200
web/src/refresh-components/popovers/ModelListContent.tsx
Normal file
200
web/src/refresh-components/popovers/ModelListContent.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import { PopoverMenu } from "@/refresh-components/Popover";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import { Text } from "@opal/components";
|
||||
import { SvgCheck, SvgChevronDown, SvgChevronRight } from "@opal/icons";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { LLMOption } from "./interfaces";
|
||||
import { buildLlmOptions, groupLlmOptions } from "./LLMPopover";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import { LLMProviderDescriptor } from "@/interfaces/llm";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/refresh-components/Collapsible";
|
||||
|
||||
export interface ModelListContentProps {
|
||||
llmProviders: LLMProviderDescriptor[] | undefined;
|
||||
currentModelName?: string;
|
||||
requiresImageInput?: boolean;
|
||||
onSelect: (option: LLMOption) => void;
|
||||
isSelected: (option: LLMOption) => boolean;
|
||||
isDisabled?: (option: LLMOption) => boolean;
|
||||
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
isLoading?: boolean;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ModelListContent({
|
||||
llmProviders,
|
||||
currentModelName,
|
||||
requiresImageInput,
|
||||
onSelect,
|
||||
isSelected,
|
||||
isDisabled,
|
||||
scrollContainerRef: externalScrollRef,
|
||||
isLoading,
|
||||
footer,
|
||||
}: ModelListContentProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const internalScrollRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
|
||||
|
||||
const llmOptions = useMemo(
|
||||
() => buildLlmOptions(llmProviders, currentModelName),
|
||||
[llmProviders, currentModelName]
|
||||
);
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
let result = llmOptions;
|
||||
if (requiresImageInput) {
|
||||
result = result.filter((opt) => opt.supportsImageInput);
|
||||
}
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(opt) =>
|
||||
opt.displayName.toLowerCase().includes(query) ||
|
||||
opt.modelName.toLowerCase().includes(query) ||
|
||||
(opt.vendor && opt.vendor.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [llmOptions, searchQuery, requiresImageInput]);
|
||||
|
||||
const groupedOptions = useMemo(
|
||||
() => groupLlmOptions(filteredOptions),
|
||||
[filteredOptions]
|
||||
);
|
||||
|
||||
// Find which group contains a currently-selected model (for auto-expand)
|
||||
const defaultGroupKey = useMemo(() => {
|
||||
for (const group of groupedOptions) {
|
||||
if (group.options.some((opt) => isSelected(opt))) {
|
||||
return group.key;
|
||||
}
|
||||
}
|
||||
return groupedOptions[0]?.key ?? "";
|
||||
}, [groupedOptions, isSelected]);
|
||||
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
|
||||
new Set([defaultGroupKey])
|
||||
);
|
||||
|
||||
// Reset expanded groups when default changes (e.g. popover re-opens)
|
||||
useEffect(() => {
|
||||
setExpandedGroups(new Set([defaultGroupKey]));
|
||||
}, [defaultGroupKey]);
|
||||
|
||||
const isSearching = searchQuery.trim().length > 0;
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
if (isSearching) return;
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isGroupOpen = (key: string) => isSearching || expandedGroups.has(key);
|
||||
|
||||
const renderModelItem = (option: LLMOption) => {
|
||||
const selected = isSelected(option);
|
||||
const disabled = isDisabled?.(option) ?? false;
|
||||
|
||||
const capabilities: string[] = [];
|
||||
if (option.supportsReasoning) capabilities.push("Reasoning");
|
||||
if (option.supportsImageInput) capabilities.push("Vision");
|
||||
const description =
|
||||
capabilities.length > 0 ? capabilities.join(", ") : undefined;
|
||||
|
||||
return (
|
||||
<LineItem
|
||||
key={`${option.provider}:${option.modelName}`}
|
||||
selected={selected}
|
||||
disabled={disabled}
|
||||
description={description}
|
||||
onClick={() => onSelect(option)}
|
||||
rightChildren={
|
||||
selected ? (
|
||||
<SvgCheck className="h-4 w-4 stroke-action-link-05 shrink-0" />
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{option.displayName}
|
||||
</LineItem>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section gap={0.5}>
|
||||
<InputTypeIn
|
||||
leftSearchIcon
|
||||
variant="internal"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search models..."
|
||||
/>
|
||||
|
||||
<PopoverMenu scrollContainerRef={scrollContainerRef}>
|
||||
{isLoading
|
||||
? [
|
||||
<Text key="loading" font="secondary-body" color="text-03">
|
||||
Loading models...
|
||||
</Text>,
|
||||
]
|
||||
: groupedOptions.length === 0
|
||||
? [
|
||||
<Text key="empty" font="secondary-body" color="text-03">
|
||||
No models found
|
||||
</Text>,
|
||||
]
|
||||
: groupedOptions.length === 1
|
||||
? [
|
||||
<Section key="single-provider" gap={0.25}>
|
||||
{groupedOptions[0]!.options.map(renderModelItem)}
|
||||
</Section>,
|
||||
]
|
||||
: groupedOptions.map((group) => {
|
||||
const open = isGroupOpen(group.key);
|
||||
return (
|
||||
<Collapsible
|
||||
key={group.key}
|
||||
open={open}
|
||||
onOpenChange={() => toggleGroup(group.key)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<LineItem
|
||||
muted
|
||||
icon={group.Icon}
|
||||
rightChildren={
|
||||
open ? (
|
||||
<SvgChevronDown className="h-4 w-4 stroke-text-04 shrink-0" />
|
||||
) : (
|
||||
<SvgChevronRight className="h-4 w-4 stroke-text-04 shrink-0" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{group.displayName}
|
||||
</LineItem>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<Section gap={0.25}>
|
||||
{group.options.map(renderModelItem)}
|
||||
</Section>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</PopoverMenu>
|
||||
|
||||
{footer}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
230
web/src/refresh-components/popovers/ModelSelector.tsx
Normal file
230
web/src/refresh-components/popovers/ModelSelector.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import { LlmManager } from "@/lib/hooks";
|
||||
import { getProviderIcon } from "@/app/admin/configuration/llm/utils";
|
||||
import { Button, SelectButton, OpenButton } from "@opal/components";
|
||||
import { SvgPlusCircle, SvgX } from "@opal/icons";
|
||||
import { LLMOption } from "@/refresh-components/popovers/interfaces";
|
||||
import ModelListContent from "@/refresh-components/popovers/ModelListContent";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
|
||||
export const MAX_MODELS = 3;
|
||||
|
||||
export interface SelectedModel {
|
||||
name: string;
|
||||
provider: string;
|
||||
modelName: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface ModelSelectorProps {
|
||||
llmManager: LlmManager;
|
||||
selectedModels: SelectedModel[];
|
||||
onAdd: (model: SelectedModel) => void;
|
||||
onRemove: (index: number) => void;
|
||||
onReplace: (index: number, model: SelectedModel) => void;
|
||||
}
|
||||
|
||||
function modelKey(provider: string, modelName: string): string {
|
||||
return `${provider}:${modelName}`;
|
||||
}
|
||||
|
||||
export default function ModelSelector({
|
||||
llmManager,
|
||||
selectedModels,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onReplace,
|
||||
}: ModelSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// null = add mode (via + button), number = replace mode (via pill click)
|
||||
const [replacingIndex, setReplacingIndex] = useState<number | null>(null);
|
||||
// Virtual anchor ref — points to the clicked pill so the popover positions above it
|
||||
const anchorRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const isMultiModel = selectedModels.length > 1;
|
||||
const atMax = selectedModels.length >= MAX_MODELS;
|
||||
|
||||
const selectedKeys = useMemo(
|
||||
() => new Set(selectedModels.map((m) => modelKey(m.provider, m.modelName))),
|
||||
[selectedModels]
|
||||
);
|
||||
|
||||
const otherSelectedKeys = useMemo(() => {
|
||||
if (replacingIndex === null) return new Set<string>();
|
||||
return new Set(
|
||||
selectedModels
|
||||
.filter((_, i) => i !== replacingIndex)
|
||||
.map((m) => modelKey(m.provider, m.modelName))
|
||||
);
|
||||
}, [selectedModels, replacingIndex]);
|
||||
|
||||
const replacingKey =
|
||||
replacingIndex !== null
|
||||
? (() => {
|
||||
const m = selectedModels[replacingIndex];
|
||||
return m ? modelKey(m.provider, m.modelName) : null;
|
||||
})()
|
||||
: null;
|
||||
|
||||
const isSelected = (option: LLMOption) => {
|
||||
const key = modelKey(option.provider, option.modelName);
|
||||
if (replacingIndex !== null) return key === replacingKey;
|
||||
return selectedKeys.has(key);
|
||||
};
|
||||
|
||||
const isDisabled = (option: LLMOption) => {
|
||||
const key = modelKey(option.provider, option.modelName);
|
||||
if (replacingIndex !== null) return otherSelectedKeys.has(key);
|
||||
return !selectedKeys.has(key) && atMax;
|
||||
};
|
||||
|
||||
const handleSelect = (option: LLMOption) => {
|
||||
const model: SelectedModel = {
|
||||
name: option.name,
|
||||
provider: option.provider,
|
||||
modelName: option.modelName,
|
||||
displayName: option.displayName,
|
||||
};
|
||||
|
||||
if (replacingIndex !== null) {
|
||||
onReplace(replacingIndex, model);
|
||||
setOpen(false);
|
||||
setReplacingIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const key = modelKey(option.provider, option.modelName);
|
||||
const existingIndex = selectedModels.findIndex(
|
||||
(m) => modelKey(m.provider, m.modelName) === key
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
onRemove(existingIndex);
|
||||
} else if (!atMax) {
|
||||
onAdd(model);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setReplacingIndex(null);
|
||||
};
|
||||
|
||||
const handlePillClick = (index: number, element: HTMLElement) => {
|
||||
anchorRef.current = element;
|
||||
setReplacingIndex(index);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<div className="flex items-center justify-end gap-1 p-1">
|
||||
{!atMax && (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgPlusCircle}
|
||||
size="sm"
|
||||
tooltip="Add Model"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
anchorRef.current = e.currentTarget as HTMLElement;
|
||||
setReplacingIndex(null);
|
||||
setOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Popover.Anchor
|
||||
virtualRef={anchorRef as React.RefObject<HTMLElement>}
|
||||
/>
|
||||
{selectedModels.length > 0 && (
|
||||
<>
|
||||
{!atMax && (
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
paddingXRem={0.5}
|
||||
paddingYRem={0.5}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
{selectedModels.map((model, index) => {
|
||||
const ProviderIcon = getProviderIcon(
|
||||
model.provider,
|
||||
model.modelName
|
||||
);
|
||||
|
||||
if (!isMultiModel) {
|
||||
return (
|
||||
<OpenButton
|
||||
key={modelKey(model.provider, model.modelName)}
|
||||
icon={ProviderIcon}
|
||||
onClick={(e: React.MouseEvent) =>
|
||||
handlePillClick(index, e.currentTarget as HTMLElement)
|
||||
}
|
||||
>
|
||||
{model.displayName}
|
||||
</OpenButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={modelKey(model.provider, model.modelName)}
|
||||
className="flex items-center"
|
||||
>
|
||||
{index > 0 && (
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
paddingXRem={0.5}
|
||||
className="h-5"
|
||||
/>
|
||||
)}
|
||||
<SelectButton
|
||||
icon={ProviderIcon}
|
||||
rightIcon={SvgX}
|
||||
state="empty"
|
||||
variant="select-tinted"
|
||||
interaction="hover"
|
||||
size="lg"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const btn = e.currentTarget as HTMLElement;
|
||||
const icons = btn.querySelectorAll(
|
||||
".interactive-foreground-icon"
|
||||
);
|
||||
const lastIcon = icons[icons.length - 1];
|
||||
if (lastIcon && lastIcon.contains(target)) {
|
||||
onRemove(index);
|
||||
} else {
|
||||
handlePillClick(index, btn);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{model.displayName}
|
||||
</SelectButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Popover.Content
|
||||
side="top"
|
||||
align="start"
|
||||
width="lg"
|
||||
avoidCollisions={false}
|
||||
>
|
||||
<ModelListContent
|
||||
llmProviders={llmManager.llmProviders}
|
||||
isLoading={llmManager.isLoadingProviders}
|
||||
onSelect={handleSelect}
|
||||
isSelected={isSelected}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -232,7 +232,6 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
onboardingDismissed,
|
||||
onboardingState,
|
||||
onboardingActions,
|
||||
llmDescriptors,
|
||||
isLoadingOnboarding,
|
||||
finishOnboarding,
|
||||
hideOnboarding,
|
||||
@@ -812,7 +811,6 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
handleFinishOnboarding={finishOnboarding}
|
||||
state={onboardingState}
|
||||
actions={onboardingActions}
|
||||
llmDescriptors={llmDescriptors}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { ADMIN_ROUTES } from "@/lib/admin-routes";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { Button, SelectCard } from "@opal/components";
|
||||
import { CardHeaderLayout } from "@opal/layouts";
|
||||
import { Card } from "@opal/layouts";
|
||||
import { Disabled, Hoverable } from "@opal/core";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
@@ -113,7 +113,7 @@ export default function CodeInterpreterPage() {
|
||||
{isEnabled || isLoading ? (
|
||||
<Hoverable.Root group="code-interpreter/Card">
|
||||
<SelectCard state="filled" padding="sm" rounding="lg">
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgTerminal}
|
||||
@@ -161,7 +161,7 @@ export default function CodeInterpreterPage() {
|
||||
rounding="lg"
|
||||
onClick={() => handleToggle(true)}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgTerminal}
|
||||
|
||||
@@ -23,7 +23,7 @@ import Message from "@/refresh-components/messages/Message";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import { Button, SelectCard, Text } from "@opal/components";
|
||||
import { Content, CardHeaderLayout } from "@opal/layouts";
|
||||
import { Content, Card } from "@opal/layouts";
|
||||
import { Hoverable } from "@opal/core";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
@@ -260,7 +260,7 @@ export default function ImageGenerationContent() {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={() => (
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useWellKnownLLMProviders,
|
||||
} from "@/hooks/useLLMProviders";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { Content, CardHeaderLayout } from "@opal/layouts";
|
||||
import { Content, Card } from "@opal/layouts";
|
||||
import { Button, SelectCard } from "@opal/components";
|
||||
import { Hoverable } from "@opal/core";
|
||||
import { SvgArrowExchange, SvgSettings, SvgTrash } from "@opal/icons";
|
||||
@@ -24,7 +24,7 @@ import { refreshLlmProviderCaches } from "@/lib/llmConfig/cache";
|
||||
import { deleteLlmProvider, setDefaultLlmModel } from "@/lib/llmConfig/svc";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Horizontal as HorizontalInput } from "@/layouts/input-layouts";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import LegacyCard from "@/refresh-components/cards/Card";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import Message from "@/refresh-components/messages/Message";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
@@ -73,79 +73,38 @@ const PROVIDER_MODAL_MAP: Record<
|
||||
string,
|
||||
(
|
||||
shouldMarkAsDefault: boolean,
|
||||
open: boolean,
|
||||
onOpenChange: (open: boolean) => void
|
||||
) => React.ReactNode
|
||||
> = {
|
||||
openai: (d, open, onOpenChange) => (
|
||||
<OpenAIModal
|
||||
shouldMarkAsDefault={d}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
openai: (d, onOpenChange) => (
|
||||
<OpenAIModal shouldMarkAsDefault={d} onOpenChange={onOpenChange} />
|
||||
),
|
||||
anthropic: (d, open, onOpenChange) => (
|
||||
<AnthropicModal
|
||||
shouldMarkAsDefault={d}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
anthropic: (d, onOpenChange) => (
|
||||
<AnthropicModal shouldMarkAsDefault={d} onOpenChange={onOpenChange} />
|
||||
),
|
||||
ollama_chat: (d, open, onOpenChange) => (
|
||||
<OllamaModal
|
||||
shouldMarkAsDefault={d}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
ollama_chat: (d, onOpenChange) => (
|
||||
<OllamaModal shouldMarkAsDefault={d} onOpenChange={onOpenChange} />
|
||||
),
|
||||
azure: (d, open, onOpenChange) => (
|
||||
<AzureModal
|
||||
shouldMarkAsDefault={d}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
azure: (d, onOpenChange) => (
|
||||
<AzureModal shouldMarkAsDefault={d} onOpenChange={onOpenChange} />
|
||||
),
|
||||
bedrock: (d, open, onOpenChange) => (
|
||||
<BedrockModal
|
||||
shouldMarkAsDefault={d}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
bedrock: (d, onOpenChange) => (
|
||||
<BedrockModal shouldMarkAsDefault={d} onOpenChange={onOpenChange} />
|
||||
),
|
||||
vertex_ai: (d, open, onOpenChange) => (
|
||||
<VertexAIModal
|
||||
shouldMarkAsDefault={d}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
vertex_ai: (d, onOpenChange) => (
|
||||
<VertexAIModal shouldMarkAsDefault={d} onOpenChange={onOpenChange} />
|
||||
),
|
||||
openrouter: (d, open, onOpenChange) => (
|
||||
<OpenRouterModal
|
||||
shouldMarkAsDefault={d}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
openrouter: (d, onOpenChange) => (
|
||||
<OpenRouterModal shouldMarkAsDefault={d} onOpenChange={onOpenChange} />
|
||||
),
|
||||
lm_studio: (d, open, onOpenChange) => (
|
||||
<LMStudioForm
|
||||
shouldMarkAsDefault={d}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
lm_studio: (d, onOpenChange) => (
|
||||
<LMStudioForm shouldMarkAsDefault={d} onOpenChange={onOpenChange} />
|
||||
),
|
||||
litellm_proxy: (d, open, onOpenChange) => (
|
||||
<LiteLLMProxyModal
|
||||
shouldMarkAsDefault={d}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
litellm_proxy: (d, onOpenChange) => (
|
||||
<LiteLLMProxyModal shouldMarkAsDefault={d} onOpenChange={onOpenChange} />
|
||||
),
|
||||
bifrost: (d, open, onOpenChange) => (
|
||||
<BifrostModal
|
||||
shouldMarkAsDefault={d}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
bifrost: (d, onOpenChange) => (
|
||||
<BifrostModal shouldMarkAsDefault={d} onOpenChange={onOpenChange} />
|
||||
),
|
||||
};
|
||||
|
||||
@@ -210,14 +169,17 @@ function ExistingProviderCard({
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
<Hoverable.Root group="ExistingProviderCard">
|
||||
<Hoverable.Root
|
||||
group="ExistingProviderCard"
|
||||
interaction={deleteModal.isOpen ? "hover" : "rest"}
|
||||
>
|
||||
<SelectCard
|
||||
state="filled"
|
||||
padding="sm"
|
||||
rounding="lg"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
icon={getProviderIcon(provider.provider)}
|
||||
title={provider.name}
|
||||
description={getProviderDisplayName(provider.provider)}
|
||||
@@ -252,12 +214,8 @@ function ExistingProviderCard({
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{getModalForExistingProvider(
|
||||
provider,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
defaultModelName
|
||||
)}
|
||||
{isOpen &&
|
||||
getModalForExistingProvider(provider, setIsOpen, defaultModelName)}
|
||||
</SelectCard>
|
||||
</Hoverable.Root>
|
||||
</>
|
||||
@@ -273,7 +231,6 @@ interface NewProviderCardProps {
|
||||
isFirstProvider: boolean;
|
||||
formFn: (
|
||||
shouldMarkAsDefault: boolean,
|
||||
open: boolean,
|
||||
onOpenChange: (open: boolean) => void
|
||||
) => React.ReactNode;
|
||||
}
|
||||
@@ -292,7 +249,7 @@ function NewProviderCard({
|
||||
rounding="lg"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
icon={getProviderIcon(provider.name)}
|
||||
title={getProviderProductName(provider.name)}
|
||||
description={getProviderDisplayName(provider.name)}
|
||||
@@ -311,7 +268,7 @@ function NewProviderCard({
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{formFn(isFirstProvider, isOpen, setIsOpen)}
|
||||
{isOpen && formFn(isFirstProvider, setIsOpen)}
|
||||
</SelectCard>
|
||||
);
|
||||
}
|
||||
@@ -336,7 +293,7 @@ function NewCustomProviderCard({
|
||||
rounding="lg"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
icon={getProviderIcon("custom")}
|
||||
title={getProviderProductName("custom")}
|
||||
description={getProviderDisplayName("custom")}
|
||||
@@ -355,11 +312,12 @@ function NewCustomProviderCard({
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CustomModal
|
||||
shouldMarkAsDefault={isFirstProvider}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
{isOpen && (
|
||||
<CustomModal
|
||||
shouldMarkAsDefault={isFirstProvider}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
)}
|
||||
</SelectCard>
|
||||
);
|
||||
}
|
||||
@@ -424,7 +382,7 @@ export default function LLMConfigurationPage() {
|
||||
|
||||
<SettingsLayouts.Body>
|
||||
{hasProviders ? (
|
||||
<Card>
|
||||
<LegacyCard>
|
||||
<HorizontalInput
|
||||
title="Default Model"
|
||||
description="This model will be used by Onyx by default in your chats."
|
||||
@@ -455,7 +413,7 @@ export default function LLMConfigurationPage() {
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
</HorizontalInput>
|
||||
</Card>
|
||||
</LegacyCard>
|
||||
) : (
|
||||
<Message
|
||||
info
|
||||
|
||||
@@ -6,7 +6,7 @@ import { InfoIcon } from "@/components/icons/icons";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { Content, CardHeaderLayout } from "@opal/layouts";
|
||||
import { Content, Card } from "@opal/layouts";
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher, FetchError } from "@/lib/fetcher";
|
||||
import { SWR_KEYS } from "@/lib/swr-keys";
|
||||
@@ -275,7 +275,7 @@ function ProviderCard({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={icon}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import { Button, SelectCard } from "@opal/components";
|
||||
import { Content, CardHeaderLayout } from "@opal/layouts";
|
||||
import { Content, Card } from "@opal/layouts";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
SvgArrowRightCircle,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
* ProviderCard — a stateful card for selecting / connecting / disconnecting
|
||||
* an external service provider (LLM, search engine, voice model, etc.).
|
||||
*
|
||||
* Built on opal `SelectCard` + `CardHeaderLayout`. Maps a three-state
|
||||
* Built on opal `SelectCard` + `Card.Header`. Maps a three-state
|
||||
* status model to the `SelectCard` state system:
|
||||
*
|
||||
* | Status | SelectCard state | Right action |
|
||||
@@ -92,7 +92,7 @@ export default function ProviderCard({
|
||||
aria-label={ariaLabel}
|
||||
onClick={isDisconnected && onConnect ? onConnect : undefined}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
<Card.Header
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={icon}
|
||||
|
||||
@@ -33,7 +33,6 @@ export default function AnthropicModal({
|
||||
variant = "llm-configuration",
|
||||
existingLlmProvider,
|
||||
shouldMarkAsDefault,
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultModelName,
|
||||
onboardingState,
|
||||
@@ -47,8 +46,6 @@ export default function AnthropicModal({
|
||||
ANTHROPIC_PROVIDER_NAME
|
||||
);
|
||||
|
||||
if (open === false) return null;
|
||||
|
||||
const onClose = () => onOpenChange?.(false);
|
||||
|
||||
const modelConfigurations = buildAvailableModelConfigurations(
|
||||
|
||||
@@ -83,7 +83,6 @@ export default function AzureModal({
|
||||
variant = "llm-configuration",
|
||||
existingLlmProvider,
|
||||
shouldMarkAsDefault,
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultModelName,
|
||||
onboardingState,
|
||||
@@ -97,8 +96,6 @@ export default function AzureModal({
|
||||
|
||||
const [addedModels, setAddedModels] = useState<ModelConfiguration[]>([]);
|
||||
|
||||
if (open === false) return null;
|
||||
|
||||
const onClose = () => {
|
||||
setAddedModels([]);
|
||||
onOpenChange?.(false);
|
||||
|
||||
@@ -313,7 +313,6 @@ export default function BedrockModal({
|
||||
variant = "llm-configuration",
|
||||
existingLlmProvider,
|
||||
shouldMarkAsDefault,
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultModelName,
|
||||
onboardingState,
|
||||
@@ -328,8 +327,6 @@ export default function BedrockModal({
|
||||
BEDROCK_PROVIDER_NAME
|
||||
);
|
||||
|
||||
if (open === false) return null;
|
||||
|
||||
const onClose = () => onOpenChange?.(false);
|
||||
|
||||
const modelConfigurations = buildAvailableModelConfigurations(
|
||||
|
||||
@@ -170,7 +170,6 @@ export default function BifrostModal({
|
||||
variant = "llm-configuration",
|
||||
existingLlmProvider,
|
||||
shouldMarkAsDefault,
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultModelName,
|
||||
onboardingState,
|
||||
@@ -185,8 +184,6 @@ export default function BifrostModal({
|
||||
BIFROST_PROVIDER_NAME
|
||||
);
|
||||
|
||||
if (open === false) return null;
|
||||
|
||||
const onClose = () => onOpenChange?.(false);
|
||||
|
||||
const modelConfigurations = buildAvailableModelConfigurations(
|
||||
|
||||
@@ -70,7 +70,9 @@ describe("Custom LLM Provider Configuration Workflow", () => {
|
||||
}
|
||||
) {
|
||||
const nameInput = screen.getByPlaceholderText("Display Name");
|
||||
const providerInput = screen.getByPlaceholderText("Provider Name");
|
||||
const providerInput = screen.getByPlaceholderText(
|
||||
"Provider Name as shown on LiteLLM"
|
||||
);
|
||||
|
||||
await user.type(nameInput, options.name);
|
||||
await user.type(providerInput, options.provider);
|
||||
@@ -99,7 +101,7 @@ describe("Custom LLM Provider Configuration Workflow", () => {
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(<CustomModal open={true} onOpenChange={() => {}} />);
|
||||
render(<CustomModal onOpenChange={() => {}} />);
|
||||
|
||||
await fillBasicFields(user, {
|
||||
name: "My Custom Provider",
|
||||
@@ -166,7 +168,7 @@ describe("Custom LLM Provider Configuration Workflow", () => {
|
||||
json: async () => ({ detail: "Invalid API key" }),
|
||||
} as Response);
|
||||
|
||||
render(<CustomModal open={true} onOpenChange={() => {}} />);
|
||||
render(<CustomModal onOpenChange={() => {}} />);
|
||||
|
||||
await fillBasicFields(user, {
|
||||
name: "Bad Provider",
|
||||
@@ -244,7 +246,6 @@ describe("Custom LLM Provider Configuration Workflow", () => {
|
||||
render(
|
||||
<CustomModal
|
||||
existingLlmProvider={existingProvider}
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
/>
|
||||
);
|
||||
@@ -339,7 +340,6 @@ describe("Custom LLM Provider Configuration Workflow", () => {
|
||||
render(
|
||||
<CustomModal
|
||||
existingLlmProvider={existingProvider}
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
/>
|
||||
);
|
||||
@@ -406,13 +406,7 @@ describe("Custom LLM Provider Configuration Workflow", () => {
|
||||
json: async () => ({}),
|
||||
} as Response);
|
||||
|
||||
render(
|
||||
<CustomModal
|
||||
shouldMarkAsDefault={true}
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
/>
|
||||
);
|
||||
render(<CustomModal shouldMarkAsDefault={true} onOpenChange={() => {}} />);
|
||||
|
||||
await fillBasicFields(user, {
|
||||
name: "New Default Provider",
|
||||
@@ -457,7 +451,7 @@ describe("Custom LLM Provider Configuration Workflow", () => {
|
||||
json: async () => ({ detail: "Database error" }),
|
||||
} as Response);
|
||||
|
||||
render(<CustomModal open={true} onOpenChange={() => {}} />);
|
||||
render(<CustomModal onOpenChange={() => {}} />);
|
||||
|
||||
await fillBasicFields(user, {
|
||||
name: "Test Provider",
|
||||
@@ -492,13 +486,15 @@ describe("Custom LLM Provider Configuration Workflow", () => {
|
||||
json: async () => ({ id: 1, name: "Provider with Custom Config" }),
|
||||
} as Response);
|
||||
|
||||
render(<CustomModal open={true} onOpenChange={() => {}} />);
|
||||
render(<CustomModal onOpenChange={() => {}} />);
|
||||
|
||||
// Fill basic fields
|
||||
const nameInput = screen.getByPlaceholderText("Display Name");
|
||||
await user.type(nameInput, "Cloudflare Provider");
|
||||
|
||||
const providerInput = screen.getByPlaceholderText("Provider Name");
|
||||
const providerInput = screen.getByPlaceholderText(
|
||||
"Provider Name as shown on LiteLLM"
|
||||
);
|
||||
await user.type(providerInput, "cloudflare");
|
||||
|
||||
// Click "Add Line" button for custom config (aria-label from KeyValueInput)
|
||||
@@ -508,8 +504,8 @@ describe("Custom LLM Provider Configuration Workflow", () => {
|
||||
await user.click(addLineButton);
|
||||
|
||||
// Fill in custom config key-value pair
|
||||
const keyInputs = screen.getAllByPlaceholderText("Key");
|
||||
const valueInputs = screen.getAllByPlaceholderText("Value");
|
||||
const keyInputs = screen.getAllByRole("textbox", { name: /Key \d+/ });
|
||||
const valueInputs = screen.getAllByRole("textbox", { name: /Value \d+/ });
|
||||
|
||||
await user.type(keyInputs[0]!, "CLOUDFLARE_ACCOUNT_ID");
|
||||
await user.type(valueInputs[0]!, "my-account-id-123");
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
submitOnboardingProvider,
|
||||
} from "@/sections/modals/llmConfig/svc";
|
||||
import {
|
||||
APIKeyField,
|
||||
DisplayNameField,
|
||||
FieldSeparator,
|
||||
ModelsAccessField,
|
||||
@@ -30,6 +31,7 @@ import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Button, Card, EmptyMessageCard } from "@opal/components";
|
||||
import { SvgMinusCircle, SvgPlusCircle } from "@opal/icons";
|
||||
import { markdown } from "@opal/utils";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { Content } from "@opal/layouts";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
@@ -181,19 +183,20 @@ function ModelConfigurationList({ formikProps }: ModelConfigurationListProps) {
|
||||
|
||||
// ─── Custom Config Processing ─────────────────────────────────────────────────
|
||||
|
||||
function customConfigProcessing(items: KeyValue[]) {
|
||||
const customConfig: { [key: string]: string } = {};
|
||||
items.forEach(({ key, value }) => {
|
||||
customConfig[key] = value;
|
||||
});
|
||||
return customConfig;
|
||||
function keyValueListToDict(items: KeyValue[]): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const { key, value } of items) {
|
||||
if (key.trim() !== "") {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function CustomModal({
|
||||
variant = "llm-configuration",
|
||||
existingLlmProvider,
|
||||
shouldMarkAsDefault,
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultModelName,
|
||||
onboardingState,
|
||||
@@ -203,8 +206,6 @@ export default function CustomModal({
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
if (open === false) return null;
|
||||
|
||||
const onClose = () => onOpenChange?.(false);
|
||||
|
||||
const initialValues = {
|
||||
@@ -215,6 +216,9 @@ export default function CustomModal({
|
||||
),
|
||||
...(isOnboarding ? buildOnboardingInitialValues() : {}),
|
||||
provider: existingLlmProvider?.provider ?? "",
|
||||
api_key: existingLlmProvider?.api_key ?? "",
|
||||
api_base: existingLlmProvider?.api_base ?? "",
|
||||
api_version: existingLlmProvider?.api_version ?? "",
|
||||
model_configurations: existingLlmProvider?.model_configurations.map(
|
||||
(mc) => ({
|
||||
name: mc.name,
|
||||
@@ -283,13 +287,18 @@ export default function CustomModal({
|
||||
return;
|
||||
}
|
||||
|
||||
// Always send custom_config as a dict (even empty) so the backend
|
||||
// preserves it as non-null — this is the signal that the provider was
|
||||
// created via CustomModal.
|
||||
const customConfig = keyValueListToDict(values.custom_config_list);
|
||||
|
||||
if (isOnboarding && onboardingState && onboardingActions) {
|
||||
await submitOnboardingProvider({
|
||||
providerName: values.provider,
|
||||
payload: {
|
||||
...values,
|
||||
model_configurations: modelConfigurations,
|
||||
custom_config: customConfigProcessing(values.custom_config_list),
|
||||
custom_config: customConfig,
|
||||
},
|
||||
onboardingState,
|
||||
onboardingActions,
|
||||
@@ -307,11 +316,11 @@ export default function CustomModal({
|
||||
values: {
|
||||
...values,
|
||||
selected_model_names: selectedModelNames,
|
||||
custom_config: customConfigProcessing(values.custom_config_list),
|
||||
custom_config: customConfig,
|
||||
},
|
||||
initialValues: {
|
||||
...initialValues,
|
||||
custom_config: customConfigProcessing(
|
||||
custom_config: keyValueListToDict(
|
||||
initialValues.custom_config_list
|
||||
),
|
||||
},
|
||||
@@ -337,32 +346,55 @@ export default function CustomModal({
|
||||
isSubmitting={formikProps.isSubmitting}
|
||||
>
|
||||
{!isOnboarding && (
|
||||
<Section gap={0}>
|
||||
<DisplayNameField disabled={!!existingLlmProvider} />
|
||||
|
||||
<FieldWrapper>
|
||||
<InputLayouts.Vertical
|
||||
<FieldWrapper>
|
||||
<InputLayouts.Vertical
|
||||
name="provider"
|
||||
title="Provider Name"
|
||||
subDescription={markdown(
|
||||
"Should be one of the providers listed at [LiteLLM](https://docs.litellm.ai/docs/providers)."
|
||||
)}
|
||||
>
|
||||
<InputTypeInField
|
||||
name="provider"
|
||||
title="Provider Name"
|
||||
subDescription="Should be one of the providers listed at https://docs.litellm.ai/docs/providers."
|
||||
>
|
||||
<InputTypeInField
|
||||
name="provider"
|
||||
placeholder="Provider Name"
|
||||
variant={existingLlmProvider ? "disabled" : undefined}
|
||||
/>
|
||||
</InputLayouts.Vertical>
|
||||
</FieldWrapper>
|
||||
</Section>
|
||||
placeholder="Provider Name as shown on LiteLLM"
|
||||
variant={existingLlmProvider ? "disabled" : undefined}
|
||||
/>
|
||||
</InputLayouts.Vertical>
|
||||
</FieldWrapper>
|
||||
)}
|
||||
|
||||
<FieldSeparator />
|
||||
<FieldWrapper>
|
||||
<InputLayouts.Vertical
|
||||
name="api_base"
|
||||
title="API Base URL"
|
||||
suffix="optional"
|
||||
>
|
||||
<InputTypeInField name="api_base" placeholder="https://" />
|
||||
</InputLayouts.Vertical>
|
||||
</FieldWrapper>
|
||||
|
||||
<FieldWrapper>
|
||||
<InputLayouts.Vertical
|
||||
name="api_version"
|
||||
title="API Version"
|
||||
suffix="optional"
|
||||
>
|
||||
<InputTypeInField name="api_version" />
|
||||
</InputLayouts.Vertical>
|
||||
</FieldWrapper>
|
||||
|
||||
<APIKeyField
|
||||
optional
|
||||
subDescription="Paste your API key if your model provider requires authentication."
|
||||
/>
|
||||
|
||||
<FieldWrapper>
|
||||
<Section gap={0.75}>
|
||||
<Content
|
||||
title="Provider Configs"
|
||||
description="Add properties as needed by the model provider. This is passed to LiteLLM completion() call as arguments in the environment variable. See LiteLLM documentation for more instructions."
|
||||
title="Additional Configs"
|
||||
description={markdown(
|
||||
"Add extra properties as needed by the model provider. These are passed to LiteLLM's `completion()` call as [environment variables](https://docs.litellm.ai/docs/set_keys#environment-variables). See [documentation](https://docs.onyx.app/admins/ai_models/custom_inference_provider) for more instructions."
|
||||
)}
|
||||
widthVariant="full"
|
||||
variant="section"
|
||||
sizePreset="main-content"
|
||||
@@ -380,6 +412,12 @@ export default function CustomModal({
|
||||
|
||||
<FieldSeparator />
|
||||
|
||||
{!isOnboarding && (
|
||||
<DisplayNameField disabled={!!existingLlmProvider} />
|
||||
)}
|
||||
|
||||
<FieldSeparator />
|
||||
|
||||
<Section gap={0.5}>
|
||||
<FieldWrapper>
|
||||
<Content
|
||||
|
||||
@@ -190,7 +190,6 @@ export default function LMStudioForm({
|
||||
variant = "llm-configuration",
|
||||
existingLlmProvider,
|
||||
shouldMarkAsDefault,
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultModelName,
|
||||
onboardingState,
|
||||
@@ -205,8 +204,6 @@ export default function LMStudioForm({
|
||||
LLMProviderName.LM_STUDIO
|
||||
);
|
||||
|
||||
if (open === false) return null;
|
||||
|
||||
const onClose = () => onOpenChange?.(false);
|
||||
|
||||
const modelConfigurations = buildAvailableModelConfigurations(
|
||||
|
||||
@@ -157,7 +157,6 @@ export default function LiteLLMProxyModal({
|
||||
variant = "llm-configuration",
|
||||
existingLlmProvider,
|
||||
shouldMarkAsDefault,
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultModelName,
|
||||
onboardingState,
|
||||
@@ -172,8 +171,6 @@ export default function LiteLLMProxyModal({
|
||||
LLMProviderName.LITELLM_PROXY
|
||||
);
|
||||
|
||||
if (open === false) return null;
|
||||
|
||||
const onClose = () => onOpenChange?.(false);
|
||||
|
||||
const modelConfigurations = buildAvailableModelConfigurations(
|
||||
|
||||
@@ -210,7 +210,6 @@ export default function OllamaModal({
|
||||
variant = "llm-configuration",
|
||||
existingLlmProvider,
|
||||
shouldMarkAsDefault,
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultModelName,
|
||||
onboardingState,
|
||||
@@ -224,8 +223,6 @@ export default function OllamaModal({
|
||||
const { wellKnownLLMProvider } =
|
||||
useWellKnownLLMProvider(OLLAMA_PROVIDER_NAME);
|
||||
|
||||
if (open === false) return null;
|
||||
|
||||
const onClose = () => onOpenChange?.(false);
|
||||
|
||||
const modelConfigurations = buildAvailableModelConfigurations(
|
||||
|
||||
@@ -33,7 +33,6 @@ export default function OpenAIModal({
|
||||
variant = "llm-configuration",
|
||||
existingLlmProvider,
|
||||
shouldMarkAsDefault,
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultModelName,
|
||||
onboardingState,
|
||||
@@ -46,8 +45,6 @@ export default function OpenAIModal({
|
||||
const { wellKnownLLMProvider } =
|
||||
useWellKnownLLMProvider(OPENAI_PROVIDER_NAME);
|
||||
|
||||
if (open === false) return null;
|
||||
|
||||
const onClose = () => onOpenChange?.(false);
|
||||
|
||||
const modelConfigurations = buildAvailableModelConfigurations(
|
||||
|
||||
@@ -156,7 +156,6 @@ export default function OpenRouterModal({
|
||||
variant = "llm-configuration",
|
||||
existingLlmProvider,
|
||||
shouldMarkAsDefault,
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultModelName,
|
||||
onboardingState,
|
||||
@@ -171,8 +170,6 @@ export default function OpenRouterModal({
|
||||
OPENROUTER_PROVIDER_NAME
|
||||
);
|
||||
|
||||
if (open === false) return null;
|
||||
|
||||
const onClose = () => onOpenChange?.(false);
|
||||
|
||||
const modelConfigurations = buildAvailableModelConfigurations(
|
||||
|
||||
@@ -46,7 +46,6 @@ export default function VertexAIModal({
|
||||
variant = "llm-configuration",
|
||||
existingLlmProvider,
|
||||
shouldMarkAsDefault,
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultModelName,
|
||||
onboardingState,
|
||||
@@ -60,8 +59,6 @@ export default function VertexAIModal({
|
||||
VERTEXAI_PROVIDER_NAME
|
||||
);
|
||||
|
||||
if (open === false) return null;
|
||||
|
||||
const onClose = () => onOpenChange?.(false);
|
||||
|
||||
const modelConfigurations = buildAvailableModelConfigurations(
|
||||
|
||||
@@ -11,48 +11,55 @@ import LMStudioForm from "@/sections/modals/llmConfig/LMStudioForm";
|
||||
import LiteLLMProxyModal from "@/sections/modals/llmConfig/LiteLLMProxyModal";
|
||||
import BifrostModal from "@/sections/modals/llmConfig/BifrostModal";
|
||||
|
||||
function detectIfRealOpenAIProvider(provider: LLMProviderView) {
|
||||
return (
|
||||
provider.provider === LLMProviderName.OPENAI &&
|
||||
provider.api_key &&
|
||||
!provider.api_base &&
|
||||
Object.keys(provider.custom_config || {}).length === 0
|
||||
);
|
||||
}
|
||||
|
||||
export function getModalForExistingProvider(
|
||||
provider: LLMProviderView,
|
||||
open?: boolean,
|
||||
onOpenChange?: (open: boolean) => void,
|
||||
defaultModelName?: string
|
||||
) {
|
||||
const props = {
|
||||
existingLlmProvider: provider,
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultModelName,
|
||||
};
|
||||
|
||||
const hasCustomConfig = provider.custom_config != null;
|
||||
|
||||
switch (provider.provider) {
|
||||
// These providers don't use custom_config themselves, so a non-null
|
||||
// custom_config means the provider was created via CustomModal.
|
||||
case LLMProviderName.OPENAI:
|
||||
// "openai" as a provider name can be used for litellm proxy / any OpenAI-compatible provider
|
||||
if (detectIfRealOpenAIProvider(provider)) {
|
||||
return <OpenAIModal {...props} />;
|
||||
} else {
|
||||
return <CustomModal {...props} />;
|
||||
}
|
||||
return hasCustomConfig ? (
|
||||
<CustomModal {...props} />
|
||||
) : (
|
||||
<OpenAIModal {...props} />
|
||||
);
|
||||
case LLMProviderName.ANTHROPIC:
|
||||
return <AnthropicModal {...props} />;
|
||||
return hasCustomConfig ? (
|
||||
<CustomModal {...props} />
|
||||
) : (
|
||||
<AnthropicModal {...props} />
|
||||
);
|
||||
case LLMProviderName.AZURE:
|
||||
return hasCustomConfig ? (
|
||||
<CustomModal {...props} />
|
||||
) : (
|
||||
<AzureModal {...props} />
|
||||
);
|
||||
case LLMProviderName.OPENROUTER:
|
||||
return hasCustomConfig ? (
|
||||
<CustomModal {...props} />
|
||||
) : (
|
||||
<OpenRouterModal {...props} />
|
||||
);
|
||||
|
||||
// These providers legitimately store settings in custom_config,
|
||||
// so always use their dedicated modals.
|
||||
case LLMProviderName.OLLAMA_CHAT:
|
||||
return <OllamaModal {...props} />;
|
||||
case LLMProviderName.AZURE:
|
||||
return <AzureModal {...props} />;
|
||||
case LLMProviderName.VERTEX_AI:
|
||||
return <VertexAIModal {...props} />;
|
||||
case LLMProviderName.BEDROCK:
|
||||
return <BedrockModal {...props} />;
|
||||
case LLMProviderName.OPENROUTER:
|
||||
return <OpenRouterModal {...props} />;
|
||||
case LLMProviderName.LM_STUDIO:
|
||||
return <LMStudioForm {...props} />;
|
||||
case LLMProviderName.LITELLM_PROXY:
|
||||
|
||||
@@ -17,7 +17,7 @@ import Switch from "@/refresh-components/inputs/Switch";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Button, LineItemButton, Tag } from "@opal/components";
|
||||
import { BaseLLMFormValues } from "@/sections/modals/llmConfig/utils";
|
||||
import { WithoutStyles } from "@opal/types";
|
||||
import { RichStr, WithoutStyles } from "@opal/types";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { Hoverable } from "@opal/core";
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
} from "@/lib/llmConfig/providers";
|
||||
|
||||
export function FieldSeparator() {
|
||||
return <Separator noPadding className="px-2" />;
|
||||
return <Separator noPadding className="p-2" />;
|
||||
}
|
||||
|
||||
export type FieldWrapperProps = WithoutStyles<
|
||||
@@ -89,11 +89,13 @@ export function DisplayNameField({ disabled = false }: DisplayNameFieldProps) {
|
||||
export interface APIKeyFieldProps {
|
||||
optional?: boolean;
|
||||
providerName?: string;
|
||||
subDescription?: string | RichStr;
|
||||
}
|
||||
|
||||
export function APIKeyField({
|
||||
optional = false,
|
||||
providerName,
|
||||
subDescription,
|
||||
}: APIKeyFieldProps) {
|
||||
return (
|
||||
<FieldWrapper>
|
||||
@@ -101,13 +103,15 @@ export function APIKeyField({
|
||||
name="api_key"
|
||||
title="API Key"
|
||||
subDescription={
|
||||
providerName
|
||||
? `Paste your API key from ${providerName} to access your models.`
|
||||
: "Paste your API key to access your models."
|
||||
subDescription
|
||||
? subDescription
|
||||
: providerName
|
||||
? `Paste your API key from ${providerName} to access your models.`
|
||||
: "Paste your API key to access your models."
|
||||
}
|
||||
suffix={optional ? "optional" : undefined}
|
||||
>
|
||||
<PasswordInputTypeInField name="api_key" placeholder="API Key" />
|
||||
<PasswordInputTypeInField name="api_key" />
|
||||
</InputLayouts.Vertical>
|
||||
</FieldWrapper>
|
||||
);
|
||||
@@ -689,7 +693,7 @@ export function LLMConfigurationModalWrapper({
|
||||
description={description}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<Modal.Body padding={0.5} gap={0.5}>
|
||||
<Modal.Body padding={0.5} gap={0}>
|
||||
{children}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
|
||||
@@ -70,7 +70,8 @@ export const submitLLMProvider = async <T extends BaseLLMFormValues>({
|
||||
// In auto mode, use recommended models from descriptor
|
||||
// In manual mode, use user's selection
|
||||
let filteredModelConfigurations: ModelConfiguration[];
|
||||
let finalDefaultModelName = rest.default_model_name;
|
||||
let finalDefaultModelName =
|
||||
rest.default_model_name || modelConfigurations[0]?.name || "";
|
||||
|
||||
if (values.is_auto_mode) {
|
||||
filteredModelConfigurations =
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
OnboardingState,
|
||||
OnboardingStep,
|
||||
} from "@/interfaces/onboarding";
|
||||
import { WellKnownLLMProviderDescriptor } from "@/interfaces/llm";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import { UserRole } from "@/lib/types";
|
||||
import NonAdminStep from "./components/NonAdminStep";
|
||||
@@ -21,7 +20,6 @@ type OnboardingFlowProps = {
|
||||
handleFinishOnboarding: () => void;
|
||||
state: OnboardingState;
|
||||
actions: OnboardingActions;
|
||||
llmDescriptors: WellKnownLLMProviderDescriptor[];
|
||||
};
|
||||
|
||||
const OnboardingFlowInner = ({
|
||||
@@ -30,7 +28,6 @@ const OnboardingFlowInner = ({
|
||||
handleFinishOnboarding,
|
||||
state: onboardingState,
|
||||
actions: onboardingActions,
|
||||
llmDescriptors,
|
||||
}: OnboardingFlowProps) => {
|
||||
const { user } = useUser();
|
||||
|
||||
@@ -57,7 +54,6 @@ const OnboardingFlowInner = ({
|
||||
<LLMStep
|
||||
state={onboardingState}
|
||||
actions={onboardingActions}
|
||||
llmDescriptors={llmDescriptors}
|
||||
disabled={
|
||||
onboardingState.currentStep !== OnboardingStep.LlmSetup
|
||||
}
|
||||
|
||||
@@ -66,7 +66,6 @@ export interface OnboardingFormProps {
|
||||
isCustomProvider?: boolean;
|
||||
onboardingState: OnboardingState;
|
||||
onboardingActions: OnboardingActions;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -75,14 +74,12 @@ export function getOnboardingForm({
|
||||
isCustomProvider,
|
||||
onboardingState,
|
||||
onboardingActions,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: OnboardingFormProps): React.ReactNode {
|
||||
const sharedProps = {
|
||||
variant: "onboarding" as const,
|
||||
onboardingState,
|
||||
onboardingActions,
|
||||
open,
|
||||
onOpenChange,
|
||||
};
|
||||
|
||||
|
||||
@@ -19,11 +19,11 @@ import { Disabled } from "@opal/core";
|
||||
import { ProviderIcon } from "@/app/admin/configuration/llm/ProviderIcon";
|
||||
import { SvgCheckCircle, SvgCpu, SvgExternalLink } from "@opal/icons";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { useLLMProviderOptions } from "@/lib/hooks/useLLMProviderOptions";
|
||||
|
||||
type LLMStepProps = {
|
||||
state: OnboardingState;
|
||||
actions: OnboardingActions;
|
||||
llmDescriptors: WellKnownLLMProviderDescriptor[];
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
@@ -92,10 +92,10 @@ const StackedProviderIcons = ({ providers }: StackedProviderIconsProps) => {
|
||||
const LLMStepInner = ({
|
||||
state: onboardingState,
|
||||
actions: onboardingActions,
|
||||
llmDescriptors,
|
||||
disabled,
|
||||
}: LLMStepProps) => {
|
||||
const isLoading = !llmDescriptors || llmDescriptors.length === 0;
|
||||
const { llmProviderOptions, isLoading } = useLLMProviderOptions();
|
||||
const llmDescriptors = llmProviderOptions ?? [];
|
||||
|
||||
const [selectedProvider, setSelectedProvider] =
|
||||
useState<SelectedProvider | null>(null);
|
||||
@@ -162,12 +162,12 @@ const LLMStepInner = ({
|
||||
<>
|
||||
{/* Render the selected provider form */}
|
||||
{selectedProvider &&
|
||||
isModalOpen &&
|
||||
getOnboardingForm({
|
||||
llmDescriptor: selectedProvider.llmDescriptor,
|
||||
isCustomProvider: selectedProvider.isCustomProvider,
|
||||
onboardingState,
|
||||
onboardingActions,
|
||||
open: isModalOpen,
|
||||
onOpenChange: handleModalClose,
|
||||
})}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ export interface SettingsProps {
|
||||
onShowBuildIntro?: () => void;
|
||||
}
|
||||
|
||||
export default function UserAvatarPopover({
|
||||
export default function AccountPopover({
|
||||
folded,
|
||||
onShowBuildIntro,
|
||||
}: SettingsProps) {
|
||||
@@ -22,21 +22,17 @@ import { CombinedSettings } from "@/interfaces/settings";
|
||||
import { SidebarTab } from "@opal/components";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import { Disabled } from "@opal/core";
|
||||
import { SvgArrowUpCircle, SvgSearch, SvgUserManage, SvgX } from "@opal/icons";
|
||||
import Spacer from "@/refresh-components/Spacer";
|
||||
import { SvgArrowUpCircle, SvgSearch, SvgX } from "@opal/icons";
|
||||
import {
|
||||
useBillingInformation,
|
||||
useLicense,
|
||||
hasActiveSubscription,
|
||||
} from "@/lib/billing";
|
||||
import { Content } from "@opal/layouts";
|
||||
import { ADMIN_ROUTES, sidebarItem } from "@/lib/admin-routes";
|
||||
import useFilter from "@/hooks/useFilter";
|
||||
import { IconFunctionComponent } from "@opal/types";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { getUserDisplayName } from "@/lib/user";
|
||||
import { APP_SLOGAN } from "@/lib/constants";
|
||||
import AccountPopover from "@/sections/sidebar/AccountPopover";
|
||||
|
||||
const SECTIONS = {
|
||||
UNLABELED: "",
|
||||
@@ -267,37 +263,27 @@ function AdminSidebarInner({
|
||||
return (
|
||||
<>
|
||||
<SidebarLayouts.Header>
|
||||
<div className="flex flex-col w-full">
|
||||
{folded ? (
|
||||
<SidebarTab
|
||||
icon={({ className }) => <SvgX className={className} size={16} />}
|
||||
href="/app"
|
||||
variant="sidebar-light"
|
||||
folded={folded}
|
||||
icon={SvgSearch}
|
||||
folded
|
||||
onClick={() => {
|
||||
onFoldChange(false);
|
||||
setFocusSearch(true);
|
||||
}}
|
||||
>
|
||||
Exit Admin Panel
|
||||
Search
|
||||
</SidebarTab>
|
||||
{folded ? (
|
||||
<SidebarTab
|
||||
icon={SvgSearch}
|
||||
folded
|
||||
onClick={() => {
|
||||
onFoldChange(false);
|
||||
setFocusSearch(true);
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</SidebarTab>
|
||||
) : (
|
||||
<InputTypeIn
|
||||
ref={searchRef}
|
||||
variant="internal"
|
||||
leftSearchIcon
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<InputTypeIn
|
||||
ref={searchRef}
|
||||
variant="internal"
|
||||
leftSearchIcon
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</SidebarLayouts.Header>
|
||||
|
||||
<SidebarLayouts.Body scrollKey="admin-sidebar">
|
||||
@@ -343,42 +329,20 @@ function AdminSidebarInner({
|
||||
|
||||
<SidebarLayouts.Footer>
|
||||
{!folded && (
|
||||
<Section gap={0} height="fit" alignItems="start">
|
||||
<div className="p-[0.38rem] w-full">
|
||||
<Content
|
||||
icon={SvgUserManage}
|
||||
title={getUserDisplayName(user)}
|
||||
sizePreset="main-ui"
|
||||
variant="body"
|
||||
prominence="muted"
|
||||
widthVariant="full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row gap-1 p-[0.38rem] w-full">
|
||||
<Text text03 secondaryAction>
|
||||
<a
|
||||
className="underline"
|
||||
href="https://onyx.app"
|
||||
target="_blank"
|
||||
>
|
||||
Onyx
|
||||
</a>
|
||||
</Text>
|
||||
<Text text03 secondaryBody>
|
||||
|
|
||||
</Text>
|
||||
{settings.webVersion ? (
|
||||
<Text text03 secondaryBody>
|
||||
{settings.webVersion}
|
||||
</Text>
|
||||
) : (
|
||||
<Text text03 secondaryBody>
|
||||
{APP_SLOGAN}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
<>
|
||||
<Separator noPadding className="px-2" />
|
||||
<Spacer rem={0.5} />
|
||||
</>
|
||||
)}
|
||||
<SidebarTab
|
||||
icon={SvgX}
|
||||
href="/app"
|
||||
variant="sidebar-light"
|
||||
folded={folded}
|
||||
>
|
||||
Exit Admin Panel
|
||||
</SidebarTab>
|
||||
<AccountPopover folded={folded} />
|
||||
</SidebarLayouts.Footer>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -76,7 +76,7 @@ import { track, AnalyticsEvent } from "@/lib/analytics";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Notification, NotificationType } from "@/interfaces/settings";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import UserAvatarPopover from "@/sections/sidebar/UserAvatarPopover";
|
||||
import AccountPopover from "@/sections/sidebar/AccountPopover";
|
||||
import ChatSearchCommandMenu from "@/sections/sidebar/ChatSearchCommandMenu";
|
||||
import { useQueryController } from "@/providers/QueryControllerProvider";
|
||||
|
||||
@@ -593,7 +593,7 @@ const MemoizedAppSidebarInner = memo(function AppSidebarInner() {
|
||||
{isAdmin ? "Admin Panel" : "Curator Panel"}
|
||||
</SidebarTab>
|
||||
)}
|
||||
<UserAvatarPopover
|
||||
<AccountPopover
|
||||
folded={folded}
|
||||
onShowBuildIntro={
|
||||
isOnyxCraftEnabled ? handleShowBuildIntro : undefined
|
||||
|
||||
@@ -411,7 +411,8 @@ test.describe("LLM Runtime Selection", () => {
|
||||
const sharedModelOptions = dialog.locator("[data-selected]");
|
||||
await expect(sharedModelOptions).toHaveCount(2);
|
||||
const openAiModelOption = dialog
|
||||
.getByRole("region", { name: /openai/i })
|
||||
.getByRole("button", { name: /openai/i })
|
||||
.locator("..")
|
||||
.locator("[data-selected]")
|
||||
.first();
|
||||
await expect(openAiModelOption).toBeVisible();
|
||||
@@ -436,7 +437,8 @@ test.describe("LLM Runtime Selection", () => {
|
||||
const secondSharedModelOptions = secondDialog.locator("[data-selected]");
|
||||
await expect(secondSharedModelOptions).toHaveCount(2);
|
||||
const anthropicModelOption = secondDialog
|
||||
.getByRole("region", { name: /anthropic/i })
|
||||
.getByRole("button", { name: /anthropic/i })
|
||||
.locator("..")
|
||||
.locator("[data-selected]")
|
||||
.first();
|
||||
await expect(anthropicModelOption).toBeVisible();
|
||||
@@ -447,7 +449,8 @@ test.describe("LLM Runtime Selection", () => {
|
||||
await page.waitForSelector('[role="dialog"]', { state: "visible" });
|
||||
const verifyDialog = page.locator('[role="dialog"]');
|
||||
const selectedAnthropicOption = verifyDialog
|
||||
.getByRole("region", { name: /anthropic/i })
|
||||
.getByRole("button", { name: /anthropic/i })
|
||||
.locator("..")
|
||||
.locator('[data-selected="true"]');
|
||||
await expect(selectedAnthropicOption).toHaveCount(1);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
8
widget/package-lock.json
generated
8
widget/package-lock.json
generated
@@ -17,7 +17,7 @@
|
||||
"@types/dompurify": "^3.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
@@ -1258,9 +1258,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -26,6 +26,6 @@
|
||||
"@types/dompurify": "^3.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user