mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-04 15:15:46 +00:00
Compare commits
2 Commits
main
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1db67d188 | ||
|
|
e010637a17 |
1
.github/workflows/pr-integration-tests.yml
vendored
1
.github/workflows/pr-integration-tests.yml
vendored
@@ -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
43
.vscode/launch.json
vendored
@@ -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",
|
||||
|
||||
31
AGENTS.md
31
AGENTS.md
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
)
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
```
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -11,9 +11,3 @@ export {
|
||||
ContentAction,
|
||||
type ContentActionProps,
|
||||
} from "@opal/layouts/ContentAction/components";
|
||||
|
||||
/* IllustrationContent */
|
||||
export {
|
||||
IllustrationContent,
|
||||
type IllustrationContentProps,
|
||||
} from "@opal/layouts/IllustrationContent/components";
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
OnboardingActionType,
|
||||
OnboardingStep,
|
||||
OnboardingState,
|
||||
} from "@/interfaces/onboarding";
|
||||
} from "../types";
|
||||
|
||||
describe("onboardingReducer", () => {
|
||||
describe("initial state", () => {
|
||||
@@ -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
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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 = {
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
OnboardingState,
|
||||
OnboardingActions,
|
||||
OnboardingStep,
|
||||
} from "@/interfaces/onboarding";
|
||||
} from "../../types";
|
||||
|
||||
/**
|
||||
* Creates a mock WellKnownLLMProviderDescriptor for testing
|
||||
@@ -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";
|
||||
@@ -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 = {
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
240
web/src/refresh-components/onboarding/useOnboardingState.ts
Normal file
240
web/src/refresh-components/onboarding/useOnboardingState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user