mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-06 07:22:42 +00:00
Compare commits
4 Commits
jamison/on
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efe51c108e | ||
|
|
c092d16c01 | ||
|
|
da715eaa58 | ||
|
|
bb18d39765 |
@@ -171,13 +171,6 @@ repos:
|
||||
pass_filenames: false
|
||||
files: ^web/package(-lock)?\.json$
|
||||
|
||||
- id: compose-variants-check
|
||||
name: Check docker-compose variants are up to date
|
||||
entry: uv run deployment/docker_compose/generate_compose_variants.py --check
|
||||
language: system
|
||||
pass_filenames: false
|
||||
files: ^deployment/docker_compose/(docker-compose\.yml|generate_compose_variants\.py|headless/docker-compose\.yml)$
|
||||
|
||||
# Uses tsgo (TypeScript's native Go compiler) for ~10x faster type checking.
|
||||
# This is a preview package - if it breaks:
|
||||
# 1. Try updating: cd web && npm update @typescript/native-preview
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -1,482 +0,0 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["ruamel.yaml>=0.18"]
|
||||
# ///
|
||||
"""Generate docker-compose variant files from the main docker-compose.yml.
|
||||
|
||||
Each variant defines which services to remove, which to add, which
|
||||
commented-out blocks to strip, and where to write the output. The main
|
||||
docker-compose.yml is the single source of truth — variants are derived
|
||||
from it so shared service definitions never drift.
|
||||
|
||||
Usage:
|
||||
uv run generate_compose_variants.py # generate all variants
|
||||
uv run generate_compose_variants.py headless # generate one variant
|
||||
uv run generate_compose_variants.py --check # check all (CI / pre-commit)
|
||||
uv run generate_compose_variants.py --check headless # check one
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
from ruamel.yaml.comments import CommentedMap
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Variant configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Variant:
|
||||
"""Describes how to derive a compose file from the main docker-compose.yml."""
|
||||
|
||||
# Variant name (used as CLI argument).
|
||||
name: str
|
||||
# Subdirectory (relative to SCRIPT_DIR) for the output file.
|
||||
output_dir: str
|
||||
# Header comment placed at the top of the generated file.
|
||||
header: str
|
||||
# Service names to remove from the base compose file.
|
||||
remove_services: set[str] = field(default_factory=set)
|
||||
# YAML strings defining services to add. Each string is a single-service
|
||||
# YAML document (e.g. "cli_server:\n image: ...").
|
||||
add_services_yaml: list[str] = field(default_factory=list)
|
||||
# Volume names to add to the top-level volumes section.
|
||||
add_volumes: list[str] = field(default_factory=list)
|
||||
# Commented-out service block names to strip (regex-matched).
|
||||
strip_commented_blocks: list[str] = field(default_factory=list)
|
||||
# Environment overrides to apply to existing services.
|
||||
# Maps service_name -> {env_var: value}. Added/overridden in the
|
||||
# service's `environment` list.
|
||||
env_overrides: dict[str, dict[str, str]] = field(default_factory=dict)
|
||||
# Volume names to remove from the top-level volumes section.
|
||||
remove_volumes: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
VARIANTS: dict[str, Variant] = {}
|
||||
|
||||
|
||||
def register(v: Variant) -> Variant:
|
||||
VARIANTS[v.name] = v
|
||||
return v
|
||||
|
||||
|
||||
# -- headless ---------------------------------------------------------------
|
||||
|
||||
register(
|
||||
Variant(
|
||||
name="headless",
|
||||
output_dir="headless",
|
||||
header="""\
|
||||
# =============================================================================
|
||||
# ONYX HEADLESS DOCKER COMPOSE (AUTO-GENERATED)
|
||||
# =============================================================================
|
||||
# This file is generated by generate_compose_variants.py from docker-compose.yml.
|
||||
# DO NOT EDIT THIS FILE DIRECTLY — your changes will be overwritten.
|
||||
#
|
||||
# To regenerate:
|
||||
# cd deployment/docker_compose
|
||||
# uv run generate_compose_variants.py headless
|
||||
#
|
||||
# Usage:
|
||||
# cd deployment/docker_compose/headless
|
||||
# docker compose up -d
|
||||
#
|
||||
# Connect via SSH:
|
||||
# ssh localhost -p 2222
|
||||
# =============================================================================
|
||||
|
||||
""",
|
||||
remove_services={"web_server", "nginx"},
|
||||
add_services_yaml=[
|
||||
"""\
|
||||
cli_server:
|
||||
image: ${ONYX_CLI_IMAGE:-onyxdotapp/onyx-cli:${IMAGE_TAG:-latest}}
|
||||
command: ["serve", "--host", "0.0.0.0", "--port", "2222"]
|
||||
depends_on:
|
||||
- api_server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${ONYX_SSH_PORT:-2222}:2222"
|
||||
environment:
|
||||
- ONYX_SERVER_URL=http://api_server:8080
|
||||
volumes:
|
||||
- cli_config:/home/onyx/.config
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
"""
|
||||
],
|
||||
add_volumes=["cli_config"],
|
||||
strip_commented_blocks=["mcp_server", "certbot"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# -- headless-lite ----------------------------------------------------------
|
||||
|
||||
register(
|
||||
Variant(
|
||||
name="headless-lite",
|
||||
output_dir="headless-lite",
|
||||
header="""\
|
||||
# =============================================================================
|
||||
# ONYX HEADLESS-LITE DOCKER COMPOSE (AUTO-GENERATED)
|
||||
# =============================================================================
|
||||
# This file is generated by generate_compose_variants.py from docker-compose.yml.
|
||||
# DO NOT EDIT THIS FILE DIRECTLY — your changes will be overwritten.
|
||||
#
|
||||
# To regenerate:
|
||||
# cd deployment/docker_compose
|
||||
# uv run generate_compose_variants.py headless-lite
|
||||
#
|
||||
# Usage:
|
||||
# cd deployment/docker_compose/headless-lite
|
||||
# docker compose up -d
|
||||
#
|
||||
# Connect via SSH:
|
||||
# ssh localhost -p 2222
|
||||
#
|
||||
# This is a minimal headless deployment: no web UI, no Vespa, no Redis,
|
||||
# no model servers, no background workers, no OpenSearch, and no MinIO.
|
||||
# Only PostgreSQL is required. Connectors and RAG search are disabled,
|
||||
# but core chat (LLM conversations, tools, user file uploads, Projects,
|
||||
# Agent knowledge, code interpreter) still works.
|
||||
# =============================================================================
|
||||
|
||||
""",
|
||||
remove_services={
|
||||
"web_server",
|
||||
"nginx",
|
||||
"background",
|
||||
"cache",
|
||||
"index",
|
||||
"indexing_model_server",
|
||||
"inference_model_server",
|
||||
"opensearch",
|
||||
"minio",
|
||||
},
|
||||
add_services_yaml=[
|
||||
"""\
|
||||
cli_server:
|
||||
image: ${ONYX_CLI_IMAGE:-onyxdotapp/onyx-cli:${IMAGE_TAG:-latest}}
|
||||
command: ["serve", "--host", "0.0.0.0", "--port", "2222"]
|
||||
depends_on:
|
||||
- api_server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${ONYX_SSH_PORT:-2222}:2222"
|
||||
environment:
|
||||
- ONYX_SERVER_URL=http://api_server:8080
|
||||
volumes:
|
||||
- cli_config:/home/onyx/.config
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
"""
|
||||
],
|
||||
add_volumes=["cli_config"],
|
||||
remove_volumes={
|
||||
"vespa_volume",
|
||||
"model_cache_huggingface",
|
||||
"indexing_huggingface_model_cache",
|
||||
"background_logs",
|
||||
"inference_model_server_logs",
|
||||
"indexing_model_server_logs",
|
||||
"opensearch-data",
|
||||
"minio_data",
|
||||
},
|
||||
strip_commented_blocks=["mcp_server", "certbot"],
|
||||
env_overrides={
|
||||
"api_server": {
|
||||
"DISABLE_VECTOR_DB": "true",
|
||||
"FILE_STORE_BACKEND": "postgres",
|
||||
"CACHE_BACKEND": "postgres",
|
||||
"AUTH_BACKEND": "postgres",
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YAML helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
SOURCE = SCRIPT_DIR / "docker-compose.yml"
|
||||
|
||||
|
||||
def load_yaml() -> tuple[YAML, CommentedMap]:
|
||||
yaml = YAML()
|
||||
yaml.preserve_quotes = True
|
||||
yaml.width = 4096 # avoid line wrapping
|
||||
with open(SOURCE) as f:
|
||||
data = yaml.load(f)
|
||||
return yaml, data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service transforms (for standalone output files)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def strip_build_blocks(services: CommentedMap) -> None:
|
||||
"""Remove ``build`` keys from all services.
|
||||
|
||||
Generated files are intended to be downloaded and used standalone — users
|
||||
pull pre-built images rather than building from source, so ``build``
|
||||
blocks are not useful and contain repo-relative paths that won't exist.
|
||||
"""
|
||||
for svc in services.values():
|
||||
if isinstance(svc, CommentedMap) and "build" in svc:
|
||||
del svc["build"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text post-processing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def remove_commented_blocks(text: str, block_names: list[str]) -> str:
|
||||
"""Remove commented-out service blocks matching *block_names*."""
|
||||
if not block_names:
|
||||
return text
|
||||
|
||||
pattern = re.compile(
|
||||
r"^\s*#\s*(" + "|".join(re.escape(n) for n in block_names) + r"):"
|
||||
)
|
||||
|
||||
lines = text.split("\n")
|
||||
result: list[str] = []
|
||||
skip = False
|
||||
blank_run = 0
|
||||
|
||||
for line in lines:
|
||||
if pattern.match(line):
|
||||
skip = True
|
||||
# Remove preceding comment/blank lines that introduce the block
|
||||
while result and (
|
||||
result[-1].strip().startswith("#") or result[-1].strip() == ""
|
||||
):
|
||||
result.pop()
|
||||
continue
|
||||
if skip:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("#") or stripped == "":
|
||||
continue
|
||||
else:
|
||||
skip = False
|
||||
|
||||
if line.strip() == "":
|
||||
blank_run += 1
|
||||
if blank_run > 2:
|
||||
continue
|
||||
else:
|
||||
blank_run = 0
|
||||
|
||||
result.append(line)
|
||||
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
def ensure_blank_line_before(text: str, pattern: str) -> str:
|
||||
"""Ensure there is a blank line before lines matching *pattern*."""
|
||||
return re.sub(r"(\n)(" + pattern + r")", r"\1\n\2", text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def generate(variant: Variant) -> str:
|
||||
"""Generate the variant's docker-compose.yml content."""
|
||||
yaml_inst, data = load_yaml()
|
||||
|
||||
services = data["services"]
|
||||
|
||||
# Remove services
|
||||
for name in variant.remove_services:
|
||||
if name in services:
|
||||
del services[name]
|
||||
|
||||
# Clean up depends_on entries that reference removed services
|
||||
remaining_services = set(services.keys())
|
||||
for svc_name in list(services):
|
||||
svc = services[svc_name]
|
||||
if not isinstance(svc, CommentedMap) or "depends_on" not in svc:
|
||||
continue
|
||||
deps = svc["depends_on"]
|
||||
if isinstance(deps, list):
|
||||
deps[:] = [d for d in deps if d in remaining_services]
|
||||
if not deps:
|
||||
del svc["depends_on"]
|
||||
elif isinstance(deps, CommentedMap):
|
||||
for dep_name in list(deps):
|
||||
if dep_name not in remaining_services:
|
||||
del deps[dep_name]
|
||||
if not deps:
|
||||
del svc["depends_on"]
|
||||
|
||||
# Collect names of services we're about to add (skip path adjustment for
|
||||
# these — their paths are already relative to the output subdirectory).
|
||||
added_service_names: set[str] = set()
|
||||
|
||||
# Add new services
|
||||
for svc_yaml in variant.add_services_yaml:
|
||||
helper = YAML()
|
||||
helper.preserve_quotes = True
|
||||
parsed = helper.load(svc_yaml)
|
||||
for svc_name, svc_def in parsed.items():
|
||||
services[svc_name] = svc_def
|
||||
added_service_names.add(svc_name)
|
||||
|
||||
# Apply environment overrides
|
||||
for svc_name, overrides in variant.env_overrides.items():
|
||||
if svc_name not in services:
|
||||
continue
|
||||
svc = services[svc_name]
|
||||
if not isinstance(svc, CommentedMap):
|
||||
continue
|
||||
env = svc.get("environment")
|
||||
if env is None:
|
||||
env = []
|
||||
svc["environment"] = env
|
||||
if isinstance(env, list):
|
||||
# Remove existing entries for overridden keys, then append
|
||||
for key in overrides:
|
||||
env[:] = [
|
||||
e
|
||||
for e in env
|
||||
if not (isinstance(e, str) and e.startswith(key + "="))
|
||||
]
|
||||
env.append(f"{key}={overrides[key]}")
|
||||
|
||||
# Strip build blocks — generated files are standalone downloads, users
|
||||
# pull images rather than building from source.
|
||||
strip_build_blocks(services)
|
||||
|
||||
# Add volumes
|
||||
if variant.add_volumes and "volumes" in data:
|
||||
for vol_name in variant.add_volumes:
|
||||
data["volumes"][vol_name] = None
|
||||
|
||||
# Remove volumes
|
||||
if variant.remove_volumes and "volumes" in data:
|
||||
for vol_name in variant.remove_volumes:
|
||||
if vol_name in data["volumes"]:
|
||||
del data["volumes"][vol_name]
|
||||
|
||||
# Serialize
|
||||
buf = StringIO()
|
||||
yaml_inst.dump(data, buf)
|
||||
body = buf.getvalue()
|
||||
|
||||
# Strip commented-out blocks
|
||||
body = remove_commented_blocks(body, variant.strip_commented_blocks)
|
||||
|
||||
# Strip the original header comment (everything before "name:")
|
||||
idx = body.find("name:")
|
||||
if idx > 0:
|
||||
body = body[idx:]
|
||||
|
||||
# Ensure blank lines before added services and the top-level volumes section
|
||||
for svc_name in added_service_names:
|
||||
body = ensure_blank_line_before(body, re.escape(f" {svc_name}:"))
|
||||
body = ensure_blank_line_before(body, r"volumes:\n")
|
||||
|
||||
return variant.header + body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def output_path(variant: Variant) -> Path:
|
||||
return SCRIPT_DIR / variant.output_dir / "docker-compose.yml"
|
||||
|
||||
|
||||
def run_generate(variant: Variant) -> None:
|
||||
generated = generate(variant)
|
||||
out = output_path(variant)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(generated)
|
||||
print(f"Generated {out.relative_to(SCRIPT_DIR)}")
|
||||
|
||||
|
||||
def run_check(variant: Variant) -> bool:
|
||||
"""Return True if up to date."""
|
||||
generated = generate(variant)
|
||||
out = output_path(variant)
|
||||
|
||||
if not out.exists():
|
||||
print(
|
||||
f"ERROR: {out.relative_to(SCRIPT_DIR)} does not exist. "
|
||||
f"Run without --check to generate it."
|
||||
)
|
||||
return False
|
||||
|
||||
existing = out.read_text()
|
||||
if existing == generated:
|
||||
print(f"OK: {out.relative_to(SCRIPT_DIR)} is up to date.")
|
||||
return True
|
||||
|
||||
diff = difflib.unified_diff(
|
||||
existing.splitlines(keepends=True),
|
||||
generated.splitlines(keepends=True),
|
||||
fromfile=str(out.relative_to(SCRIPT_DIR)),
|
||||
tofile="(generated)",
|
||||
)
|
||||
sys.stdout.writelines(diff)
|
||||
print(
|
||||
f"\nERROR: {out.relative_to(SCRIPT_DIR)} is out of date. "
|
||||
f"Run 'uv run generate_compose_variants.py {variant.name}' to update."
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate docker-compose variant files from docker-compose.yml"
|
||||
)
|
||||
parser.add_argument(
|
||||
"variant",
|
||||
nargs="?",
|
||||
choices=list(VARIANTS.keys()),
|
||||
help="Variant to generate (default: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Check that generated files match existing ones (for CI / pre-commit)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
targets = [VARIANTS[args.variant]] if args.variant else list(VARIANTS.values())
|
||||
|
||||
if args.check:
|
||||
all_ok = all(run_check(v) for v in targets)
|
||||
sys.exit(0 if all_ok else 1)
|
||||
else:
|
||||
for v in targets:
|
||||
run_generate(v)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,154 +0,0 @@
|
||||
# =============================================================================
|
||||
# ONYX HEADLESS-LITE DOCKER COMPOSE (AUTO-GENERATED)
|
||||
# =============================================================================
|
||||
# This file is generated by generate_compose_variants.py from docker-compose.yml.
|
||||
# DO NOT EDIT THIS FILE DIRECTLY — your changes will be overwritten.
|
||||
#
|
||||
# To regenerate:
|
||||
# cd deployment/docker_compose
|
||||
# uv run generate_compose_variants.py headless-lite
|
||||
#
|
||||
# Usage:
|
||||
# cd deployment/docker_compose/headless-lite
|
||||
# docker compose up -d
|
||||
#
|
||||
# Connect via SSH:
|
||||
# ssh localhost -p 2222
|
||||
#
|
||||
# This is a minimal headless deployment: no web UI, no Vespa, no Redis,
|
||||
# no model servers, no background workers, no OpenSearch, and no MinIO.
|
||||
# Only PostgreSQL is required. Connectors and RAG search are disabled,
|
||||
# but core chat (LLM conversations, tools, user file uploads, Projects,
|
||||
# Agent knowledge, code interpreter) still works.
|
||||
# =============================================================================
|
||||
|
||||
name: onyx
|
||||
|
||||
services:
|
||||
api_server:
|
||||
image: ${ONYX_BACKEND_IMAGE:-onyxdotapp/onyx-backend:${IMAGE_TAG:-latest}}
|
||||
command: >
|
||||
/bin/sh -c "alembic upgrade head &&
|
||||
echo \"Starting Onyx Api Server\" &&
|
||||
uvicorn onyx.main:app --host 0.0.0.0 --port 8080"
|
||||
# Check env.template and copy to .env for env vars
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
depends_on:
|
||||
relational_db:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
# DEV: To expose ports, either:
|
||||
# 1. Use docker-compose.dev.yml: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --wait
|
||||
# 2. Uncomment the ports below
|
||||
# ports:
|
||||
# - "8080:8080"
|
||||
environment:
|
||||
# Auth Settings
|
||||
- AUTH_TYPE=${AUTH_TYPE:-basic}
|
||||
- POSTGRES_HOST=${POSTGRES_HOST:-relational_db}
|
||||
- VESPA_HOST=${VESPA_HOST:-index}
|
||||
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
|
||||
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
|
||||
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
|
||||
- REDIS_HOST=${REDIS_HOST:-cache}
|
||||
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
|
||||
- CODE_INTERPRETER_BASE_URL=${CODE_INTERPRETER_BASE_URL:-http://code-interpreter:8000}
|
||||
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
|
||||
- S3_AWS_ACCESS_KEY_ID=${S3_AWS_ACCESS_KEY_ID:-minioadmin}
|
||||
- S3_AWS_SECRET_ACCESS_KEY=${S3_AWS_SECRET_ACCESS_KEY:-minioadmin}
|
||||
- ENABLE_CRAFT=${ENABLE_CRAFT:-false}
|
||||
- OUTPUTS_TEMPLATE_PATH=${OUTPUTS_TEMPLATE_PATH:-/app/onyx/server/features/build/sandbox/kubernetes/docker/templates/outputs}
|
||||
- VENV_TEMPLATE_PATH=${VENV_TEMPLATE_PATH:-/app/onyx/server/features/build/sandbox/kubernetes/docker/templates/venv}
|
||||
- WEB_TEMPLATE_PATH=${WEB_TEMPLATE_PATH:-/app/onyx/server/features/build/sandbox/kubernetes/docker/templates/outputs/web}
|
||||
- PERSISTENT_DOCUMENT_STORAGE_PATH=${PERSISTENT_DOCUMENT_STORAGE_PATH:-/app/file-system}
|
||||
- DISABLE_VECTOR_DB=true
|
||||
- FILE_STORE_BACKEND=postgres
|
||||
- CACHE_BACKEND=postgres
|
||||
- AUTH_BACKEND=postgres
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
|
||||
interval: 30s
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
start_period: 25s
|
||||
# Optional, only for debugging purposes
|
||||
volumes:
|
||||
- api_server_logs:/var/log/onyx
|
||||
# Shared volume for persistent document storage (Craft file-system mode)
|
||||
- file-system:/app/file-system
|
||||
|
||||
relational_db:
|
||||
image: postgres:15.2-alpine
|
||||
shm_size: 1g
|
||||
command: -c 'max_connections=250'
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
# PRODUCTION: Override the defaults by passing in the environment variables
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER:-postgres}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password}
|
||||
# DEV: To expose ports, either:
|
||||
# 1. Use docker-compose.dev.yml: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --wait
|
||||
# 2. Uncomment the ports below
|
||||
# ports:
|
||||
# - "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
- db_volume:/var/lib/postgresql/data
|
||||
|
||||
# This container name cannot have an underscore in it due to Vespa expectations of the URL
|
||||
code-interpreter:
|
||||
image: onyxdotapp/code-interpreter:${CODE_INTERPRETER_IMAGE_TAG:-latest}
|
||||
command: ["bash", "./entrypoint.sh", "code-interpreter-api"]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
|
||||
# Below is needed for the `docker-out-of-docker` execution mode
|
||||
# For Linux rootless Docker, set DOCKER_SOCK_PATH=${XDG_RUNTIME_DIR}/docker.sock
|
||||
user: root
|
||||
volumes:
|
||||
- ${DOCKER_SOCK_PATH:-/var/run/docker.sock}:/var/run/docker.sock
|
||||
|
||||
cli_server:
|
||||
image: ${ONYX_CLI_IMAGE:-onyxdotapp/onyx-cli:${IMAGE_TAG:-latest}}
|
||||
command: ["serve", "--host", "0.0.0.0", "--port", "2222"]
|
||||
depends_on:
|
||||
- api_server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${ONYX_SSH_PORT:-2222}:2222"
|
||||
environment:
|
||||
- ONYX_SERVER_URL=http://api_server:8080
|
||||
volumes:
|
||||
- cli_config:/home/onyx/.config
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
|
||||
volumes:
|
||||
# Necessary for persisting data for use
|
||||
db_volume:
|
||||
# Logs preserved across container restarts
|
||||
api_server_logs:
|
||||
# Shared volume for persistent document storage (Craft file-system mode)
|
||||
file-system:
|
||||
cli_config:
|
||||
@@ -1,422 +0,0 @@
|
||||
# =============================================================================
|
||||
# ONYX HEADLESS DOCKER COMPOSE (AUTO-GENERATED)
|
||||
# =============================================================================
|
||||
# This file is generated by generate_compose_variants.py from docker-compose.yml.
|
||||
# DO NOT EDIT THIS FILE DIRECTLY — your changes will be overwritten.
|
||||
#
|
||||
# To regenerate:
|
||||
# cd deployment/docker_compose
|
||||
# uv run generate_compose_variants.py headless
|
||||
#
|
||||
# Usage:
|
||||
# cd deployment/docker_compose/headless
|
||||
# docker compose up -d
|
||||
#
|
||||
# Connect via SSH:
|
||||
# ssh localhost -p 2222
|
||||
# =============================================================================
|
||||
|
||||
name: onyx
|
||||
|
||||
services:
|
||||
api_server:
|
||||
image: ${ONYX_BACKEND_IMAGE:-onyxdotapp/onyx-backend:${IMAGE_TAG:-latest}}
|
||||
command: >
|
||||
/bin/sh -c "alembic upgrade head &&
|
||||
echo \"Starting Onyx Api Server\" &&
|
||||
uvicorn onyx.main:app --host 0.0.0.0 --port 8080"
|
||||
# Check env.template and copy to .env for env vars
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
depends_on:
|
||||
relational_db:
|
||||
condition: service_started
|
||||
index:
|
||||
condition: service_started
|
||||
opensearch:
|
||||
condition: service_started
|
||||
required: false
|
||||
cache:
|
||||
condition: service_started
|
||||
inference_model_server:
|
||||
condition: service_started
|
||||
minio:
|
||||
condition: service_started
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
# DEV: To expose ports, either:
|
||||
# 1. Use docker-compose.dev.yml: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --wait
|
||||
# 2. Uncomment the ports below
|
||||
# ports:
|
||||
# - "8080:8080"
|
||||
environment:
|
||||
# Auth Settings
|
||||
- AUTH_TYPE=${AUTH_TYPE:-basic}
|
||||
- FILE_STORE_BACKEND=${FILE_STORE_BACKEND:-s3}
|
||||
- POSTGRES_HOST=${POSTGRES_HOST:-relational_db}
|
||||
- VESPA_HOST=${VESPA_HOST:-index}
|
||||
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
|
||||
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
|
||||
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
|
||||
- REDIS_HOST=${REDIS_HOST:-cache}
|
||||
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
|
||||
- CODE_INTERPRETER_BASE_URL=${CODE_INTERPRETER_BASE_URL:-http://code-interpreter:8000}
|
||||
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
|
||||
- S3_AWS_ACCESS_KEY_ID=${S3_AWS_ACCESS_KEY_ID:-minioadmin}
|
||||
- S3_AWS_SECRET_ACCESS_KEY=${S3_AWS_SECRET_ACCESS_KEY:-minioadmin}
|
||||
# Onyx Craft configuration (disabled by default, set ENABLE_CRAFT=true in .env to enable)
|
||||
# Use --include-craft with install script, or manually set in .env file
|
||||
- ENABLE_CRAFT=${ENABLE_CRAFT:-false}
|
||||
- OUTPUTS_TEMPLATE_PATH=${OUTPUTS_TEMPLATE_PATH:-/app/onyx/server/features/build/sandbox/kubernetes/docker/templates/outputs}
|
||||
- VENV_TEMPLATE_PATH=${VENV_TEMPLATE_PATH:-/app/onyx/server/features/build/sandbox/kubernetes/docker/templates/venv}
|
||||
- WEB_TEMPLATE_PATH=${WEB_TEMPLATE_PATH:-/app/onyx/server/features/build/sandbox/kubernetes/docker/templates/outputs/web}
|
||||
- PERSISTENT_DOCUMENT_STORAGE_PATH=${PERSISTENT_DOCUMENT_STORAGE_PATH:-/app/file-system}
|
||||
# PRODUCTION: Uncomment the line below to use if IAM_AUTH is true and you are using iam auth for postgres
|
||||
# volumes:
|
||||
# - ./bundle.pem:/app/bundle.pem:ro
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
|
||||
interval: 30s
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
start_period: 25s
|
||||
# Optional, only for debugging purposes
|
||||
volumes:
|
||||
- api_server_logs:/var/log/onyx
|
||||
# Shared volume for persistent document storage (Craft file-system mode)
|
||||
- file-system:/app/file-system
|
||||
|
||||
background:
|
||||
image: ${ONYX_BACKEND_IMAGE:-onyxdotapp/onyx-backend:${IMAGE_TAG:-latest}}
|
||||
command: >
|
||||
/bin/sh -c "
|
||||
if [ -f /app/scripts/setup_craft_templates.sh ]; then
|
||||
/app/scripts/setup_craft_templates.sh;
|
||||
fi &&
|
||||
if [ -f /etc/ssl/certs/custom-ca.crt ]; then
|
||||
update-ca-certificates;
|
||||
fi &&
|
||||
/app/scripts/supervisord_entrypoint.sh"
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
depends_on:
|
||||
relational_db:
|
||||
condition: service_started
|
||||
index:
|
||||
condition: service_started
|
||||
opensearch:
|
||||
condition: service_started
|
||||
required: false
|
||||
cache:
|
||||
condition: service_started
|
||||
inference_model_server:
|
||||
condition: service_started
|
||||
indexing_model_server:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- FILE_STORE_BACKEND=${FILE_STORE_BACKEND:-s3}
|
||||
- POSTGRES_HOST=${POSTGRES_HOST:-relational_db}
|
||||
- VESPA_HOST=${VESPA_HOST:-index}
|
||||
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
|
||||
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
|
||||
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
|
||||
- REDIS_HOST=${REDIS_HOST:-cache}
|
||||
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
|
||||
- INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server}
|
||||
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
|
||||
- S3_AWS_ACCESS_KEY_ID=${S3_AWS_ACCESS_KEY_ID:-minioadmin}
|
||||
- S3_AWS_SECRET_ACCESS_KEY=${S3_AWS_SECRET_ACCESS_KEY:-minioadmin}
|
||||
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}
|
||||
- DISCORD_BOT_INVOKE_CHAR=${DISCORD_BOT_INVOKE_CHAR:-!}
|
||||
# API Server connection for Discord bot message processing
|
||||
- API_SERVER_PROTOCOL=${API_SERVER_PROTOCOL:-http}
|
||||
- API_SERVER_HOST=${API_SERVER_HOST:-api_server}
|
||||
# Onyx Craft configuration (set up automatically on container startup)
|
||||
- ENABLE_CRAFT=${ENABLE_CRAFT:-false}
|
||||
- OUTPUTS_TEMPLATE_PATH=${OUTPUTS_TEMPLATE_PATH:-/app/onyx/server/features/build/sandbox/kubernetes/docker/templates/outputs}
|
||||
- VENV_TEMPLATE_PATH=${VENV_TEMPLATE_PATH:-/app/onyx/server/features/build/sandbox/kubernetes/docker/templates/venv}
|
||||
- WEB_TEMPLATE_PATH=${WEB_TEMPLATE_PATH:-/app/onyx/server/features/build/sandbox/kubernetes/docker/templates/outputs/web}
|
||||
- PERSISTENT_DOCUMENT_STORAGE_PATH=${PERSISTENT_DOCUMENT_STORAGE_PATH:-/app/file-system}
|
||||
# PRODUCTION: Uncomment the line below to use if IAM_AUTH is true and you are using iam auth for postgres
|
||||
# volumes:
|
||||
# - ./bundle.pem:/app/bundle.pem:ro
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
# Optional, only for debugging purposes
|
||||
volumes:
|
||||
- background_logs:/var/log/onyx
|
||||
# Shared volume for persistent document storage (Craft file-system mode)
|
||||
- file-system:/app/file-system
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
# PRODUCTION: Uncomment the following lines if you need to include a custom CA certificate
|
||||
# This section enables the use of a custom CA certificate
|
||||
# If present, the custom CA certificate is mounted as a volume
|
||||
# The container checks for its existence and updates the system's CA certificates
|
||||
# This allows for secure communication with services using custom SSL certificates
|
||||
# Optional volume mount for CA certificate
|
||||
# volumes:
|
||||
# # Maps to the CA_CERT_PATH environment variable in the Dockerfile
|
||||
# - ${CA_CERT_PATH:-./custom-ca.crt}:/etc/ssl/certs/custom-ca.crt:ro
|
||||
|
||||
inference_model_server:
|
||||
image: ${ONYX_MODEL_SERVER_IMAGE:-onyxdotapp/onyx-model-server:${IMAGE_TAG:-latest}}
|
||||
command: >
|
||||
/bin/sh -c "if [ \"${DISABLE_MODEL_SERVER:-}\" = \"True\" ] || [ \"${DISABLE_MODEL_SERVER:-}\" = \"true\" ]; then
|
||||
echo 'Skipping service...';
|
||||
exit 0;
|
||||
else
|
||||
exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000;
|
||||
fi"
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Not necessary, this is just to reduce download time during startup
|
||||
- model_cache_huggingface:/app/.cache/huggingface/
|
||||
# Optional, only for debugging purposes
|
||||
- inference_model_server_logs:/var/log/onyx
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9000/api/health')"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
indexing_model_server:
|
||||
image: ${ONYX_MODEL_SERVER_IMAGE:-onyxdotapp/onyx-model-server:${IMAGE_TAG:-latest}}
|
||||
command: >
|
||||
/bin/sh -c "if [ \"${DISABLE_MODEL_SERVER:-}\" = \"True\" ] || [ \"${DISABLE_MODEL_SERVER:-}\" = \"true\" ]; then
|
||||
echo 'Skipping service...';
|
||||
exit 0;
|
||||
else
|
||||
exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000;
|
||||
fi"
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- INDEXING_ONLY=True
|
||||
volumes:
|
||||
# Not necessary, this is just to reduce download time during startup
|
||||
- indexing_huggingface_model_cache:/app/.cache/huggingface/
|
||||
# Optional, only for debugging purposes
|
||||
- indexing_model_server_logs:/var/log/onyx
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9000/api/health')"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
relational_db:
|
||||
image: postgres:15.2-alpine
|
||||
shm_size: 1g
|
||||
command: -c 'max_connections=250'
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
# PRODUCTION: Override the defaults by passing in the environment variables
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER:-postgres}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password}
|
||||
# DEV: To expose ports, either:
|
||||
# 1. Use docker-compose.dev.yml: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --wait
|
||||
# 2. Uncomment the ports below
|
||||
# ports:
|
||||
# - "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
- db_volume:/var/lib/postgresql/data
|
||||
|
||||
# This container name cannot have an underscore in it due to Vespa expectations of the URL
|
||||
index:
|
||||
image: vespaengine/vespa:8.609.39
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
- VESPA_SKIP_UPGRADE_CHECK=${VESPA_SKIP_UPGRADE_CHECK:-true}
|
||||
# DEV: To expose ports, either:
|
||||
# 1. Use docker-compose.dev.yml: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --wait
|
||||
# 2. Uncomment the ports below
|
||||
# ports:
|
||||
# - "19071:19071"
|
||||
# - "8081:8081"
|
||||
volumes:
|
||||
- vespa_volume:/opt/vespa/var
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
|
||||
opensearch:
|
||||
image: opensearchproject/opensearch:3.4.0
|
||||
restart: unless-stopped
|
||||
# Controls whether this service runs. In order to enable it, add
|
||||
# opensearch-enabled to COMPOSE_PROFILES in the environment for this
|
||||
# docker-compose.
|
||||
# NOTE: Now enabled on by default. To explicitly disable this service,
|
||||
# uncomment this profile and ensure COMPOSE_PROFILES in your env does not
|
||||
# list the profile, or when running docker compose, include all desired
|
||||
# service names but this one. Additionally set
|
||||
# OPENSEARCH_FOR_ONYX_ENABLED=false in your env.
|
||||
# profiles: ["opensearch-enabled"]
|
||||
environment:
|
||||
# We need discovery.type=single-node so that OpenSearch doesn't try
|
||||
# forming a cluster and waiting for other nodes to become live.
|
||||
- discovery.type=single-node
|
||||
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
|
||||
# This and the JVM config below come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
|
||||
# We do this to avoid unstable performance from page swaps.
|
||||
- bootstrap.memory_lock=true # Disable JVM heap memory swapping.
|
||||
# Java heap should be ~50% of memory limit. For now we assume a limit of
|
||||
# 4g although in practice the container can request more than this.
|
||||
# See https://opster.com/guides/opensearch/opensearch-basics/opensearch-heap-size-usage-and-jvm-garbage-collection/
|
||||
# Xms is the starting size, Xmx is the maximum size. These should be the
|
||||
# same.
|
||||
- "OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g"
|
||||
volumes:
|
||||
- opensearch-data:/usr/share/opensearch/data
|
||||
# These come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
|
||||
ulimits:
|
||||
# Similarly to bootstrap.memory_lock, we don't want to impose limits on
|
||||
# how much memory a process can lock from being swapped.
|
||||
memlock:
|
||||
soft: -1 # Set memlock to unlimited (no soft or hard limit).
|
||||
hard: -1
|
||||
nofile:
|
||||
soft: 65536 # Maximum number of open files for the opensearch user - set to at least 65536.
|
||||
hard: 65536
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
|
||||
cache:
|
||||
image: redis:7.4-alpine
|
||||
restart: unless-stopped
|
||||
# DEV: To expose ports, either:
|
||||
# 1. Use docker-compose.dev.yml: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --wait
|
||||
# 2. Uncomment the ports below
|
||||
# ports:
|
||||
# - "6379:6379"
|
||||
# docker silently mounts /data even without an explicit volume mount, which enables
|
||||
# persistence. explicitly setting save and appendonly forces ephemeral behavior.
|
||||
command: redis-server --save "" --appendonly no
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
# Use tmpfs to prevent creation of anonymous volumes for /data
|
||||
tmpfs:
|
||||
- /data
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2025-07-23T15-54-02Z-cpuv1
|
||||
profiles: ["s3-filestore"]
|
||||
restart: unless-stopped
|
||||
# DEV: To expose ports, either:
|
||||
# 1. Use docker-compose.dev.yml: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --wait
|
||||
# 2. Uncomment the ports below
|
||||
# ports:
|
||||
# - "9004:9000"
|
||||
# - "9005:9001"
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
# Note: we've seen the default bucket creation logic not work in some cases
|
||||
MINIO_DEFAULT_BUCKETS: ${S3_FILE_STORE_BUCKET_NAME:-onyx-file-store-bucket}
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
command: server /data --console-address ":9001"
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 30s
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
|
||||
code-interpreter:
|
||||
image: onyxdotapp/code-interpreter:${CODE_INTERPRETER_IMAGE_TAG:-latest}
|
||||
command: ["bash", "./entrypoint.sh", "code-interpreter-api"]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
|
||||
# Below is needed for the `docker-out-of-docker` execution mode
|
||||
# For Linux rootless Docker, set DOCKER_SOCK_PATH=${XDG_RUNTIME_DIR}/docker.sock
|
||||
user: root
|
||||
volumes:
|
||||
- ${DOCKER_SOCK_PATH:-/var/run/docker.sock}:/var/run/docker.sock
|
||||
|
||||
cli_server:
|
||||
image: ${ONYX_CLI_IMAGE:-onyxdotapp/onyx-cli:${IMAGE_TAG:-latest}}
|
||||
command: ["serve", "--host", "0.0.0.0", "--port", "2222"]
|
||||
depends_on:
|
||||
- api_server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${ONYX_SSH_PORT:-2222}:2222"
|
||||
environment:
|
||||
- ONYX_SERVER_URL=http://api_server:8080
|
||||
volumes:
|
||||
- cli_config:/home/onyx/.config
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
|
||||
volumes:
|
||||
# Necessary for persisting data for use
|
||||
db_volume:
|
||||
vespa_volume: # Created by the container itself
|
||||
minio_data:
|
||||
# Caches to prevent re-downloading models, not strictly necessary
|
||||
model_cache_huggingface:
|
||||
indexing_huggingface_model_cache:
|
||||
# Logs preserved across container restarts
|
||||
api_server_logs:
|
||||
background_logs:
|
||||
# mcp_server_logs:
|
||||
inference_model_server_logs:
|
||||
indexing_model_server_logs:
|
||||
# Shared volume for persistent document storage (Craft file-system mode)
|
||||
file-system:
|
||||
# Persistent data for OpenSearch.
|
||||
opensearch-data:
|
||||
cli_config:
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user