Compare commits

..

3 Commits

Author SHA1 Message Date
Jamison Lahman
77ce667b21 nit 2026-04-05 18:45:22 -07:00
Jamison Lahman
1e0a8afc72 INTERNAL_URL 2026-04-05 18:05:02 -07:00
Jamison Lahman
85302a1cde feat(cli): --config-file and --server-url 2026-04-05 17:32:48 -07:00
36 changed files with 749 additions and 1401 deletions

View File

@@ -1,4 +1,4 @@
from typing import Any
from typing import Any, Literal
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,6 +19,7 @@ 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,
@@ -44,6 +45,8 @@ 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
@@ -53,6 +56,25 @@ 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]:
@@ -209,6 +231,7 @@ 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,
@@ -382,6 +405,7 @@ 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"),
@@ -423,6 +447,7 @@ 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"),
@@ -465,6 +490,7 @@ 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,

View File

@@ -1,9 +1,11 @@
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
@@ -33,6 +35,27 @@ 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.
@@ -62,6 +85,7 @@ 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():

View File

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

View File

@@ -317,6 +317,7 @@ 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",

View File

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

View File

@@ -12,11 +12,6 @@ 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
@@ -396,6 +391,10 @@ 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
@@ -578,6 +577,7 @@ 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"
)

View File

@@ -8,8 +8,6 @@ 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
@@ -47,23 +45,6 @@ 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],
@@ -85,8 +66,6 @@ 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(
@@ -298,8 +277,6 @@ 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

View File

@@ -1,58 +0,0 @@
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,
}
)

View File

@@ -6,7 +6,6 @@ import (
"text/tabwriter"
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
"github.com/spf13/cobra"
)
@@ -25,7 +24,7 @@ Use --json for machine-readable output.`,
onyx-cli agents --json
onyx-cli agents --json | jq '.[].name'`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
cfg := loadConfig(cmd)
if !cfg.IsConfigured() {
return exitcodes.New(exitcodes.NotConfigured, "onyx CLI is not configured\n Run: onyx-cli configure")
}

View File

@@ -11,7 +11,6 @@ import (
"syscall"
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
"github.com/onyx-dot-app/onyx/cli/internal/models"
"github.com/onyx-dot-app/onyx/cli/internal/overflow"
@@ -49,7 +48,7 @@ to a temp file. Set --max-output 0 to disable truncation.`,
cat error.log | onyx-cli ask --prompt "Find the root cause"
echo "what is onyx?" | onyx-cli ask`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
cfg := loadConfig(cmd)
if !cfg.IsConfigured() {
return exitcodes.New(exitcodes.NotConfigured, "onyx CLI is not configured\n Run: onyx-cli configure")
}

View File

@@ -21,7 +21,7 @@ an interactive setup wizard will guide you through configuration.`,
Example: ` onyx-cli chat
onyx-cli`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
cfg := loadConfig(cmd)
// First-run: onboarding
if !config.ConfigExists() || !cfg.IsConfigured() {

View File

@@ -69,7 +69,7 @@ Use --dry-run to test the connection without saving the configuration.`,
return exitcodes.New(exitcodes.BadRequest, "both --server-url and --api-key are required for non-interactive setup\n Run 'onyx-cli configure' without flags for interactive setup")
}
cfg := config.Load()
cfg := loadConfig(cmd)
onboarding.Run(&cfg)
return nil
},

View File

@@ -12,7 +12,7 @@ func newExperimentsCmd() *cobra.Command {
Use: "experiments",
Short: "List experimental features and their status",
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
cfg := loadConfig(cmd)
_, _ = fmt.Fprintln(cmd.OutOrStdout(), config.ExperimentsText(cfg.Features))
return nil
},

View File

@@ -13,6 +13,20 @@ import (
"github.com/spf13/cobra"
)
// loadConfig loads the CLI config, using the --config-file persistent flag if set.
func loadConfig(cmd *cobra.Command) config.OnyxCliConfig {
cf, _ := cmd.Flags().GetString("config-file")
return config.Load(cf)
}
// effectiveConfigPath returns the config file path, respecting --config-file.
func effectiveConfigPath(cmd *cobra.Command) string {
if cf, _ := cmd.Flags().GetString("config-file"); cf != "" {
return cf
}
return config.ConfigFilePath()
}
// Version and Commit are set via ldflags at build time.
var (
Version string
@@ -29,7 +43,7 @@ func fullVersion() string {
func printVersion(cmd *cobra.Command) {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Client version: %s\n", fullVersion())
cfg := config.Load()
cfg := loadConfig(cmd)
if !cfg.IsConfigured() {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Server version: unknown (not configured)\n")
return
@@ -84,6 +98,8 @@ func Execute() error {
}
rootCmd.PersistentFlags().BoolVar(&opts.Debug, "debug", false, "run in debug mode")
rootCmd.PersistentFlags().String("config-file", "",
"Path to config file (default: "+config.ConfigFilePath()+")")
// Custom --version flag instead of Cobra's built-in (which only shows one version string)
var showVersion bool

View File

@@ -50,16 +50,14 @@ func sessionEnv(s ssh.Session, key string) string {
return ""
}
func validateAPIKey(serverURL string, apiKey string) error {
func validateAPIKey(serverCfg config.OnyxCliConfig, apiKey string) error {
trimmedKey := strings.TrimSpace(apiKey)
if len(trimmedKey) > maxAPIKeyLength {
return fmt.Errorf("API key is too long (max %d characters)", maxAPIKeyLength)
}
cfg := config.OnyxCliConfig{
ServerURL: serverURL,
APIKey: trimmedKey,
}
cfg := serverCfg
cfg.APIKey = trimmedKey
client := api.NewClient(cfg)
ctx, cancel := context.WithTimeout(context.Background(), apiKeyValidationTimeout)
defer cancel()
@@ -83,7 +81,7 @@ type authValidatedMsg struct {
type authModel struct {
input textinput.Model
serverURL string
serverCfg config.OnyxCliConfig
state authState
apiKey string // set on successful validation
errMsg string
@@ -91,7 +89,7 @@ type authModel struct {
aborted bool
}
func newAuthModel(serverURL, initialErr string) authModel {
func newAuthModel(serverCfg config.OnyxCliConfig, initialErr string) authModel {
ti := textinput.New()
ti.Prompt = " API Key: "
ti.EchoMode = textinput.EchoPassword
@@ -102,7 +100,7 @@ func newAuthModel(serverURL, initialErr string) authModel {
return authModel{
input: ti,
serverURL: serverURL,
serverCfg: serverCfg,
errMsg: initialErr,
}
}
@@ -138,9 +136,9 @@ func (m authModel) Update(msg tea.Msg) (authModel, tea.Cmd) {
}
m.state = authValidating
m.errMsg = ""
serverURL := m.serverURL
serverCfg := m.serverCfg
return m, func() tea.Msg {
return authValidatedMsg{key: key, err: validateAPIKey(serverURL, key)}
return authValidatedMsg{key: key, err: validateAPIKey(serverCfg, key)}
}
}
@@ -171,12 +169,13 @@ func (m authModel) Update(msg tea.Msg) (authModel, tea.Cmd) {
}
func (m authModel) View() string {
settingsURL := strings.TrimRight(m.serverURL, "/") + "/app/settings/accounts-access"
serverURL := m.serverCfg.ServerURL
settingsURL := strings.TrimRight(serverURL, "/") + "/app/settings/accounts-access"
var b strings.Builder
b.WriteString("\n")
b.WriteString(" \x1b[1;35mOnyx CLI\x1b[0m\n")
b.WriteString(" \x1b[90m" + m.serverURL + "\x1b[0m\n")
b.WriteString(" \x1b[90m" + serverURL + "\x1b[0m\n")
b.WriteString("\n")
b.WriteString(" Generate an API key at:\n")
b.WriteString(" \x1b[4;34m" + settingsURL + "\x1b[0m\n")
@@ -215,7 +214,7 @@ type serveModel struct {
func newServeModel(serverCfg config.OnyxCliConfig, initialErr string) serveModel {
return serveModel{
auth: newAuthModel(serverCfg.ServerURL, initialErr),
auth: newAuthModel(serverCfg, initialErr),
serverCfg: serverCfg,
}
}
@@ -238,11 +237,8 @@ func (m serveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit
}
if m.auth.apiKey != "" {
cfg := config.OnyxCliConfig{
ServerURL: m.serverCfg.ServerURL,
APIKey: m.auth.apiKey,
DefaultAgentID: m.serverCfg.DefaultAgentID,
}
cfg := m.serverCfg
cfg.APIKey = m.auth.apiKey
m.tui = tui.NewModel(cfg)
m.authed = true
w, h := m.width, m.height
@@ -280,6 +276,8 @@ func newServeCmd() *cobra.Command {
rateLimitPerMin int
rateLimitBurst int
rateLimitCache int
serverURL string
apiServerURL string
)
cmd := &cobra.Command{
@@ -300,9 +298,18 @@ environment variable (the --host-key flag takes precedence).`,
Example: ` onyx-cli serve --port 2222
ssh localhost -p 2222
onyx-cli serve --host 0.0.0.0 --port 2222
onyx-cli serve --idle-timeout 30m --max-session-timeout 2h`,
onyx-cli serve --idle-timeout 30m --max-session-timeout 2h
onyx-cli serve --server-url https://my-onyx.example.com
onyx-cli serve --api-server-url http://api_server:8080 # bypass nginx
onyx-cli serve --config-file /etc/onyx-cli/config.json # global flag`,
RunE: func(cmd *cobra.Command, args []string) error {
serverCfg := config.Load()
serverCfg := loadConfig(cmd)
if cmd.Flags().Changed("server-url") {
serverCfg.ServerURL = serverURL
}
if cmd.Flags().Changed("api-server-url") {
serverCfg.InternalURL = apiServerURL
}
if serverCfg.ServerURL == "" {
return exitcodes.New(exitcodes.NotConfigured, "server URL is not configured\n Run: onyx-cli configure")
}
@@ -333,7 +340,7 @@ environment variable (the --host-key flag takes precedence).`,
var envErr string
if apiKey != "" {
if err := validateAPIKey(serverCfg.ServerURL, apiKey); err != nil {
if err := validateAPIKey(serverCfg, apiKey); err != nil {
envErr = fmt.Sprintf("ONYX_API_KEY from SSH environment is invalid: %s", err.Error())
apiKey = ""
}
@@ -341,11 +348,8 @@ environment variable (the --host-key flag takes precedence).`,
if apiKey != "" {
// Env key is valid — go straight to the TUI.
cfg := config.OnyxCliConfig{
ServerURL: serverCfg.ServerURL,
APIKey: apiKey,
DefaultAgentID: serverCfg.DefaultAgentID,
}
cfg := serverCfg
cfg.APIKey = apiKey
return tui.NewModel(cfg), []tea.ProgramOption{
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
@@ -446,6 +450,10 @@ environment variable (the --host-key flag takes precedence).`,
defaultServeRateLimitCacheSize,
"Maximum number of IP limiter entries tracked in memory",
)
cmd.Flags().StringVar(&serverURL, "server-url", "",
"Onyx server URL (overrides config file and $"+config.EnvServerURL+")")
cmd.Flags().StringVar(&apiServerURL, "api-server-url", "",
"API server URL for direct access, bypassing nginx (overrides $"+config.EnvAPIServerURL+")")
return cmd
}

View File

@@ -4,10 +4,10 @@ import (
"context"
"errors"
"fmt"
"os"
"time"
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
"github.com/onyx-dot-app/onyx/cli/internal/version"
log "github.com/sirupsen/logrus"
@@ -23,19 +23,21 @@ is valid. Also reports the server version and warns if it is below the
minimum required.`,
Example: ` onyx-cli validate-config`,
RunE: func(cmd *cobra.Command, args []string) error {
cfgPath := effectiveConfigPath(cmd)
// Check config file
if !config.ConfigExists() {
return exitcodes.Newf(exitcodes.NotConfigured, "config file not found at %s\n Run: onyx-cli configure", config.ConfigFilePath())
if _, err := os.Stat(cfgPath); err != nil {
return exitcodes.Newf(exitcodes.NotConfigured, "config file not found at %s\n Run: onyx-cli configure", cfgPath)
}
cfg := config.Load()
cfg := loadConfig(cmd)
// Check API key
if !cfg.IsConfigured() {
return exitcodes.New(exitcodes.NotConfigured, "API key is missing\n Run: onyx-cli configure")
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\n", config.ConfigFilePath())
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\n", cfgPath)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Server: %s\n", cfg.ServerURL)
// Test connection

View File

@@ -16,18 +16,30 @@ import (
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/models"
log "github.com/sirupsen/logrus"
)
// Client is the Onyx API client.
type Client struct {
baseURL string
serverURL string // root server URL (for reachability checks)
baseURL string // API base URL (includes /api when going through nginx)
apiKey string
httpClient *http.Client // default 30s timeout for quick requests
longHTTPClient *http.Client // 5min timeout for streaming/uploads
}
// NewClient creates a new API client from config.
//
// When InternalURL is set, requests go directly to the API server (no /api
// prefix needed — mirrors INTERNAL_URL in the web server). Otherwise,
// requests go through the nginx proxy at ServerURL which strips /api.
func NewClient(cfg config.OnyxCliConfig) *Client {
baseURL := apiBaseURL(cfg)
log.WithFields(log.Fields{
"server_url": cfg.ServerURL,
"internal_url": cfg.InternalURL,
"base_url": baseURL,
}).Debug("creating API client")
var transport *http.Transport
if t, ok := http.DefaultTransport.(*http.Transport); ok {
transport = t.Clone()
@@ -35,8 +47,9 @@ func NewClient(cfg config.OnyxCliConfig) *Client {
transport = &http.Transport{}
}
return &Client{
baseURL: strings.TrimRight(cfg.ServerURL, "/"),
apiKey: cfg.APIKey,
serverURL: strings.TrimRight(cfg.ServerURL, "/"),
baseURL: baseURL,
apiKey: cfg.APIKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
@@ -48,14 +61,27 @@ func NewClient(cfg config.OnyxCliConfig) *Client {
}
}
// apiBaseURL returns the base URL for API requests. When InternalURL is set,
// it points directly at the API server. Otherwise it goes through the nginx
// proxy at ServerURL/api.
func apiBaseURL(cfg config.OnyxCliConfig) string {
if cfg.InternalURL != "" {
return strings.TrimRight(cfg.InternalURL, "/")
}
return strings.TrimRight(cfg.ServerURL, "/") + "/api"
}
// UpdateConfig replaces the client's config.
func (c *Client) UpdateConfig(cfg config.OnyxCliConfig) {
c.baseURL = strings.TrimRight(cfg.ServerURL, "/")
c.serverURL = strings.TrimRight(cfg.ServerURL, "/")
c.baseURL = apiBaseURL(cfg)
c.apiKey = cfg.APIKey
}
func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
url := c.baseURL + path
log.WithFields(log.Fields{"method": method, "url": url}).Debug("API request")
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
}
@@ -87,12 +113,16 @@ func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, r
resp, err := c.httpClient.Do(req)
if err != nil {
log.WithError(err).WithField("url", req.URL.String()).Debug("API request failed")
return err
}
defer func() { _ = resp.Body.Close() }()
log.WithFields(log.Fields{"url": req.URL.String(), "status": resp.StatusCode}).Debug("API response")
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
log.WithFields(log.Fields{"status": resp.StatusCode, "body": string(respBody)}).Debug("API error response")
return &OnyxAPIError{StatusCode: resp.StatusCode, Detail: string(respBody)}
}
@@ -105,16 +135,26 @@ func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, r
// TestConnection checks if the server is reachable and credentials are valid.
// Returns nil on success, or an error with a descriptive message on failure.
func (c *Client) TestConnection(ctx context.Context) error {
// Step 1: Basic reachability
req, err := c.newRequest(ctx, "GET", "/", nil)
// Step 1: Basic reachability (hit the server root, not the API prefix)
reachURL := c.serverURL
if reachURL == "" {
reachURL = c.baseURL
}
log.WithFields(log.Fields{
"reach_url": reachURL,
"base_url": c.baseURL,
}).Debug("testing connection — step 1: reachability")
req, err := http.NewRequestWithContext(ctx, "GET", reachURL+"/", nil)
if err != nil {
return fmt.Errorf("cannot connect to %s: %w", c.baseURL, err)
return fmt.Errorf("cannot connect to %s: %w", reachURL, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("cannot connect to %s — is the server running?", c.baseURL)
log.WithError(err).Debug("reachability check failed")
return fmt.Errorf("cannot connect to %s — is the server running?", reachURL)
}
log.WithField("status", resp.StatusCode).Debug("reachability check response")
_ = resp.Body.Close()
serverHeader := strings.ToLower(resp.Header.Get("Server"))
@@ -127,7 +167,8 @@ func (c *Client) TestConnection(ctx context.Context) error {
}
// Step 2: Authenticated check
req2, err := c.newRequest(ctx, "GET", "/api/me", nil)
log.WithField("url", c.baseURL+"/me").Debug("testing connection — step 2: auth check")
req2, err := c.newRequest(ctx, "GET", "/me", nil)
if err != nil {
return fmt.Errorf("server reachable but API error: %w", err)
}
@@ -167,7 +208,7 @@ func (c *Client) TestConnection(ctx context.Context) error {
// ListAgents returns visible agents.
func (c *Client) ListAgents(ctx context.Context) ([]models.AgentSummary, error) {
var raw []models.AgentSummary
if err := c.doJSON(ctx, "GET", "/api/persona", nil, &raw); err != nil {
if err := c.doJSON(ctx, "GET", "/persona", nil, &raw); err != nil {
return nil, err
}
var result []models.AgentSummary
@@ -184,7 +225,7 @@ func (c *Client) ListChatSessions(ctx context.Context) ([]models.ChatSessionDeta
var resp struct {
Sessions []models.ChatSessionDetails `json:"sessions"`
}
if err := c.doJSON(ctx, "GET", "/api/chat/get-user-chat-sessions", nil, &resp); err != nil {
if err := c.doJSON(ctx, "GET", "/chat/get-user-chat-sessions", nil, &resp); err != nil {
return nil, err
}
return resp.Sessions, nil
@@ -193,7 +234,7 @@ func (c *Client) ListChatSessions(ctx context.Context) ([]models.ChatSessionDeta
// GetChatSession returns full details for a session.
func (c *Client) GetChatSession(ctx context.Context, sessionID string) (*models.ChatSessionDetailResponse, error) {
var resp models.ChatSessionDetailResponse
if err := c.doJSON(ctx, "GET", "/api/chat/get-chat-session/"+sessionID, nil, &resp); err != nil {
if err := c.doJSON(ctx, "GET", "/chat/get-chat-session/"+sessionID, nil, &resp); err != nil {
return nil, err
}
return &resp, nil
@@ -210,7 +251,7 @@ func (c *Client) RenameChatSession(ctx context.Context, sessionID string, name *
var resp struct {
NewName string `json:"new_name"`
}
if err := c.doJSON(ctx, "PUT", "/api/chat/rename-chat-session", payload, &resp); err != nil {
if err := c.doJSON(ctx, "PUT", "/chat/rename-chat-session", payload, &resp); err != nil {
return "", err
}
return resp.NewName, nil
@@ -236,7 +277,7 @@ func (c *Client) UploadFile(ctx context.Context, filePath string) (*models.FileD
}
_ = writer.Close()
req, err := c.newRequest(ctx, "POST", "/api/user/projects/file/upload", &buf)
req, err := c.newRequest(ctx, "POST", "/user/projects/file/upload", &buf)
if err != nil {
return nil, err
}
@@ -275,7 +316,7 @@ func (c *Client) GetBackendVersion(ctx context.Context) (string, error) {
var resp struct {
BackendVersion string `json:"backend_version"`
}
if err := c.doJSON(ctx, "GET", "/api/version", nil, &resp); err != nil {
if err := c.doJSON(ctx, "GET", "/version", nil, &resp); err != nil {
return "", err
}
return resp.BackendVersion, nil
@@ -283,7 +324,7 @@ func (c *Client) GetBackendVersion(ctx context.Context) (string, error) {
// StopChatSession sends a stop signal for a streaming session (best-effort).
func (c *Client) StopChatSession(ctx context.Context, sessionID string) {
req, err := c.newRequest(ctx, "POST", "/api/chat/stop-chat-session/"+sessionID, nil)
req, err := c.newRequest(ctx, "POST", "/chat/stop-chat-session/"+sessionID, nil)
if err != nil {
return
}

View File

@@ -64,7 +64,7 @@ func (c *Client) SendMessageStream(
return
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/chat/send-chat-message", nil)
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/send-chat-message", nil)
if err != nil {
ch <- models.ErrorEvent{Error: fmt.Sprintf("request error: %v", err), IsRetryable: false}
return

View File

@@ -10,6 +10,7 @@ import (
const (
EnvServerURL = "ONYX_SERVER_URL"
EnvAPIServerURL = "ONYX_API_SERVER_URL"
EnvAPIKey = "ONYX_API_KEY"
EnvAgentID = "ONYX_PERSONA_ID"
EnvSSHHostKey = "ONYX_SSH_HOST_KEY"
@@ -27,6 +28,7 @@ type Features struct {
// OnyxCliConfig holds the CLI configuration.
type OnyxCliConfig struct {
ServerURL string `json:"server_url"`
InternalURL string `json:"internal_url,omitempty"`
APIKey string `json:"api_key"`
DefaultAgentID int `json:"default_persona_id"`
Features Features `json:"features,omitempty"`
@@ -78,30 +80,47 @@ func ConfigExists() bool {
return err == nil
}
// LoadFromDisk reads config from the file only, without applying environment
// variable overrides. Use this when you need the persisted config values
// (e.g., to preserve them during a save operation).
func LoadFromDisk() OnyxCliConfig {
// LoadFromDisk reads config from the given file path without applying
// environment variable overrides. Use this when you need the persisted
// config values (e.g., to preserve them during a save operation).
// If no path is provided, the default config file path is used.
func LoadFromDisk(path ...string) OnyxCliConfig {
p := ConfigFilePath()
if len(path) > 0 && path[0] != "" {
p = path[0]
}
cfg := DefaultConfig()
data, err := os.ReadFile(ConfigFilePath())
data, err := os.ReadFile(p)
if err == nil {
if jsonErr := json.Unmarshal(data, &cfg); jsonErr != nil {
fmt.Fprintf(os.Stderr, "warning: config file %s is malformed: %v (using defaults)\n", ConfigFilePath(), jsonErr)
fmt.Fprintf(os.Stderr, "warning: config file %s is malformed: %v (using defaults)\n", p, jsonErr)
}
}
return cfg
}
// Load reads config from file and applies environment variable overrides.
func Load() OnyxCliConfig {
cfg := LoadFromDisk()
// Load reads config from the given file path and applies environment variable
// overrides. If no path is provided, the default config file path is used.
func Load(path ...string) OnyxCliConfig {
cfg := LoadFromDisk(path...)
applyEnvOverrides(&cfg)
return cfg
}
// Environment overrides
func applyEnvOverrides(cfg *OnyxCliConfig) {
if v := os.Getenv(EnvServerURL); v != "" {
cfg.ServerURL = v
}
// ONYX_API_SERVER_URL takes precedence; fall back to INTERNAL_URL
// (the env var used by the web server) for compatibility.
if v := os.Getenv(EnvAPIServerURL); v != "" {
cfg.InternalURL = v
} else if v := os.Getenv("INTERNAL_URL"); v != "" {
cfg.InternalURL = v
}
if v := os.Getenv(EnvAPIKey); v != "" {
cfg.APIKey = v
}
@@ -117,8 +136,6 @@ func Load() OnyxCliConfig {
fmt.Fprintf(os.Stderr, "warning: invalid value %q for %s (expected true/false), ignoring\n", v, EnvStreamMarkdown)
}
}
return cfg
}
// Save writes the config to disk, creating parent directories if needed.

View File

@@ -1,126 +0,0 @@
"use client";
import { useCallback } from "react";
import { Button } from "@opal/components";
import { Text } from "@opal/components";
import { ContentAction } from "@opal/layouts";
import { SvgEyeOff, SvgX } from "@opal/icons";
import { getProviderIcon } from "@/app/admin/configuration/llm/utils";
import AgentMessage, {
AgentMessageProps,
} from "@/app/app/message/messageComponents/AgentMessage";
import { cn } from "@/lib/utils";
import { markdown } from "@opal/utils";
export interface MultiModelPanelProps {
/** Provider name for icon lookup */
provider: string;
/** Model name for icon lookup and display */
modelName: string;
/** Display-friendly model name */
displayName: string;
/** Whether this panel is the preferred/selected response */
isPreferred: boolean;
/** Whether this panel is currently hidden */
isHidden: boolean;
/** Whether this is a non-preferred panel in selection mode (pushed off-screen) */
isNonPreferredInSelection: boolean;
/** Callback when user clicks this panel to select as preferred */
onSelect: () => void;
/** Callback to hide/show this panel */
onToggleVisibility: () => void;
/** Props to pass through to AgentMessage */
agentMessageProps: AgentMessageProps;
}
/**
* A single model's response panel within the multi-model view.
*
* Renders in two states:
* - **Hidden** — compact header strip only (provider icon + strikethrough name + show button).
* - **Visible** — full header plus `AgentMessage` body. Clicking anywhere on a
* visible non-preferred panel marks it as preferred.
*
* The `isNonPreferredInSelection` flag disables pointer events on the body and
* hides the footer so the panel acts as a passive comparison surface.
*/
export default function MultiModelPanel({
provider,
modelName,
displayName,
isPreferred,
isHidden,
isNonPreferredInSelection,
onSelect,
onToggleVisibility,
agentMessageProps,
}: MultiModelPanelProps) {
const ProviderIcon = getProviderIcon(provider, modelName);
const handlePanelClick = useCallback(() => {
if (!isHidden && !isPreferred) onSelect();
}, [isHidden, isPreferred, onSelect]);
const header = (
<div
className={cn(
"rounded-12",
isPreferred ? "bg-background-tint-02" : "bg-background-tint-00"
)}
>
<ContentAction
sizePreset="main-ui"
variant="body"
paddingVariant="lg"
icon={ProviderIcon}
title={isHidden ? markdown(`~~${displayName}~~`) : displayName}
rightChildren={
<div className="flex items-center gap-1 px-2">
{isPreferred && (
<span className="text-action-link-05 shrink-0">
<Text font="secondary-body" color="inherit" nowrap>
Preferred Response
</Text>
</span>
)}
{!isPreferred && (
<Button
prominence="tertiary"
icon={isHidden ? SvgEyeOff : SvgX}
size="md"
onClick={(e) => {
e.stopPropagation();
onToggleVisibility();
}}
tooltip={isHidden ? "Show response" : "Hide response"}
/>
)}
</div>
}
/>
</div>
);
// Hidden/collapsed panel — just the header row
if (isHidden) {
return header;
}
return (
<div
className={cn(
"flex flex-col gap-3 min-w-0 rounded-16 transition-colors",
!isPreferred && "cursor-pointer hover:bg-background-tint-02"
)}
onClick={handlePanelClick}
>
{header}
<div className={cn(isNonPreferredInSelection && "pointer-events-none")}>
<AgentMessage
{...agentMessageProps}
hideFooter={isNonPreferredInSelection}
/>
</div>
</div>
);
}

View File

@@ -1,372 +0,0 @@
"use client";
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { FullChatState } from "@/app/app/message/messageComponents/interfaces";
import { Message } from "@/app/app/interfaces";
import { LlmManager } from "@/lib/hooks";
import { RegenerationFactory } from "@/app/app/message/messageComponents/AgentMessage";
import MultiModelPanel from "@/app/app/message/MultiModelPanel";
import { MultiModelResponse } from "@/app/app/message/interfaces";
import { cn } from "@/lib/utils";
export interface MultiModelResponseViewProps {
responses: MultiModelResponse[];
chatState: FullChatState;
llmManager: LlmManager | null;
onRegenerate?: RegenerationFactory;
parentMessage?: Message | null;
otherMessagesCanSwitchTo?: number[];
onMessageSelection?: (nodeId: number) => void;
/** Called whenever the set of hidden panel indices changes */
onHiddenPanelsChange?: (hidden: Set<number>) => void;
}
// How many pixels of a non-preferred panel are visible at the viewport edge
const PEEK_W = 64;
// Uniform panel width used in the selection-mode carousel
const SELECTION_PANEL_W = 400;
// Compact width for hidden panels in the carousel track
const HIDDEN_PANEL_W = 220;
// Generation-mode panel widths (from Figma)
const GEN_PANEL_W_2 = 640; // 2 panels side-by-side
const GEN_PANEL_W_3 = 436; // 3 panels side-by-side
// Gap between panels — matches CSS gap-6 (24px)
const PANEL_GAP = 24;
// Minimum panel width before horizontal scroll kicks in
const MIN_PANEL_W = 300;
/**
* Renders N model responses side-by-side with two layout modes:
*
* **Generation mode** — equal-width panels in a horizontally-scrollable row.
* Panel width is determined by the number of visible (non-hidden) panels.
*
* **Selection mode** — activated when the user clicks a panel to mark it as
* preferred. All panels (including hidden ones) sit in a fixed-width carousel
* track. A CSS `translateX` transform slides the track so the preferred panel
* is centered in the viewport; the other panels peek in from the edges through
* a mask gradient. Non-preferred visible panels are height-capped to the
* preferred panel's measured height, dimmed at 50% opacity, and receive a
* bottom fade-out overlay.
*
* Hidden panels render as a compact header-only strip at `HIDDEN_PANEL_W` in
* both modes and are excluded from layout width calculations.
*/
export default function MultiModelResponseView({
responses,
chatState,
llmManager,
onRegenerate,
parentMessage,
otherMessagesCanSwitchTo,
onMessageSelection,
onHiddenPanelsChange,
}: MultiModelResponseViewProps) {
const [preferredIndex, setPreferredIndex] = useState<number | null>(null);
const [hiddenPanels, setHiddenPanels] = useState<Set<number>>(new Set());
// Controls animation: false = panels at start position, true = panels at peek position
const [selectionEntered, setSelectionEntered] = useState(false);
// Measures the overflow-hidden carousel container for responsive preferred-panel sizing.
const [trackContainerW, setTrackContainerW] = useState(0);
const roRef = useRef<ResizeObserver | null>(null);
const trackContainerRef = useCallback((el: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
setTrackContainerW(entry?.contentRect.width ?? 0);
});
ro.observe(el);
setTrackContainerW(el.offsetWidth);
roRef.current = ro;
}, []);
// Measures the preferred panel's height to cap non-preferred panels in selection mode.
const [preferredPanelHeight, setPreferredPanelHeight] = useState<
number | null
>(null);
const preferredRoRef = useRef<ResizeObserver | null>(null);
// Tracks which non-preferred panels overflow the preferred height cap
const [overflowingPanels, setOverflowingPanels] = useState<Set<number>>(
new Set()
);
const preferredPanelRef = useCallback((el: HTMLDivElement | null) => {
if (preferredRoRef.current) {
preferredRoRef.current.disconnect();
preferredRoRef.current = null;
}
if (!el) {
setPreferredPanelHeight(null);
return;
}
const ro = new ResizeObserver(([entry]) => {
setPreferredPanelHeight(entry?.contentRect.height ?? 0);
});
ro.observe(el);
setPreferredPanelHeight(el.offsetHeight);
preferredRoRef.current = ro;
}, []);
const isGenerating = useMemo(
() => responses.some((r) => r.isGenerating),
[responses]
);
// Non-hidden responses — used for layout width decisions and selection-mode gating
const visibleResponses = useMemo(
() => responses.filter((r) => !hiddenPanels.has(r.modelIndex)),
[responses, hiddenPanels]
);
const toggleVisibility = useCallback(
(modelIndex: number) => {
setHiddenPanels((prev) => {
const next = new Set(prev);
if (next.has(modelIndex)) {
next.delete(modelIndex);
} else {
// Don't hide the last visible panel
const visibleCount = responses.length - next.size;
if (visibleCount <= 1) return prev;
next.add(modelIndex);
}
onHiddenPanelsChange?.(next);
return next;
});
},
[responses.length, onHiddenPanelsChange]
);
const handleSelectPreferred = useCallback(
(modelIndex: number) => {
if (isGenerating) return;
setPreferredIndex(modelIndex);
const response = responses.find((r) => r.modelIndex === modelIndex);
if (!response) return;
if (onMessageSelection) {
onMessageSelection(response.nodeId);
}
},
[isGenerating, responses, onMessageSelection]
);
// Clear preferred selection when generation starts
useEffect(() => {
if (isGenerating) {
setPreferredIndex(null);
}
}, [isGenerating]);
// Find preferred panel position — used for both the selection guard and carousel layout
const preferredIdx = responses.findIndex(
(r) => r.modelIndex === preferredIndex
);
// Selection mode when preferred is set, found in responses, not generating, and at least 2 visible panels
const showSelectionMode =
preferredIndex !== null &&
preferredIdx !== -1 &&
!isGenerating &&
visibleResponses.length > 1;
// Trigger the slide-out animation one frame after entering selection mode
useEffect(() => {
if (!showSelectionMode) {
setSelectionEntered(false);
return;
}
const raf = requestAnimationFrame(() => setSelectionEntered(true));
return () => cancelAnimationFrame(raf);
}, [showSelectionMode]);
// Build panel props — isHidden reflects actual hidden state
const buildPanelProps = useCallback(
(response: MultiModelResponse, isNonPreferred: boolean) => ({
provider: response.provider,
modelName: response.modelName,
displayName: response.displayName,
isPreferred: preferredIndex === response.modelIndex,
isHidden: hiddenPanels.has(response.modelIndex),
isNonPreferredInSelection: isNonPreferred,
onSelect: () => handleSelectPreferred(response.modelIndex),
onToggleVisibility: () => toggleVisibility(response.modelIndex),
agentMessageProps: {
rawPackets: response.packets,
packetCount: response.packetCount,
chatState,
nodeId: response.nodeId,
messageId: response.messageId,
currentFeedback: response.currentFeedback,
llmManager,
otherMessagesCanSwitchTo,
onMessageSelection,
onRegenerate,
parentMessage,
},
}),
[
preferredIndex,
hiddenPanels,
handleSelectPreferred,
toggleVisibility,
chatState,
llmManager,
otherMessagesCanSwitchTo,
onMessageSelection,
onRegenerate,
parentMessage,
]
);
if (showSelectionMode) {
// ── Selection Layout (transform-based carousel) ──
//
// All panels (including hidden) sit in the track at their original A/B/C positions.
// Hidden panels use HIDDEN_PANEL_W; non-preferred use SELECTION_PANEL_W;
// preferred uses dynamicPrefW (up to GEN_PANEL_W_2).
const n = responses.length;
const dynamicPrefW =
trackContainerW > 0
? Math.min(trackContainerW - 2 * (PEEK_W + PANEL_GAP), GEN_PANEL_W_2)
: GEN_PANEL_W_2;
const selectionWidths = responses.map((r, i) => {
if (hiddenPanels.has(r.modelIndex)) return HIDDEN_PANEL_W;
if (i === preferredIdx) return dynamicPrefW;
return SELECTION_PANEL_W;
});
const panelLeftEdges = selectionWidths.reduce<number[]>((acc, w, i) => {
acc.push(i === 0 ? 0 : acc[i - 1]! + selectionWidths[i - 1]! + PANEL_GAP);
return acc;
}, []);
const preferredCenterInTrack =
panelLeftEdges[preferredIdx]! + selectionWidths[preferredIdx]! / 2;
// Start position: hidden panels at HIDDEN_PANEL_W, visible at SELECTION_PANEL_W
const uniformTrackW =
responses.reduce(
(sum, r) =>
sum +
(hiddenPanels.has(r.modelIndex) ? HIDDEN_PANEL_W : SELECTION_PANEL_W),
0
) +
(n - 1) * PANEL_GAP;
const trackTransform = selectionEntered
? `translateX(${trackContainerW / 2 - preferredCenterInTrack}px)`
: `translateX(${(trackContainerW - uniformTrackW) / 2}px)`;
return (
<div
ref={trackContainerRef}
className="w-full overflow-hidden"
style={{
maskImage: `linear-gradient(to right, transparent 0px, black ${PEEK_W}px, black calc(100% - ${PEEK_W}px), transparent 100%)`,
WebkitMaskImage: `linear-gradient(to right, transparent 0px, black ${PEEK_W}px, black calc(100% - ${PEEK_W}px), transparent 100%)`,
}}
>
<div
className="flex items-start"
style={{
gap: `${PANEL_GAP}px`,
transition: selectionEntered
? "transform 0.45s cubic-bezier(0.2, 0, 0, 1)"
: "none",
transform: trackTransform,
}}
>
{responses.map((r, i) => {
const isHidden = hiddenPanels.has(r.modelIndex);
const isPref = r.modelIndex === preferredIndex;
const isNonPref = !isHidden && !isPref;
const finalW = selectionWidths[i]!;
const startW = isHidden ? HIDDEN_PANEL_W : SELECTION_PANEL_W;
const capped = isNonPref && preferredPanelHeight != null;
const overflows = capped && overflowingPanels.has(r.modelIndex);
return (
<div
key={r.modelIndex}
ref={(el) => {
if (isPref) preferredPanelRef(el);
if (capped && el) {
const doesOverflow = el.scrollHeight > el.clientHeight;
setOverflowingPanels((prev) => {
const had = prev.has(r.modelIndex);
if (doesOverflow === had) return prev;
const next = new Set(prev);
if (doesOverflow) next.add(r.modelIndex);
else next.delete(r.modelIndex);
return next;
});
}
}}
style={{
width: `${selectionEntered ? finalW : startW}px`,
flexShrink: 0,
transition: selectionEntered
? "width 0.45s cubic-bezier(0.2, 0, 0, 1)"
: "none",
maxHeight: capped ? preferredPanelHeight : undefined,
overflow: capped ? "hidden" : undefined,
position: capped ? "relative" : undefined,
}}
>
<div className={cn(isNonPref && "opacity-50")}>
<MultiModelPanel {...buildPanelProps(r, isNonPref)} />
</div>
{overflows && (
<div
className="absolute inset-x-0 bottom-0 h-24 pointer-events-none"
style={{
background:
"linear-gradient(to top, var(--background-tint-01) 0%, transparent 100%)",
}}
/>
)}
</div>
);
})}
</div>
</div>
);
}
// ── Generation Layout (equal panels side-by-side) ──
// Panel width based on number of visible (non-hidden) panels.
const panelWidth =
visibleResponses.length <= 2 ? GEN_PANEL_W_2 : GEN_PANEL_W_3;
return (
<div className="overflow-x-auto">
<div className="flex gap-6 items-start w-fit mx-auto">
{responses.map((r) => {
const isHidden = hiddenPanels.has(r.modelIndex);
return (
<div
key={r.modelIndex}
style={
isHidden
? {
width: HIDDEN_PANEL_W,
minWidth: HIDDEN_PANEL_W,
maxWidth: HIDDEN_PANEL_W,
flexShrink: 0,
overflow: "hidden" as const,
}
: { width: panelWidth, minWidth: MIN_PANEL_W }
}
>
<MultiModelPanel {...buildPanelProps(r, false)} />
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,16 +0,0 @@
import { Packet } from "@/app/app/services/streamingModels";
import { FeedbackType } from "@/app/app/interfaces";
export interface MultiModelResponse {
modelIndex: number;
provider: string;
modelName: string;
displayName: string;
packets: Packet[];
packetCount: number;
nodeId: number;
messageId?: number;
currentFeedback?: FeedbackType | null;
isGenerating?: boolean;
}

View File

@@ -49,8 +49,6 @@ export interface AgentMessageProps {
parentMessage?: Message | null;
// Duration in seconds for processing this message (agent messages only)
processingDurationSeconds?: number;
/** Hide the feedback/toolbar footer (used in multi-model non-preferred panels) */
hideFooter?: boolean;
}
// TODO: Consider more robust comparisons:
@@ -78,8 +76,7 @@ function arePropsEqual(
prev.parentMessage?.messageId === next.parentMessage?.messageId &&
prev.llmManager?.isLoadingProviders ===
next.llmManager?.isLoadingProviders &&
prev.processingDurationSeconds === next.processingDurationSeconds &&
prev.hideFooter === next.hideFooter
prev.processingDurationSeconds === next.processingDurationSeconds
// Skip: chatState.regenerate, chatState.setPresentingDocument,
// most of llmManager, onMessageSelection (function/object props)
);
@@ -98,7 +95,6 @@ const AgentMessage = React.memo(function AgentMessage({
onRegenerate,
parentMessage,
processingDurationSeconds,
hideFooter,
}: AgentMessageProps) {
const markdownRef = useRef<HTMLDivElement>(null);
const finalAnswerRef = useRef<HTMLDivElement>(null);
@@ -330,7 +326,7 @@ const AgentMessage = React.memo(function AgentMessage({
</div>
{/* Feedback buttons - only show when streaming and rendering complete */}
{isComplete && !hideFooter && (
{isComplete && (
<MessageToolbar
nodeId={nodeId}
messageId={messageId}

View File

@@ -133,7 +133,7 @@ async function createFederatedConnector(
async function updateFederatedConnector(
id: number,
credentials: CredentialForm | null,
credentials: CredentialForm,
config?: ConfigForm
): Promise<{ success: boolean; message: string }> {
try {
@@ -143,7 +143,7 @@ async function updateFederatedConnector(
"Content-Type": "application/json",
},
body: JSON.stringify({
credentials: credentials ?? undefined,
credentials,
config: config || {},
}),
});
@@ -201,9 +201,7 @@ export function FederatedConnectorForm({
const isEditMode = connectorId !== undefined;
const [formState, setFormState] = useState<FormState>({
// 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 || {},
credentials: preloadedConnectorData?.credentials || {},
config: preloadedConnectorData?.config || {},
schema: preloadedCredentialSchema?.credentials || null,
configurationSchema: null,
@@ -211,7 +209,6 @@ 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);
@@ -336,7 +333,6 @@ export function FederatedConnectorForm({
}
const handleCredentialChange = (key: string, value: string) => {
setCredentialsModified(true);
setFormState((prev) => ({
...prev,
credentials: {
@@ -358,11 +354,6 @@ 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);
@@ -420,10 +411,8 @@ export function FederatedConnectorForm({
setSubmitSuccess(null);
try {
const shouldValidateCredentials = !isEditMode || credentialsModified;
// Validate required fields (skip for credentials in edit mode when unchanged)
if (formState.schema && shouldValidateCredentials) {
// Validate required fields
if (formState.schema) {
const missingRequired = Object.entries(formState.schema)
.filter(
([key, field]) => field.required && !formState.credentials[key]
@@ -453,20 +442,16 @@ export function FederatedConnectorForm({
}
setConfigValidationErrors({});
// 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;
}
// 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;
}
// Create or update the connector
@@ -474,7 +459,7 @@ export function FederatedConnectorForm({
isEditMode && connectorId
? await updateFederatedConnector(
connectorId,
credentialsModified ? formState.credentials : null,
formState.credentials,
formState.config
)
: await createFederatedConnector(
@@ -553,16 +538,14 @@ export function FederatedConnectorForm({
id={fieldKey}
type={fieldSpec.secret ? "password" : "text"}
placeholder={
isEditMode && !credentialsModified
? "•••••••• (leave blank to keep current value)"
: fieldSpec.example
? String(fieldSpec.example)
: fieldSpec.description
fieldSpec.example
? String(fieldSpec.example)
: fieldSpec.description
}
value={formState.credentials[fieldKey] || ""}
onChange={(e) => handleCredentialChange(fieldKey, e.target.value)}
className="w-96"
required={!isEditMode && fieldSpec.required}
required={fieldSpec.required}
/>
</div>
))}

View File

@@ -1,10 +1,25 @@
"use client";
import { LLMProviderDescriptor } from "@/interfaces/llm";
import React, { createContext, useContext, useCallback } from "react";
import {
WellKnownLLMProviderDescriptor,
LLMProviderDescriptor,
} from "@/interfaces/llm";
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from "react";
import { useUser } from "@/providers/UserProvider";
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;
@@ -14,26 +29,79 @@ 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 () => {
await refetchProviders();
}, [refetchProviders]);
// Refetch provider lists and re-test default provider if needed
await Promise.all([
refetchProviders(),
refetchOptions(),
testDefaultProvider(),
]);
}, [refetchProviders, refetchOptions, testDefaultProvider]);
return (
<ProviderContext.Provider
value={{
shouldShowConfigurationNeeded,
providerOptions: providerOptions ?? [],
refreshProviderInfo,
llmProviders,
isLoadingProviders,

View File

@@ -17,6 +17,7 @@ const mockProviderStatus = {
llmProviders: [] as unknown[],
isLoadingProviders: false,
hasProviders: false,
providerOptions: [],
refreshProviderInfo: jest.fn(),
};
@@ -70,6 +71,7 @@ describe("useShowOnboarding", () => {
mockProviderStatus.llmProviders = [];
mockProviderStatus.isLoadingProviders = false;
mockProviderStatus.hasProviders = false;
mockProviderStatus.providerOptions = [];
});
it("returns showOnboarding=false while providers are loading", () => {
@@ -196,6 +198,7 @@ describe("useShowOnboarding", () => {
OnboardingStep.Welcome
);
expect(result.current.onboardingActions).toBeDefined();
expect(result.current.llmDescriptors).toEqual([]);
});
describe("localStorage persistence", () => {

View File

@@ -1,192 +0,0 @@
"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,
};
}

View File

@@ -9,6 +9,7 @@ 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";
@@ -21,6 +22,7 @@ function getOnboardingCompletedKey(userId: string): string {
function useOnboardingState(liveAgent?: MinimalPersonaSnapshot): {
state: OnboardingState;
llmDescriptors: WellKnownLLMProviderDescriptor[];
actions: OnboardingActions;
isLoading: boolean;
hasProviders: boolean;
@@ -33,6 +35,7 @@ function useOnboardingState(liveAgent?: MinimalPersonaSnapshot): {
llmProviders,
isLoadingProviders,
hasProviders: hasLlmProviders,
providerOptions,
refreshProviderInfo,
} = useProviderStatus();
@@ -40,6 +43,7 @@ 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
@@ -231,6 +235,7 @@ function useOnboardingState(liveAgent?: MinimalPersonaSnapshot): {
return {
state,
llmDescriptors,
actions: {
nextStep,
prevStep,
@@ -275,6 +280,7 @@ export function useShowOnboarding({
const {
state: onboardingState,
actions: onboardingActions,
llmDescriptors,
isLoading: isLoadingOnboarding,
hasProviders: hasAnyProvider,
} = useOnboardingState(liveAgent);
@@ -344,6 +350,7 @@ export function useShowOnboarding({
onboardingDismissed,
onboardingState,
onboardingActions,
llmDescriptors,
isLoadingOnboarding,
hideOnboarding,
finishOnboarding,

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import Popover from "@/refresh-components/Popover";
import Popover, { PopoverMenu } from "@/refresh-components/Popover";
import { LlmDescriptor, LlmManager } from "@/lib/hooks";
import { structureValue } from "@/lib/llmConfig/utils";
import {
@@ -11,11 +11,25 @@ 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 { SvgRefreshCw } from "@opal/icons";
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 { OpenButton } from "@opal/components";
import { LLMOption, LLMOptionGroup } from "./interfaces";
import ModelListContent from "./ModelListContent";
export interface LLMPopoverProps {
llmManager: LlmManager;
@@ -136,6 +150,7 @@ export default function LLMPopover({
const isLoadingProviders = llmManager.isLoadingProviders;
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const { user } = useUser();
const [localTemperature, setLocalTemperature] = useState(
@@ -146,7 +161,9 @@ 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];
@@ -165,28 +182,39 @@ export default function LLMPopover({
[llmManager]
);
const isSelected = useCallback(
(option: LLMOption) =>
option.modelName === llmManager.currentLlm.modelName &&
option.provider === llmManager.currentLlm.provider,
[llmManager.currentLlm.modelName, llmManager.currentLlm.provider]
const llmOptions = useMemo(
() => buildLlmOptions(llmProviders, currentModelName),
[llmProviders, currentModelName]
);
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)
// 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))
);
setOpen(false);
},
[llmManager, onSelect]
}
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]
);
// 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 =
@@ -206,30 +234,122 @@ export default function LLMPopover({
return currentModel;
}, [llmProviders, currentModelName, llmManager.currentLlm.modelName]);
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>
// 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>
</div>
</>
) : undefined;
);
};
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -253,16 +373,129 @@ export default function LLMPopover({
</div>
<Popover.Content side="top" align="end" width="xl">
<ModelListContent
llmProviders={llmProviders}
currentModelName={currentModelName}
requiresImageInput={requiresImageInput}
isLoading={isLoadingProviders}
onSelect={handleSelectModel}
isSelected={isSelected}
scrollContainerRef={scrollContainerRef}
footer={temperatureFooter}
/>
<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>
</Popover.Content>
</Popover>
);

View File

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

View File

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

View File

@@ -232,6 +232,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
onboardingDismissed,
onboardingState,
onboardingActions,
llmDescriptors,
isLoadingOnboarding,
finishOnboarding,
hideOnboarding,
@@ -811,6 +812,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
handleFinishOnboarding={finishOnboarding}
state={onboardingState}
actions={onboardingActions}
llmDescriptors={llmDescriptors}
/>
)}

View File

@@ -10,6 +10,7 @@ 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";
@@ -20,6 +21,7 @@ type OnboardingFlowProps = {
handleFinishOnboarding: () => void;
state: OnboardingState;
actions: OnboardingActions;
llmDescriptors: WellKnownLLMProviderDescriptor[];
};
const OnboardingFlowInner = ({
@@ -28,6 +30,7 @@ const OnboardingFlowInner = ({
handleFinishOnboarding,
state: onboardingState,
actions: onboardingActions,
llmDescriptors,
}: OnboardingFlowProps) => {
const { user } = useUser();
@@ -54,6 +57,7 @@ const OnboardingFlowInner = ({
<LLMStep
state={onboardingState}
actions={onboardingActions}
llmDescriptors={llmDescriptors}
disabled={
onboardingState.currentStep !== OnboardingStep.LlmSetup
}

View File

@@ -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 { llmProviderOptions, isLoading } = useLLMProviderOptions();
const llmDescriptors = llmProviderOptions ?? [];
const isLoading = !llmDescriptors || llmDescriptors.length === 0;
const [selectedProvider, setSelectedProvider] =
useState<SelectedProvider | null>(null);

View File

@@ -411,8 +411,7 @@ test.describe("LLM Runtime Selection", () => {
const sharedModelOptions = dialog.locator("[data-selected]");
await expect(sharedModelOptions).toHaveCount(2);
const openAiModelOption = dialog
.getByRole("button", { name: /openai/i })
.locator("..")
.getByRole("region", { name: /openai/i })
.locator("[data-selected]")
.first();
await expect(openAiModelOption).toBeVisible();
@@ -437,8 +436,7 @@ test.describe("LLM Runtime Selection", () => {
const secondSharedModelOptions = secondDialog.locator("[data-selected]");
await expect(secondSharedModelOptions).toHaveCount(2);
const anthropicModelOption = secondDialog
.getByRole("button", { name: /anthropic/i })
.locator("..")
.getByRole("region", { name: /anthropic/i })
.locator("[data-selected]")
.first();
await expect(anthropicModelOption).toBeVisible();
@@ -449,8 +447,7 @@ test.describe("LLM Runtime Selection", () => {
await page.waitForSelector('[role="dialog"]', { state: "visible" });
const verifyDialog = page.locator('[role="dialog"]');
const selectedAnthropicOption = verifyDialog
.getByRole("button", { name: /anthropic/i })
.locator("..")
.getByRole("region", { name: /anthropic/i })
.locator('[data-selected="true"]');
await expect(selectedAnthropicOption).toHaveCount(1);
await page.keyboard.press("Escape");