Compare commits

..

2 Commits

Author SHA1 Message Date
Evan Lohn
b1db67d188 pr comments 2026-03-03 22:19:15 -08:00
Evan Lohn
e010637a17 chore: remove lightweight mode 2026-03-03 22:11:17 -08:00
60 changed files with 420 additions and 1035 deletions

View File

@@ -335,7 +335,6 @@ jobs:
# TODO(Nik): https://linear.app/onyx-app/issue/ENG-1/update-test-infra-to-use-test-license
LICENSE_ENFORCEMENT_ENABLED=false
CHECK_TTL_MANAGEMENT_TASK_FREQUENCY_IN_HOURS=0.001
USE_LIGHTWEIGHT_BACKGROUND_WORKER=false
EOF
fi

43
.vscode/launch.json vendored
View File

@@ -40,19 +40,7 @@
}
},
{
"name": "Celery (lightweight mode)",
"configurations": [
"Celery primary",
"Celery background",
"Celery beat"
],
"presentation": {
"group": "1"
},
"stopAll": true
},
{
"name": "Celery (standard mode)",
"name": "Celery",
"configurations": [
"Celery primary",
"Celery light",
@@ -253,35 +241,6 @@
},
"consoleTitle": "Celery light Console"
},
{
"name": "Celery background",
"type": "debugpy",
"request": "launch",
"module": "celery",
"cwd": "${workspaceFolder}/backend",
"envFile": "${workspaceFolder}/.vscode/.env",
"env": {
"LOG_LEVEL": "INFO",
"PYTHONUNBUFFERED": "1",
"PYTHONPATH": "."
},
"args": [
"-A",
"onyx.background.celery.versioned_apps.background",
"worker",
"--pool=threads",
"--concurrency=20",
"--prefetch-multiplier=4",
"--loglevel=INFO",
"--hostname=background@%n",
"-Q",
"vespa_metadata_sync,connector_deletion,doc_permissions_upsert,checkpoint_cleanup,index_attempt_cleanup,docprocessing,connector_doc_fetching,connector_pruning,connector_doc_permissions_sync,connector_external_group_sync,csv_generation,kg_processing,monitoring,user_file_processing,user_file_project_sync,user_file_delete,opensearch_migration"
],
"presentation": {
"group": "2"
},
"consoleTitle": "Celery background Console"
},
{
"name": "Celery heavy",
"type": "debugpy",

View File

@@ -86,37 +86,6 @@ Onyx uses Celery for asynchronous task processing with multiple specialized work
- Monitoring tasks (every 5 minutes)
- Cleanup tasks (hourly)
#### Worker Deployment Modes
Onyx supports two deployment modes for background workers, controlled by the `USE_LIGHTWEIGHT_BACKGROUND_WORKER` environment variable:
**Lightweight Mode** (default, `USE_LIGHTWEIGHT_BACKGROUND_WORKER=true`):
- Runs a single consolidated `background` worker that handles all background tasks:
- Light worker tasks (Vespa operations, permissions sync, deletion)
- Document processing (indexing pipeline)
- Document fetching (connector data retrieval)
- Pruning operations (from `heavy` worker)
- Knowledge graph processing (from `kg_processing` worker)
- Monitoring tasks (from `monitoring` worker)
- User file processing (from `user_file_processing` worker)
- Lower resource footprint (fewer worker processes)
- Suitable for smaller deployments or development environments
- Default concurrency: 20 threads (increased to handle combined workload)
**Standard Mode** (`USE_LIGHTWEIGHT_BACKGROUND_WORKER=false`):
- Runs separate specialized workers as documented above (light, docprocessing, docfetching, heavy, kg_processing, monitoring, user_file_processing)
- Better isolation and scalability
- Can scale individual workers independently based on workload
- Suitable for production deployments with higher load
The deployment mode affects:
- **Backend**: Worker processes spawned by supervisord or dev scripts
- **Helm**: Which Kubernetes deployments are created
- **Dev Environment**: Which workers `dev_run_background_jobs.py` spawns
#### Key Features
- **Thread-based Workers**: All workers use thread pools (not processes) for stability

View File

@@ -1,15 +0,0 @@
from onyx.background.celery.apps import app_base
from onyx.background.celery.apps.background import celery_app
celery_app.autodiscover_tasks(
app_base.filter_task_modules(
[
"ee.onyx.background.celery.tasks.doc_permission_syncing",
"ee.onyx.background.celery.tasks.external_group_syncing",
"ee.onyx.background.celery.tasks.cleanup",
"ee.onyx.background.celery.tasks.tenant_provisioning",
"ee.onyx.background.celery.tasks.query_history",
]
)
)

View File

@@ -1,142 +0,0 @@
from typing import Any
from typing import cast
from celery import Celery
from celery import signals
from celery import Task
from celery.apps.worker import Worker
from celery.signals import celeryd_init
from celery.signals import worker_init
from celery.signals import worker_process_init
from celery.signals import worker_ready
from celery.signals import worker_shutdown
import onyx.background.celery.apps.app_base as app_base
from onyx.background.celery.celery_utils import httpx_init_vespa_pool
from onyx.configs.app_configs import MANAGED_VESPA
from onyx.configs.app_configs import VESPA_CLOUD_CERT_PATH
from onyx.configs.app_configs import VESPA_CLOUD_KEY_PATH
from onyx.configs.constants import POSTGRES_CELERY_WORKER_BACKGROUND_APP_NAME
from onyx.db.engine.sql_engine import SqlEngine
from onyx.utils.logger import setup_logger
from shared_configs.configs import MULTI_TENANT
logger = setup_logger()
celery_app = Celery(__name__)
celery_app.config_from_object("onyx.background.celery.configs.background")
celery_app.Task = app_base.TenantAwareTask # type: ignore [misc]
@signals.task_prerun.connect
def on_task_prerun(
sender: Any | None = None,
task_id: str | None = None,
task: Task | None = None,
args: tuple | None = None,
kwargs: dict | None = None,
**kwds: Any,
) -> None:
app_base.on_task_prerun(sender, task_id, task, args, kwargs, **kwds)
@signals.task_postrun.connect
def on_task_postrun(
sender: Any | None = None,
task_id: str | None = None,
task: Task | None = None,
args: tuple | None = None,
kwargs: dict | None = None,
retval: Any | None = None,
state: str | None = None,
**kwds: Any,
) -> None:
app_base.on_task_postrun(sender, task_id, task, args, kwargs, retval, state, **kwds)
@celeryd_init.connect
def on_celeryd_init(sender: str, conf: Any = None, **kwargs: Any) -> None:
app_base.on_celeryd_init(sender, conf, **kwargs)
@worker_init.connect
def on_worker_init(sender: Worker, **kwargs: Any) -> None:
EXTRA_CONCURRENCY = 8 # small extra fudge factor for connection limits
logger.info("worker_init signal received for consolidated background worker.")
SqlEngine.set_app_name(POSTGRES_CELERY_WORKER_BACKGROUND_APP_NAME)
pool_size = cast(int, sender.concurrency) # type: ignore
SqlEngine.init_engine(pool_size=pool_size, max_overflow=EXTRA_CONCURRENCY)
# Initialize Vespa httpx pool (needed for light worker tasks)
if MANAGED_VESPA:
httpx_init_vespa_pool(
sender.concurrency + EXTRA_CONCURRENCY, # type: ignore
ssl_cert=VESPA_CLOUD_CERT_PATH,
ssl_key=VESPA_CLOUD_KEY_PATH,
)
else:
httpx_init_vespa_pool(sender.concurrency + EXTRA_CONCURRENCY) # type: ignore
app_base.wait_for_redis(sender, **kwargs)
app_base.wait_for_db(sender, **kwargs)
app_base.wait_for_vespa_or_shutdown(sender, **kwargs)
# Less startup checks in multi-tenant case
if MULTI_TENANT:
return
app_base.on_secondary_worker_init(sender, **kwargs)
@worker_ready.connect
def on_worker_ready(sender: Any, **kwargs: Any) -> None:
app_base.on_worker_ready(sender, **kwargs)
@worker_shutdown.connect
def on_worker_shutdown(sender: Any, **kwargs: Any) -> None:
app_base.on_worker_shutdown(sender, **kwargs)
@worker_process_init.connect
def init_worker(**kwargs: Any) -> None: # noqa: ARG001
SqlEngine.reset_engine()
@signals.setup_logging.connect
def on_setup_logging(
loglevel: Any, logfile: Any, format: Any, colorize: Any, **kwargs: Any
) -> None:
app_base.on_setup_logging(loglevel, logfile, format, colorize, **kwargs)
base_bootsteps = app_base.get_bootsteps()
for bootstep in base_bootsteps:
celery_app.steps["worker"].add(bootstep)
celery_app.autodiscover_tasks(
app_base.filter_task_modules(
[
# Original background worker tasks
"onyx.background.celery.tasks.pruning",
"onyx.background.celery.tasks.monitoring",
"onyx.background.celery.tasks.user_file_processing",
"onyx.background.celery.tasks.llm_model_update",
# Light worker tasks
"onyx.background.celery.tasks.shared",
"onyx.background.celery.tasks.vespa",
"onyx.background.celery.tasks.connector_deletion",
"onyx.background.celery.tasks.doc_permission_syncing",
"onyx.background.celery.tasks.opensearch_migration",
# Docprocessing worker tasks
"onyx.background.celery.tasks.docprocessing",
# Docfetching worker tasks
"onyx.background.celery.tasks.docfetching",
# Sandbox cleanup tasks (isolated in build feature)
"onyx.server.features.build.sandbox.tasks",
]
)
)

View File

@@ -1,23 +0,0 @@
import onyx.background.celery.configs.base as shared_config
from onyx.configs.app_configs import CELERY_WORKER_BACKGROUND_CONCURRENCY
broker_url = shared_config.broker_url
broker_connection_retry_on_startup = shared_config.broker_connection_retry_on_startup
broker_pool_limit = shared_config.broker_pool_limit
broker_transport_options = shared_config.broker_transport_options
redis_socket_keepalive = shared_config.redis_socket_keepalive
redis_retry_on_timeout = shared_config.redis_retry_on_timeout
redis_backend_health_check_interval = shared_config.redis_backend_health_check_interval
result_backend = shared_config.result_backend
result_expires = shared_config.result_expires # 86400 seconds is the default
task_default_priority = shared_config.task_default_priority
task_acks_late = shared_config.task_acks_late
worker_concurrency = CELERY_WORKER_BACKGROUND_CONCURRENCY
worker_pool = "threads"
# Increased from 1 to 4 to handle fast light worker tasks more efficiently
# This allows the worker to prefetch multiple tasks per thread
worker_prefetch_multiplier = 4

View File

@@ -1,10 +0,0 @@
from celery import Celery
from onyx.utils.variable_functionality import fetch_versioned_implementation
from onyx.utils.variable_functionality import set_is_ee_based_on_env_variable
set_is_ee_based_on_env_variable()
app: Celery = fetch_versioned_implementation(
"onyx.background.celery.apps.background",
"celery_app",
)

View File

@@ -495,14 +495,7 @@ CELERY_WORKER_PRIMARY_POOL_OVERFLOW = int(
os.environ.get("CELERY_WORKER_PRIMARY_POOL_OVERFLOW") or 4
)
# Consolidated background worker (light, docprocessing, docfetching, heavy, monitoring, user_file_processing)
# separate workers' defaults: light=24, docprocessing=6, docfetching=1, heavy=4, kg=2, monitoring=1, user_file=2
# Total would be 40, but we use a more conservative default of 20 for the consolidated worker
CELERY_WORKER_BACKGROUND_CONCURRENCY = int(
os.environ.get("CELERY_WORKER_BACKGROUND_CONCURRENCY") or 20
)
# Individual worker concurrency settings (used when USE_LIGHTWEIGHT_BACKGROUND_WORKER is False or on Kuberenetes deployments)
# Individual worker concurrency settings
CELERY_WORKER_HEAVY_CONCURRENCY = int(
os.environ.get("CELERY_WORKER_HEAVY_CONCURRENCY") or 4
)

View File

@@ -84,7 +84,6 @@ POSTGRES_CELERY_WORKER_LIGHT_APP_NAME = "celery_worker_light"
POSTGRES_CELERY_WORKER_DOCPROCESSING_APP_NAME = "celery_worker_docprocessing"
POSTGRES_CELERY_WORKER_DOCFETCHING_APP_NAME = "celery_worker_docfetching"
POSTGRES_CELERY_WORKER_INDEXING_CHILD_APP_NAME = "celery_worker_indexing_child"
POSTGRES_CELERY_WORKER_BACKGROUND_APP_NAME = "celery_worker_background"
POSTGRES_CELERY_WORKER_HEAVY_APP_NAME = "celery_worker_heavy"
POSTGRES_CELERY_WORKER_MONITORING_APP_NAME = "celery_worker_monitoring"
POSTGRES_CELERY_WORKER_USER_FILE_PROCESSING_APP_NAME = (

View File

@@ -16,10 +16,6 @@ def monitor_process(process_name: str, process: subprocess.Popen) -> None:
def run_jobs() -> None:
# Check if we should use lightweight mode, defaults to True, change to False to use separate background workers
use_lightweight = True
# command setup
cmd_worker_primary = [
"celery",
"-A",
@@ -74,6 +70,48 @@ def run_jobs() -> None:
"--queues=connector_doc_fetching",
]
cmd_worker_heavy = [
"celery",
"-A",
"onyx.background.celery.versioned_apps.heavy",
"worker",
"--pool=threads",
"--concurrency=4",
"--prefetch-multiplier=1",
"--loglevel=INFO",
"--hostname=heavy@%n",
"-Q",
"connector_pruning,connector_doc_permissions_sync,connector_external_group_sync,csv_generation,sandbox",
]
cmd_worker_monitoring = [
"celery",
"-A",
"onyx.background.celery.versioned_apps.monitoring",
"worker",
"--pool=threads",
"--concurrency=1",
"--prefetch-multiplier=1",
"--loglevel=INFO",
"--hostname=monitoring@%n",
"-Q",
"monitoring",
]
cmd_worker_user_file_processing = [
"celery",
"-A",
"onyx.background.celery.versioned_apps.user_file_processing",
"worker",
"--pool=threads",
"--concurrency=2",
"--prefetch-multiplier=1",
"--loglevel=INFO",
"--hostname=user_file_processing@%n",
"-Q",
"user_file_processing,user_file_project_sync,user_file_delete",
]
cmd_beat = [
"celery",
"-A",
@@ -82,144 +120,31 @@ def run_jobs() -> None:
"--loglevel=INFO",
]
# Prepare background worker commands based on mode
if use_lightweight:
print("Starting workers in LIGHTWEIGHT mode (single background worker)")
cmd_worker_background = [
"celery",
"-A",
"onyx.background.celery.versioned_apps.background",
"worker",
"--pool=threads",
"--concurrency=6",
"--prefetch-multiplier=1",
"--loglevel=INFO",
"--hostname=background@%n",
"-Q",
"connector_pruning,connector_doc_permissions_sync,connector_external_group_sync,csv_generation,monitoring,user_file_processing,user_file_project_sync,user_file_delete,opensearch_migration",
]
background_workers = [("BACKGROUND", cmd_worker_background)]
else:
print("Starting workers in STANDARD mode (separate background workers)")
cmd_worker_heavy = [
"celery",
"-A",
"onyx.background.celery.versioned_apps.heavy",
"worker",
"--pool=threads",
"--concurrency=4",
"--prefetch-multiplier=1",
"--loglevel=INFO",
"--hostname=heavy@%n",
"-Q",
"connector_pruning,sandbox",
]
cmd_worker_monitoring = [
"celery",
"-A",
"onyx.background.celery.versioned_apps.monitoring",
"worker",
"--pool=threads",
"--concurrency=1",
"--prefetch-multiplier=1",
"--loglevel=INFO",
"--hostname=monitoring@%n",
"-Q",
"monitoring",
]
cmd_worker_user_file_processing = [
"celery",
"-A",
"onyx.background.celery.versioned_apps.user_file_processing",
"worker",
"--pool=threads",
"--concurrency=2",
"--prefetch-multiplier=1",
"--loglevel=INFO",
"--hostname=user_file_processing@%n",
"-Q",
"user_file_processing,user_file_project_sync,connector_doc_permissions_sync,connector_external_group_sync,csv_generation,user_file_delete",
]
background_workers = [
("HEAVY", cmd_worker_heavy),
("MONITORING", cmd_worker_monitoring),
("USER_FILE_PROCESSING", cmd_worker_user_file_processing),
]
all_workers = [
("PRIMARY", cmd_worker_primary),
("LIGHT", cmd_worker_light),
("DOCPROCESSING", cmd_worker_docprocessing),
("DOCFETCHING", cmd_worker_docfetching),
("HEAVY", cmd_worker_heavy),
("MONITORING", cmd_worker_monitoring),
("USER_FILE_PROCESSING", cmd_worker_user_file_processing),
("BEAT", cmd_beat),
]
# spawn processes
worker_primary_process = subprocess.Popen(
cmd_worker_primary, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
)
worker_light_process = subprocess.Popen(
cmd_worker_light, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
)
worker_docprocessing_process = subprocess.Popen(
cmd_worker_docprocessing,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
worker_docfetching_process = subprocess.Popen(
cmd_worker_docfetching,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
beat_process = subprocess.Popen(
cmd_beat, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
)
# Spawn background worker processes based on mode
background_processes = []
for name, cmd in background_workers:
processes = []
for name, cmd in all_workers:
process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
)
background_processes.append((name, process))
processes.append((name, process))
# monitor threads
worker_primary_thread = threading.Thread(
target=monitor_process, args=("PRIMARY", worker_primary_process)
)
worker_light_thread = threading.Thread(
target=monitor_process, args=("LIGHT", worker_light_process)
)
worker_docprocessing_thread = threading.Thread(
target=monitor_process, args=("DOCPROCESSING", worker_docprocessing_process)
)
worker_docfetching_thread = threading.Thread(
target=monitor_process, args=("DOCFETCHING", worker_docfetching_process)
)
beat_thread = threading.Thread(target=monitor_process, args=("BEAT", beat_process))
# Create monitor threads for background workers
background_threads = []
for name, process in background_processes:
threads = []
for name, process in processes:
thread = threading.Thread(target=monitor_process, args=(name, process))
background_threads.append(thread)
# Start all threads
worker_primary_thread.start()
worker_light_thread.start()
worker_docprocessing_thread.start()
worker_docfetching_thread.start()
beat_thread.start()
for thread in background_threads:
threads.append(thread)
thread.start()
# Wait for all threads
worker_primary_thread.join()
worker_light_thread.join()
worker_docprocessing_thread.join()
worker_docfetching_thread.join()
beat_thread.join()
for thread in background_threads:
for thread in threads:
thread.join()

View File

@@ -1,23 +1,5 @@
#!/bin/sh
# Entrypoint script for supervisord that sets environment variables
# for controlling which celery workers to start
# Default to lightweight mode if not set
if [ -z "$USE_LIGHTWEIGHT_BACKGROUND_WORKER" ]; then
export USE_LIGHTWEIGHT_BACKGROUND_WORKER="true"
fi
# Set the complementary variable for supervisord
# because it doesn't support %(not ENV_USE_LIGHTWEIGHT_BACKGROUND_WORKER) syntax
if [ "$USE_LIGHTWEIGHT_BACKGROUND_WORKER" = "true" ]; then
export USE_SEPARATE_BACKGROUND_WORKERS="false"
else
export USE_SEPARATE_BACKGROUND_WORKERS="true"
fi
echo "Worker mode configuration:"
echo " USE_LIGHTWEIGHT_BACKGROUND_WORKER=$USE_LIGHTWEIGHT_BACKGROUND_WORKER"
echo " USE_SEPARATE_BACKGROUND_WORKERS=$USE_SEPARATE_BACKGROUND_WORKERS"
# Entrypoint script for supervisord
# Launch supervisord with environment variables available
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf

View File

@@ -39,7 +39,6 @@ autorestart=true
startsecs=10
stopasgroup=true
# Standard mode: Light worker for fast operations
# NOTE: only allowing configuration here and not in the other celery workers,
# since this is often the bottleneck for "sync" jobs (e.g. document set syncing,
# user group syncing, deletion, etc.)
@@ -54,26 +53,7 @@ redirect_stderr=true
autorestart=true
startsecs=10
stopasgroup=true
autostart=%(ENV_USE_SEPARATE_BACKGROUND_WORKERS)s
# Lightweight mode: single consolidated background worker
# Used when USE_LIGHTWEIGHT_BACKGROUND_WORKER=true (default)
# Consolidates: light, docprocessing, docfetching, heavy, monitoring, user_file_processing
[program:celery_worker_background]
command=celery -A onyx.background.celery.versioned_apps.background worker
--loglevel=INFO
--hostname=background@%%n
-Q vespa_metadata_sync,connector_deletion,doc_permissions_upsert,checkpoint_cleanup,index_attempt_cleanup,sandbox,docprocessing,connector_doc_fetching,connector_pruning,connector_doc_permissions_sync,connector_external_group_sync,csv_generation,monitoring,user_file_processing,user_file_project_sync,opensearch_migration
stdout_logfile=/var/log/celery_worker_background.log
stdout_logfile_maxbytes=16MB
redirect_stderr=true
autorestart=true
startsecs=10
stopasgroup=true
autostart=%(ENV_USE_LIGHTWEIGHT_BACKGROUND_WORKER)s
# Standard mode: separate workers for different background tasks
# Used when USE_LIGHTWEIGHT_BACKGROUND_WORKER=false
[program:celery_worker_heavy]
command=celery -A onyx.background.celery.versioned_apps.heavy worker
--loglevel=INFO
@@ -85,9 +65,7 @@ redirect_stderr=true
autorestart=true
startsecs=10
stopasgroup=true
autostart=%(ENV_USE_SEPARATE_BACKGROUND_WORKERS)s
# Standard mode: Document processing worker
[program:celery_worker_docprocessing]
command=celery -A onyx.background.celery.versioned_apps.docprocessing worker
--loglevel=INFO
@@ -99,7 +77,6 @@ redirect_stderr=true
autorestart=true
startsecs=10
stopasgroup=true
autostart=%(ENV_USE_SEPARATE_BACKGROUND_WORKERS)s
[program:celery_worker_user_file_processing]
command=celery -A onyx.background.celery.versioned_apps.user_file_processing worker
@@ -112,9 +89,7 @@ redirect_stderr=true
autorestart=true
startsecs=10
stopasgroup=true
autostart=%(ENV_USE_SEPARATE_BACKGROUND_WORKERS)s
# Standard mode: Document fetching worker
[program:celery_worker_docfetching]
command=celery -A onyx.background.celery.versioned_apps.docfetching worker
--loglevel=INFO
@@ -126,7 +101,6 @@ redirect_stderr=true
autorestart=true
startsecs=10
stopasgroup=true
autostart=%(ENV_USE_SEPARATE_BACKGROUND_WORKERS)s
[program:celery_worker_monitoring]
command=celery -A onyx.background.celery.versioned_apps.monitoring worker
@@ -139,7 +113,6 @@ redirect_stderr=true
autorestart=true
startsecs=10
stopasgroup=true
autostart=%(ENV_USE_SEPARATE_BACKGROUND_WORKERS)s
# Job scheduler for periodic tasks
@@ -197,7 +170,6 @@ command=tail -qF
/var/log/celery_beat.log
/var/log/celery_worker_primary.log
/var/log/celery_worker_light.log
/var/log/celery_worker_background.log
/var/log/celery_worker_heavy.log
/var/log/celery_worker_docprocessing.log
/var/log/celery_worker_monitoring.log

View File

@@ -138,7 +138,6 @@ services:
- indexing_model_server
restart: unless-stopped
environment:
- USE_LIGHTWEIGHT_BACKGROUND_WORKER=${USE_LIGHTWEIGHT_BACKGROUND_WORKER:-true}
- ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true
- MULTI_TENANT=true
- LOG_LEVEL=DEBUG

View File

@@ -52,7 +52,6 @@ services:
- indexing_model_server
restart: unless-stopped
environment:
- USE_LIGHTWEIGHT_BACKGROUND_WORKER=${USE_LIGHTWEIGHT_BACKGROUND_WORKER:-true}
- AUTH_TYPE=${AUTH_TYPE:-oidc}
- POSTGRES_HOST=relational_db
- VESPA_HOST=index

View File

@@ -65,7 +65,6 @@ services:
- indexing_model_server
restart: unless-stopped
environment:
- USE_LIGHTWEIGHT_BACKGROUND_WORKER=${USE_LIGHTWEIGHT_BACKGROUND_WORKER:-true}
- AUTH_TYPE=${AUTH_TYPE:-oidc}
- POSTGRES_HOST=relational_db
- VESPA_HOST=index

View File

@@ -70,7 +70,6 @@ services:
- indexing_model_server
restart: unless-stopped
environment:
- USE_LIGHTWEIGHT_BACKGROUND_WORKER=${USE_LIGHTWEIGHT_BACKGROUND_WORKER:-true}
- AUTH_TYPE=${AUTH_TYPE:-oidc}
- POSTGRES_HOST=relational_db
- VESPA_HOST=index

View File

@@ -58,7 +58,6 @@ services:
env_file:
- .env_eval
environment:
- USE_LIGHTWEIGHT_BACKGROUND_WORKER=${USE_LIGHTWEIGHT_BACKGROUND_WORKER:-true}
- AUTH_TYPE=disabled
- POSTGRES_HOST=relational_db
- VESPA_HOST=index

View File

@@ -146,7 +146,6 @@ services:
- indexing_model_server
restart: unless-stopped
environment:
- USE_LIGHTWEIGHT_BACKGROUND_WORKER=${USE_LIGHTWEIGHT_BACKGROUND_WORKER:-true}
- FILE_STORE_BACKEND=${FILE_STORE_BACKEND:-s3}
- POSTGRES_HOST=${POSTGRES_HOST:-relational_db}
- VESPA_HOST=${VESPA_HOST:-index}

View File

@@ -143,7 +143,6 @@ module.exports = {
"**/src/app/**/utils/*.test.ts",
"**/src/app/**/hooks/*.test.ts", // Pure packet processor tests
"**/src/refresh-components/**/*.test.ts",
"**/src/sections/**/*.test.ts",
// Add more patterns here as you add more unit tests
],
},
@@ -157,8 +156,6 @@ module.exports = {
"**/src/components/**/*.test.tsx",
"**/src/lib/**/*.test.tsx",
"**/src/refresh-components/**/*.test.tsx",
"**/src/hooks/**/*.test.tsx",
"**/src/sections/**/*.test.tsx",
// Add more patterns here as you add more integration tests
],
},

View File

@@ -1,87 +0,0 @@
# IllustrationContent
**Import:** `import { IllustrationContent, type IllustrationContentProps } from "@opal/layouts";`
A vertically-stacked, center-aligned layout for empty states, error pages, and informational placeholders. Pairs a large illustration with a title and optional description.
## Why IllustrationContent?
Empty states and placeholder screens share a recurring pattern: a large illustration centered above a title and description. `IllustrationContent` standardises that pattern so every empty state looks consistent without hand-rolling flex containers and spacing each time.
## Layout Structure
```
┌─────────────────────────────────┐
│ (1.25rem pad) │
│ ┌───────────────────┐ │
│ │ illustration │ │
│ │ 7.5rem × 7.5rem │ │
│ └───────────────────┘ │
│ (0.75rem gap) │
│ title (center) │
│ (0.75rem gap) │
│ description (center) │
│ (1.25rem pad) │
└─────────────────────────────────┘
```
- Outer container: `flex flex-col items-center gap-3 p-5 text-center`.
- Illustration: `w-[7.5rem] h-[7.5rem]` (120px), no extra padding.
- Title: `<p>` with `font-main-content-emphasis text-text-04`.
- Description: `<p>` with `font-secondary-body text-text-03`.
## Props
| Prop | Type | Default | Description |
|---|---|---|---|
| `illustration` | `IconFunctionComponent` | — | Optional illustration component rendered at 7.5rem × 7.5rem, centered. Works with any `@opal/illustrations` SVG. |
| `title` | `string` | **(required)** | Main title text, center-aligned. |
| `description` | `string` | — | Optional description below the title, center-aligned. |
## Usage Examples
### Empty search results
```tsx
import { IllustrationContent } from "@opal/layouts";
import SvgNoResult from "@opal/illustrations/no-result";
<IllustrationContent
illustration={SvgNoResult}
title="No results found"
description="Try adjusting your search or filters."
/>
```
### Not found page
```tsx
import { IllustrationContent } from "@opal/layouts";
import SvgNotFound from "@opal/illustrations/not-found";
<IllustrationContent
illustration={SvgNotFound}
title="Page not found"
description="The page you're looking for doesn't exist or has been moved."
/>
```
### Title only (no illustration, no description)
```tsx
import { IllustrationContent } from "@opal/layouts";
<IllustrationContent title="Nothing here yet" />
```
### Empty state with illustration and title (no description)
```tsx
import { IllustrationContent } from "@opal/layouts";
import SvgEmpty from "@opal/illustrations/empty";
<IllustrationContent
illustration={SvgEmpty}
title="No items"
/>
```

View File

@@ -1,83 +0,0 @@
import type { IconFunctionComponent } from "@opal/types";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface IllustrationContentProps {
/** Optional illustration rendered at 7.5rem × 7.5rem (120px), centered. */
illustration?: IconFunctionComponent;
/** Main title text, center-aligned. Uses `font-main-content-emphasis`. */
title: string;
/** Optional description below the title, center-aligned. Uses `font-secondary-body`. */
description?: string;
}
// ---------------------------------------------------------------------------
// IllustrationContent
// ---------------------------------------------------------------------------
/**
* A vertically-stacked, center-aligned layout for empty states, error pages,
* and informational placeholders.
*
* Renders an optional illustration on top, followed by a title and an optional
* description — all center-aligned with consistent spacing.
*
* **Layout structure:**
*
* ```
* ┌─────────────────────────────────┐
* │ (1.25rem pad) │
* │ ┌───────────────────┐ │
* │ │ illustration │ │
* │ │ 7.5rem × 7.5rem │ │
* │ └───────────────────┘ │
* │ (0.75rem gap) │
* │ title (center) │
* │ (0.75rem gap) │
* │ description (center) │
* │ (1.25rem pad) │
* └─────────────────────────────────┘
* ```
*
* @example
* ```tsx
* import { IllustrationContent } from "@opal/layouts";
* import SvgNoResult from "@opal/illustrations/no-result";
*
* <IllustrationContent
* illustration={SvgNoResult}
* title="No results found"
* description="Try adjusting your search or filters."
* />
* ```
*/
function IllustrationContent({
illustration: Illustration,
title,
description,
}: IllustrationContentProps) {
return (
<div className="flex flex-col items-center gap-3 p-5 text-center">
{Illustration && (
<Illustration
aria-hidden="true"
className="shrink-0 w-[7.5rem] h-[7.5rem]"
/>
)}
<p className="font-main-content-emphasis text-text-04">{title}</p>
{description && (
<p className="font-secondary-body text-text-03">{description}</p>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
export { IllustrationContent, type IllustrationContentProps };

View File

@@ -1,8 +1,8 @@
# @opal/layouts
**Import:** `import { Content, ContentAction, IllustrationContent } from "@opal/layouts";`
**Import:** `import { Content, ContentAction } from "@opal/layouts";`
Layout primitives for composing content blocks. These components handle sizing, font selection, icon alignment, and optional inline editing — things that are tedious to get right by hand and easy to get wrong.
Layout primitives for composing icon + title + description rows. These components handle sizing, font selection, icon alignment, and optional inline editing — things that are tedious to get right by hand and easy to get wrong.
## Components
@@ -10,15 +10,13 @@ Layout primitives for composing content blocks. These components handle sizing,
|---|---|---|
| [`Content`](./Content/README.md) | Icon + title + description row. Routes to an internal layout (`ContentXl`, `ContentLg`, `ContentMd`, or `ContentSm`) based on `sizePreset` and `variant`. | [Content README](./Content/README.md) |
| [`ContentAction`](./ContentAction/README.md) | Wraps `Content` in a flex-row with an optional `rightChildren` slot for action buttons. Adds padding alignment via the shared `SizeVariant` scale. | [ContentAction README](./ContentAction/README.md) |
| [`IllustrationContent`](./IllustrationContent/README.md) | Center-aligned illustration + title + description stack for empty states, error pages, and placeholders. | [IllustrationContent README](./IllustrationContent/README.md) |
## Quick Start
```tsx
import { Content, ContentAction, IllustrationContent } from "@opal/layouts";
import { Content, ContentAction } from "@opal/layouts";
import { Button } from "@opal/components";
import SvgSettings from "@opal/icons/settings";
import SvgNoResult from "@opal/illustrations/no-result";
// Simple heading
<Content
@@ -51,13 +49,6 @@ import SvgNoResult from "@opal/illustrations/no-result";
<Button icon={SvgSettings} prominence="tertiary" />
}
/>
// Empty state with illustration
<IllustrationContent
illustration={SvgNoResult}
title="No results found"
description="Try adjusting your search or filters."
/>
```
## Architecture
@@ -83,12 +74,10 @@ From `@opal/layouts`:
// Components
Content
ContentAction
IllustrationContent
// Types
ContentProps
ContentActionProps
IllustrationContentProps
SizePreset
ContentVariant
```

View File

@@ -11,9 +11,3 @@ export {
ContentAction,
type ContentActionProps,
} from "@opal/layouts/ContentAction/components";
/* IllustrationContent */
export {
IllustrationContent,
type IllustrationContentProps,
} from "@opal/layouts/IllustrationContent/components";

View File

@@ -23,7 +23,7 @@ import { LLM_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import {
buildInitialValues,
testApiKeyHelper,
} from "@/sections/onboarding/components/llmConnectionHelpers";
} from "@/refresh-components/onboarding/components/llmConnectionHelpers";
import OnboardingInfoPages from "@/app/craft/onboarding/components/OnboardingInfoPages";
import OnboardingUserInfo from "@/app/craft/onboarding/components/OnboardingUserInfo";
import OnboardingLlmSetup, {

View File

@@ -1,259 +1,17 @@
"use client";
import { useReducer, useCallback, useEffect, useRef, useState } from "react";
import { onboardingReducer, initialState } from "@/sections/onboarding/reducer";
import {
OnboardingActions,
OnboardingActionType,
OnboardingData,
OnboardingState,
OnboardingStep,
} from "@/interfaces/onboarding";
import { WellKnownLLMProviderDescriptor } from "@/interfaces/llm";
import { updateUserPersonalization } from "@/lib/userSettings";
import { useUser } from "@/providers/UserProvider";
import { useCallback, useEffect, useRef, useState } from "react";
import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces";
import { useLLMProviders } from "@/hooks/useLLMProviders";
import { useProviderStatus } from "@/components/chat/ProviderContext";
import { useOnboardingState } from "@/refresh-components/onboarding/useOnboardingState";
function getOnboardingCompletedKey(userId: string): string {
return `onyx:onboardingCompleted:${userId}`;
}
function useOnboardingState(liveAgent?: MinimalPersonaSnapshot): {
state: OnboardingState;
llmDescriptors: WellKnownLLMProviderDescriptor[];
actions: OnboardingActions;
isLoading: boolean;
hasProviders: boolean;
} {
const [state, dispatch] = useReducer(onboardingReducer, initialState);
const { user, refreshUser } = useUser();
// Get provider data from ProviderContext instead of duplicating the call
const {
llmProviders,
isLoadingProviders,
hasProviders: hasLlmProviders,
providerOptions,
refreshProviderInfo,
} = useProviderStatus();
// Only fetch persona-specific providers (different endpoint)
const { refetch: refreshPersonaProviders } = useLLMProviders(liveAgent?.id);
const userName = user?.personalization?.name;
const llmDescriptors = providerOptions;
const nameUpdateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null
);
const hasInitializedForUserRef = useRef<string | undefined>(undefined);
// Initialize onboarding to the earliest incomplete step — runs once per user
// after both user data and provider data have loaded. After initialization,
// user actions (Next / Prev / goToStep) drive navigation; the effect never
// re-runs so it cannot override user-driven state (e.g. button active).
useEffect(() => {
if (
isLoadingProviders ||
!user ||
hasInitializedForUserRef.current === user.id
) {
return;
}
hasInitializedForUserRef.current = user.id;
// Pre-populate state with existing data
if (userName) {
dispatch({
type: OnboardingActionType.UPDATE_DATA,
payload: { userName },
});
}
dispatch({
type: OnboardingActionType.UPDATE_DATA,
payload: { llmProviders: (llmProviders ?? []).map((p) => p.provider) },
});
// Determine the earliest incomplete step
// Name step is incomplete if userName is not set
if (!userName) {
// Stay at Welcome/Name step (no dispatch needed, this is the initial state)
return;
}
// LlmSetup step is incomplete if no LLM providers are configured
if (!hasLlmProviders) {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: false,
});
dispatch({
type: OnboardingActionType.GO_TO_STEP,
step: OnboardingStep.LlmSetup,
});
return;
}
// All steps complete - go to Complete step
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: true,
});
dispatch({
type: OnboardingActionType.GO_TO_STEP,
step: OnboardingStep.Complete,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [llmProviders, isLoadingProviders, userName, hasLlmProviders, user]);
const nextStep = useCallback(() => {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: false,
});
if (state.currentStep === OnboardingStep.Name) {
const hasProviders = (state.data.llmProviders?.length ?? 0) > 0;
if (hasProviders) {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: true,
});
} else {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: false,
});
}
}
if (state.currentStep === OnboardingStep.LlmSetup) {
refreshProviderInfo();
if (liveAgent) {
refreshPersonaProviders();
}
}
dispatch({ type: OnboardingActionType.NEXT_STEP });
}, [state, refreshProviderInfo, refreshPersonaProviders, liveAgent]);
const prevStep = useCallback(() => {
dispatch({ type: OnboardingActionType.PREV_STEP });
}, []);
const goToStep = useCallback(
(step: OnboardingStep) => {
const hasProviders = (state.data.llmProviders?.length ?? 0) > 0;
if (step === OnboardingStep.LlmSetup && hasProviders) {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: true,
});
} else if (step === OnboardingStep.LlmSetup) {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: false,
});
}
dispatch({ type: OnboardingActionType.GO_TO_STEP, step });
},
[state]
);
const updateName = useCallback(
(name: string) => {
dispatch({
type: OnboardingActionType.UPDATE_DATA,
payload: { userName: name },
});
if (nameUpdateTimeoutRef.current) {
clearTimeout(nameUpdateTimeoutRef.current);
}
if (name === "") {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: false,
});
} else {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: true,
});
}
nameUpdateTimeoutRef.current = setTimeout(async () => {
try {
await updateUserPersonalization({ name });
await refreshUser();
} catch (_e) {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: false,
});
console.error("Error updating user name:", _e);
} finally {
nameUpdateTimeoutRef.current = null;
}
}, 500);
},
[refreshUser]
);
const updateData = useCallback((data: Partial<OnboardingData>) => {
dispatch({ type: OnboardingActionType.UPDATE_DATA, payload: data });
}, []);
const setLoading = useCallback((isLoading: boolean) => {
dispatch({ type: OnboardingActionType.SET_LOADING, isLoading });
}, []);
const setButtonActive = useCallback((active: boolean) => {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: active,
});
}, []);
const setError = useCallback((error: string | undefined) => {
dispatch({ type: OnboardingActionType.SET_ERROR, error });
}, []);
const reset = useCallback(() => {
dispatch({ type: OnboardingActionType.RESET });
}, []);
useEffect(() => {
return () => {
if (nameUpdateTimeoutRef.current) {
clearTimeout(nameUpdateTimeoutRef.current);
}
};
}, []);
return {
state,
llmDescriptors,
actions: {
nextStep,
prevStep,
goToStep,
setButtonActive,
updateName,
updateData,
setLoading,
setError,
reset,
},
isLoading: isLoadingProviders,
hasProviders: hasLlmProviders,
};
}
interface UseShowOnboardingParams {
liveAgent: MinimalPersonaSnapshot | undefined;
isLoadingProviders: boolean;
hasAnyProvider: boolean | undefined;
isLoadingChatSessions: boolean;
chatSessionsCount: number;
userId: string | undefined;
@@ -261,6 +19,8 @@ interface UseShowOnboardingParams {
export function useShowOnboarding({
liveAgent,
isLoadingProviders,
hasAnyProvider,
isLoadingChatSessions,
chatSessionsCount,
userId,
@@ -276,17 +36,14 @@ export function useShowOnboarding({
setOnboardingDismissed(dismissed);
}, [userId]);
// Initialize onboarding state — single source of truth for provider data
// Initialize onboarding state
const {
state: onboardingState,
actions: onboardingActions,
llmDescriptors,
isLoading: isLoadingOnboarding,
hasProviders: hasAnyProvider,
} = useOnboardingState(liveAgent);
const isLoadingProviders = isLoadingOnboarding;
// Track which user we've already evaluated onboarding for.
// Re-check when userId changes (logout/login, account switching without full reload).
const hasCheckedOnboardingForUserId = useRef<string | undefined>(undefined);

View File

@@ -1,15 +1,9 @@
"use client";
import { memo } from "react";
import OnboardingHeader from "./components/OnboardingHeader";
import NameStep from "./steps/NameStep";
import LLMStep from "./steps/LLMStep";
import FinalStep from "./steps/FinalStep";
import {
OnboardingActions,
OnboardingState,
OnboardingStep,
} from "@/interfaces/onboarding";
import { OnboardingActions, OnboardingState, OnboardingStep } from "./types";
import { WellKnownLLMProviderDescriptor } from "@/interfaces/llm";
import { useUser } from "@/providers/UserProvider";
import { UserRole } from "@/lib/types";
@@ -33,12 +27,9 @@ const OnboardingFlowInner = ({
llmDescriptors,
}: OnboardingFlowProps) => {
const { user } = useUser();
if (!user) return null;
const hasStarted = onboardingState.currentStep !== OnboardingStep.Welcome;
return user.role === UserRole.ADMIN ? (
return user?.role === UserRole.ADMIN ? (
showOnboarding ? (
<div
className="flex flex-col items-center justify-center w-full max-w-[var(--app-page-main-content-width)] gap-2 mb-4"
@@ -83,7 +74,7 @@ const OnboardingFlowInner = ({
// if the admin hasn't set their name.
<NonAdminStep />
)
) : !user.personalization?.name ? (
) : !user?.personalization?.name ? (
<NonAdminStep />
) : null;
};

View File

@@ -3,7 +3,7 @@ import {
OnboardingActionType,
OnboardingStep,
OnboardingState,
} from "@/interfaces/onboarding";
} from "../types";
describe("onboardingReducer", () => {
describe("initial state", () => {

View File

@@ -2,39 +2,39 @@ import React from "react";
import { renderHook, act } from "@testing-library/react";
import "@testing-library/jest-dom";
import { useShowOnboarding } from "@/hooks/useShowOnboarding";
import { OnboardingStep } from "@/interfaces/onboarding";
import { OnboardingStep } from "../types";
// Mock underlying dependencies used by the inlined useOnboardingState
jest.mock("@/providers/UserProvider", () => ({
useUser: () => ({
user: null,
refreshUser: jest.fn(),
}),
}));
// Configurable mock for useProviderStatus
const mockProviderStatus = {
llmProviders: [] as unknown[],
isLoadingProviders: false,
hasProviders: false,
providerOptions: [],
refreshProviderInfo: jest.fn(),
// Mock useOnboardingState to isolate useShowOnboarding logic
const mockActions = {
nextStep: jest.fn(),
prevStep: jest.fn(),
goToStep: jest.fn(),
setButtonActive: jest.fn(),
updateName: jest.fn(),
updateData: jest.fn(),
setLoading: jest.fn(),
setError: jest.fn(),
reset: jest.fn(),
};
jest.mock("@/components/chat/ProviderContext", () => ({
useProviderStatus: () => mockProviderStatus,
}));
let mockStepIndex = 0;
jest.mock("@/hooks/useLLMProviders", () => ({
useLLMProviders: () => ({
refetch: jest.fn(),
jest.mock("@/refresh-components/onboarding/useOnboardingState", () => ({
useOnboardingState: () => ({
state: {
currentStep: OnboardingStep.Welcome,
stepIndex: mockStepIndex,
totalSteps: 3,
data: {},
isButtonActive: true,
isLoading: false,
},
llmDescriptors: [],
actions: mockActions,
isLoading: false,
}),
}));
jest.mock("@/lib/userSettings", () => ({
updateUserPersonalization: jest.fn(),
}));
function renderUseShowOnboarding(
overrides: {
isLoadingProviders?: boolean;
@@ -44,18 +44,14 @@ function renderUseShowOnboarding(
userId?: string;
} = {}
) {
// Configure the provider mock based on overrides
mockProviderStatus.isLoadingProviders = overrides.isLoadingProviders ?? false;
mockProviderStatus.hasProviders = overrides.hasAnyProvider ?? false;
mockProviderStatus.llmProviders = overrides.hasAnyProvider
? [{ provider: "openai" }]
: [];
const defaultParams = {
liveAgent: undefined as undefined,
isLoadingChatSessions: overrides.isLoadingChatSessions ?? false,
chatSessionsCount: overrides.chatSessionsCount ?? 0,
userId: "userId" in overrides ? overrides.userId : "user-1",
liveAgent: undefined,
isLoadingProviders: false,
hasAnyProvider: false,
isLoadingChatSessions: false,
chatSessionsCount: 0,
userId: "user-1",
...overrides,
};
return renderHook((props) => useShowOnboarding(props), {
@@ -67,11 +63,7 @@ describe("useShowOnboarding", () => {
beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
// Reset mock to defaults
mockProviderStatus.llmProviders = [];
mockProviderStatus.isLoadingProviders = false;
mockProviderStatus.hasProviders = false;
mockProviderStatus.providerOptions = [];
mockStepIndex = 0;
});
it("returns showOnboarding=false while providers are loading", () => {
@@ -127,12 +119,11 @@ describe("useShowOnboarding", () => {
});
expect(result.current.showOnboarding).toBe(true);
// Simulate providers arriving — update the mock
mockProviderStatus.hasProviders = true;
mockProviderStatus.llmProviders = [{ provider: "openai" }];
// Re-render with same userId but provider data now available
rerender({
liveAgent: undefined,
isLoadingProviders: false,
hasAnyProvider: true,
isLoadingChatSessions: false,
chatSessionsCount: 0,
userId: "user-1",
@@ -142,6 +133,31 @@ describe("useShowOnboarding", () => {
expect(result.current.showOnboarding).toBe(false);
});
it("does not self-correct when user has advanced past Welcome step", () => {
const { result, rerender } = renderUseShowOnboarding({
hasAnyProvider: false,
chatSessionsCount: 0,
userId: "user-1",
});
expect(result.current.showOnboarding).toBe(true);
// Simulate user advancing past Welcome (e.g. they configured an LLM provider)
mockStepIndex = 1;
// Re-render with same userId but provider data now available
rerender({
liveAgent: undefined,
isLoadingProviders: false,
hasAnyProvider: true,
isLoadingChatSessions: false,
chatSessionsCount: 0,
userId: "user-1",
});
// Should stay true — user is actively using onboarding
expect(result.current.showOnboarding).toBe(true);
});
it("re-evaluates when userId changes", () => {
const { result, rerender } = renderUseShowOnboarding({
hasAnyProvider: false,
@@ -150,12 +166,11 @@ describe("useShowOnboarding", () => {
});
expect(result.current.showOnboarding).toBe(true);
// Change to a new userId with providers available — update the mock
mockProviderStatus.hasProviders = true;
mockProviderStatus.llmProviders = [{ provider: "openai" }];
// Change to a new userId with providers available
rerender({
liveAgent: undefined,
isLoadingProviders: false,
hasAnyProvider: true,
isLoadingChatSessions: false,
chatSessionsCount: 0,
userId: "user-2",
@@ -192,7 +207,7 @@ describe("useShowOnboarding", () => {
expect(result.current.showOnboarding).toBe(false);
});
it("returns onboardingState and actions", () => {
it("returns onboardingState and actions from useOnboardingState", () => {
const { result } = renderUseShowOnboarding();
expect(result.current.onboardingState.currentStep).toBe(
OnboardingStep.Welcome

View File

@@ -1,6 +1,4 @@
"use client";
import { memo, useCallback, useState } from "react";
import React, { memo, useCallback, useState } from "react";
import Text from "@/refresh-components/texts/Text";
import Truncated from "@/refresh-components/texts/Truncated";
import IconButton from "@/refresh-components/buttons/IconButton";

View File

@@ -1,12 +1,9 @@
"use client";
import React, { useRef, useState, useEffect } from "react";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import Button from "@/refresh-components/buttons/Button";
import { updateUserPersonalization } from "@/lib/userSettings";
import { useUser } from "@/providers/UserProvider";
import { toast } from "@/hooks/useToast";
import IconButton from "@/refresh-components/buttons/IconButton";
import { Button as OpalButton } from "@opal/components";
import InputAvatar from "@/refresh-components/inputs/InputAvatar";
@@ -40,13 +37,8 @@ export default function NonAdminStep() {
setSavedName(name);
setShowHeader(true);
setIsEditing(false);
// Don't call refreshUser() here — it would cause OnboardingFlow to
// unmount this component (since user.personalization.name becomes set),
// hiding the confirmation banner before the user sees it.
// refreshUser() is called in handleDismissConfirmation instead.
})
.catch((error) => {
toast.error("Failed to save name. Please try again.");
console.error(error);
});
};

View File

@@ -1,10 +1,10 @@
import React from "react";
import { STEP_CONFIG } from "@/sections/onboarding/constants";
import { STEP_CONFIG } from "@/refresh-components/onboarding/constants";
import {
OnboardingActions,
OnboardingState,
OnboardingStep,
} from "@/interfaces/onboarding";
} from "@/refresh-components/onboarding/types";
import Text from "@/refresh-components/texts/Text";
import Button from "@/refresh-components/buttons/Button";
import { Button as OpalButton } from "@opal/components";

View File

@@ -1,4 +1,7 @@
import { OnboardingStep, FinalStepItemProps } from "@/interfaces/onboarding";
import {
OnboardingStep,
FinalStepItemProps,
} from "@/refresh-components/onboarding/types";
import { SvgGlobe, SvgImage, SvgUsers } from "@opal/icons";
type StepConfig = {

View File

@@ -1,6 +1,4 @@
"use client";
import { useMemo } from "react";
import React, { useMemo } from "react";
import * as Yup from "yup";
import { FormikField } from "@/refresh-components/form/FormikField";
import { FormField } from "@/refresh-components/form/FormField";
@@ -15,7 +13,7 @@ import {
OnboardingFormWrapper,
OnboardingFormChildProps,
} from "./OnboardingFormWrapper";
import { OnboardingActions, OnboardingState } from "@/interfaces/onboarding";
import { OnboardingActions, OnboardingState } from "../types";
import { buildInitialValues } from "../components/llmConnectionHelpers";
import ConnectionProviderIcon from "@/refresh-components/ConnectionProviderIcon";
import InlineExternalLink from "@/refresh-components/InlineExternalLink";

View File

@@ -1,6 +1,4 @@
"use client";
import { useMemo } from "react";
import React, { useMemo } from "react";
import * as Yup from "yup";
import { FormikField } from "@/refresh-components/form/FormikField";
import { FormField } from "@/refresh-components/form/FormField";
@@ -13,7 +11,7 @@ import {
OnboardingFormWrapper,
OnboardingFormChildProps,
} from "./OnboardingFormWrapper";
import { OnboardingActions, OnboardingState } from "@/interfaces/onboarding";
import { OnboardingActions, OnboardingState } from "../types";
import { buildInitialValues } from "../components/llmConnectionHelpers";
import ConnectionProviderIcon from "@/refresh-components/ConnectionProviderIcon";
import InlineExternalLink from "@/refresh-components/InlineExternalLink";

View File

@@ -1,6 +1,4 @@
"use client";
import { useMemo } from "react";
import React, { useMemo } from "react";
import * as Yup from "yup";
import { FormikField } from "@/refresh-components/form/FormikField";
import { FormField } from "@/refresh-components/form/FormField";
@@ -18,7 +16,7 @@ import {
OnboardingFormWrapper,
OnboardingFormChildProps,
} from "./OnboardingFormWrapper";
import { OnboardingActions, OnboardingState } from "@/interfaces/onboarding";
import { OnboardingActions, OnboardingState } from "../types";
import { buildInitialValues } from "../components/llmConnectionHelpers";
import ConnectionProviderIcon from "@/refresh-components/ConnectionProviderIcon";
import InlineExternalLink from "@/refresh-components/InlineExternalLink";

View File

@@ -1,5 +1,3 @@
"use client";
import { useMemo, useState } from "react";
import * as Yup from "yup";
import { FormikField } from "@/refresh-components/form/FormikField";
@@ -16,7 +14,7 @@ import {
OnboardingFormWrapper,
OnboardingFormChildProps,
} from "./OnboardingFormWrapper";
import { OnboardingActions, OnboardingState } from "@/interfaces/onboarding";
import { OnboardingActions, OnboardingState } from "../types";
import { buildInitialValues } from "../components/llmConnectionHelpers";
import ConnectionProviderIcon from "@/refresh-components/ConnectionProviderIcon";

View File

@@ -17,7 +17,7 @@ import {
OnboardingFormWrapper,
OnboardingFormChildProps,
} from "./OnboardingFormWrapper";
import { OnboardingActions, OnboardingState } from "@/interfaces/onboarding";
import { OnboardingActions, OnboardingState } from "../types";
import { buildInitialValues } from "../components/llmConnectionHelpers";
import ConnectionProviderIcon from "@/refresh-components/ConnectionProviderIcon";
import InlineExternalLink from "@/refresh-components/InlineExternalLink";

View File

@@ -1,5 +1,3 @@
"use client";
import React, { useState, useMemo, ReactNode } from "react";
import { Form, Formik, FormikProps } from "formik";
import * as Yup from "yup";
@@ -12,7 +10,7 @@ import {
LLM_ADMIN_URL,
LLM_PROVIDERS_ADMIN_URL,
} from "@/lib/llmConfig/constants";
import { OnboardingActions, OnboardingState } from "@/interfaces/onboarding";
import { OnboardingActions, OnboardingState } from "../types";
import { APIFormFieldState } from "@/refresh-components/form/types";
import {
testApiKeyHelper,

View File

@@ -1,6 +1,4 @@
"use client";
import { useMemo } from "react";
import React, { useMemo } from "react";
import * as Yup from "yup";
import { FormikField } from "@/refresh-components/form/FormikField";
import { FormField } from "@/refresh-components/form/FormField";
@@ -15,7 +13,7 @@ import {
OnboardingFormWrapper,
OnboardingFormChildProps,
} from "./OnboardingFormWrapper";
import { OnboardingActions, OnboardingState } from "@/interfaces/onboarding";
import { OnboardingActions, OnboardingState } from "../types";
import { buildInitialValues } from "../components/llmConnectionHelpers";
import ConnectionProviderIcon from "@/refresh-components/ConnectionProviderIcon";
import InlineExternalLink from "@/refresh-components/InlineExternalLink";

View File

@@ -1,6 +1,4 @@
"use client";
import { useMemo } from "react";
import React, { useMemo } from "react";
import * as Yup from "yup";
import { FormikField } from "@/refresh-components/form/FormikField";
import { FormField } from "@/refresh-components/form/FormField";
@@ -15,7 +13,7 @@ import {
OnboardingFormWrapper,
OnboardingFormChildProps,
} from "./OnboardingFormWrapper";
import { OnboardingActions, OnboardingState } from "@/interfaces/onboarding";
import { OnboardingActions, OnboardingState } from "../types";
import { buildInitialValues } from "../components/llmConnectionHelpers";
import ConnectionProviderIcon from "@/refresh-components/ConnectionProviderIcon";
import InlineExternalLink from "@/refresh-components/InlineExternalLink";

View File

@@ -1,12 +1,12 @@
"use client";
import { useMemo } from "react";
import React, { useMemo } from "react";
import * as Yup from "yup";
import { FormikField } from "@/refresh-components/form/FormikField";
import { FormField } from "@/refresh-components/form/FormField";
import InputComboBox from "@/refresh-components/inputs/InputComboBox";
import InputFile from "@/refresh-components/inputs/InputFile";
import Separator from "@/refresh-components/Separator";
import { cn, noProp } from "@/lib/utils";
import { SvgRefreshCw } from "@opal/icons";
import {
ModelConfiguration,
WellKnownLLMProviderDescriptor,
@@ -15,7 +15,7 @@ import {
OnboardingFormWrapper,
OnboardingFormChildProps,
} from "./OnboardingFormWrapper";
import { OnboardingActions, OnboardingState } from "@/interfaces/onboarding";
import { OnboardingActions, OnboardingState } from "../types";
import {
buildInitialValues,
testApiKeyHelper,

View File

@@ -14,7 +14,7 @@ import {
OnboardingState,
OnboardingActions,
OnboardingStep,
} from "@/interfaces/onboarding";
} from "../../types";
/**
* Creates a mock WellKnownLLMProviderDescriptor for testing

View File

@@ -3,7 +3,7 @@ import {
WellKnownLLMProviderDescriptor,
LLMProviderName,
} from "@/interfaces/llm";
import { OnboardingActions, OnboardingState } from "@/interfaces/onboarding";
import { OnboardingActions, OnboardingState } from "../types";
import { OpenAIOnboardingForm } from "./OpenAIOnboardingForm";
import { AnthropicOnboardingForm } from "./AnthropicOnboardingForm";
import { OllamaOnboardingForm } from "./OllamaOnboardingForm";

View File

@@ -3,7 +3,7 @@ import {
OnboardingAction,
OnboardingActionType,
OnboardingStep,
} from "@/interfaces/onboarding";
} from "./types";
import { STEP_NAVIGATION, STEP_CONFIG, TOTAL_STEPS } from "./constants";
export const initialState: OnboardingState = {

View File

@@ -2,8 +2,8 @@ import React from "react";
import Link from "next/link";
import type { Route } from "next";
import Button from "@/refresh-components/buttons/Button";
import { FINAL_SETUP_CONFIG } from "@/sections/onboarding/constants";
import { FinalStepItemProps } from "@/interfaces/onboarding";
import { FINAL_SETUP_CONFIG } from "@/refresh-components/onboarding/constants";
import { FinalStepItemProps } from "@/refresh-components/onboarding/types";
import { SvgExternalLink } from "@opal/icons";
import { Section } from "@/layouts/general-layouts";
import { ContentAction } from "@opal/layouts";

View File

@@ -5,11 +5,7 @@ import Text from "@/refresh-components/texts/Text";
import Button from "@/refresh-components/buttons/Button";
import Separator from "@/refresh-components/Separator";
import LLMProviderCard from "../components/LLMProviderCard";
import {
OnboardingActions,
OnboardingState,
OnboardingStep,
} from "@/interfaces/onboarding";
import { OnboardingActions, OnboardingState, OnboardingStep } from "../types";
import { WellKnownLLMProviderDescriptor } from "@/interfaces/llm";
import {
getOnboardingForm,
@@ -141,7 +137,7 @@ const LLMStepInner = ({
tertiary
rightIcon={SvgExternalLink}
disabled={disabled}
href="/admin/configuration/llm"
href="admin/configuration/llm"
>
View in Admin Panel
</Button>

View File

@@ -3,11 +3,7 @@
import React, { useRef } from "react";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import {
OnboardingState,
OnboardingActions,
OnboardingStep,
} from "@/interfaces/onboarding";
import { OnboardingState, OnboardingActions, OnboardingStep } from "../types";
import InputAvatar from "@/refresh-components/inputs/InputAvatar";
import { cn } from "@/lib/utils";
import IconButton from "@/refresh-components/buttons/IconButton";

View File

@@ -0,0 +1,240 @@
import { useReducer, useCallback, useEffect, useRef } from "react";
import { onboardingReducer, initialState } from "./reducer";
import {
OnboardingActions,
OnboardingActionType,
OnboardingData,
OnboardingState,
OnboardingStep,
} from "./types";
import { WellKnownLLMProviderDescriptor } from "@/interfaces/llm";
import { updateUserPersonalization } from "@/lib/userSettings";
import { useUser } from "@/providers/UserProvider";
import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces";
import { useLLMProviders } from "@/hooks/useLLMProviders";
import { useProviderStatus } from "@/components/chat/ProviderContext";
export function useOnboardingState(liveAgent?: MinimalPersonaSnapshot): {
state: OnboardingState;
llmDescriptors: WellKnownLLMProviderDescriptor[];
actions: OnboardingActions;
isLoading: boolean;
} {
const [state, dispatch] = useReducer(onboardingReducer, initialState);
const { user, refreshUser } = useUser();
// Get provider data from ProviderContext instead of duplicating the call
const {
llmProviders,
isLoadingProviders,
hasProviders: hasLlmProviders,
providerOptions,
refreshProviderInfo,
} = useProviderStatus();
// Only fetch persona-specific providers (different endpoint)
const { refetch: refreshPersonaProviders } = useLLMProviders(liveAgent?.id);
const userName = user?.personalization?.name;
const llmDescriptors = providerOptions;
const nameUpdateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null
);
// Navigate to the earliest incomplete step in the onboarding flow.
// Step order: Welcome -> Name -> LlmSetup -> Complete
// We check steps in order and stop at the first incomplete one.
useEffect(() => {
// Don't run logic until data has loaded
if (isLoadingProviders) {
return;
}
// Pre-populate state with existing data
if (userName) {
dispatch({
type: OnboardingActionType.UPDATE_DATA,
payload: { userName },
});
}
if (hasLlmProviders) {
dispatch({
type: OnboardingActionType.UPDATE_DATA,
payload: { llmProviders: (llmProviders ?? []).map((p) => p.provider) },
});
}
// Determine the earliest incomplete step
// Name step is incomplete if userName is not set
if (!userName) {
// Stay at Welcome/Name step (no dispatch needed, this is the initial state)
return;
}
// LlmSetup step is incomplete if no LLM providers are configured
if (!hasLlmProviders) {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: false,
});
dispatch({
type: OnboardingActionType.GO_TO_STEP,
step: OnboardingStep.LlmSetup,
});
return;
}
// All steps complete - go to Complete step
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: true,
});
dispatch({
type: OnboardingActionType.GO_TO_STEP,
step: OnboardingStep.Complete,
});
}, [llmProviders, isLoadingProviders]);
const nextStep = useCallback(() => {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: false,
});
if (state.currentStep === OnboardingStep.Name) {
const hasProviders = (state.data.llmProviders?.length ?? 0) > 0;
if (hasProviders) {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: true,
});
} else {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: false,
});
}
}
if (state.currentStep === OnboardingStep.LlmSetup) {
refreshProviderInfo();
if (liveAgent) {
refreshPersonaProviders();
}
}
dispatch({ type: OnboardingActionType.NEXT_STEP });
}, [state, refreshProviderInfo, llmProviders, refreshPersonaProviders]);
const prevStep = useCallback(() => {
dispatch({ type: OnboardingActionType.PREV_STEP });
}, []);
const goToStep = useCallback(
(step: OnboardingStep) => {
const hasProviders = state.data.llmProviders?.length || 0 > 0;
if (step === OnboardingStep.LlmSetup && hasProviders) {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: true,
});
} else if (step === OnboardingStep.LlmSetup) {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: false,
});
}
dispatch({ type: OnboardingActionType.GO_TO_STEP, step });
},
[llmProviders]
);
const updateName = useCallback(
(name: string) => {
dispatch({
type: OnboardingActionType.UPDATE_DATA,
payload: { userName: name },
});
if (nameUpdateTimeoutRef.current) {
clearTimeout(nameUpdateTimeoutRef.current);
}
if (name === "") {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: false,
});
} else {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: true,
});
}
nameUpdateTimeoutRef.current = setTimeout(async () => {
try {
await updateUserPersonalization({ name });
await refreshUser();
} catch (_e) {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: false,
});
console.error("Error updating user name:", _e);
} finally {
nameUpdateTimeoutRef.current = null;
}
}, 500);
},
[refreshUser]
);
const updateData = useCallback((data: Partial<OnboardingData>) => {
dispatch({ type: OnboardingActionType.UPDATE_DATA, payload: data });
}, []);
const setLoading = useCallback((isLoading: boolean) => {
dispatch({ type: OnboardingActionType.SET_LOADING, isLoading });
}, []);
const setButtonActive = useCallback((active: boolean) => {
dispatch({
type: OnboardingActionType.SET_BUTTON_ACTIVE,
isButtonActive: active,
});
}, []);
const setError = useCallback((error: string | undefined) => {
dispatch({ type: OnboardingActionType.SET_ERROR, error });
}, []);
const reset = useCallback(() => {
dispatch({ type: OnboardingActionType.RESET });
}, []);
useEffect(() => {
return () => {
if (nameUpdateTimeoutRef.current) {
clearTimeout(nameUpdateTimeoutRef.current);
}
};
}, []);
return {
state,
llmDescriptors,
actions: {
nextStep,
prevStep,
goToStep,
setButtonActive,
updateName,
updateData,
setLoading,
setError,
reset,
},
isLoading: isLoadingProviders || !!liveAgent,
};
}

View File

@@ -60,8 +60,8 @@ import {
import ProjectChatSessionList from "@/app/app/components/projects/ProjectChatSessionList";
import { cn } from "@/lib/utils";
import Suggestions from "@/sections/Suggestions";
import OnboardingFlow from "@/sections/onboarding/OnboardingFlow";
import { OnboardingStep } from "@/interfaces/onboarding";
import OnboardingFlow from "@/refresh-components/onboarding/OnboardingFlow";
import { OnboardingStep } from "@/refresh-components/onboarding/types";
import { useShowOnboarding } from "@/hooks/useShowOnboarding";
import * as AppLayouts from "@/layouts/app-layouts";
import { SvgChevronDown, SvgFileText } from "@opal/icons";
@@ -232,6 +232,8 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
hideOnboarding,
} = useShowOnboarding({
liveAgent,
isLoadingProviders: llmManager.isLoadingProviders,
hasAnyProvider: llmManager.hasAnyProvider,
isLoadingChatSessions,
chatSessionsCount: chatSessions.length,
userId: user?.id,

View File

@@ -18,10 +18,7 @@ export const buildDefaultInitialValues = (
existingLlmProvider?: LLMProviderView,
modelConfigurations?: ModelConfiguration[]
) => {
const defaultModelName =
existingLlmProvider?.model_configurations?.[0]?.name ??
modelConfigurations?.[0]?.name ??
"";
const defaultModelName = modelConfigurations?.[0]?.name ?? "";
// Auto mode must be explicitly enabled by the user
// Default to false for new providers, preserve existing value when editing