mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Compare commits
6 Commits
ci/gate-e2
...
feat/keep-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aed43d708 | ||
|
|
17e1578c46 | ||
|
|
09e42041ce | ||
|
|
a50e95f210 | ||
|
|
92b395d82a | ||
|
|
86abfbd394 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ log-ingestion.txt
|
||||
/logs
|
||||
*.log
|
||||
*.mp3
|
||||
!autogpt_platform/frontend/public/notification.mp3
|
||||
mem.sqlite3
|
||||
venvAutoGPT
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import fastapi
|
||||
import fastapi.testclient
|
||||
import pytest
|
||||
|
||||
from backend.api.features.v1 import v1_router
|
||||
|
||||
app = fastapi.FastAPI()
|
||||
app.include_router(v1_router)
|
||||
client = fastapi.testclient.TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_app_auth(mock_jwt_user):
|
||||
from autogpt_libs.auth.jwt_utils import get_jwt_payload
|
||||
|
||||
app.dependency_overrides[get_jwt_payload] = mock_jwt_user["get_jwt_payload"]
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_onboarding_profile_success(mocker):
|
||||
mock_extract = mocker.patch(
|
||||
"backend.api.features.v1.extract_business_understanding",
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
mock_upsert = mocker.patch(
|
||||
"backend.api.features.v1.upsert_business_understanding",
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
|
||||
from backend.data.understanding import BusinessUnderstandingInput
|
||||
|
||||
mock_extract.return_value = BusinessUnderstandingInput.model_construct(
|
||||
user_name="John",
|
||||
user_role="Founder/CEO",
|
||||
pain_points=["Finding leads"],
|
||||
suggested_prompts={"Learn": ["How do I automate lead gen?"]},
|
||||
)
|
||||
mock_upsert.return_value = AsyncMock()
|
||||
|
||||
response = client.post(
|
||||
"/onboarding/profile",
|
||||
json={
|
||||
"user_name": "John",
|
||||
"user_role": "Founder/CEO",
|
||||
"pain_points": ["Finding leads", "Email & outreach"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
mock_extract.assert_awaited_once()
|
||||
mock_upsert.assert_awaited_once()
|
||||
|
||||
|
||||
def test_onboarding_profile_missing_fields():
|
||||
response = client.post(
|
||||
"/onboarding/profile",
|
||||
json={"user_name": "John"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -63,12 +63,17 @@ from backend.data.onboarding import (
|
||||
UserOnboardingUpdate,
|
||||
complete_onboarding_step,
|
||||
complete_re_run_agent,
|
||||
format_onboarding_for_extraction,
|
||||
get_recommended_agents,
|
||||
get_user_onboarding,
|
||||
onboarding_enabled,
|
||||
reset_user_onboarding,
|
||||
update_user_onboarding,
|
||||
)
|
||||
from backend.data.tally import extract_business_understanding
|
||||
from backend.data.understanding import (
|
||||
BusinessUnderstandingInput,
|
||||
upsert_business_understanding,
|
||||
)
|
||||
from backend.data.user import (
|
||||
get_or_create_user,
|
||||
get_user_by_id,
|
||||
@@ -282,35 +287,33 @@ async def get_onboarding_agents(
|
||||
return await get_recommended_agents(user_id)
|
||||
|
||||
|
||||
class OnboardingStatusResponse(pydantic.BaseModel):
|
||||
"""Response for onboarding status check."""
|
||||
class OnboardingProfileRequest(pydantic.BaseModel):
|
||||
"""Request body for onboarding profile submission."""
|
||||
|
||||
is_onboarding_enabled: bool
|
||||
is_chat_enabled: bool
|
||||
user_name: str = pydantic.Field(min_length=1, max_length=100)
|
||||
user_role: str = pydantic.Field(min_length=1, max_length=100)
|
||||
pain_points: list[str] = pydantic.Field(default_factory=list, max_length=20)
|
||||
|
||||
|
||||
class OnboardingStatusResponse(pydantic.BaseModel):
|
||||
"""Response for onboarding completion check."""
|
||||
|
||||
is_completed: bool
|
||||
|
||||
|
||||
@v1_router.get(
|
||||
"/onboarding/enabled",
|
||||
summary="Is onboarding enabled",
|
||||
"/onboarding/completed",
|
||||
summary="Check if onboarding is completed",
|
||||
tags=["onboarding", "public"],
|
||||
response_model=OnboardingStatusResponse,
|
||||
dependencies=[Security(requires_user)],
|
||||
)
|
||||
async def is_onboarding_enabled(
|
||||
async def is_onboarding_completed(
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
) -> OnboardingStatusResponse:
|
||||
# Check if chat is enabled for user
|
||||
is_chat_enabled = await is_feature_enabled(Flag.CHAT, user_id, False)
|
||||
|
||||
# If chat is enabled, skip legacy onboarding
|
||||
if is_chat_enabled:
|
||||
return OnboardingStatusResponse(
|
||||
is_onboarding_enabled=False,
|
||||
is_chat_enabled=True,
|
||||
)
|
||||
|
||||
user_onboarding = await get_user_onboarding(user_id)
|
||||
return OnboardingStatusResponse(
|
||||
is_onboarding_enabled=await onboarding_enabled(),
|
||||
is_chat_enabled=False,
|
||||
is_completed=OnboardingStep.VISIT_COPILOT in user_onboarding.completedSteps,
|
||||
)
|
||||
|
||||
|
||||
@@ -325,6 +328,38 @@ async def reset_onboarding(user_id: Annotated[str, Security(get_user_id)]):
|
||||
return await reset_user_onboarding(user_id)
|
||||
|
||||
|
||||
@v1_router.post(
|
||||
"/onboarding/profile",
|
||||
summary="Submit onboarding profile",
|
||||
tags=["onboarding"],
|
||||
dependencies=[Security(requires_user)],
|
||||
)
|
||||
async def submit_onboarding_profile(
|
||||
data: OnboardingProfileRequest,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
):
|
||||
formatted = format_onboarding_for_extraction(
|
||||
user_name=data.user_name,
|
||||
user_role=data.user_role,
|
||||
pain_points=data.pain_points,
|
||||
)
|
||||
|
||||
try:
|
||||
understanding_input = await extract_business_understanding(formatted)
|
||||
except Exception:
|
||||
understanding_input = BusinessUnderstandingInput.model_construct()
|
||||
|
||||
# Ensure the direct fields are set even if LLM missed them
|
||||
understanding_input.user_name = data.user_name
|
||||
understanding_input.user_role = data.user_role
|
||||
if not understanding_input.pain_points:
|
||||
understanding_input.pain_points = data.pain_points
|
||||
|
||||
await upsert_business_understanding(user_id, understanding_input)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
########################################################
|
||||
##################### Blocks ###########################
|
||||
########################################################
|
||||
|
||||
@@ -28,23 +28,30 @@ Steps:
|
||||
|
||||
### Workflow for Creating/Editing Agents
|
||||
|
||||
1. **Discover blocks**: Call `find_block(query, include_schemas=true)` to
|
||||
1. **If editing**: First narrow to the specific agent by UUID, then fetch its
|
||||
graph: `find_library_agent(query="<agent_id>", include_graph=true)`. This
|
||||
returns the full graph structure (nodes + links). **Never edit blindly** —
|
||||
always inspect the current graph first so you know exactly what to change.
|
||||
Avoid using `include_graph=true` with broad keyword searches, as fetching
|
||||
multiple graphs at once is expensive and consumes LLM context budget.
|
||||
2. **Discover blocks**: Call `find_block(query, include_schemas=true)` to
|
||||
search for relevant blocks. This returns block IDs, names, descriptions,
|
||||
and full input/output schemas.
|
||||
2. **Find library agents**: Call `find_library_agent` to discover reusable
|
||||
3. **Find library agents**: Call `find_library_agent` to discover reusable
|
||||
agents that can be composed as sub-agents via `AgentExecutorBlock`.
|
||||
3. **Generate JSON**: Build the agent JSON using block schemas:
|
||||
- Use block IDs from step 1 as `block_id` in nodes
|
||||
4. **Generate/modify JSON**: Build or modify the agent JSON using block schemas:
|
||||
- Use block IDs from step 2 as `block_id` in nodes
|
||||
- Wire outputs to inputs using links
|
||||
- Set design-time config in `input_default`
|
||||
- Use `AgentInputBlock` for values the user provides at runtime
|
||||
4. **Write to workspace**: Save the JSON to a workspace file so the user
|
||||
- When editing, apply targeted changes and preserve unchanged parts
|
||||
5. **Write to workspace**: Save the JSON to a workspace file so the user
|
||||
can review it: `write_workspace_file(filename="agent.json", content=...)`
|
||||
5. **Validate**: Call `validate_agent_graph` with the agent JSON to check
|
||||
6. **Validate**: Call `validate_agent_graph` with the agent JSON to check
|
||||
for errors
|
||||
6. **Fix if needed**: Call `fix_agent_graph` to auto-fix common issues,
|
||||
7. **Fix if needed**: Call `fix_agent_graph` to auto-fix common issues,
|
||||
or fix manually based on the error descriptions. Iterate until valid.
|
||||
7. **Save**: Call `create_agent` (new) or `edit_agent` (existing) with
|
||||
8. **Save**: Call `create_agent` (new) or `edit_agent` (existing) with
|
||||
the final `agent_json`
|
||||
|
||||
### Agent JSON Structure
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
@@ -9,7 +10,7 @@ if TYPE_CHECKING:
|
||||
from backend.api.features.library.model import LibraryAgent
|
||||
from backend.api.features.store.model import StoreAgent, StoreAgentDetails
|
||||
|
||||
from backend.data.db_accessors import library_db, store_db
|
||||
from backend.data.db_accessors import graph_db, library_db, store_db
|
||||
from backend.util.exceptions import DatabaseError, NotFoundError
|
||||
|
||||
from .models import (
|
||||
@@ -34,12 +35,13 @@ async def search_agents(
|
||||
source: SearchSource,
|
||||
session_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
include_graph: bool = False,
|
||||
) -> ToolResponseBase:
|
||||
"""Search for agents in marketplace or user library."""
|
||||
if source == "marketplace":
|
||||
return await _search_marketplace(query, session_id)
|
||||
else:
|
||||
return await _search_library(query, session_id, user_id)
|
||||
return await _search_library(query, session_id, user_id, include_graph)
|
||||
|
||||
|
||||
async def _search_marketplace(query: str, session_id: str | None) -> ToolResponseBase:
|
||||
@@ -105,7 +107,10 @@ async def _search_marketplace(query: str, session_id: str | None) -> ToolRespons
|
||||
|
||||
|
||||
async def _search_library(
|
||||
query: str, session_id: str | None, user_id: str | None
|
||||
query: str,
|
||||
session_id: str | None,
|
||||
user_id: str | None,
|
||||
include_graph: bool = False,
|
||||
) -> ToolResponseBase:
|
||||
"""Search user's library agents, with direct UUID lookup fallback."""
|
||||
if not user_id:
|
||||
@@ -149,6 +154,10 @@ async def _search_library(
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
truncation_notice: str | None = None
|
||||
if include_graph and agents:
|
||||
truncation_notice = await _enrich_agents_with_graph(agents, user_id)
|
||||
|
||||
if not agents:
|
||||
if not query:
|
||||
return NoResultsResponse(
|
||||
@@ -182,13 +191,17 @@ async def _search_library(
|
||||
else:
|
||||
title = f"Found {len(agents)} agent{'s' if len(agents) != 1 else ''} in your library for '{query}'"
|
||||
|
||||
message = (
|
||||
"Found agents in the user's library. You can provide a link to view "
|
||||
"an agent at: /library/agents/{agent_id}. Use agent_output to get "
|
||||
"execution results, or run_agent to execute. Let the user know we can "
|
||||
"create a custom agent for them based on their needs."
|
||||
)
|
||||
if truncation_notice:
|
||||
message = f"{message}\n\nNote: {truncation_notice}"
|
||||
|
||||
return AgentsFoundResponse(
|
||||
message=(
|
||||
"Found agents in the user's library. You can provide a link to view "
|
||||
"an agent at: /library/agents/{agent_id}. Use agent_output to get "
|
||||
"execution results, or run_agent to execute. Let the user know we can "
|
||||
"create a custom agent for them based on their needs."
|
||||
),
|
||||
message=message,
|
||||
title=title,
|
||||
agents=agents,
|
||||
count=len(agents),
|
||||
@@ -196,6 +209,81 @@ async def _search_library(
|
||||
)
|
||||
|
||||
|
||||
_MAX_GRAPH_FETCHES = 10
|
||||
|
||||
|
||||
_GRAPH_FETCH_TIMEOUT = 15 # seconds
|
||||
|
||||
|
||||
async def _enrich_agents_with_graph(
|
||||
agents: list[AgentInfo], user_id: str
|
||||
) -> str | None:
|
||||
"""Fetch and attach full Graph (nodes + links) to each agent in-place.
|
||||
|
||||
Only the first ``_MAX_GRAPH_FETCHES`` agents with a ``graph_id`` are
|
||||
enriched. If some agents are skipped, a truncation notice is returned
|
||||
so the caller can surface it to the copilot.
|
||||
|
||||
Graphs are fetched with ``for_export=True`` so that credentials, API keys,
|
||||
and other secrets in ``input_default`` are stripped before the data reaches
|
||||
the LLM context.
|
||||
|
||||
Returns a truncation notice string when some agents were skipped, or
|
||||
``None`` when all eligible agents were enriched.
|
||||
"""
|
||||
with_graph_id = [a for a in agents if a.graph_id]
|
||||
fetchable = with_graph_id[:_MAX_GRAPH_FETCHES]
|
||||
if not fetchable:
|
||||
return None
|
||||
|
||||
gdb = graph_db()
|
||||
|
||||
async def _fetch(agent: AgentInfo) -> None:
|
||||
graph_id = agent.graph_id
|
||||
if not graph_id:
|
||||
return
|
||||
try:
|
||||
graph = await gdb.get_graph(
|
||||
graph_id,
|
||||
version=agent.graph_version,
|
||||
user_id=user_id,
|
||||
for_export=True,
|
||||
)
|
||||
if graph is None:
|
||||
logger.warning("Graph not found for agent %s", graph_id)
|
||||
agent.graph = graph
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch graph for agent %s: %s", graph_id, e)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*[_fetch(a) for a in fetchable]),
|
||||
timeout=_GRAPH_FETCH_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"include_graph: timed out after %ds fetching graphs", _GRAPH_FETCH_TIMEOUT
|
||||
)
|
||||
|
||||
skipped = len(with_graph_id) - len(fetchable)
|
||||
if skipped > 0:
|
||||
logger.warning(
|
||||
"include_graph: fetched graphs for %d/%d agents "
|
||||
"(_MAX_GRAPH_FETCHES=%d, %d skipped)",
|
||||
len(fetchable),
|
||||
len(with_graph_id),
|
||||
_MAX_GRAPH_FETCHES,
|
||||
skipped,
|
||||
)
|
||||
return (
|
||||
f"Graph data included for {len(fetchable)} of "
|
||||
f"{len(with_graph_id)} eligible agents (limit: {_MAX_GRAPH_FETCHES}). "
|
||||
f"To fetch graphs for remaining agents, narrow your search to a "
|
||||
f"specific agent by UUID."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _marketplace_agent_to_info(agent: StoreAgent | StoreAgentDetails) -> AgentInfo:
|
||||
"""Convert a marketplace agent (StoreAgent or StoreAgentDetails) to an AgentInfo."""
|
||||
return AgentInfo(
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Tests for agent search direct lookup functionality."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from .agent_search import search_agents
|
||||
from .models import AgentsFoundResponse, NoResultsResponse
|
||||
from .agent_search import _enrich_agents_with_graph, search_agents
|
||||
from .models import AgentInfo, AgentsFoundResponse, NoResultsResponse
|
||||
|
||||
_TEST_USER_ID = "test-user-agent-search"
|
||||
|
||||
@@ -133,10 +134,10 @@ class TestMarketplaceSlugLookup:
|
||||
class TestLibraryUUIDLookup:
|
||||
"""Tests for UUID direct lookup in library search."""
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_uuid_lookup_found_by_graph_id(self):
|
||||
"""UUID query matching a graph_id returns the agent directly."""
|
||||
agent_id = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
|
||||
@staticmethod
|
||||
def _make_mock_library_agent(
|
||||
agent_id: str = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
|
||||
) -> MagicMock:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.id = "lib-agent-id"
|
||||
mock_agent.name = "My Library Agent"
|
||||
@@ -150,6 +151,13 @@ class TestLibraryUUIDLookup:
|
||||
mock_agent.graph_version = 1
|
||||
mock_agent.input_schema = {}
|
||||
mock_agent.output_schema = {}
|
||||
return mock_agent
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_uuid_lookup_found_by_graph_id(self):
|
||||
"""UUID query matching a graph_id returns the agent directly."""
|
||||
agent_id = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
|
||||
mock_agent = self._make_mock_library_agent(agent_id)
|
||||
|
||||
mock_lib_db = MagicMock()
|
||||
mock_lib_db.get_library_agent_by_graph_id = AsyncMock(return_value=mock_agent)
|
||||
@@ -168,3 +176,427 @@ class TestLibraryUUIDLookup:
|
||||
assert isinstance(response, AgentsFoundResponse)
|
||||
assert response.count == 1
|
||||
assert response.agents[0].name == "My Library Agent"
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_include_graph_fetches_graph(self):
|
||||
"""include_graph=True attaches BaseGraph to agent results."""
|
||||
from backend.data.graph import BaseGraph
|
||||
|
||||
agent_id = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
|
||||
mock_agent = self._make_mock_library_agent(agent_id)
|
||||
mock_lib_db = MagicMock()
|
||||
mock_lib_db.get_library_agent_by_graph_id = AsyncMock(return_value=mock_agent)
|
||||
|
||||
fake_graph = BaseGraph(id=agent_id, name="My Library Agent", description="test")
|
||||
mock_graph_db = MagicMock()
|
||||
mock_graph_db.get_graph = AsyncMock(return_value=fake_graph)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.library_db",
|
||||
return_value=mock_lib_db,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.graph_db",
|
||||
return_value=mock_graph_db,
|
||||
),
|
||||
):
|
||||
response = await search_agents(
|
||||
query=agent_id,
|
||||
source="library",
|
||||
session_id="s",
|
||||
user_id=_TEST_USER_ID,
|
||||
include_graph=True,
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentsFoundResponse)
|
||||
assert response.agents[0].graph is not None
|
||||
assert response.agents[0].graph.id == agent_id
|
||||
mock_graph_db.get_graph.assert_awaited_once_with(
|
||||
agent_id,
|
||||
version=1,
|
||||
user_id=_TEST_USER_ID,
|
||||
for_export=True,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_include_graph_false_skips_fetch(self):
|
||||
"""include_graph=False (default) does not fetch graph data."""
|
||||
agent_id = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
|
||||
mock_agent = self._make_mock_library_agent(agent_id)
|
||||
mock_lib_db = MagicMock()
|
||||
mock_lib_db.get_library_agent_by_graph_id = AsyncMock(return_value=mock_agent)
|
||||
|
||||
mock_graph_db = MagicMock()
|
||||
mock_graph_db.get_graph = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.library_db",
|
||||
return_value=mock_lib_db,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.graph_db",
|
||||
return_value=mock_graph_db,
|
||||
),
|
||||
):
|
||||
response = await search_agents(
|
||||
query=agent_id,
|
||||
source="library",
|
||||
session_id="s",
|
||||
user_id=_TEST_USER_ID,
|
||||
include_graph=False,
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentsFoundResponse)
|
||||
assert response.agents[0].graph is None
|
||||
mock_graph_db.get_graph.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_include_graph_handles_fetch_failure(self):
|
||||
"""include_graph=True still returns agents when graph fetch fails."""
|
||||
agent_id = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
|
||||
mock_agent = self._make_mock_library_agent(agent_id)
|
||||
mock_lib_db = MagicMock()
|
||||
mock_lib_db.get_library_agent_by_graph_id = AsyncMock(return_value=mock_agent)
|
||||
|
||||
mock_graph_db = MagicMock()
|
||||
mock_graph_db.get_graph = AsyncMock(side_effect=Exception("DB down"))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.library_db",
|
||||
return_value=mock_lib_db,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.graph_db",
|
||||
return_value=mock_graph_db,
|
||||
),
|
||||
):
|
||||
response = await search_agents(
|
||||
query=agent_id,
|
||||
source="library",
|
||||
session_id="s",
|
||||
user_id=_TEST_USER_ID,
|
||||
include_graph=True,
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentsFoundResponse)
|
||||
assert response.agents[0].graph is None
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_include_graph_handles_none_return(self):
|
||||
"""include_graph=True handles get_graph returning None."""
|
||||
agent_id = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
|
||||
mock_agent = self._make_mock_library_agent(agent_id)
|
||||
mock_lib_db = MagicMock()
|
||||
mock_lib_db.get_library_agent_by_graph_id = AsyncMock(return_value=mock_agent)
|
||||
|
||||
mock_graph_db = MagicMock()
|
||||
mock_graph_db.get_graph = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.library_db",
|
||||
return_value=mock_lib_db,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.graph_db",
|
||||
return_value=mock_graph_db,
|
||||
),
|
||||
):
|
||||
response = await search_agents(
|
||||
query=agent_id,
|
||||
source="library",
|
||||
session_id="s",
|
||||
user_id=_TEST_USER_ID,
|
||||
include_graph=True,
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentsFoundResponse)
|
||||
assert response.agents[0].graph is None
|
||||
|
||||
|
||||
class TestEnrichAgentsWithGraph:
|
||||
"""Tests for _enrich_agents_with_graph edge cases."""
|
||||
|
||||
@staticmethod
|
||||
def _make_mock_library_agent(
|
||||
agent_id: str = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
|
||||
graph_id: str | None = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
|
||||
) -> MagicMock:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.id = f"lib-{agent_id[:8]}"
|
||||
mock_agent.name = f"Agent {agent_id[:8]}"
|
||||
mock_agent.description = "A library agent"
|
||||
mock_agent.creator_name = "testuser"
|
||||
mock_agent.status.value = "HEALTHY"
|
||||
mock_agent.can_access_graph = True
|
||||
mock_agent.has_external_trigger = False
|
||||
mock_agent.new_output = False
|
||||
mock_agent.graph_id = graph_id
|
||||
mock_agent.graph_version = 1
|
||||
mock_agent.input_schema = {}
|
||||
mock_agent.output_schema = {}
|
||||
return mock_agent
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_truncation_surfaces_in_response(self):
|
||||
"""When >_MAX_GRAPH_FETCHES agents have graphs, the response contains a truncation notice."""
|
||||
from backend.copilot.tools.agent_search import _MAX_GRAPH_FETCHES
|
||||
from backend.data.graph import BaseGraph
|
||||
|
||||
agent_count = _MAX_GRAPH_FETCHES + 5
|
||||
mock_agents = []
|
||||
for i in range(agent_count):
|
||||
uid = f"a1b2c3d4-e5f6-4a7b-8c9d-{i:012d}"
|
||||
mock_agents.append(self._make_mock_library_agent(uid, uid))
|
||||
|
||||
mock_lib_db = MagicMock()
|
||||
mock_search_results = MagicMock()
|
||||
mock_search_results.agents = mock_agents
|
||||
mock_lib_db.list_library_agents = AsyncMock(return_value=mock_search_results)
|
||||
|
||||
fake_graph = BaseGraph(id="x", name="g", description="d")
|
||||
mock_gdb = MagicMock()
|
||||
mock_gdb.get_graph = AsyncMock(return_value=fake_graph)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.library_db",
|
||||
return_value=mock_lib_db,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.graph_db",
|
||||
return_value=mock_gdb,
|
||||
),
|
||||
):
|
||||
response = await search_agents(
|
||||
query="",
|
||||
source="library",
|
||||
session_id="s",
|
||||
user_id=_TEST_USER_ID,
|
||||
include_graph=True,
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentsFoundResponse)
|
||||
assert mock_gdb.get_graph.await_count == _MAX_GRAPH_FETCHES
|
||||
enriched = [a for a in response.agents if a.graph is not None]
|
||||
assert len(enriched) == _MAX_GRAPH_FETCHES
|
||||
assert "Graph data included for" in response.message
|
||||
assert str(_MAX_GRAPH_FETCHES) in response.message
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_mixed_graph_id_presence(self):
|
||||
"""Agents without graph_id are skipped during enrichment."""
|
||||
from backend.data.graph import BaseGraph
|
||||
|
||||
agent_with = self._make_mock_library_agent(
|
||||
"aaaa0000-0000-0000-0000-000000000001",
|
||||
"aaaa0000-0000-0000-0000-000000000001",
|
||||
)
|
||||
agent_without = self._make_mock_library_agent(
|
||||
"bbbb0000-0000-0000-0000-000000000002",
|
||||
graph_id=None,
|
||||
)
|
||||
|
||||
mock_lib_db = MagicMock()
|
||||
mock_search_results = MagicMock()
|
||||
mock_search_results.agents = [agent_with, agent_without]
|
||||
mock_lib_db.list_library_agents = AsyncMock(return_value=mock_search_results)
|
||||
|
||||
fake_graph = BaseGraph(
|
||||
id="aaaa0000-0000-0000-0000-000000000001", name="g", description="d"
|
||||
)
|
||||
mock_gdb = MagicMock()
|
||||
mock_gdb.get_graph = AsyncMock(return_value=fake_graph)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.library_db",
|
||||
return_value=mock_lib_db,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.graph_db",
|
||||
return_value=mock_gdb,
|
||||
),
|
||||
):
|
||||
response = await search_agents(
|
||||
query="",
|
||||
source="library",
|
||||
session_id="s",
|
||||
user_id=_TEST_USER_ID,
|
||||
include_graph=True,
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentsFoundResponse)
|
||||
assert len(response.agents) == 2
|
||||
assert response.agents[0].graph is not None
|
||||
assert response.agents[1].graph is None
|
||||
mock_gdb.get_graph.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_partial_failure_across_multiple_agents(self):
|
||||
"""When some graph fetches fail, successful ones still have graphs attached."""
|
||||
from backend.data.graph import BaseGraph
|
||||
|
||||
id_ok = "aaaa0000-0000-0000-0000-000000000001"
|
||||
id_fail = "bbbb0000-0000-0000-0000-000000000002"
|
||||
agent_ok = self._make_mock_library_agent(id_ok, id_ok)
|
||||
agent_fail = self._make_mock_library_agent(id_fail, id_fail)
|
||||
|
||||
mock_lib_db = MagicMock()
|
||||
mock_search_results = MagicMock()
|
||||
mock_search_results.agents = [agent_ok, agent_fail]
|
||||
mock_lib_db.list_library_agents = AsyncMock(return_value=mock_search_results)
|
||||
|
||||
fake_graph = BaseGraph(id=id_ok, name="g", description="d")
|
||||
|
||||
async def _side_effect(graph_id, **kwargs):
|
||||
if graph_id == id_fail:
|
||||
raise Exception("DB error")
|
||||
return fake_graph
|
||||
|
||||
mock_gdb = MagicMock()
|
||||
mock_gdb.get_graph = AsyncMock(side_effect=_side_effect)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.library_db",
|
||||
return_value=mock_lib_db,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.graph_db",
|
||||
return_value=mock_gdb,
|
||||
),
|
||||
):
|
||||
response = await search_agents(
|
||||
query="",
|
||||
source="library",
|
||||
session_id="s",
|
||||
user_id=_TEST_USER_ID,
|
||||
include_graph=True,
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentsFoundResponse)
|
||||
assert response.agents[0].graph is not None
|
||||
assert response.agents[0].graph.id == id_ok
|
||||
assert response.agents[1].graph is None
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_keyword_search_with_include_graph(self):
|
||||
"""include_graph works via keyword search (non-UUID path)."""
|
||||
from backend.data.graph import BaseGraph
|
||||
|
||||
agent_id = "cccc0000-0000-0000-0000-000000000003"
|
||||
mock_agent = self._make_mock_library_agent(agent_id, agent_id)
|
||||
|
||||
mock_lib_db = MagicMock()
|
||||
mock_search_results = MagicMock()
|
||||
mock_search_results.agents = [mock_agent]
|
||||
mock_lib_db.list_library_agents = AsyncMock(return_value=mock_search_results)
|
||||
|
||||
fake_graph = BaseGraph(id=agent_id, name="g", description="d")
|
||||
mock_gdb = MagicMock()
|
||||
mock_gdb.get_graph = AsyncMock(return_value=fake_graph)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.library_db",
|
||||
return_value=mock_lib_db,
|
||||
),
|
||||
patch(
|
||||
"backend.copilot.tools.agent_search.graph_db",
|
||||
return_value=mock_gdb,
|
||||
),
|
||||
):
|
||||
response = await search_agents(
|
||||
query="email",
|
||||
source="library",
|
||||
session_id="s",
|
||||
user_id=_TEST_USER_ID,
|
||||
include_graph=True,
|
||||
)
|
||||
|
||||
assert isinstance(response, AgentsFoundResponse)
|
||||
assert response.agents[0].graph is not None
|
||||
assert response.agents[0].graph.id == agent_id
|
||||
mock_gdb.get_graph.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_timeout_preserves_successful_fetches(self):
|
||||
"""On timeout, agents that already fetched their graph keep the result."""
|
||||
fast_agent = AgentInfo(
|
||||
id="a1",
|
||||
name="Fast",
|
||||
description="d",
|
||||
source="library",
|
||||
graph_id="fast-graph",
|
||||
)
|
||||
slow_agent = AgentInfo(
|
||||
id="a2",
|
||||
name="Slow",
|
||||
description="d",
|
||||
source="library",
|
||||
graph_id="slow-graph",
|
||||
)
|
||||
fake_graph = MagicMock()
|
||||
fake_graph.id = "graph-1"
|
||||
|
||||
async def mock_get_graph(
|
||||
graph_id, *, version=None, user_id=None, for_export=False
|
||||
):
|
||||
if graph_id == "fast-graph":
|
||||
return fake_graph
|
||||
await asyncio.sleep(999)
|
||||
return MagicMock()
|
||||
|
||||
mock_gdb = MagicMock()
|
||||
mock_gdb.get_graph = AsyncMock(side_effect=mock_get_graph)
|
||||
|
||||
with (
|
||||
patch("backend.copilot.tools.agent_search.graph_db", return_value=mock_gdb),
|
||||
patch("backend.copilot.tools.agent_search._GRAPH_FETCH_TIMEOUT", 0.1),
|
||||
):
|
||||
await _enrich_agents_with_graph([fast_agent, slow_agent], _TEST_USER_ID)
|
||||
|
||||
assert fast_agent.graph is fake_graph
|
||||
assert slow_agent.graph is None
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_enrich_success(self):
|
||||
"""All agents get their graphs when no timeout occurs."""
|
||||
agent = AgentInfo(
|
||||
id="a1", name="Test", description="d", source="library", graph_id="g1"
|
||||
)
|
||||
fake_graph = MagicMock()
|
||||
fake_graph.id = "graph-1"
|
||||
|
||||
mock_gdb = MagicMock()
|
||||
mock_gdb.get_graph = AsyncMock(return_value=fake_graph)
|
||||
|
||||
with patch(
|
||||
"backend.copilot.tools.agent_search.graph_db", return_value=mock_gdb
|
||||
):
|
||||
result = await _enrich_agents_with_graph([agent], _TEST_USER_ID)
|
||||
|
||||
assert agent.graph is fake_graph
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_enrich_skips_agents_without_graph_id(self):
|
||||
"""Agents without graph_id are not fetched."""
|
||||
agent_no_id = AgentInfo(
|
||||
id="a1", name="Test", description="d", source="library", graph_id=None
|
||||
)
|
||||
|
||||
mock_gdb = MagicMock()
|
||||
mock_gdb.get_graph = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"backend.copilot.tools.agent_search.graph_db", return_value=mock_gdb
|
||||
):
|
||||
result = await _enrich_agents_with_graph([agent_no_id], _TEST_USER_ID)
|
||||
|
||||
mock_gdb.get_graph.assert_not_called()
|
||||
assert result is None
|
||||
|
||||
@@ -20,7 +20,8 @@ class FindLibraryAgentTool(BaseTool):
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Search user's library agents. Returns graph_id, schemas for sub-agent composition. "
|
||||
"Omit query to list all."
|
||||
"Omit query to list all. Set include_graph=true to also fetch the full "
|
||||
"graph structure (nodes + links) for debugging or editing."
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -32,6 +33,15 @@ class FindLibraryAgentTool(BaseTool):
|
||||
"type": "string",
|
||||
"description": "Search by name/description. Omit to list all.",
|
||||
},
|
||||
"include_graph": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"When true, includes the full graph structure "
|
||||
"(nodes + links) for each found agent. "
|
||||
"Use when you need to inspect, debug, or edit an agent."
|
||||
),
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
@@ -45,6 +55,7 @@ class FindLibraryAgentTool(BaseTool):
|
||||
user_id: str | None,
|
||||
session: ChatSession,
|
||||
query: str = "",
|
||||
include_graph: bool = False,
|
||||
**kwargs,
|
||||
) -> ToolResponseBase:
|
||||
return await search_agents(
|
||||
@@ -52,4 +63,5 @@ class FindLibraryAgentTool(BaseTool):
|
||||
source="library",
|
||||
session_id=session.session_id,
|
||||
user_id=user_id,
|
||||
include_graph=include_graph,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.data.graph import BaseGraph
|
||||
from backend.data.model import CredentialsMetaInput
|
||||
|
||||
|
||||
@@ -122,6 +123,10 @@ class AgentInfo(BaseModel):
|
||||
default=None,
|
||||
description="Input schema for the agent, including field names, types, and defaults",
|
||||
)
|
||||
graph: BaseGraph | None = Field(
|
||||
default=None,
|
||||
description="Full graph structure (nodes + links) when include_graph is requested",
|
||||
)
|
||||
|
||||
|
||||
class AgentsFoundResponse(ToolResponseBase):
|
||||
|
||||
@@ -90,11 +90,12 @@ async def test_simulate_block_basic():
|
||||
|
||||
with patch(
|
||||
"backend.executor.simulator.get_openai_client", return_value=mock_client
|
||||
):
|
||||
) as mock_get_client:
|
||||
outputs = []
|
||||
async for name, data in simulate_block(mock_block, {"query": "test"}):
|
||||
outputs.append((name, data))
|
||||
|
||||
mock_get_client.assert_called_once_with(prefer_openrouter=True)
|
||||
assert ("result", "simulated output") in outputs
|
||||
# Empty error pin should NOT be yielded — the simulator omits empty values
|
||||
assert ("error", "") not in outputs
|
||||
|
||||
@@ -436,6 +436,28 @@ async def get_recommended_agents(user_id: str) -> list[StoreAgentDetails]:
|
||||
return [StoreAgentDetails.from_db(agent) for agent in recommended_agents]
|
||||
|
||||
|
||||
def format_onboarding_for_extraction(
|
||||
user_name: str,
|
||||
user_role: str,
|
||||
pain_points: list[str],
|
||||
) -> str:
|
||||
"""Format onboarding wizard answers as Q&A text for LLM extraction."""
|
||||
|
||||
def normalize(value: str) -> str:
|
||||
return " ".join(value.strip().split())
|
||||
|
||||
name = normalize(user_name)
|
||||
role = normalize(user_role)
|
||||
points = [normalize(p) for p in pain_points if normalize(p)]
|
||||
|
||||
lines = [
|
||||
f"Q: What is your name?\nA: {name}",
|
||||
f"Q: What best describes your role?\nA: {role}",
|
||||
f"Q: What tasks are eating your time?\nA: {', '.join(points)}",
|
||||
]
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
@cached(maxsize=1, ttl_seconds=300) # Cache for 5 minutes since this rarely changes
|
||||
async def onboarding_enabled() -> bool:
|
||||
"""
|
||||
|
||||
27
autogpt_platform/backend/backend/data/onboarding_test.py
Normal file
27
autogpt_platform/backend/backend/data/onboarding_test.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from backend.data.onboarding import format_onboarding_for_extraction
|
||||
|
||||
|
||||
def test_format_onboarding_for_extraction_basic():
|
||||
result = format_onboarding_for_extraction(
|
||||
user_name="John",
|
||||
user_role="Founder/CEO",
|
||||
pain_points=["Finding leads", "Email & outreach"],
|
||||
)
|
||||
assert "Q: What is your name?" in result
|
||||
assert "A: John" in result
|
||||
assert "Q: What best describes your role?" in result
|
||||
assert "A: Founder/CEO" in result
|
||||
assert "Q: What tasks are eating your time?" in result
|
||||
assert "Finding leads" in result
|
||||
assert "Email & outreach" in result
|
||||
|
||||
|
||||
def test_format_onboarding_for_extraction_with_other():
|
||||
result = format_onboarding_for_extraction(
|
||||
user_name="Jane",
|
||||
user_role="Data Scientist",
|
||||
pain_points=["Research", "Building dashboards"],
|
||||
)
|
||||
assert "A: Jane" in result
|
||||
assert "A: Data Scientist" in result
|
||||
assert "Research, Building dashboards" in result
|
||||
@@ -31,7 +31,6 @@ Inspired by https://github.com/Significant-Gravitas/agent-simulator
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
@@ -44,39 +43,18 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Default simulator model — Gemini 2.5 Flash via OpenRouter (fast, cheap, good at
|
||||
# JSON generation). Configurable via SIMULATION_MODEL env var or
|
||||
# ChatConfig.simulation_model.
|
||||
# JSON generation). Configurable via ChatConfig.simulation_model
|
||||
# (CHAT_SIMULATION_MODEL env var).
|
||||
_DEFAULT_SIMULATOR_MODEL = "google/gemini-2.5-flash"
|
||||
|
||||
|
||||
def _simulator_model() -> str:
|
||||
# 1. Environment variable override (highest priority).
|
||||
env_model = os.environ.get("SIMULATION_MODEL")
|
||||
if env_model:
|
||||
model = env_model
|
||||
else:
|
||||
# 2. ChatConfig.simulation_model (falls back to default).
|
||||
try:
|
||||
from backend.copilot.config import ChatConfig # noqa: PLC0415
|
||||
|
||||
model = ChatConfig().simulation_model or _DEFAULT_SIMULATOR_MODEL
|
||||
except Exception:
|
||||
model = _DEFAULT_SIMULATOR_MODEL
|
||||
|
||||
# get_openai_client() may return a direct OpenAI client (not OpenRouter).
|
||||
# Direct OpenAI expects bare model names ("gpt-4o-mini"), not the
|
||||
# OpenRouter-prefixed form ("openai/gpt-4o-mini"). Strip the prefix when
|
||||
# the internal OpenAI key is configured (i.e. not going through OpenRouter).
|
||||
try:
|
||||
from backend.util.settings import Settings # noqa: PLC0415
|
||||
from backend.copilot.config import ChatConfig # noqa: PLC0415
|
||||
|
||||
secrets = Settings().secrets
|
||||
if secrets.openai_internal_api_key and "/" in model:
|
||||
model = model.split("/", 1)[1]
|
||||
return ChatConfig().simulation_model or _DEFAULT_SIMULATOR_MODEL
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return model
|
||||
return _DEFAULT_SIMULATOR_MODEL
|
||||
|
||||
|
||||
_TEMPERATURE = 0.2
|
||||
@@ -136,7 +114,7 @@ async def _call_llm_for_simulation(
|
||||
RuntimeError: If no LLM client is available.
|
||||
ValueError: If all retry attempts are exhausted.
|
||||
"""
|
||||
client = get_openai_client()
|
||||
client = get_openai_client(prefer_openrouter=True)
|
||||
if client is None:
|
||||
raise RuntimeError(
|
||||
"[SIMULATOR ERROR — NOT A BLOCK FAILURE] No LLM client available "
|
||||
|
||||
@@ -163,23 +163,31 @@ async def get_async_supabase() -> "AClient":
|
||||
|
||||
|
||||
@cached(ttl_seconds=3600)
|
||||
def get_openai_client() -> "AsyncOpenAI | None":
|
||||
def get_openai_client(*, prefer_openrouter: bool = False) -> "AsyncOpenAI | None":
|
||||
"""
|
||||
Get a process-cached async OpenAI client for embeddings.
|
||||
Get a process-cached async OpenAI client.
|
||||
|
||||
Prefers openai_internal_api_key (direct OpenAI). Falls back to
|
||||
open_router_api_key via OpenRouter's OpenAI-compatible endpoint.
|
||||
Returns None if neither key is configured.
|
||||
By default prefers openai_internal_api_key (direct OpenAI) and falls back
|
||||
to open_router_api_key via OpenRouter.
|
||||
|
||||
When ``prefer_openrouter=True``, returns an OpenRouter client or None —
|
||||
does **not** fall back to direct OpenAI (which can't route non-OpenAI
|
||||
models like ``google/gemini-2.5-flash``).
|
||||
"""
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
if settings.secrets.openai_internal_api_key:
|
||||
return AsyncOpenAI(api_key=settings.secrets.openai_internal_api_key)
|
||||
if settings.secrets.open_router_api_key:
|
||||
return AsyncOpenAI(
|
||||
api_key=settings.secrets.open_router_api_key,
|
||||
base_url=OPENROUTER_BASE_URL,
|
||||
)
|
||||
openai_key = settings.secrets.openai_internal_api_key
|
||||
openrouter_key = settings.secrets.open_router_api_key
|
||||
|
||||
if prefer_openrouter:
|
||||
if openrouter_key:
|
||||
return AsyncOpenAI(api_key=openrouter_key, base_url=OPENROUTER_BASE_URL)
|
||||
return None
|
||||
else:
|
||||
if openai_key:
|
||||
return AsyncOpenAI(api_key=openai_key)
|
||||
if openrouter_key:
|
||||
return AsyncOpenAI(api_key=openrouter_key, base_url=OPENROUTER_BASE_URL)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
69
autogpt_platform/backend/backend/util/clients_test.py
Normal file
69
autogpt_platform/backend/backend/util/clients_test.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Tests for get_openai_client prefer_openrouter parameter."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.util.clients import get_openai_client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_client_cache():
|
||||
"""Clear the @cached singleton between tests."""
|
||||
get_openai_client.cache_clear()
|
||||
yield
|
||||
get_openai_client.cache_clear()
|
||||
|
||||
|
||||
def _mock_secrets(*, openai_key: str = "", openrouter_key: str = "") -> MagicMock:
|
||||
secrets = MagicMock()
|
||||
secrets.openai_internal_api_key = openai_key
|
||||
secrets.open_router_api_key = openrouter_key
|
||||
return secrets
|
||||
|
||||
|
||||
class TestGetOpenaiClientDefault:
|
||||
def test_prefers_openai_key(self):
|
||||
secrets = _mock_secrets(openai_key="sk-openai", openrouter_key="sk-or")
|
||||
with patch("backend.util.clients.settings") as mock_settings:
|
||||
mock_settings.secrets = secrets
|
||||
client = get_openai_client()
|
||||
assert client is not None
|
||||
assert client.api_key == "sk-openai"
|
||||
assert "openrouter" not in str(client.base_url or "")
|
||||
|
||||
def test_falls_back_to_openrouter(self):
|
||||
secrets = _mock_secrets(openrouter_key="sk-or")
|
||||
with patch("backend.util.clients.settings") as mock_settings:
|
||||
mock_settings.secrets = secrets
|
||||
client = get_openai_client()
|
||||
assert client is not None
|
||||
assert client.api_key == "sk-or"
|
||||
|
||||
def test_returns_none_when_no_keys(self):
|
||||
secrets = _mock_secrets()
|
||||
with patch("backend.util.clients.settings") as mock_settings:
|
||||
mock_settings.secrets = secrets
|
||||
assert get_openai_client() is None
|
||||
|
||||
|
||||
class TestGetOpenaiClientPreferOpenrouter:
|
||||
def test_returns_openrouter_client(self):
|
||||
secrets = _mock_secrets(openai_key="sk-openai", openrouter_key="sk-or")
|
||||
with patch("backend.util.clients.settings") as mock_settings:
|
||||
mock_settings.secrets = secrets
|
||||
client = get_openai_client(prefer_openrouter=True)
|
||||
assert client is not None
|
||||
assert client.api_key == "sk-or"
|
||||
|
||||
def test_returns_none_without_openrouter_key(self):
|
||||
secrets = _mock_secrets(openai_key="sk-openai")
|
||||
with patch("backend.util.clients.settings") as mock_settings:
|
||||
mock_settings.secrets = secrets
|
||||
assert get_openai_client(prefer_openrouter=True) is None
|
||||
|
||||
def test_returns_none_when_no_keys(self):
|
||||
secrets = _mock_secrets()
|
||||
with patch("backend.util.clients.settings") as mock_settings:
|
||||
mock_settings.secrets = secrets
|
||||
assert get_openai_client(prefer_openrouter=True) is None
|
||||
@@ -122,6 +122,7 @@
|
||||
"tailwind-merge": "2.6.0",
|
||||
"tailwind-scrollbar": "3.1.0",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"twemoji": "14.0.2",
|
||||
"use-stick-to-bottom": "1.1.2",
|
||||
"uuid": "11.1.0",
|
||||
"vaul": "1.1.2",
|
||||
@@ -150,6 +151,7 @@
|
||||
"@types/react-modal": "3.16.3",
|
||||
"@types/react-window": "2.0.0",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"@vitest/coverage-v8": "4.0.17",
|
||||
"axe-playwright": "2.2.2",
|
||||
"chromatic": "13.3.3",
|
||||
"concurrently": "9.2.1",
|
||||
@@ -171,7 +173,6 @@
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.9.3",
|
||||
"vite-tsconfig-paths": "6.0.4",
|
||||
"@vitest/coverage-v8": "4.0.17",
|
||||
"vitest": "4.0.17"
|
||||
},
|
||||
"msw": {
|
||||
|
||||
50
autogpt_platform/frontend/pnpm-lock.yaml
generated
50
autogpt_platform/frontend/pnpm-lock.yaml
generated
@@ -288,6 +288,9 @@ importers:
|
||||
tailwindcss-animate:
|
||||
specifier: 1.0.7
|
||||
version: 1.0.7(tailwindcss@3.4.17)
|
||||
twemoji:
|
||||
specifier: 14.0.2
|
||||
version: 14.0.2
|
||||
use-stick-to-bottom:
|
||||
specifier: 1.1.2
|
||||
version: 1.1.2(react@18.3.1)
|
||||
@@ -5498,6 +5501,10 @@ packages:
|
||||
resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
fs-extra@8.1.0:
|
||||
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
|
||||
engines: {node: '>=6 <7 || >=8'}
|
||||
|
||||
fs-monkey@1.1.0:
|
||||
resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==}
|
||||
|
||||
@@ -6149,6 +6156,12 @@ packages:
|
||||
jsonc-parser@2.2.1:
|
||||
resolution: {integrity: sha512-o6/yDBYccGvTz1+QFevz6l6OBZ2+fMVu2JZ9CIhzsYRX4mjaK5IyX9eldUdCmga16zlgQxyrj5pt9kzuj2C02w==}
|
||||
|
||||
jsonfile@4.0.0:
|
||||
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
|
||||
|
||||
jsonfile@5.0.0:
|
||||
resolution: {integrity: sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==}
|
||||
|
||||
jsonfile@6.2.0:
|
||||
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
||||
|
||||
@@ -8218,6 +8231,12 @@ packages:
|
||||
tty-browserify@0.0.1:
|
||||
resolution: {integrity: sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==}
|
||||
|
||||
twemoji-parser@14.0.0:
|
||||
resolution: {integrity: sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==}
|
||||
|
||||
twemoji@14.0.2:
|
||||
resolution: {integrity: sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -8342,6 +8361,10 @@ packages:
|
||||
unist-util-visit@5.0.0:
|
||||
resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==}
|
||||
|
||||
universalify@0.1.2:
|
||||
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
|
||||
universalify@2.0.1:
|
||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
@@ -14595,6 +14618,12 @@ snapshots:
|
||||
jsonfile: 6.2.0
|
||||
universalify: 2.0.1
|
||||
|
||||
fs-extra@8.1.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 4.0.0
|
||||
universalify: 0.1.2
|
||||
|
||||
fs-monkey@1.1.0: {}
|
||||
|
||||
fs.realpath@1.0.0: {}
|
||||
@@ -15333,6 +15362,16 @@ snapshots:
|
||||
|
||||
jsonc-parser@2.2.1: {}
|
||||
|
||||
jsonfile@4.0.0:
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
jsonfile@5.0.0:
|
||||
dependencies:
|
||||
universalify: 0.1.2
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
jsonfile@6.2.0:
|
||||
dependencies:
|
||||
universalify: 2.0.1
|
||||
@@ -17893,6 +17932,15 @@ snapshots:
|
||||
|
||||
tty-browserify@0.0.1: {}
|
||||
|
||||
twemoji-parser@14.0.0: {}
|
||||
|
||||
twemoji@14.0.2:
|
||||
dependencies:
|
||||
fs-extra: 8.1.0
|
||||
jsonfile: 5.0.0
|
||||
twemoji-parser: 14.0.0
|
||||
universalify: 0.1.2
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -18030,6 +18078,8 @@ snapshots:
|
||||
unist-util-is: 6.0.1
|
||||
unist-util-visit-parents: 6.0.2
|
||||
|
||||
universalify@0.1.2: {}
|
||||
|
||||
universalify@2.0.1: {}
|
||||
|
||||
unplugin@1.0.1:
|
||||
|
||||
BIN
autogpt_platform/frontend/public/notification.mp3
Normal file
BIN
autogpt_platform/frontend/public/notification.mp3
Normal file
Binary file not shown.
@@ -1,33 +0,0 @@
|
||||
"use client";
|
||||
import { OnboardingText } from "../components/OnboardingText";
|
||||
import OnboardingButton from "../components/OnboardingButton";
|
||||
import Image from "next/image";
|
||||
import { useOnboarding } from "../../../../providers/onboarding/onboarding-provider";
|
||||
|
||||
export default function Page() {
|
||||
useOnboarding(1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Image
|
||||
src="/gpt_dark_RGB.svg"
|
||||
alt="GPT Dark Logo"
|
||||
className="-mb-2"
|
||||
width={300}
|
||||
height={300}
|
||||
/>
|
||||
<OnboardingText className="mb-3" variant="header" center>
|
||||
Welcome to AutoGPT
|
||||
</OnboardingText>
|
||||
<OnboardingText className="mb-12" center>
|
||||
Think of AutoGPT as your digital teammate, working intelligently to
|
||||
<br />
|
||||
complete tasks based on your directions. Let's learn a bit about
|
||||
you to
|
||||
<br />
|
||||
tailor your experience.
|
||||
</OnboardingText>
|
||||
<OnboardingButton href="/onboarding/2-reason">Continue</OnboardingButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
"use client";
|
||||
import OnboardingButton from "../components/OnboardingButton";
|
||||
import {
|
||||
OnboardingFooter,
|
||||
OnboardingHeader,
|
||||
OnboardingStep,
|
||||
} from "../components/OnboardingStep";
|
||||
import { OnboardingText } from "../components/OnboardingText";
|
||||
import OnboardingList from "../components/OnboardingList";
|
||||
import { isEmptyOrWhitespace } from "@/lib/utils";
|
||||
import { useOnboarding } from "../../../../providers/onboarding/onboarding-provider";
|
||||
|
||||
const reasons = [
|
||||
{
|
||||
label: "Content & Marketing",
|
||||
text: "Content creation, social media management, blogging, creative writing",
|
||||
id: "content_marketing",
|
||||
},
|
||||
{
|
||||
label: "Business & Workflow Automation",
|
||||
text: "Operations, task management, productivity",
|
||||
id: "business_workflow_automation",
|
||||
},
|
||||
{
|
||||
label: "Data & Research",
|
||||
text: "Data analysis, insights, research, financial operation",
|
||||
id: "data_research",
|
||||
},
|
||||
{
|
||||
label: "AI & Innovation",
|
||||
text: "AI experimentation, automation testing, advanced AI applications",
|
||||
id: "ai_innovation",
|
||||
},
|
||||
{
|
||||
label: "Personal productivity",
|
||||
text: "Automating daily tasks, organizing information, personal workflows",
|
||||
id: "personal_productivity",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
const { state, updateState } = useOnboarding(2, "WELCOME");
|
||||
|
||||
return (
|
||||
<OnboardingStep>
|
||||
<OnboardingHeader backHref={"/onboarding/1-welcome"}>
|
||||
<OnboardingText className="mt-4" variant="header" center>
|
||||
What's your main reason for using AutoGPT?
|
||||
</OnboardingText>
|
||||
<OnboardingText className="mt-1" center>
|
||||
Select the option that best matches your needs
|
||||
</OnboardingText>
|
||||
</OnboardingHeader>
|
||||
<OnboardingList
|
||||
elements={reasons}
|
||||
selectedId={state?.usageReason}
|
||||
onSelect={(usageReason) => updateState({ usageReason })}
|
||||
/>
|
||||
<OnboardingFooter>
|
||||
<OnboardingButton
|
||||
href="/onboarding/3-services"
|
||||
disabled={isEmptyOrWhitespace(state?.usageReason)}
|
||||
>
|
||||
Next
|
||||
</OnboardingButton>
|
||||
</OnboardingFooter>
|
||||
</OnboardingStep>
|
||||
);
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
"use client";
|
||||
import OnboardingButton from "../components/OnboardingButton";
|
||||
import {
|
||||
OnboardingStep,
|
||||
OnboardingHeader,
|
||||
OnboardingFooter,
|
||||
} from "../components/OnboardingStep";
|
||||
import { OnboardingText } from "../components/OnboardingText";
|
||||
import { OnboardingGrid } from "../components/OnboardingGrid";
|
||||
import { useCallback } from "react";
|
||||
import OnboardingInput from "../components/OnboardingInput";
|
||||
import { useOnboarding } from "../../../../providers/onboarding/onboarding-provider";
|
||||
|
||||
const services = [
|
||||
{
|
||||
name: "D-ID",
|
||||
text: "Generate AI-powered avatars and videos for dynamic content creation.",
|
||||
icon: "/integrations/d-id.png",
|
||||
},
|
||||
{
|
||||
name: "Discord",
|
||||
text: "A chat platform for communities and teams, supporting text, voice, and video.",
|
||||
icon: "/integrations/discord.png",
|
||||
},
|
||||
{
|
||||
name: "GitHub",
|
||||
text: "AutoGPT can track issues, manage repos, and automate workflows with GitHub.",
|
||||
icon: "/integrations/github.png",
|
||||
},
|
||||
{
|
||||
name: "Google Workspace",
|
||||
text: "Automate emails, calendar events, and document management in AutoGPT with Google Workspace.",
|
||||
icon: "/integrations/google.png",
|
||||
},
|
||||
{
|
||||
name: "Google Maps",
|
||||
text: "Fetch locations, directions, and real-time geodata for navigation.",
|
||||
icon: "/integrations/maps.png",
|
||||
},
|
||||
{
|
||||
name: "HubSpot",
|
||||
text: "Manage customer relationships, automate marketing, and track sales.",
|
||||
icon: "/integrations/hubspot.png",
|
||||
},
|
||||
{
|
||||
name: "Linear",
|
||||
text: "Streamline project management and issue tracking with a modern workflow.",
|
||||
icon: "/integrations/linear.png",
|
||||
},
|
||||
{
|
||||
name: "Medium",
|
||||
text: "Publish and explore insightful content with a powerful writing platform.",
|
||||
icon: "/integrations/medium.png",
|
||||
},
|
||||
{
|
||||
name: "Mem0",
|
||||
text: "AI-powered memory assistant for smarter data organization and recall.",
|
||||
icon: "/integrations/mem0.png",
|
||||
},
|
||||
{
|
||||
name: "Notion",
|
||||
text: "Organize work, notes, and databases in an all-in-one workspace.",
|
||||
icon: "/integrations/notion.png",
|
||||
},
|
||||
{
|
||||
name: "NVIDIA",
|
||||
text: "Accelerate AI, graphics, and computing with cutting-edge technology.",
|
||||
icon: "/integrations/nvidia.jpg",
|
||||
},
|
||||
{
|
||||
name: "OpenWeatherMap",
|
||||
text: "Access real-time weather data and forecasts worldwide.",
|
||||
icon: "/integrations/openweathermap.png",
|
||||
},
|
||||
{
|
||||
name: "Pinecone",
|
||||
text: "Store and search vector data for AI-driven applications.",
|
||||
icon: "/integrations/pinecone.png",
|
||||
},
|
||||
{
|
||||
name: "Reddit",
|
||||
text: "Explore trending discussions and engage with online communities.",
|
||||
icon: "/integrations/reddit.png",
|
||||
},
|
||||
{
|
||||
name: "Slant3D",
|
||||
text: "Automate and optimize 3D printing workflows with AI.",
|
||||
icon: "/integrations/slant3d.jpeg",
|
||||
},
|
||||
{
|
||||
name: "SMTP",
|
||||
text: "Send and manage emails with secure and reliable delivery.",
|
||||
icon: "/integrations/smtp.png",
|
||||
},
|
||||
{
|
||||
name: "Todoist",
|
||||
text: "Organize tasks and projects with a simple, intuitive to-do list.",
|
||||
icon: "/integrations/todoist.png",
|
||||
},
|
||||
{
|
||||
name: "Twitter (X)",
|
||||
text: "Stay connected and share updates on the world's biggest conversation platform.",
|
||||
icon: "/integrations/x.png",
|
||||
},
|
||||
{
|
||||
name: "Unreal Speech",
|
||||
text: "Generate natural-sounding AI voices for speech applications.",
|
||||
icon: "/integrations/unreal-speech.png",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
const { state, updateState } = useOnboarding(3, "USAGE_REASON");
|
||||
|
||||
const switchIntegration = useCallback(
|
||||
(name: string) => {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const integrations = state.integrations.includes(name)
|
||||
? state.integrations.filter((i) => i !== name)
|
||||
: [...state.integrations, name];
|
||||
|
||||
updateState({ integrations });
|
||||
},
|
||||
[state, updateState],
|
||||
);
|
||||
|
||||
return (
|
||||
<OnboardingStep>
|
||||
<OnboardingHeader backHref={"/onboarding/2-reason"}>
|
||||
<OnboardingText className="mt-4" variant="header" center>
|
||||
What platforms or services would you like AutoGPT to work with?
|
||||
</OnboardingText>
|
||||
<OnboardingText className="mt-1" center>
|
||||
You can select more than one option
|
||||
</OnboardingText>
|
||||
</OnboardingHeader>
|
||||
|
||||
<div className="w-fit">
|
||||
<OnboardingText className="my-4" variant="subheader">
|
||||
Available integrations
|
||||
</OnboardingText>
|
||||
<OnboardingGrid
|
||||
elements={services}
|
||||
selected={state?.integrations}
|
||||
onSelect={switchIntegration}
|
||||
/>
|
||||
<OnboardingText className="mt-12" variant="subheader">
|
||||
Help us grow our integrations
|
||||
</OnboardingText>
|
||||
<OnboardingText className="my-4">
|
||||
Let us know which partnerships you'd like to see next
|
||||
</OnboardingText>
|
||||
<OnboardingInput
|
||||
className="mb-4"
|
||||
placeholder="Others (please specify)"
|
||||
value={state?.otherIntegrations || ""}
|
||||
onChange={(otherIntegrations) => updateState({ otherIntegrations })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OnboardingFooter>
|
||||
<OnboardingButton className="mb-2" href="/onboarding/4-agent">
|
||||
Next
|
||||
</OnboardingButton>
|
||||
</OnboardingFooter>
|
||||
</OnboardingStep>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
"use client";
|
||||
import { isEmptyOrWhitespace } from "@/lib/utils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useOnboarding } from "../../../../providers/onboarding/onboarding-provider";
|
||||
import OnboardingAgentCard from "../components/OnboardingAgentCard";
|
||||
import OnboardingButton from "../components/OnboardingButton";
|
||||
import {
|
||||
OnboardingFooter,
|
||||
OnboardingHeader,
|
||||
OnboardingStep,
|
||||
} from "../components/OnboardingStep";
|
||||
import { OnboardingText } from "../components/OnboardingText";
|
||||
import { getV1RecommendedOnboardingAgents } from "@/app/api/__generated__/endpoints/onboarding/onboarding";
|
||||
import { resolveResponse } from "@/app/api/helpers";
|
||||
import { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails";
|
||||
|
||||
export default function Page() {
|
||||
const { state, updateState, completeStep } = useOnboarding(4, "INTEGRATIONS");
|
||||
const [agents, setAgents] = useState<StoreAgentDetails[]>([]);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
resolveResponse(getV1RecommendedOnboardingAgents()).then((agents) => {
|
||||
if (agents.length < 2) {
|
||||
completeStep("CONGRATS");
|
||||
router.replace("/");
|
||||
}
|
||||
setAgents(agents);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Deselect agent if it's not in the list of agents
|
||||
if (
|
||||
state?.selectedStoreListingVersionId &&
|
||||
agents.length > 0 &&
|
||||
!agents.some(
|
||||
(agent) =>
|
||||
agent.store_listing_version_id ===
|
||||
state.selectedStoreListingVersionId,
|
||||
)
|
||||
) {
|
||||
updateState({
|
||||
selectedStoreListingVersionId: null,
|
||||
agentInput: {},
|
||||
});
|
||||
}
|
||||
}, [state?.selectedStoreListingVersionId, updateState, agents]);
|
||||
|
||||
return (
|
||||
<OnboardingStep>
|
||||
<OnboardingHeader backHref={"/onboarding/3-services"}>
|
||||
<OnboardingText className="mt-4" variant="header" center>
|
||||
Choose an agent
|
||||
</OnboardingText>
|
||||
<OnboardingText className="mt-1" center>
|
||||
We think these agents are a good match for you based on your answers
|
||||
</OnboardingText>
|
||||
</OnboardingHeader>
|
||||
|
||||
<div className="my-12 flex items-center justify-between gap-5">
|
||||
<OnboardingAgentCard
|
||||
agent={agents[0]}
|
||||
selected={
|
||||
agents[0] !== undefined
|
||||
? state?.selectedStoreListingVersionId ==
|
||||
agents[0]?.store_listing_version_id
|
||||
: false
|
||||
}
|
||||
onClick={() =>
|
||||
updateState({
|
||||
selectedStoreListingVersionId: agents[0].store_listing_version_id,
|
||||
agentInput: {},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<OnboardingAgentCard
|
||||
agent={agents[1]}
|
||||
selected={
|
||||
agents[1] !== undefined
|
||||
? state?.selectedStoreListingVersionId ==
|
||||
agents[1]?.store_listing_version_id
|
||||
: false
|
||||
}
|
||||
onClick={() =>
|
||||
updateState({
|
||||
selectedStoreListingVersionId: agents[1].store_listing_version_id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OnboardingFooter>
|
||||
<OnboardingButton
|
||||
href="/onboarding/5-run"
|
||||
disabled={isEmptyOrWhitespace(state?.selectedStoreListingVersionId)}
|
||||
>
|
||||
Next
|
||||
</OnboardingButton>
|
||||
</OnboardingFooter>
|
||||
</OnboardingStep>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { CredentialsMetaInput } from "@/app/api/__generated__/models/credentialsMetaInput";
|
||||
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
|
||||
import { CredentialsInput } from "@/components/contextual/CredentialsInput/CredentialsInput";
|
||||
import { useState } from "react";
|
||||
import { getSchemaDefaultCredentials } from "../../helpers";
|
||||
import { areAllCredentialsSet, getCredentialFields } from "./helpers";
|
||||
|
||||
type Credential = CredentialsMetaInput | undefined;
|
||||
type Credentials = Record<string, Credential>;
|
||||
|
||||
type Props = {
|
||||
agent: GraphModel | null;
|
||||
siblingInputs?: Record<string, any>;
|
||||
onCredentialsChange: (
|
||||
credentials: Record<string, CredentialsMetaInput>,
|
||||
) => void;
|
||||
onValidationChange: (isValid: boolean) => void;
|
||||
onLoadingChange: (isLoading: boolean) => void;
|
||||
};
|
||||
|
||||
export function AgentOnboardingCredentials(props: Props) {
|
||||
const [inputCredentials, setInputCredentials] = useState<Credentials>({});
|
||||
|
||||
const fields = getCredentialFields(props.agent);
|
||||
const required = Object.keys(fields || {}).length > 0;
|
||||
|
||||
if (!required) return null;
|
||||
|
||||
function handleSelectCredentials(key: string, value: Credential) {
|
||||
const updated = { ...inputCredentials, [key]: value };
|
||||
setInputCredentials(updated);
|
||||
|
||||
const sanitized: Record<string, CredentialsMetaInput> = {};
|
||||
for (const [k, v] of Object.entries(updated)) {
|
||||
if (v) sanitized[k] = v;
|
||||
}
|
||||
|
||||
props.onCredentialsChange(sanitized);
|
||||
|
||||
const isValid = !required || areAllCredentialsSet(fields, updated);
|
||||
props.onValidationChange(isValid);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.entries(fields).map(([key, inputSubSchema]) => (
|
||||
<div key={key} className="mt-4">
|
||||
<CredentialsInput
|
||||
schema={inputSubSchema}
|
||||
selectedCredentials={
|
||||
inputCredentials[key] ??
|
||||
getSchemaDefaultCredentials(inputSubSchema)
|
||||
}
|
||||
onSelectCredentials={(value) => handleSelectCredentials(key, value)}
|
||||
siblingInputs={props.siblingInputs}
|
||||
onLoaded={(loaded) => props.onLoadingChange(!loaded)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { CredentialsMetaInput } from "@/app/api/__generated__/models/credentialsMetaInput";
|
||||
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
|
||||
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api/types";
|
||||
|
||||
export function getCredentialFields(
|
||||
agent: GraphModel | null,
|
||||
): AgentCredentialsFields {
|
||||
if (!agent) return {};
|
||||
|
||||
const hasNoInputs =
|
||||
!agent.credentials_input_schema ||
|
||||
typeof agent.credentials_input_schema !== "object" ||
|
||||
!("properties" in agent.credentials_input_schema) ||
|
||||
!agent.credentials_input_schema.properties;
|
||||
|
||||
if (hasNoInputs) return {};
|
||||
|
||||
return agent.credentials_input_schema.properties as AgentCredentialsFields;
|
||||
}
|
||||
|
||||
export type AgentCredentialsFields = Record<
|
||||
string,
|
||||
BlockIOCredentialsSubSchema
|
||||
>;
|
||||
|
||||
export function areAllCredentialsSet(
|
||||
fields: AgentCredentialsFields,
|
||||
inputs: Record<string, CredentialsMetaInput | undefined>,
|
||||
) {
|
||||
const required = Object.keys(fields || {});
|
||||
return required.every((k) => Boolean(inputs[k]));
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { OnboardingText } from "../../components/OnboardingText";
|
||||
|
||||
type RunAgentHintProps = {
|
||||
handleNewRun: () => void;
|
||||
};
|
||||
|
||||
export function RunAgentHint(props: RunAgentHintProps) {
|
||||
return (
|
||||
<div className="ml-[104px] w-[481px] pl-5">
|
||||
<div className="flex flex-col">
|
||||
<OnboardingText variant="header">Run your first agent</OnboardingText>
|
||||
<span className="mt-9 text-base font-normal leading-normal text-zinc-600">
|
||||
A 'run' is when your agent starts working on a task
|
||||
</span>
|
||||
<span className="mt-4 text-base font-normal leading-normal text-zinc-600">
|
||||
Click on <b>New Run</b> below to try it out
|
||||
</span>
|
||||
|
||||
<div
|
||||
onClick={props.handleNewRun}
|
||||
className={cn(
|
||||
"mt-16 flex h-[68px] w-[330px] items-center justify-center rounded-xl border-2 border-violet-700 bg-neutral-50",
|
||||
"cursor-pointer transition-all duration-200 ease-in-out hover:bg-violet-50",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="38"
|
||||
height="38"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g stroke="#6d28d9" strokeWidth="1.2" strokeLinecap="round">
|
||||
<line x1="16" y1="8" x2="16" y2="24" />
|
||||
<line x1="8" y1="16" x2="24" y2="16" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="ml-3 font-sans text-[19px] font-medium leading-normal text-violet-700">
|
||||
New run
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails";
|
||||
import StarRating from "../../components/StarRating";
|
||||
import SmartImage from "@/components/__legacy__/SmartImage";
|
||||
|
||||
type Props = {
|
||||
storeAgent: StoreAgentDetails | null;
|
||||
};
|
||||
|
||||
export function SelectedAgentCard(props: Props) {
|
||||
return (
|
||||
<div className="fixed left-1/4 top-1/2 w-[481px] -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="h-[156px] w-[481px] rounded-xl bg-white px-6 pb-5 pt-4">
|
||||
<span className="font-sans text-xs font-medium tracking-wide text-zinc-500">
|
||||
SELECTED AGENT
|
||||
</span>
|
||||
{props.storeAgent ? (
|
||||
<div className="mt-4 flex h-20 rounded-lg bg-violet-50 p-3">
|
||||
{/* Left image */}
|
||||
<SmartImage
|
||||
src={props.storeAgent.agent_image[0]}
|
||||
alt="Agent cover"
|
||||
className="w-[350px] rounded-lg"
|
||||
/>
|
||||
{/* Right content */}
|
||||
<div className="ml-3 flex flex-1 flex-col">
|
||||
<div className="mb-2 flex flex-col items-start">
|
||||
<span className="data-sentry-unmask w-[292px] truncate font-sans text-[14px] font-medium leading-tight text-zinc-800">
|
||||
{props.storeAgent.agent_name}
|
||||
</span>
|
||||
<span className="data-sentry-unmask font-norma w-[292px] truncate font-sans text-xs text-zinc-600">
|
||||
by {props.storeAgent.creator}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex w-[292px] items-center justify-between">
|
||||
<span className="truncate font-sans text-xs font-normal leading-tight text-zinc-600">
|
||||
{props.storeAgent.runs.toLocaleString("en-US")} runs
|
||||
</span>
|
||||
<StarRating
|
||||
className="font-sans text-xs font-normal leading-tight text-zinc-600"
|
||||
starSize={12}
|
||||
rating={props.storeAgent.rating || 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex h-20 animate-pulse rounded-lg bg-gray-300 p-2" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import type {
|
||||
BlockIOCredentialsSubSchema,
|
||||
CredentialsMetaInput,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import type { InputValues } from "./types";
|
||||
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
|
||||
|
||||
export function computeInitialAgentInputs(
|
||||
agent: GraphModel | null,
|
||||
existingInputs?: InputValues | null,
|
||||
): InputValues {
|
||||
const properties = agent?.input_schema?.properties || {};
|
||||
const result: InputValues = {};
|
||||
|
||||
Object.entries(properties).forEach(([key, subSchema]) => {
|
||||
if (
|
||||
existingInputs &&
|
||||
key in existingInputs &&
|
||||
existingInputs[key] != null
|
||||
) {
|
||||
result[key] = existingInputs[key];
|
||||
return;
|
||||
}
|
||||
const def = (subSchema as unknown as { default?: string | number }).default;
|
||||
result[key] = def ?? "";
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
type IsRunDisabledParams = {
|
||||
agent: GraphModel | null;
|
||||
isRunning: boolean;
|
||||
agentInputs: InputValues | null | undefined;
|
||||
};
|
||||
|
||||
export function isRunDisabled({
|
||||
agent,
|
||||
isRunning,
|
||||
agentInputs,
|
||||
}: IsRunDisabledParams) {
|
||||
const hasEmptyInput = Object.values(agentInputs || {}).some(
|
||||
(value) => String(value).trim() === "",
|
||||
);
|
||||
|
||||
if (hasEmptyInput) return true;
|
||||
if (!agent) return true;
|
||||
if (isRunning) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getSchemaDefaultCredentials(
|
||||
schema: BlockIOCredentialsSubSchema,
|
||||
): CredentialsMetaInput | undefined {
|
||||
return schema.default as CredentialsMetaInput | undefined;
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/__legacy__/ui/card";
|
||||
import { RunAgentInputs } from "@/components/contextual/RunAgentInputs/RunAgentInputs";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { CircleNotchIcon } from "@phosphor-icons/react/dist/ssr";
|
||||
import { Play } from "lucide-react";
|
||||
import OnboardingButton from "../components/OnboardingButton";
|
||||
import { OnboardingHeader, OnboardingStep } from "../components/OnboardingStep";
|
||||
import { OnboardingText } from "../components/OnboardingText";
|
||||
import { AgentOnboardingCredentials } from "./components/AgentOnboardingCredentials/AgentOnboardingCredentials";
|
||||
import { RunAgentHint } from "./components/RunAgentHint";
|
||||
import { SelectedAgentCard } from "./components/SelectedAgentCard";
|
||||
import { isRunDisabled } from "./helpers";
|
||||
import type { InputValues } from "./types";
|
||||
import { useOnboardingRunStep } from "./useOnboardingRunStep";
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
ready,
|
||||
error,
|
||||
showInput,
|
||||
agentGraph,
|
||||
onboarding,
|
||||
storeAgent,
|
||||
runningAgent,
|
||||
handleSetAgentInput,
|
||||
handleRunAgent,
|
||||
handleNewRun,
|
||||
handleCredentialsChange,
|
||||
handleCredentialsValidationChange,
|
||||
handleCredentialsLoadingChange,
|
||||
} = useOnboardingRunStep();
|
||||
|
||||
if (error) {
|
||||
return <ErrorCard responseError={error} />;
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<CircleNotchIcon className="size-10 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingStep dotted>
|
||||
<OnboardingHeader backHref={"/onboarding/4-agent"} transparent />
|
||||
<div className="flex min-h-[80vh] items-center justify-center">
|
||||
<SelectedAgentCard storeAgent={storeAgent} />
|
||||
<div className="w-[481px]" />
|
||||
{!showInput ? (
|
||||
<RunAgentHint handleNewRun={handleNewRun} />
|
||||
) : (
|
||||
<div className="ml-[104px] w-[481px] pl-5">
|
||||
<div className="flex flex-col">
|
||||
<OnboardingText variant="header">
|
||||
Provide details for your agent
|
||||
</OnboardingText>
|
||||
<span className="mt-9 text-base font-normal leading-normal text-zinc-600">
|
||||
Give your agent the details it needs to work—just enter <br />
|
||||
the key information and get started.
|
||||
</span>
|
||||
<span className="mt-4 text-base font-normal leading-normal text-zinc-600">
|
||||
When you're done, click <b>Run Agent</b>.
|
||||
</span>
|
||||
|
||||
<Card className="agpt-box mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-poppins text-lg">Input</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{Object.entries(
|
||||
agentGraph?.input_schema.properties || {},
|
||||
).map(([key, inputSubSchema]) => (
|
||||
<RunAgentInputs
|
||||
key={key}
|
||||
schema={inputSubSchema}
|
||||
value={onboarding.state?.agentInput?.[key]}
|
||||
placeholder={inputSubSchema.description}
|
||||
onChange={(value) => handleSetAgentInput(key, value)}
|
||||
/>
|
||||
))}
|
||||
<AgentOnboardingCredentials
|
||||
agent={agentGraph}
|
||||
siblingInputs={
|
||||
(onboarding.state?.agentInput as Record<string, any>) ||
|
||||
undefined
|
||||
}
|
||||
onCredentialsChange={handleCredentialsChange}
|
||||
onValidationChange={handleCredentialsValidationChange}
|
||||
onLoadingChange={handleCredentialsLoadingChange}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<OnboardingButton
|
||||
variant="violet"
|
||||
className="mt-8 w-[136px]"
|
||||
loading={runningAgent}
|
||||
disabled={isRunDisabled({
|
||||
agent: agentGraph,
|
||||
isRunning: runningAgent,
|
||||
agentInputs:
|
||||
(onboarding.state?.agentInput as unknown as InputValues) ||
|
||||
null,
|
||||
})}
|
||||
onClick={handleRunAgent}
|
||||
icon={<Play className="mr-2" size={18} />}
|
||||
>
|
||||
Run agent
|
||||
</OnboardingButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</OnboardingStep>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export type InputPrimitive = string | number;
|
||||
export type InputValues = Record<string, InputPrimitive>;
|
||||
@@ -1,157 +0,0 @@
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { useOnboarding } from "@/providers/onboarding/onboarding-provider";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { computeInitialAgentInputs } from "./helpers";
|
||||
import { InputValues } from "./types";
|
||||
import { okData, resolveResponse } from "@/app/api/helpers";
|
||||
import { postV2AddMarketplaceAgent } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import {
|
||||
useGetV2GetAgentByVersion,
|
||||
useGetV2GetAgentGraph,
|
||||
} from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { CredentialsMetaInput } from "@/app/api/__generated__/models/credentialsMetaInput";
|
||||
import { GraphID } from "@/lib/autogpt-server-api";
|
||||
|
||||
export function useOnboardingRunStep() {
|
||||
const onboarding = useOnboarding(undefined, "AGENT_CHOICE");
|
||||
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const [runningAgent, setRunningAgent] = useState(false);
|
||||
|
||||
const [inputCredentials, setInputCredentials] = useState<
|
||||
Record<string, CredentialsMetaInput>
|
||||
>({});
|
||||
|
||||
const [credentialsValid, setCredentialsValid] = useState(true);
|
||||
const [credentialsLoaded, setCredentialsLoaded] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const api = useBackendAPI();
|
||||
|
||||
const currentAgentVersion =
|
||||
onboarding.state?.selectedStoreListingVersionId ?? "";
|
||||
|
||||
const {
|
||||
data: storeAgent,
|
||||
error: storeAgentQueryError,
|
||||
isSuccess: storeAgentQueryIsSuccess,
|
||||
} = useGetV2GetAgentByVersion(currentAgentVersion, {
|
||||
query: {
|
||||
enabled: !!currentAgentVersion,
|
||||
select: okData,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: agentGraphMeta,
|
||||
error: agentGraphQueryError,
|
||||
isSuccess: agentGraphQueryIsSuccess,
|
||||
} = useGetV2GetAgentGraph(currentAgentVersion, {
|
||||
query: {
|
||||
enabled: !!currentAgentVersion,
|
||||
select: okData,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onboarding.setStep(5);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (agentGraphMeta && onboarding.state) {
|
||||
const initialAgentInputs = computeInitialAgentInputs(
|
||||
agentGraphMeta,
|
||||
(onboarding.state.agentInput as unknown as InputValues) || null,
|
||||
);
|
||||
|
||||
onboarding.updateState({ agentInput: initialAgentInputs });
|
||||
}
|
||||
}, [agentGraphMeta]);
|
||||
|
||||
function handleNewRun() {
|
||||
if (!onboarding.state) return;
|
||||
|
||||
setShowInput(true);
|
||||
onboarding.setStep(6);
|
||||
onboarding.completeStep("AGENT_NEW_RUN");
|
||||
}
|
||||
|
||||
function handleSetAgentInput(key: string, value: string) {
|
||||
if (!onboarding.state) return;
|
||||
|
||||
onboarding.updateState({
|
||||
agentInput: {
|
||||
...onboarding.state.agentInput,
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRunAgent() {
|
||||
if (!agentGraphMeta || !storeAgent || !onboarding.state) {
|
||||
toast({
|
||||
title: "Error getting agent",
|
||||
description:
|
||||
"Either the agent is not available or there was an error getting it.",
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setRunningAgent(true);
|
||||
|
||||
try {
|
||||
const libraryAgent = await resolveResponse(
|
||||
postV2AddMarketplaceAgent({
|
||||
store_listing_version_id: storeAgent?.store_listing_version_id || "",
|
||||
source: "onboarding",
|
||||
}),
|
||||
);
|
||||
|
||||
const { id: runID } = await api.executeGraph(
|
||||
libraryAgent.graph_id as GraphID,
|
||||
libraryAgent.graph_version,
|
||||
onboarding.state.agentInput || {},
|
||||
inputCredentials,
|
||||
"onboarding",
|
||||
);
|
||||
|
||||
onboarding.updateState({ onboardingAgentExecutionId: runID });
|
||||
|
||||
router.push("/onboarding/6-congrats");
|
||||
} catch (error) {
|
||||
console.error("Error running agent:", error);
|
||||
|
||||
toast({
|
||||
title: "Error running agent",
|
||||
description:
|
||||
"There was an error running your agent. Please try again or try choosing a different agent if it still fails.",
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
setRunningAgent(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ready: agentGraphQueryIsSuccess && storeAgentQueryIsSuccess,
|
||||
error: agentGraphQueryError || storeAgentQueryError,
|
||||
agentGraph: agentGraphMeta || null,
|
||||
onboarding,
|
||||
showInput,
|
||||
storeAgent: storeAgent || null,
|
||||
runningAgent,
|
||||
credentialsValid,
|
||||
credentialsLoaded,
|
||||
handleSetAgentInput,
|
||||
handleRunAgent,
|
||||
handleNewRun,
|
||||
handleCredentialsChange: setInputCredentials,
|
||||
handleCredentialsValidationChange: setCredentialsValid,
|
||||
handleCredentialsLoadingChange: (v: boolean) => setCredentialsLoaded(!v),
|
||||
};
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
"use client";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useOnboarding } from "../../../../providers/onboarding/onboarding-provider";
|
||||
import { resolveResponse } from "@/app/api/helpers";
|
||||
import { getV1OnboardingState } from "@/app/api/__generated__/endpoints/onboarding/onboarding";
|
||||
import { postV2AddMarketplaceAgent } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { Confetti } from "@/components/molecules/Confetti/Confetti";
|
||||
import type { ConfettiRef } from "@/components/molecules/Confetti/Confetti";
|
||||
|
||||
export default function Page() {
|
||||
const { completeStep } = useOnboarding(7, "AGENT_INPUT");
|
||||
const router = useRouter();
|
||||
const api = useBackendAPI();
|
||||
const [showText, setShowText] = useState(false);
|
||||
const [showSubtext, setShowSubtext] = useState(false);
|
||||
const confettiRef = useRef<ConfettiRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Fire side cannons for a celebratory effect
|
||||
const duration = 1500;
|
||||
const end = Date.now() + duration;
|
||||
|
||||
function frame() {
|
||||
confettiRef.current?.fire({
|
||||
particleCount: 4,
|
||||
angle: 60,
|
||||
spread: 70,
|
||||
origin: { x: 0, y: 0.6 },
|
||||
shapes: ["square"],
|
||||
scalar: 0.8,
|
||||
gravity: 0.6,
|
||||
decay: 0.93,
|
||||
});
|
||||
confettiRef.current?.fire({
|
||||
particleCount: 4,
|
||||
angle: 120,
|
||||
spread: 70,
|
||||
origin: { x: 1, y: 0.6 },
|
||||
shapes: ["square"],
|
||||
scalar: 0.8,
|
||||
gravity: 0.6,
|
||||
decay: 0.93,
|
||||
});
|
||||
|
||||
if (Date.now() < end) {
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
}
|
||||
|
||||
frame();
|
||||
|
||||
const timer0 = setTimeout(() => {
|
||||
setShowText(true);
|
||||
}, 100);
|
||||
|
||||
const timer1 = setTimeout(() => {
|
||||
setShowSubtext(true);
|
||||
}, 500);
|
||||
|
||||
const timer2 = setTimeout(async () => {
|
||||
completeStep("CONGRATS");
|
||||
|
||||
try {
|
||||
const onboarding = await resolveResponse(getV1OnboardingState());
|
||||
if (onboarding?.selectedStoreListingVersionId) {
|
||||
try {
|
||||
const libraryAgent = await resolveResponse(
|
||||
postV2AddMarketplaceAgent({
|
||||
store_listing_version_id:
|
||||
onboarding.selectedStoreListingVersionId,
|
||||
source: "onboarding",
|
||||
}),
|
||||
);
|
||||
router.replace(`/library/agents/${libraryAgent.id}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to add agent to library:", error);
|
||||
router.replace("/library");
|
||||
}
|
||||
} else {
|
||||
router.replace("/library");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get onboarding data:", error);
|
||||
router.replace("/library");
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer0);
|
||||
clearTimeout(timer1);
|
||||
clearTimeout(timer2);
|
||||
};
|
||||
}, [completeStep, router, api]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center bg-violet-100">
|
||||
<Confetti ref={confettiRef} manualstart />
|
||||
<div
|
||||
className={cn(
|
||||
"z-10 -mb-16 text-9xl duration-500",
|
||||
showText ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
🎉
|
||||
</div>
|
||||
<h1
|
||||
className={cn(
|
||||
"font-poppins text-9xl font-medium tracking-tighter text-violet-700 duration-500",
|
||||
showText ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
Congrats!
|
||||
</h1>
|
||||
<p
|
||||
className={cn(
|
||||
"mb-16 mt-4 font-poppins text-2xl font-medium text-violet-800 transition-opacity duration-500",
|
||||
showSubtext ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
You earned 3$ for running your first agent
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import StarRating from "./StarRating";
|
||||
import SmartImage from "@/components/__legacy__/SmartImage";
|
||||
import { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails";
|
||||
|
||||
type OnboardingAgentCardProps = {
|
||||
agent?: StoreAgentDetails;
|
||||
selected?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export default function OnboardingAgentCard({
|
||||
agent,
|
||||
selected,
|
||||
onClick,
|
||||
}: OnboardingAgentCardProps) {
|
||||
if (!agent) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative animate-pulse",
|
||||
"h-[394px] w-[368px] rounded-[20px] border border-transparent bg-zinc-200",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
agent_image,
|
||||
creator_avatar,
|
||||
agent_name,
|
||||
description,
|
||||
creator,
|
||||
runs,
|
||||
rating,
|
||||
} = agent;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative cursor-pointer transition-all duration-200 ease-in-out",
|
||||
"h-[394px] w-[368px] rounded-[20px] border border-transparent bg-white",
|
||||
selected ? "bg-[#F5F3FF80]" : "hover:border-zinc-400",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Image container */}
|
||||
<div className="relative">
|
||||
<SmartImage
|
||||
src={agent_image?.[0]}
|
||||
alt="Agent cover"
|
||||
className="m-2 h-[196px] w-[350px] rounded-[16px]"
|
||||
/>
|
||||
{/* Profile picture overlay */}
|
||||
<div className="absolute bottom-2 left-4">
|
||||
<SmartImage
|
||||
src={creator_avatar}
|
||||
alt="Profile picture"
|
||||
className="h-[50px] w-[50px] rounded-full border border-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content container */}
|
||||
<div className="flex h-[180px] flex-col justify-between px-4 pb-3">
|
||||
{/* Text content wrapper */}
|
||||
<div>
|
||||
{/* Title - 2 lines max */}
|
||||
<p className="data-sentry-unmask text-md line-clamp-2 max-h-[50px] font-sans text-base font-medium leading-normal text-zinc-800">
|
||||
{agent_name}
|
||||
</p>
|
||||
|
||||
{/* Author - single line with truncate */}
|
||||
<p className="data-sentry-unmask truncate text-sm font-normal leading-normal text-zinc-600">
|
||||
by {creator}
|
||||
</p>
|
||||
|
||||
{/* Description - 3 lines max */}
|
||||
<p
|
||||
className={cn(
|
||||
"mt-2 line-clamp-3 text-sm leading-5",
|
||||
selected ? "text-zinc-500" : "text-zinc-400",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bottom stats */}
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="mt-1 font-sans text-sm font-medium text-zinc-800">
|
||||
{runs?.toLocaleString("en-US")} runs
|
||||
</span>
|
||||
<StarRating rating={rating} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 rounded-[20px] border-2 transition-all duration-200 ease-in-out",
|
||||
selected ? "border-violet-700" : "border-transparent",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface OnboardingBackButtonProps {
|
||||
href: string;
|
||||
}
|
||||
|
||||
export default function OnboardingBackButton({
|
||||
href,
|
||||
}: OnboardingBackButtonProps) {
|
||||
return (
|
||||
<Link
|
||||
className="flex items-center gap-2 font-sans text-base font-medium text-zinc-700 transition-colors duration-200 hover:text-zinc-800"
|
||||
href={href}
|
||||
>
|
||||
<ChevronLeft size={24} className="-mr-1" />
|
||||
<span>Back</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { LoadingSpinner } from "@/components/__legacy__/ui/loading";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
|
||||
const variants = {
|
||||
default: "bg-zinc-700 hover:bg-zinc-800",
|
||||
violet: "bg-violet-600 hover:bg-violet-700",
|
||||
};
|
||||
|
||||
type OnboardingButtonProps = {
|
||||
className?: string;
|
||||
variant?: keyof typeof variants;
|
||||
children?: React.ReactNode;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function OnboardingButton({
|
||||
className,
|
||||
variant = "default",
|
||||
children,
|
||||
loading,
|
||||
disabled,
|
||||
onClick,
|
||||
href,
|
||||
icon,
|
||||
}: OnboardingButtonProps) {
|
||||
const [internalLoading, setInternalLoading] = useState(false);
|
||||
const isLoading = loading !== undefined ? loading : internalLoading;
|
||||
|
||||
const buttonClasses = useMemo(
|
||||
() =>
|
||||
cn(
|
||||
"font-sans text-white text-sm font-medium",
|
||||
"inline-flex justify-center items-center",
|
||||
"h-12 min-w-[100px] rounded-full py-3 px-5",
|
||||
"transition-colors duration-200",
|
||||
className,
|
||||
disabled ? "bg-zinc-300 cursor-not-allowed" : variants[variant],
|
||||
),
|
||||
[disabled, variant, className],
|
||||
);
|
||||
|
||||
const onClickInternal = useCallback(() => {
|
||||
setInternalLoading(true);
|
||||
if (onClick) {
|
||||
onClick();
|
||||
}
|
||||
}, [setInternalLoading, onClick]);
|
||||
|
||||
if (href && !disabled) {
|
||||
return (
|
||||
<Link href={href} onClick={onClickInternal} className={buttonClasses}>
|
||||
{isLoading && <LoadingSpinner className="mr-2 size-5" />}
|
||||
{icon && !isLoading && <>{icon}</>}
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClickInternal}
|
||||
disabled={disabled}
|
||||
className={buttonClasses}
|
||||
>
|
||||
{isLoading && <LoadingSpinner className="mr-2 size-5" />}
|
||||
{icon && !isLoading && <>{icon}</>}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import SmartImage from "@/components/__legacy__/SmartImage";
|
||||
|
||||
type OnboardingGridElementProps = {
|
||||
name: string;
|
||||
text: string;
|
||||
icon: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
function OnboardingGridElement({
|
||||
name,
|
||||
text,
|
||||
icon,
|
||||
selected,
|
||||
onClick,
|
||||
}: OnboardingGridElementProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"relative flex h-[236px] w-[200px] flex-col items-start gap-2 rounded-xl border border-transparent bg-white p-[15px] font-sans",
|
||||
"transition-all duration-200 ease-in-out",
|
||||
selected ? "bg-[#F5F3FF80]" : "hover:border-zinc-400",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<SmartImage
|
||||
src={icon}
|
||||
alt={`Logo of ${name}`}
|
||||
imageContain
|
||||
className="h-12 w-12 rounded-lg"
|
||||
/>
|
||||
<span className="text-md mt-4 w-full text-left font-medium leading-normal text-[#121212]">
|
||||
{name}
|
||||
</span>
|
||||
<span className="w-full text-left text-[11.5px] font-normal leading-5 text-zinc-500">
|
||||
{text}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 rounded-xl border-2 transition-all duration-200 ease-in-out",
|
||||
selected ? "border-violet-700" : "border-transparent",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type OnboardingGridProps = {
|
||||
className?: string;
|
||||
elements: Array<{
|
||||
name: string;
|
||||
text: string;
|
||||
icon: string;
|
||||
}>;
|
||||
selected?: string[];
|
||||
onSelect: (name: string) => void;
|
||||
};
|
||||
|
||||
export function OnboardingGrid({
|
||||
className,
|
||||
elements,
|
||||
selected,
|
||||
onSelect,
|
||||
}: OnboardingGridProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
className,
|
||||
"grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{elements.map((element) => (
|
||||
<OnboardingGridElement
|
||||
key={element.name}
|
||||
name={element.name}
|
||||
text={element.text}
|
||||
icon={element.icon}
|
||||
selected={selected?.includes(element.name) || false}
|
||||
onClick={() => onSelect(element.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OnboardingInputProps {
|
||||
className?: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function OnboardingInput({
|
||||
className,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
}: OnboardingInputProps) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
className,
|
||||
"font-poppin relative h-[50px] w-[512px] rounded-[25px] border border-transparent bg-white px-4 text-sm font-normal leading-normal text-zinc-900",
|
||||
"transition-all duration-200 ease-in-out placeholder:text-zinc-400",
|
||||
"focus:border-transparent focus:bg-[#F5F3FF80] focus:outline-none focus:ring-2 focus:ring-violet-700",
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
type OnboardingListElementProps = {
|
||||
label: string;
|
||||
text: string;
|
||||
selected?: boolean;
|
||||
custom?: boolean;
|
||||
onClick: (content: string) => void;
|
||||
};
|
||||
|
||||
export function OnboardingListElement({
|
||||
label,
|
||||
text,
|
||||
selected,
|
||||
custom,
|
||||
onClick,
|
||||
}: OnboardingListElementProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [content, setContent] = useState(text);
|
||||
|
||||
useEffect(() => {
|
||||
if (selected && custom && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [selected, custom]);
|
||||
|
||||
const setCustomText = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setContent(e.target.value);
|
||||
onClick(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onClick(content)}
|
||||
className={cn(
|
||||
"relative flex h-[78px] w-[530px] items-center rounded-xl border border-transparent px-5 py-4 transition-all duration-200 ease-in-out",
|
||||
selected ? "bg-[#F5F3FF80]" : "bg-white hover:border-zinc-400",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full flex-col items-start gap-1">
|
||||
<span className="text-sm font-medium text-zinc-700">{label}</span>
|
||||
{custom && selected ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={cn(
|
||||
selected ? "text-zinc-600" : "text-zinc-400",
|
||||
"font-poppin w-full border-0 bg-[#F5F3FF80] text-sm focus:outline-none",
|
||||
)}
|
||||
placeholder="Please specify"
|
||||
value={content}
|
||||
onChange={setCustomText}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
selected ? "text-zinc-600" : "text-zinc-400",
|
||||
"text-sm",
|
||||
)}
|
||||
>
|
||||
{custom ? "Please specify" : text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!custom && (
|
||||
<div className="absolute right-4">
|
||||
<Check
|
||||
size={24}
|
||||
className={cn(
|
||||
"transition-all duration-200 ease-in-out",
|
||||
selected ? "text-violet-700" : "text-transparent",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 rounded-xl border-2 transition-all duration-200 ease-in-out",
|
||||
selected ? "border-violet-700" : "border-transparent",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type OnboardingListProps = {
|
||||
className?: string;
|
||||
elements: Array<{
|
||||
label: string;
|
||||
text: string;
|
||||
id: string;
|
||||
}>;
|
||||
selectedId?: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
};
|
||||
|
||||
function OnboardingList({
|
||||
className,
|
||||
elements,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: OnboardingListProps) {
|
||||
const isCustom = useCallback(() => {
|
||||
return (
|
||||
selectedId !== null &&
|
||||
!elements.some((element) => element.id === selectedId)
|
||||
);
|
||||
}, [selectedId, elements]);
|
||||
|
||||
return (
|
||||
<div className={cn(className, "flex flex-col gap-2")}>
|
||||
{elements.map((element) => (
|
||||
<OnboardingListElement
|
||||
key={element.id}
|
||||
label={element.label}
|
||||
text={element.text}
|
||||
selected={element.id === selectedId}
|
||||
onClick={() => onSelect(element.id)}
|
||||
/>
|
||||
))}
|
||||
<OnboardingListElement
|
||||
label="Other"
|
||||
text={isCustom() ? selectedId! : ""}
|
||||
selected={isCustom()}
|
||||
custom
|
||||
onClick={(c) => {
|
||||
onSelect(c);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OnboardingList;
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
interface OnboardingProgressProps {
|
||||
totalSteps: number;
|
||||
toStep: number;
|
||||
}
|
||||
|
||||
export default function OnboardingProgress({
|
||||
totalSteps,
|
||||
toStep,
|
||||
}: OnboardingProgressProps) {
|
||||
const [animatedStep, setAnimatedStep] = useState(toStep - 1);
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
// On initial mount, just set the position without animation
|
||||
isInitialMount.current = false;
|
||||
return;
|
||||
}
|
||||
// After initial mount, animate position changes
|
||||
setAnimatedStep(toStep - 1);
|
||||
}, [toStep]);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center gap-3">
|
||||
{/* Background circles */}
|
||||
{Array.from({ length: totalSteps + 1 }).map((_, index) => (
|
||||
<div key={index} className="h-2 w-2 rounded-full bg-zinc-400" />
|
||||
))}
|
||||
|
||||
{/* Animated progress indicator */}
|
||||
<div
|
||||
className={`absolute left-0 h-2 w-7 rounded-full bg-zinc-400 ${
|
||||
!isInitialMount.current
|
||||
? "transition-all duration-300 ease-in-out"
|
||||
: ""
|
||||
}`}
|
||||
style={{
|
||||
transform: `translateX(${animatedStep * 20}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
import { ReactNode } from "react";
|
||||
import OnboardingBackButton from "./OnboardingBackButton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import OnboardingProgress from "./OnboardingProgress";
|
||||
import { useOnboarding } from "../../../../providers/onboarding/onboarding-provider";
|
||||
|
||||
export function OnboardingStep({
|
||||
dotted,
|
||||
children,
|
||||
}: {
|
||||
dotted?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex min-h-screen w-full flex-col">
|
||||
{dotted && (
|
||||
<div className="absolute left-1/2 h-full w-1/2 bg-white bg-[radial-gradient(#e5e7eb77_1px,transparent_1px)] [background-size:10px_10px]"></div>
|
||||
)}
|
||||
<div className="z-10 flex flex-col items-center">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OnboardingHeaderProps {
|
||||
backHref: string;
|
||||
transparent?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function OnboardingHeader({
|
||||
backHref,
|
||||
transparent,
|
||||
children,
|
||||
}: OnboardingHeaderProps) {
|
||||
const { step } = useOnboarding();
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-10 w-full">
|
||||
<div
|
||||
className={cn(transparent ? "bg-transparent" : "bg-gray-100", "pb-5")}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between px-5 py-4">
|
||||
<OnboardingBackButton href={backHref} />
|
||||
<OnboardingProgress totalSteps={5} toStep={(step || 1) - 1} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{!transparent && (
|
||||
<div className="h-4 w-full bg-gradient-to-b from-gray-100 via-gray-100/50 to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OnboardingFooter({ children }: { children?: ReactNode }) {
|
||||
return (
|
||||
<div className="sticky bottom-0 z-10 w-full">
|
||||
<div className="h-4 w-full bg-gradient-to-t from-gray-100 via-gray-100/50 to-transparent" />
|
||||
<div className="flex justify-center bg-gray-100">
|
||||
<div className="px-5 py-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const variants = {
|
||||
header: "font-poppin text-xl font-medium leading-7 text-zinc-900",
|
||||
subheader: "font-sans text-sm font-medium leading-6 text-zinc-800",
|
||||
default: "font-sans text-sm font-normal leading-6 text-zinc-500",
|
||||
};
|
||||
|
||||
export function OnboardingText({
|
||||
className,
|
||||
center,
|
||||
variant = "default",
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
center?: boolean;
|
||||
variant?: keyof typeof variants;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full",
|
||||
center ? "text-center" : "text-left",
|
||||
variants[variant] || variants.default,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
interface Props {
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
}
|
||||
|
||||
export function ProgressBar({ currentStep, totalSteps }: Props) {
|
||||
const percent = (currentStep / totalSteps) * 100;
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 top-0 h-[0.625rem] w-full bg-neutral-300">
|
||||
<div
|
||||
className="h-full bg-purple-400 shadow-[0_0_4px_2px_rgba(168,85,247,0.5)] transition-all duration-500 ease-out"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SelectableCard({
|
||||
icon,
|
||||
label,
|
||||
selected,
|
||||
onClick,
|
||||
className,
|
||||
}: Props) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"flex h-[9rem] w-[10.375rem] shrink-0 flex-col items-center justify-center gap-3 rounded-xl border-2 bg-white px-6 py-5 transition-all hover:shadow-sm md:shrink lg:gap-2 lg:px-10 lg:py-8",
|
||||
className,
|
||||
selected
|
||||
? "border-purple-500 bg-purple-50 shadow-sm"
|
||||
: "border-transparent",
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
variant="lead"
|
||||
as="span"
|
||||
className={selected ? "text-neutral-900" : "text-purple-600"}
|
||||
>
|
||||
{icon}
|
||||
</Text>
|
||||
<Text variant="body-medium" as="span" className="whitespace-nowrap">
|
||||
{label}
|
||||
</Text>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMemo } from "react";
|
||||
import { FaRegStar, FaStar, FaStarHalfAlt } from "react-icons/fa";
|
||||
|
||||
export default function StarRating({
|
||||
className,
|
||||
starSize,
|
||||
rating,
|
||||
}: {
|
||||
className?: string;
|
||||
starSize?: number;
|
||||
rating: number;
|
||||
}) {
|
||||
// Round to 1 decimal place
|
||||
const roundedRating = Math.round(rating * 10) / 10;
|
||||
starSize ??= 15;
|
||||
|
||||
// Generate array of 5 star values
|
||||
const stars = useMemo(
|
||||
() =>
|
||||
Array(5)
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
const difference = roundedRating - index;
|
||||
|
||||
if (difference >= 1) {
|
||||
return "full";
|
||||
} else if (difference > 0) {
|
||||
// Half star for values between 0.2 and 0.8
|
||||
return difference >= 0.8
|
||||
? "full"
|
||||
: difference >= 0.2
|
||||
? "half"
|
||||
: "empty";
|
||||
}
|
||||
return "empty";
|
||||
}),
|
||||
[roundedRating],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-0.5 text-sm font-medium text-zinc-800",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Display numerical rating */}
|
||||
<span className="mr-1 mt-0.5">{roundedRating}</span>
|
||||
|
||||
{/* Display stars */}
|
||||
{stars.map((starType, index) => {
|
||||
if (starType === "full") {
|
||||
return <FaStar size={starSize} key={index} />;
|
||||
} else if (starType === "half") {
|
||||
return <FaStarHalfAlt size={starSize} key={index} />;
|
||||
} else {
|
||||
return <FaRegStar size={starSize} key={index} />;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
totalSteps: number;
|
||||
currentStep: number;
|
||||
}
|
||||
|
||||
export function StepIndicator({ totalSteps, currentStep }: Props) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{Array.from({ length: totalSteps }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"h-2 rounded-full transition-all",
|
||||
i + 1 === currentStep ? "w-6 bg-foreground" : "w-2 bg-gray-300",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,8 @@ export default function OnboardingLayout({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center bg-gray-100">
|
||||
<main className="mx-auto flex w-full flex-col items-center">
|
||||
<div className="relative flex min-h-screen w-full flex-col bg-gray-100">
|
||||
<main className="flex w-full flex-1 flex-col items-center justify-center">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,73 +1,59 @@
|
||||
"use client";
|
||||
import { getV1OnboardingState } from "@/app/api/__generated__/endpoints/onboarding/onboarding";
|
||||
import { getOnboardingStatus, resolveResponse } from "@/app/api/helpers";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { CaretLeft } from "@phosphor-icons/react";
|
||||
import { ProgressBar } from "./components/ProgressBar";
|
||||
import { StepIndicator } from "./components/StepIndicator";
|
||||
import { PainPointsStep } from "./steps/PainPointsStep";
|
||||
import { PreparingStep } from "./steps/PreparingStep";
|
||||
import { RoleStep } from "./steps/RoleStep";
|
||||
import { WelcomeStep } from "./steps/WelcomeStep";
|
||||
import { useOnboardingWizardStore } from "./store";
|
||||
import { useOnboardingPage } from "./useOnboardingPage";
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const { currentStep, isLoading, handlePreparingComplete } =
|
||||
useOnboardingPage();
|
||||
const prevStep = useOnboardingWizardStore((s) => s.prevStep);
|
||||
|
||||
useEffect(() => {
|
||||
async function redirectToStep() {
|
||||
try {
|
||||
// Check if onboarding is enabled (also gets chat flag for redirect)
|
||||
const { shouldShowOnboarding } = await getOnboardingStatus();
|
||||
if (isLoading) return null;
|
||||
|
||||
if (!shouldShowOnboarding) {
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
const totalSteps = 4;
|
||||
const showDots = currentStep <= 3;
|
||||
const showBack = currentStep > 1 && currentStep <= 3;
|
||||
|
||||
const onboarding = await resolveResponse(getV1OnboardingState());
|
||||
const showProgressBar = currentStep <= 3;
|
||||
|
||||
// Handle completed onboarding
|
||||
if (onboarding.completedSteps.includes("GET_RESULTS")) {
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<div className="flex min-h-screen w-full flex-col items-center">
|
||||
{showProgressBar && (
|
||||
<ProgressBar currentStep={currentStep} totalSteps={totalSteps} />
|
||||
)}
|
||||
|
||||
// Redirect to appropriate step based on completed steps
|
||||
if (onboarding.completedSteps.includes("AGENT_INPUT")) {
|
||||
router.push("/onboarding/5-run");
|
||||
return;
|
||||
}
|
||||
{showBack && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevStep}
|
||||
className="text-md absolute left-6 top-6 flex items-center gap-1 text-zinc-500 transition-colors duration-200 hover:text-zinc-900"
|
||||
>
|
||||
<CaretLeft size={16} />
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
|
||||
if (onboarding.completedSteps.includes("AGENT_NEW_RUN")) {
|
||||
router.push("/onboarding/5-run");
|
||||
return;
|
||||
}
|
||||
<div className="flex flex-1 items-center py-16">
|
||||
{currentStep === 1 && <WelcomeStep />}
|
||||
{currentStep === 2 && <RoleStep />}
|
||||
{currentStep === 3 && <PainPointsStep />}
|
||||
{currentStep === 4 && (
|
||||
<PreparingStep onComplete={handlePreparingComplete} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
if (onboarding.completedSteps.includes("AGENT_CHOICE")) {
|
||||
router.push("/onboarding/5-run");
|
||||
return;
|
||||
}
|
||||
|
||||
if (onboarding.completedSteps.includes("INTEGRATIONS")) {
|
||||
router.push("/onboarding/4-agent");
|
||||
return;
|
||||
}
|
||||
|
||||
if (onboarding.completedSteps.includes("USAGE_REASON")) {
|
||||
router.push("/onboarding/3-services");
|
||||
return;
|
||||
}
|
||||
|
||||
if (onboarding.completedSteps.includes("WELCOME")) {
|
||||
router.push("/onboarding/2-reason");
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: redirect to first step
|
||||
router.push("/onboarding/1-welcome");
|
||||
} catch (error) {
|
||||
console.error("Failed to determine onboarding step:", error);
|
||||
router.replace("/");
|
||||
}
|
||||
}
|
||||
|
||||
redirectToStep();
|
||||
}, [router]);
|
||||
|
||||
return <LoadingSpinner size="large" cover />;
|
||||
{showDots && (
|
||||
<div className="pb-8">
|
||||
<StepIndicator totalSteps={3} currentStep={currentStep} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client";
|
||||
import { postV1ResetOnboardingProgress } from "@/app/api/__generated__/endpoints/onboarding/onboarding";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function OnboardingResetPage() {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
postV1ResetOnboardingProgress()
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Onboarding reset successfully",
|
||||
description: "You can now start the onboarding process again",
|
||||
variant: "success",
|
||||
});
|
||||
|
||||
router.push("/onboarding");
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: "Failed to reset onboarding",
|
||||
description: "Please try again later",
|
||||
variant: "destructive",
|
||||
});
|
||||
});
|
||||
}, [toast, router]);
|
||||
|
||||
return <LoadingSpinner cover />;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { FadeIn } from "@/components/atoms/FadeIn/FadeIn";
|
||||
import { SelectableCard } from "../components/SelectableCard";
|
||||
import { usePainPointsStep } from "./usePainPointsStep";
|
||||
import { Emoji } from "@/components/atoms/Emoji/Emoji";
|
||||
|
||||
const ALL_PAIN_POINTS: { id: string; label: string; icon: ReactNode }[] = [
|
||||
{
|
||||
id: "Finding leads",
|
||||
label: "Finding leads",
|
||||
icon: <Emoji text="🔍" size={32} />,
|
||||
},
|
||||
{
|
||||
id: "Email & outreach",
|
||||
label: "Email & outreach",
|
||||
icon: <Emoji text="📧" size={32} />,
|
||||
},
|
||||
{
|
||||
id: "Reports & data",
|
||||
label: "Reports & data",
|
||||
icon: <Emoji text="📊" size={32} />,
|
||||
},
|
||||
{
|
||||
id: "Customer support",
|
||||
label: "Customer support",
|
||||
icon: <Emoji text="💬" size={32} />,
|
||||
},
|
||||
{
|
||||
id: "Social media",
|
||||
label: "Social media",
|
||||
icon: <Emoji text="📱" size={32} />,
|
||||
},
|
||||
{
|
||||
id: "CRM & data entry",
|
||||
label: "CRM & data entry",
|
||||
icon: <Emoji text="📝" size={32} />,
|
||||
},
|
||||
{
|
||||
id: "Scheduling",
|
||||
label: "Scheduling",
|
||||
icon: <Emoji text="🗓️" size={32} />,
|
||||
},
|
||||
{ id: "Research", label: "Research", icon: <Emoji text="🔬" size={32} /> },
|
||||
{
|
||||
id: "Something else",
|
||||
label: "Something else",
|
||||
icon: <Emoji text="🚩" size={32} />,
|
||||
},
|
||||
];
|
||||
|
||||
function orderPainPoints(topIDs: string[]) {
|
||||
const top = topIDs
|
||||
.map((id) => ALL_PAIN_POINTS.find((p) => p.id === id))
|
||||
.filter((p): p is (typeof ALL_PAIN_POINTS)[number] => p != null);
|
||||
const rest = ALL_PAIN_POINTS.filter(
|
||||
(p) => !topIDs.includes(p.id) && p.id !== "Something else",
|
||||
);
|
||||
const somethingElse = ALL_PAIN_POINTS.find((p) => p.id === "Something else")!;
|
||||
return [...top, ...rest, somethingElse];
|
||||
}
|
||||
|
||||
export function PainPointsStep() {
|
||||
const {
|
||||
topIDs,
|
||||
painPoints,
|
||||
otherPainPoint,
|
||||
togglePainPoint,
|
||||
setOtherPainPoint,
|
||||
hasSomethingElse,
|
||||
canContinue,
|
||||
handleLaunch,
|
||||
} = usePainPointsStep();
|
||||
|
||||
const orderedPainPoints = orderPainPoints(topIDs);
|
||||
|
||||
return (
|
||||
<FadeIn>
|
||||
<div className="flex w-full flex-col items-center gap-12 px-4">
|
||||
<div className="flex max-w-lg flex-col items-center gap-2 px-4 text-center">
|
||||
<Text
|
||||
variant="h3"
|
||||
className="!text-[1.5rem] !leading-[2rem] md:!text-[1.75rem] md:!leading-[2.5rem]"
|
||||
>
|
||||
What's eating your time?
|
||||
</Text>
|
||||
<Text variant="lead" className="!text-zinc-500">
|
||||
Pick the tasks you'd love to hand off to Autopilot
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col items-center gap-4">
|
||||
<div className="flex w-full max-w-[100vw] flex-nowrap gap-4 overflow-x-auto px-8 scrollbar-none md:grid md:grid-cols-3 md:overflow-hidden md:px-0">
|
||||
{orderedPainPoints.map((p) => (
|
||||
<SelectableCard
|
||||
key={p.id}
|
||||
icon={p.icon}
|
||||
label={p.label}
|
||||
selected={painPoints.includes(p.id)}
|
||||
onClick={() => togglePainPoint(p.id)}
|
||||
className="p-8"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!hasSomethingElse ? (
|
||||
<Text variant="small" className="!text-zinc-500">
|
||||
Pick as many as you want — you can always change later
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasSomethingElse && (
|
||||
<div className="-mb-5 w-full px-8 md:px-0">
|
||||
<Input
|
||||
id="other-pain-point"
|
||||
label="Other pain point"
|
||||
hideLabel
|
||||
placeholder="What else takes up your time?"
|
||||
value={otherPainPoint}
|
||||
onChange={(e) => setOtherPainPoint(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleLaunch}
|
||||
disabled={!canContinue}
|
||||
className="w-full max-w-xs"
|
||||
>
|
||||
Launch Autopilot
|
||||
</Button>
|
||||
</div>
|
||||
</FadeIn>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { AutoGPTLogo } from "@/components/atoms/AutoGPTLogo/AutoGPTLogo";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { TypingText } from "@/components/molecules/TypingText/TypingText";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "@phosphor-icons/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const CHECKLIST = [
|
||||
"Personalizing your experience",
|
||||
"Connecting automation engines",
|
||||
"Building your space",
|
||||
] as const;
|
||||
|
||||
const STEP_DURATION_MS = 4000;
|
||||
const STEP_INTERVAL = STEP_DURATION_MS / CHECKLIST.length;
|
||||
|
||||
interface Props {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function PreparingStep({ onComplete }: Props) {
|
||||
const [started, setStarted] = useState(false);
|
||||
const [completedItems, setCompletedItems] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
onCompleteRef.current = onComplete;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setStarted(true), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!started) return;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const progressInterval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const pct = Math.min(100, (elapsed / STEP_DURATION_MS) * 100);
|
||||
setProgress(pct);
|
||||
|
||||
const items = Math.min(
|
||||
CHECKLIST.length,
|
||||
Math.floor(elapsed / STEP_INTERVAL) + 1,
|
||||
);
|
||||
setCompletedItems(items);
|
||||
|
||||
if (elapsed >= STEP_DURATION_MS) {
|
||||
clearInterval(progressInterval);
|
||||
onCompleteRef.current();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearInterval(progressInterval);
|
||||
}, [started]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-8 px-4">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<AutoGPTLogo
|
||||
className="relative right-[3rem] h-24 w-[12rem]"
|
||||
hideText
|
||||
/>
|
||||
<Text variant="h3" className="text-center">
|
||||
<TypingText
|
||||
text="Preparing your workspace..."
|
||||
active={started}
|
||||
delay={400}
|
||||
speed={60}
|
||||
/>
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200">
|
||||
<div
|
||||
className="h-full rounded-full bg-purple-500 transition-all duration-100 ease-linear"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-3">
|
||||
{CHECKLIST.map((item, i) => (
|
||||
<li key={item} className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-full transition-colors",
|
||||
i < completedItems
|
||||
? "bg-neutral-900 text-white"
|
||||
: "bg-gray-200 text-gray-400",
|
||||
)}
|
||||
>
|
||||
<Check size={14} weight="bold" />
|
||||
</div>
|
||||
<Text
|
||||
variant="body"
|
||||
as="span"
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
i < completedItems ? "!text-black" : "!text-zinc-500",
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
|
||||
import { FadeIn } from "@/components/atoms/FadeIn/FadeIn";
|
||||
import { SelectableCard } from "../components/SelectableCard";
|
||||
import { useOnboardingWizardStore } from "../store";
|
||||
import { Emoji } from "@/components/atoms/Emoji/Emoji";
|
||||
|
||||
const IMG_SIZE = 42;
|
||||
|
||||
const ROLES = [
|
||||
{
|
||||
id: "Founder/CEO",
|
||||
label: "Founder / CEO",
|
||||
icon: <Emoji text="🎯" size={IMG_SIZE} />,
|
||||
},
|
||||
{
|
||||
id: "Operations",
|
||||
label: "Operations",
|
||||
icon: <Emoji text="⚙️" size={IMG_SIZE} />,
|
||||
},
|
||||
{
|
||||
id: "Sales/BD",
|
||||
label: "Sales / BD",
|
||||
icon: <Emoji text="📈" size={IMG_SIZE} />,
|
||||
},
|
||||
{
|
||||
id: "Marketing",
|
||||
label: "Marketing",
|
||||
icon: <Emoji text="📢" size={IMG_SIZE} />,
|
||||
},
|
||||
{
|
||||
id: "Product/PM",
|
||||
label: "Product / PM",
|
||||
icon: <Emoji text="🔨" size={IMG_SIZE} />,
|
||||
},
|
||||
{
|
||||
id: "Engineering",
|
||||
label: "Engineering",
|
||||
icon: <Emoji text="💻" size={IMG_SIZE} />,
|
||||
},
|
||||
{
|
||||
id: "HR/People",
|
||||
label: "HR / People",
|
||||
icon: <Emoji text="👤" size={IMG_SIZE} />,
|
||||
},
|
||||
{ id: "Other", label: "Other", icon: <Emoji text="🚩" size={IMG_SIZE} /> },
|
||||
] as const;
|
||||
|
||||
export function RoleStep() {
|
||||
const name = useOnboardingWizardStore((s) => s.name);
|
||||
const role = useOnboardingWizardStore((s) => s.role);
|
||||
const otherRole = useOnboardingWizardStore((s) => s.otherRole);
|
||||
const setRole = useOnboardingWizardStore((s) => s.setRole);
|
||||
const setOtherRole = useOnboardingWizardStore((s) => s.setOtherRole);
|
||||
const nextStep = useOnboardingWizardStore((s) => s.nextStep);
|
||||
|
||||
const isOther = role === "Other";
|
||||
const canContinue = role && (!isOther || otherRole.trim());
|
||||
|
||||
function handleContinue() {
|
||||
if (canContinue) {
|
||||
nextStep();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FadeIn>
|
||||
<div className="flex w-full flex-col items-center gap-12 px-4">
|
||||
<div className="mx-auto flex w-full max-w-lg flex-col items-center gap-2 px-4 text-center">
|
||||
<Text
|
||||
variant="h3"
|
||||
className="!text-[1.5rem] !leading-[2rem] md:!text-[1.75rem] md:!leading-[2.5rem]"
|
||||
>
|
||||
What best describes you, {name}?
|
||||
</Text>
|
||||
<Text variant="lead" className="!text-zinc-500">
|
||||
Autopilot will tailor automations to your world
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-[100vw] flex-nowrap gap-4 overflow-x-auto px-8 scrollbar-none md:grid md:grid-cols-4 md:overflow-hidden md:px-0">
|
||||
{ROLES.map((r) => (
|
||||
<SelectableCard
|
||||
key={r.id}
|
||||
icon={r.icon}
|
||||
label={r.label}
|
||||
selected={role === r.id}
|
||||
onClick={() => setRole(r.id)}
|
||||
className="p-8"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isOther && (
|
||||
<div className="-mb-5 w-full px-8 md:px-0">
|
||||
<Input
|
||||
id="other-role"
|
||||
label="Other role"
|
||||
hideLabel
|
||||
placeholder="Describe your role..."
|
||||
value={otherRole}
|
||||
onChange={(e) => setOtherRole(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleContinue}
|
||||
disabled={!canContinue}
|
||||
className="w-full max-w-xs"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</FadeIn>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { AutoGPTLogo } from "@/components/atoms/AutoGPTLogo/AutoGPTLogo";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { Question } from "@phosphor-icons/react";
|
||||
import { FadeIn } from "@/components/atoms/FadeIn/FadeIn";
|
||||
import { useOnboardingWizardStore } from "../store";
|
||||
|
||||
export function WelcomeStep() {
|
||||
const name = useOnboardingWizardStore((s) => s.name);
|
||||
const setName = useOnboardingWizardStore((s) => s.setName);
|
||||
const nextStep = useOnboardingWizardStore((s) => s.nextStep);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
nextStep();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FadeIn>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex w-full max-w-lg flex-col items-center gap-4 px-4 md:gap-8"
|
||||
>
|
||||
<div className="mb-8 flex flex-col items-center gap-3 text-center md:mb-0">
|
||||
<AutoGPTLogo
|
||||
className="relative right-[3rem] h-24 w-[12rem]"
|
||||
hideText
|
||||
/>
|
||||
<Text variant="h3">Welcome to AutoGPT</Text>
|
||||
<Text variant="lead" as="span" className="!text-zinc-500">
|
||||
Let's personalize your experience so{" "}
|
||||
<span className="relative mr-3 inline-block bg-gradient-to-r from-purple-500 to-indigo-500 bg-clip-text text-transparent">
|
||||
Autopilot
|
||||
<span className="absolute -right-4 top-0">
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="What is Autopilot?"
|
||||
className="inline-flex text-purple-500"
|
||||
>
|
||||
<Question size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Autopilot is AutoGPT's AI assistant that watches your
|
||||
connected apps, spots repetitive tasks you do every day
|
||||
and runs them for you automatically.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
</span>{" "}
|
||||
can start saving you time right away
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="first-name"
|
||||
label="Your first name"
|
||||
placeholder="e.g. John"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!name.trim()}
|
||||
className="w-full max-w-xs"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</form>
|
||||
</FadeIn>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useOnboardingWizardStore } from "../store";
|
||||
|
||||
const ROLE_TOP_PICKS: Record<string, string[]> = {
|
||||
"Founder/CEO": [
|
||||
"Finding leads",
|
||||
"Reports & data",
|
||||
"Email & outreach",
|
||||
"Scheduling",
|
||||
],
|
||||
Operations: ["CRM & data entry", "Scheduling", "Reports & data"],
|
||||
"Sales/BD": ["Finding leads", "Email & outreach", "CRM & data entry"],
|
||||
Marketing: ["Social media", "Email & outreach", "Research"],
|
||||
"Product/PM": ["Research", "Reports & data", "Scheduling"],
|
||||
Engineering: ["Research", "Reports & data", "CRM & data entry"],
|
||||
"HR/People": ["Scheduling", "Email & outreach", "CRM & data entry"],
|
||||
};
|
||||
|
||||
export function getTopPickIDs(role: string) {
|
||||
return ROLE_TOP_PICKS[role] ?? [];
|
||||
}
|
||||
|
||||
export function usePainPointsStep() {
|
||||
const role = useOnboardingWizardStore((s) => s.role);
|
||||
const painPoints = useOnboardingWizardStore((s) => s.painPoints);
|
||||
const otherPainPoint = useOnboardingWizardStore((s) => s.otherPainPoint);
|
||||
const togglePainPoint = useOnboardingWizardStore((s) => s.togglePainPoint);
|
||||
const setOtherPainPoint = useOnboardingWizardStore(
|
||||
(s) => s.setOtherPainPoint,
|
||||
);
|
||||
const nextStep = useOnboardingWizardStore((s) => s.nextStep);
|
||||
|
||||
const topIDs = getTopPickIDs(role);
|
||||
const hasSomethingElse = painPoints.includes("Something else");
|
||||
const canContinue =
|
||||
painPoints.length > 0 &&
|
||||
(!hasSomethingElse || Boolean(otherPainPoint.trim()));
|
||||
|
||||
function handleLaunch() {
|
||||
if (canContinue) {
|
||||
nextStep();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
topIDs,
|
||||
painPoints,
|
||||
otherPainPoint,
|
||||
togglePainPoint,
|
||||
setOtherPainPoint,
|
||||
hasSomethingElse,
|
||||
canContinue,
|
||||
handleLaunch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export type Step = 1 | 2 | 3 | 4;
|
||||
|
||||
interface OnboardingWizardState {
|
||||
currentStep: Step;
|
||||
name: string;
|
||||
role: string;
|
||||
otherRole: string;
|
||||
painPoints: string[];
|
||||
otherPainPoint: string;
|
||||
setName(name: string): void;
|
||||
setRole(role: string): void;
|
||||
setOtherRole(otherRole: string): void;
|
||||
togglePainPoint(painPoint: string): void;
|
||||
setOtherPainPoint(otherPainPoint: string): void;
|
||||
nextStep(): void;
|
||||
prevStep(): void;
|
||||
goToStep(step: Step): void;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export const useOnboardingWizardStore = create<OnboardingWizardState>(
|
||||
(set) => ({
|
||||
currentStep: 1,
|
||||
name: "",
|
||||
role: "",
|
||||
otherRole: "",
|
||||
painPoints: [],
|
||||
otherPainPoint: "",
|
||||
setName(name) {
|
||||
set({ name });
|
||||
},
|
||||
setRole(role) {
|
||||
set({ role });
|
||||
},
|
||||
setOtherRole(otherRole) {
|
||||
set({ otherRole });
|
||||
},
|
||||
togglePainPoint(painPoint) {
|
||||
set((state) => {
|
||||
const exists = state.painPoints.includes(painPoint);
|
||||
return {
|
||||
painPoints: exists
|
||||
? state.painPoints.filter((p) => p !== painPoint)
|
||||
: [...state.painPoints, painPoint],
|
||||
};
|
||||
});
|
||||
},
|
||||
setOtherPainPoint(otherPainPoint) {
|
||||
set({ otherPainPoint });
|
||||
},
|
||||
nextStep() {
|
||||
set((state) => ({
|
||||
currentStep: Math.min(4, state.currentStep + 1) as Step,
|
||||
}));
|
||||
},
|
||||
prevStep() {
|
||||
set((state) => ({
|
||||
currentStep: Math.max(1, state.currentStep - 1) as Step,
|
||||
}));
|
||||
},
|
||||
goToStep(step) {
|
||||
set({ currentStep: step });
|
||||
},
|
||||
reset() {
|
||||
set({
|
||||
currentStep: 1,
|
||||
name: "",
|
||||
role: "",
|
||||
otherRole: "",
|
||||
painPoints: [],
|
||||
otherPainPoint: "",
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
getV1OnboardingState,
|
||||
postV1CompleteOnboardingStep,
|
||||
postV1SubmitOnboardingProfile,
|
||||
} from "@/app/api/__generated__/endpoints/onboarding/onboarding";
|
||||
import { resolveResponse } from "@/app/api/helpers";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Step, useOnboardingWizardStore } from "./store";
|
||||
|
||||
function parseStep(value: string | null): Step {
|
||||
const n = Number(value);
|
||||
if (n >= 1 && n <= 4) return n as Step;
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function useOnboardingPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { isLoggedIn } = useSupabase();
|
||||
const currentStep = useOnboardingWizardStore((s) => s.currentStep);
|
||||
const goToStep = useOnboardingWizardStore((s) => s.goToStep);
|
||||
const reset = useOnboardingWizardStore((s) => s.reset);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const hasSubmitted = useRef(false);
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
// Initialise store from URL on mount, reset form data
|
||||
useEffect(() => {
|
||||
if (hasInitialized.current) return;
|
||||
hasInitialized.current = true;
|
||||
const urlStep = parseStep(searchParams.get("step"));
|
||||
reset();
|
||||
goToStep(urlStep);
|
||||
}, [searchParams, reset, goToStep]);
|
||||
|
||||
// Sync store → URL when step changes
|
||||
useEffect(() => {
|
||||
const urlStep = parseStep(searchParams.get("step"));
|
||||
if (currentStep !== urlStep) {
|
||||
router.replace(`/onboarding?step=${currentStep}`, { scroll: false });
|
||||
}
|
||||
}, [currentStep, router, searchParams]);
|
||||
|
||||
// Check if onboarding already completed
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
async function checkCompletion() {
|
||||
try {
|
||||
const onboarding = await resolveResponse(getV1OnboardingState());
|
||||
if (onboarding.completedSteps.includes("VISIT_COPILOT")) {
|
||||
router.replace("/copilot");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// If we can't check, show onboarding anyway
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
checkCompletion();
|
||||
}, [isLoggedIn, router]);
|
||||
|
||||
// Submit profile when entering step 4
|
||||
useEffect(() => {
|
||||
if (currentStep !== 4 || hasSubmitted.current) return;
|
||||
hasSubmitted.current = true;
|
||||
|
||||
const { name, role, otherRole, painPoints, otherPainPoint } =
|
||||
useOnboardingWizardStore.getState();
|
||||
const resolvedRole = role === "Other" ? otherRole : role;
|
||||
const resolvedPainPoints = painPoints
|
||||
.filter((p) => p !== "Something else")
|
||||
.concat(
|
||||
painPoints.includes("Something else") && otherPainPoint.trim()
|
||||
? [otherPainPoint.trim()]
|
||||
: [],
|
||||
);
|
||||
|
||||
postV1SubmitOnboardingProfile({
|
||||
user_name: name,
|
||||
user_role: resolvedRole,
|
||||
pain_points: resolvedPainPoints,
|
||||
}).catch(() => {
|
||||
// Best effort — profile data is non-critical for accessing copilot
|
||||
});
|
||||
}, [currentStep]);
|
||||
|
||||
async function handlePreparingComplete() {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
await postV1CompleteOnboardingStep({ step: "VISIT_COPILOT" });
|
||||
router.replace("/copilot");
|
||||
return;
|
||||
} catch {
|
||||
if (attempt < 2) await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
router.replace("/copilot");
|
||||
}
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
isLoading,
|
||||
handlePreparingComplete,
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export async function GET(request: Request) {
|
||||
const { searchParams, origin } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
|
||||
let next = "/";
|
||||
let next = "/copilot";
|
||||
|
||||
if (code) {
|
||||
const supabase = await getServerSupabase();
|
||||
@@ -25,15 +25,9 @@ export async function GET(request: Request) {
|
||||
const api = new BackendAPI();
|
||||
await api.createUser();
|
||||
|
||||
// Get onboarding status from backend (includes chat flag evaluated for this user)
|
||||
const { shouldShowOnboarding } = await getOnboardingStatus();
|
||||
if (shouldShowOnboarding) {
|
||||
next = "/onboarding";
|
||||
revalidatePath("/onboarding", "layout");
|
||||
} else {
|
||||
next = "/";
|
||||
revalidatePath(next, "layout");
|
||||
}
|
||||
next = shouldShowOnboarding ? "/onboarding" : "/copilot";
|
||||
revalidatePath(next, "layout");
|
||||
} catch (createUserError) {
|
||||
console.error("Error creating user:", createUserError);
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { parseAsString, useQueryState } from "nuqs";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { formatNotificationTitle } from "../../helpers";
|
||||
import { useCopilotUIStore } from "../../store";
|
||||
import { NotificationToggle } from "./components/NotificationToggle/NotificationToggle";
|
||||
import { DeleteChatDialog } from "../DeleteChatDialog/DeleteChatDialog";
|
||||
@@ -123,9 +124,8 @@ export function ChatSidebar() {
|
||||
useEffect(() => {
|
||||
if (!sessionId || !completedSessionIDs.has(sessionId)) return;
|
||||
clearCompletedSession(sessionId);
|
||||
const remaining = completedSessionIDs.size - 1;
|
||||
document.title =
|
||||
remaining > 0 ? `(${remaining}) Otto is ready - AutoGPT` : "AutoGPT";
|
||||
const remaining = Math.max(0, completedSessionIDs.size - 1);
|
||||
document.title = formatNotificationTitle(remaining);
|
||||
}, [sessionId, completedSessionIDs, clearCompletedSession]);
|
||||
|
||||
const sessions =
|
||||
|
||||
@@ -56,8 +56,8 @@ export function NotificationBanner() {
|
||||
<div className="flex items-center gap-3 border-b border-amber-200 bg-amber-50 px-4 py-2.5">
|
||||
<BellRinging className="h-5 w-5 shrink-0 text-amber-600" weight="fill" />
|
||||
<Text variant="body" className="flex-1 text-sm text-amber-800">
|
||||
Enable browser notifications to know when Otto finishes working, even
|
||||
when you switch tabs.
|
||||
Enable browser notifications to know when AutoPilot finishes working,
|
||||
even when you switch tabs.
|
||||
</Text>
|
||||
<Button variant="primary" size="small" onClick={handleEnable}>
|
||||
Enable
|
||||
|
||||
@@ -77,11 +77,12 @@ export function NotificationDialog() {
|
||||
<BellRinging className="h-6 w-6 text-violet-600" weight="fill" />
|
||||
</div>
|
||||
<Text variant="body" className="text-center text-neutral-600">
|
||||
Otto can notify you when a response is ready, even if you switch
|
||||
tabs or close this page. Enable notifications so you never miss one.
|
||||
AutoPilot can notify you when a response is ready, even if you
|
||||
switch tabs or close this page. Enable notifications so you never
|
||||
miss one.
|
||||
</Text>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Dialog.Footer className="justify-center">
|
||||
<Button variant="secondary" onClick={handleDismiss}>
|
||||
Not now
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ORIGINAL_TITLE,
|
||||
formatNotificationTitle,
|
||||
parseSessionIDs,
|
||||
} from "./helpers";
|
||||
|
||||
describe("formatNotificationTitle", () => {
|
||||
it("returns base title when count is 0", () => {
|
||||
expect(formatNotificationTitle(0)).toBe(ORIGINAL_TITLE);
|
||||
});
|
||||
|
||||
it("returns formatted title with count", () => {
|
||||
expect(formatNotificationTitle(3)).toBe(
|
||||
`(3) AutoPilot is ready - ${ORIGINAL_TITLE}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns base title for negative count", () => {
|
||||
expect(formatNotificationTitle(-1)).toBe(ORIGINAL_TITLE);
|
||||
});
|
||||
|
||||
it("returns base title for NaN", () => {
|
||||
expect(formatNotificationTitle(NaN)).toBe(ORIGINAL_TITLE);
|
||||
});
|
||||
|
||||
it("returns formatted title for count of 1", () => {
|
||||
expect(formatNotificationTitle(1)).toBe(
|
||||
`(1) AutoPilot is ready - ${ORIGINAL_TITLE}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSessionIDs", () => {
|
||||
it("returns empty set for null", () => {
|
||||
expect(parseSessionIDs(null)).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("returns empty set for undefined", () => {
|
||||
expect(parseSessionIDs(undefined)).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("returns empty set for empty string", () => {
|
||||
expect(parseSessionIDs("")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("parses valid JSON array of strings", () => {
|
||||
expect(parseSessionIDs('["a","b","c"]')).toEqual(new Set(["a", "b", "c"]));
|
||||
});
|
||||
|
||||
it("filters out non-string elements", () => {
|
||||
expect(parseSessionIDs('[1,"valid",null,true,"also-valid"]')).toEqual(
|
||||
new Set(["valid", "also-valid"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty set for non-array JSON", () => {
|
||||
expect(parseSessionIDs('{"key":"value"}')).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("returns empty set for JSON string value", () => {
|
||||
expect(parseSessionIDs('"oops"')).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("returns empty set for JSON number value", () => {
|
||||
expect(parseSessionIDs("42")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("returns empty set for malformed JSON", () => {
|
||||
expect(parseSessionIDs("{broken")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("deduplicates entries", () => {
|
||||
expect(parseSessionIDs('["a","a","b"]')).toEqual(new Set(["a", "b"]));
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,33 @@
|
||||
import type { UIMessage } from "ai";
|
||||
|
||||
export const ORIGINAL_TITLE = "AutoGPT";
|
||||
|
||||
/**
|
||||
* Build the document title showing how many sessions are ready.
|
||||
* Returns the base title when count is 0.
|
||||
*/
|
||||
export function formatNotificationTitle(count: number): string {
|
||||
return count > 0
|
||||
? `(${count}) AutoPilot is ready - ${ORIGINAL_TITLE}`
|
||||
: ORIGINAL_TITLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse a JSON string (from localStorage) into a `Set<string>` of
|
||||
* session IDs. Returns an empty set for `null`, malformed, or non-array values.
|
||||
*/
|
||||
export function parseSessionIDs(raw: string | null | undefined): Set<string> {
|
||||
if (!raw) return new Set();
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? new Set<string>(parsed.filter((v) => typeof v === "string"))
|
||||
: new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a refetchSession result indicates the backend still has an
|
||||
* active SSE stream for this session.
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { FeatureFlagPage } from "@/services/feature-flags/FeatureFlagPage";
|
||||
import { Flag } from "@/services/feature-flags/use-get-flag";
|
||||
import { CopilotPage } from "./CopilotPage";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<FeatureFlagPage flag={Flag.CHAT} whenDisabled="/library">
|
||||
<CopilotPage />
|
||||
</FeatureFlagPage>
|
||||
);
|
||||
return <CopilotPage />;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import { Key, storage } from "@/services/storage/local-storage";
|
||||
import { create } from "zustand";
|
||||
import { ORIGINAL_TITLE, parseSessionIDs } from "./helpers";
|
||||
|
||||
export interface DeleteTarget {
|
||||
id: string;
|
||||
title: string | null | undefined;
|
||||
}
|
||||
|
||||
const isClient = typeof window !== "undefined";
|
||||
|
||||
function persistCompletedSessions(ids: Set<string>) {
|
||||
if (!isClient) return;
|
||||
try {
|
||||
if (ids.size === 0) {
|
||||
storage.clean(Key.COPILOT_COMPLETED_SESSIONS);
|
||||
} else {
|
||||
storage.set(Key.COPILOT_COMPLETED_SESSIONS, JSON.stringify([...ids]));
|
||||
}
|
||||
} catch {
|
||||
// Keep in-memory state authoritative if persistence is unavailable
|
||||
}
|
||||
}
|
||||
|
||||
interface CopilotUIState {
|
||||
/** Prompt extracted from URL hash (e.g. /copilot#prompt=...) for input prefill. */
|
||||
initialPrompt: string | null;
|
||||
@@ -44,23 +60,30 @@ export const useCopilotUIStore = create<CopilotUIState>((set) => ({
|
||||
isDrawerOpen: false,
|
||||
setDrawerOpen: (open) => set({ isDrawerOpen: open }),
|
||||
|
||||
completedSessionIDs: new Set<string>(),
|
||||
completedSessionIDs: isClient
|
||||
? parseSessionIDs(storage.get(Key.COPILOT_COMPLETED_SESSIONS))
|
||||
: new Set(),
|
||||
addCompletedSession: (id) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.completedSessionIDs);
|
||||
next.add(id);
|
||||
persistCompletedSessions(next);
|
||||
return { completedSessionIDs: next };
|
||||
}),
|
||||
clearCompletedSession: (id) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.completedSessionIDs);
|
||||
next.delete(id);
|
||||
persistCompletedSessions(next);
|
||||
return { completedSessionIDs: next };
|
||||
}),
|
||||
clearAllCompletedSessions: () =>
|
||||
set({ completedSessionIDs: new Set<string>() }),
|
||||
clearAllCompletedSessions: () => {
|
||||
persistCompletedSessions(new Set());
|
||||
set({ completedSessionIDs: new Set<string>() });
|
||||
},
|
||||
|
||||
isNotificationsEnabled:
|
||||
isClient &&
|
||||
storage.get(Key.COPILOT_NOTIFICATIONS_ENABLED) === "true" &&
|
||||
typeof Notification !== "undefined" &&
|
||||
Notification.permission === "granted",
|
||||
@@ -69,7 +92,8 @@ export const useCopilotUIStore = create<CopilotUIState>((set) => ({
|
||||
set({ isNotificationsEnabled: enabled });
|
||||
},
|
||||
|
||||
isSoundEnabled: storage.get(Key.COPILOT_SOUND_ENABLED) !== "false",
|
||||
isSoundEnabled:
|
||||
!isClient || storage.get(Key.COPILOT_SOUND_ENABLED) !== "false",
|
||||
toggleSound: () =>
|
||||
set((state) => {
|
||||
const next = !state.isSoundEnabled;
|
||||
@@ -85,11 +109,14 @@ export const useCopilotUIStore = create<CopilotUIState>((set) => ({
|
||||
storage.clean(Key.COPILOT_SOUND_ENABLED);
|
||||
storage.clean(Key.COPILOT_NOTIFICATION_BANNER_DISMISSED);
|
||||
storage.clean(Key.COPILOT_NOTIFICATION_DIALOG_DISMISSED);
|
||||
storage.clean(Key.COPILOT_COMPLETED_SESSIONS);
|
||||
set({
|
||||
completedSessionIDs: new Set<string>(),
|
||||
isNotificationsEnabled: false,
|
||||
isSoundEnabled: true,
|
||||
});
|
||||
document.title = "AutoGPT";
|
||||
if (isClient) {
|
||||
document.title = ORIGINAL_TITLE;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
import { getGetV2ListSessionsQueryKey } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import type { WebSocketNotification } from "@/lib/autogpt-server-api/types";
|
||||
import { Key } from "@/services/storage/local-storage";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
ORIGINAL_TITLE,
|
||||
formatNotificationTitle,
|
||||
parseSessionIDs,
|
||||
} from "./helpers";
|
||||
import { useCopilotUIStore } from "./store";
|
||||
|
||||
const ORIGINAL_TITLE = "AutoGPT";
|
||||
const NOTIFICATION_SOUND_PATH = "/sounds/notification.mp3";
|
||||
const NOTIFICATION_SOUND_PATH = "/notification.mp3";
|
||||
|
||||
/**
|
||||
* Show a browser notification with click-to-navigate behaviour.
|
||||
* Wrapped in try-catch so it degrades gracefully in service-worker or
|
||||
* other restricted contexts where the Notification constructor throws.
|
||||
*/
|
||||
function showBrowserNotification(
|
||||
title: string,
|
||||
opts: { body: string; icon: string; sessionID: string },
|
||||
) {
|
||||
try {
|
||||
const n = new Notification(title, { body: opts.body, icon: opts.icon });
|
||||
n.onclick = () => {
|
||||
window.focus();
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("sessionId", opts.sessionID);
|
||||
window.history.pushState({}, "", url.toString());
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
n.close();
|
||||
};
|
||||
} catch {
|
||||
// Notification constructor is unavailable (e.g. service-worker context).
|
||||
// The user will still see the in-app badge and title update.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens for copilot completion notifications via WebSocket.
|
||||
@@ -12,17 +44,23 @@ const NOTIFICATION_SOUND_PATH = "/sounds/notification.mp3";
|
||||
*/
|
||||
export function useCopilotNotifications(activeSessionID: string | null) {
|
||||
const api = useBackendAPI();
|
||||
const queryClient = useQueryClient();
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const activeSessionRef = useRef(activeSessionID);
|
||||
activeSessionRef.current = activeSessionID;
|
||||
const windowFocusedRef = useRef(true);
|
||||
|
||||
// Pre-load audio element
|
||||
// Pre-load audio element and sync document title with persisted state
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const audio = new Audio(NOTIFICATION_SOUND_PATH);
|
||||
audio.volume = 0.5;
|
||||
audioRef.current = audio;
|
||||
|
||||
const count = useCopilotUIStore.getState().completedSessionIDs.size;
|
||||
if (count > 0) {
|
||||
document.title = formatNotificationTitle(count);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Listen for WebSocket notifications
|
||||
@@ -49,7 +87,7 @@ export function useCopilotNotifications(activeSessionID: string | null) {
|
||||
// Always update UI state (checkmark + title) regardless of notification setting
|
||||
state.addCompletedSession(sessionID);
|
||||
const count = useCopilotUIStore.getState().completedSessionIDs.size;
|
||||
document.title = `(${count}) Otto is ready - ${ORIGINAL_TITLE}`;
|
||||
document.title = formatNotificationTitle(count);
|
||||
|
||||
// Sound and browser notifications are gated by the user setting
|
||||
if (!state.isNotificationsEnabled) return;
|
||||
@@ -65,18 +103,11 @@ export function useCopilotNotifications(activeSessionID: string | null) {
|
||||
Notification.permission === "granted" &&
|
||||
isUserAway
|
||||
) {
|
||||
const n = new Notification("Otto is ready", {
|
||||
showBrowserNotification("AutoPilot is ready", {
|
||||
body: "A response is waiting for you.",
|
||||
icon: "/favicon.ico",
|
||||
sessionID,
|
||||
});
|
||||
n.onclick = () => {
|
||||
window.focus();
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("sessionId", sessionID);
|
||||
window.history.pushState({}, "", url.toString());
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
n.close();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,4 +146,24 @@ export function useCopilotNotifications(activeSessionID: string | null) {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Sync completedSessionIDs across tabs via localStorage storage events
|
||||
useEffect(() => {
|
||||
function handleStorage(e: StorageEvent) {
|
||||
if (e.key !== Key.COPILOT_COMPLETED_SESSIONS) return;
|
||||
// localStorage is the shared source of truth — adopt it directly so both
|
||||
// additions (new completions) and removals (cleared sessions) propagate.
|
||||
const next = parseSessionIDs(e.newValue);
|
||||
useCopilotUIStore.setState({ completedSessionIDs: next });
|
||||
document.title = formatNotificationTitle(next.size);
|
||||
|
||||
// Refetch the session list so the sidebar reflects the latest
|
||||
// is_processing state (avoids stale spinner after cross-tab clear).
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListSessionsQueryKey(),
|
||||
});
|
||||
}
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, [queryClient]);
|
||||
}
|
||||
|
||||
@@ -36,13 +36,11 @@ export async function login(email: string, password: string) {
|
||||
const api = new BackendAPI();
|
||||
await api.createUser();
|
||||
|
||||
// Get onboarding status from backend (includes chat flag evaluated for this user)
|
||||
const { shouldShowOnboarding } = await getOnboardingStatus();
|
||||
const next = shouldShowOnboarding ? "/onboarding" : "/";
|
||||
|
||||
return {
|
||||
success: true,
|
||||
next,
|
||||
next: shouldShowOnboarding ? "/onboarding" : "/copilot",
|
||||
};
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
|
||||
@@ -112,6 +112,7 @@ export function useLoginPage() {
|
||||
: "Unexpected error during login",
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
|
||||
@@ -69,11 +69,12 @@ export async function signup(
|
||||
};
|
||||
}
|
||||
|
||||
// Get onboarding status from backend (includes chat flag evaluated for this user)
|
||||
const { shouldShowOnboarding } = await getOnboardingStatus();
|
||||
const next = shouldShowOnboarding ? "/onboarding" : "/";
|
||||
|
||||
return { success: true, next };
|
||||
return {
|
||||
success: true,
|
||||
next: shouldShowOnboarding ? "/onboarding" : "/copilot",
|
||||
};
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
return {
|
||||
|
||||
@@ -106,8 +106,6 @@ export function useSignupPage() {
|
||||
data.agreeToTerms,
|
||||
);
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (!result.success) {
|
||||
if (result.error === "user_already_exists") {
|
||||
setFeedback("User with this email already exists");
|
||||
@@ -141,6 +139,10 @@ export function useSignupPage() {
|
||||
: "Unexpected error during signup",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { InfiniteData } from "@tanstack/react-query";
|
||||
import {
|
||||
getV1IsOnboardingEnabled,
|
||||
getV1OnboardingState,
|
||||
} from "./__generated__/endpoints/onboarding/onboarding";
|
||||
import { getV1CheckIfOnboardingIsCompleted } from "./__generated__/endpoints/onboarding/onboarding";
|
||||
import { Pagination } from "./__generated__/models/pagination";
|
||||
|
||||
export type OKData<TResponse extends { status: number; data?: any }> =
|
||||
@@ -178,10 +175,8 @@ export async function resolveResponse<
|
||||
}
|
||||
|
||||
export async function getOnboardingStatus() {
|
||||
const status = await resolveResponse(getV1IsOnboardingEnabled());
|
||||
const onboarding = await resolveResponse(getV1OnboardingState());
|
||||
const isCompleted = onboarding.completedSteps.includes("CONGRATS");
|
||||
const status = await resolveResponse(getV1CheckIfOnboardingIsCompleted());
|
||||
return {
|
||||
shouldShowOnboarding: status.is_onboarding_enabled && !isCompleted,
|
||||
shouldShowOnboarding: !status.is_completed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5348,11 +5348,11 @@
|
||||
"security": [{ "HTTPBearerJWT": [] }]
|
||||
}
|
||||
},
|
||||
"/api/onboarding/enabled": {
|
||||
"/api/onboarding/completed": {
|
||||
"get": {
|
||||
"tags": ["v1", "onboarding", "public"],
|
||||
"summary": "Is onboarding enabled",
|
||||
"operationId": "getV1Is onboarding enabled",
|
||||
"summary": "Check if onboarding is completed",
|
||||
"operationId": "getV1Check if onboarding is completed",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -5371,6 +5371,41 @@
|
||||
"security": [{ "HTTPBearerJWT": [] }]
|
||||
}
|
||||
},
|
||||
"/api/onboarding/profile": {
|
||||
"post": {
|
||||
"tags": ["v1", "onboarding"],
|
||||
"summary": "Submit onboarding profile",
|
||||
"operationId": "postV1Submit onboarding profile",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OnboardingProfileRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": { "application/json": { "schema": {} } }
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [{ "HTTPBearerJWT": [] }]
|
||||
}
|
||||
},
|
||||
"/api/onboarding/reset": {
|
||||
"post": {
|
||||
"tags": ["v1", "onboarding"],
|
||||
@@ -7460,6 +7495,13 @@
|
||||
],
|
||||
"title": "Inputs",
|
||||
"description": "Input schema for the agent, including field names, types, and defaults"
|
||||
},
|
||||
"graph": {
|
||||
"anyOf": [
|
||||
{ "$ref": "#/components/schemas/BaseGraph-Output" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"description": "Full graph structure (nodes + links) when include_graph is requested"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -11182,18 +11224,40 @@
|
||||
"title": "OAuthApplicationPublicInfo",
|
||||
"description": "Public information about an OAuth application (for consent screen)"
|
||||
},
|
||||
"OnboardingStatusResponse": {
|
||||
"OnboardingProfileRequest": {
|
||||
"properties": {
|
||||
"is_onboarding_enabled": {
|
||||
"type": "boolean",
|
||||
"title": "Is Onboarding Enabled"
|
||||
"user_name": {
|
||||
"type": "string",
|
||||
"maxLength": 100,
|
||||
"minLength": 1,
|
||||
"title": "User Name"
|
||||
},
|
||||
"is_chat_enabled": { "type": "boolean", "title": "Is Chat Enabled" }
|
||||
"user_role": {
|
||||
"type": "string",
|
||||
"maxLength": 100,
|
||||
"minLength": 1,
|
||||
"title": "User Role"
|
||||
},
|
||||
"pain_points": {
|
||||
"items": { "type": "string" },
|
||||
"type": "array",
|
||||
"maxItems": 20,
|
||||
"title": "Pain Points"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["is_onboarding_enabled", "is_chat_enabled"],
|
||||
"required": ["user_name", "user_role"],
|
||||
"title": "OnboardingProfileRequest",
|
||||
"description": "Request body for onboarding profile submission."
|
||||
},
|
||||
"OnboardingStatusResponse": {
|
||||
"properties": {
|
||||
"is_completed": { "type": "boolean", "title": "Is Completed" }
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["is_completed"],
|
||||
"title": "OnboardingStatusResponse",
|
||||
"description": "Response for onboarding status check."
|
||||
"description": "Response for onboarding completion check."
|
||||
},
|
||||
"OnboardingStep": {
|
||||
"type": "string",
|
||||
|
||||
@@ -46,14 +46,6 @@ export default async function RootLayout({
|
||||
className={`${fonts.poppins.variable} ${fonts.sans.variable} ${fonts.mono.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<SetupAnalytics
|
||||
host={host}
|
||||
ga={{
|
||||
gaId: process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || "G-FH2XK2W4GN",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen">
|
||||
<ErrorBoundary context="application">
|
||||
<Providers
|
||||
@@ -63,6 +55,13 @@ export default async function RootLayout({
|
||||
// enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<SetupAnalytics
|
||||
host={host}
|
||||
ga={{
|
||||
gaId:
|
||||
process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || "G-FH2XK2W4GN",
|
||||
}}
|
||||
/>
|
||||
<div className="flex min-h-screen flex-col items-stretch justify-items-stretch">
|
||||
{children}
|
||||
<TallyPopupSimple />
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
interface Props extends React.SVGProps<SVGSVGElement> {
|
||||
hideText?: boolean;
|
||||
}
|
||||
|
||||
export function AutoGPTLogo({ hideText = false, className, ...props }: Props) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 89 40"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="AutoGPT Logo"
|
||||
className={className ?? "h-10 w-[5.5rem]"}
|
||||
{...props}
|
||||
>
|
||||
<g id="AutoGPT-logo 1" clipPath="url(#clip0_3364_2463)">
|
||||
<path
|
||||
id="Vector"
|
||||
d="M69.1364 28.8681V38.6414C69.1364 39.3617 68.5471 39.951 67.8301 39.951C67.0541 39.951 66.4124 39.4599 66.4124 38.6414V24.0584C66.4124 20.9644 68.9236 18.4531 72.0177 18.4531C75.1117 18.4531 77.623 20.9644 77.623 24.0584C77.623 27.1525 75.1117 29.6637 72.0177 29.6637C70.9634 29.6637 69.9812 29.3723 69.1397 28.8681H69.1364ZM70.2856 22.3231C71.2417 22.3231 72.0177 23.0991 72.0177 24.0552C72.0177 25.0112 71.2417 25.7872 70.2856 25.7872C70.1088 25.7872 69.9353 25.761 69.7749 25.7119C70.2824 26.3994 71.0976 26.8447 72.0177 26.8447C73.5565 26.8447 74.8039 25.5973 74.8039 24.0584C74.8039 22.5196 73.5565 21.2721 72.0177 21.2721C71.0976 21.2721 70.2824 21.7174 69.7749 22.405C69.9353 22.3559 70.1088 22.3297 70.2856 22.3297V22.3231Z"
|
||||
fill="url(#paint0_linear_3364_2463)"
|
||||
/>
|
||||
<path
|
||||
id="Vector_2"
|
||||
d="M62.133 28.8675V35.144C62.133 35.7137 61.9005 36.2343 61.524 36.6075C60.6989 37.4326 59.1699 37.4326 58.3448 36.6075C57.2611 35.5238 58.2891 33.6903 56.3509 31.752C54.4126 29.8137 51.1974 29.8694 49.318 31.752C48.4504 32.6196 47.9102 33.8212 47.9102 35.144C47.9102 35.8643 48.4995 36.4536 49.2198 36.4536C49.999 36.4536 50.6375 35.9625 50.6375 35.144C50.6375 34.5743 50.87 34.057 51.2465 33.6805C52.0716 32.8554 53.6006 32.8554 54.4257 33.6805C55.6076 34.8624 54.4126 36.5289 56.4196 38.536C58.3022 40.4186 61.5731 40.4186 63.4524 38.536C64.3201 37.6683 64.8603 36.4667 64.8603 35.144V24.0545C64.8603 20.9605 62.3491 18.4492 59.255 18.4492C56.161 18.4492 53.6497 20.9605 53.6497 24.0545C53.6497 27.1486 56.161 29.6598 59.255 29.6598C60.3093 29.6598 61.2948 29.3684 62.133 28.8642V28.8675ZM59.255 26.8441C58.335 26.8441 57.5197 26.3988 57.0122 25.7112C57.1727 25.7603 57.3462 25.7865 57.523 25.7865C58.479 25.7865 59.255 25.0106 59.255 24.0545C59.255 23.0985 58.479 22.3225 57.523 22.3225C57.3462 22.3225 57.1727 22.3487 57.0122 22.3978C57.5197 21.7103 58.335 21.265 59.255 21.265C60.7938 21.265 62.0413 22.5124 62.0413 24.0512C62.0413 25.5901 60.7938 26.8375 59.255 26.8375V26.8441Z"
|
||||
fill="url(#paint1_linear_3364_2463)"
|
||||
/>
|
||||
<path
|
||||
id="Vector_3"
|
||||
d="M81.709 12.959C81.709 9.51134 80.3371 6.24048 77.9045 3.80453C75.4685 1.36858 72.1977 0 68.75 0C65.3024 0 62.0315 1.37186 59.5956 3.80453C57.1596 6.24048 55.791 9.51461 55.791 12.959V13.5451C55.791 14.2948 56.4 14.9038 57.1498 14.9038C57.8996 14.9038 58.5085 14.2948 58.5085 13.5451V12.959C58.5085 10.2349 59.5956 7.64836 61.5175 5.72645C63.4394 3.80453 66.0259 2.71425 68.75 2.71425C71.4741 2.71425 74.0574 3.80126 75.9826 5.72645C77.9045 7.64836 78.9948 10.2349 78.9948 12.959C78.9948 13.7088 79.6037 14.3178 80.3535 14.3178C81.1033 14.3178 81.7123 13.7088 81.7123 12.959H81.709Z"
|
||||
fill="url(#paint2_linear_3364_2463)"
|
||||
/>
|
||||
<path
|
||||
id="Vector_4"
|
||||
d="M81.7092 17.061V18.7341H83.8963C84.6232 18.7341 85.2191 19.33 85.2191 20.0569C85.2191 20.7837 84.6952 21.4582 83.8963 21.4582H81.7092V35.1964C81.7092 35.7661 81.9417 36.2834 82.3182 36.6599C83.1433 37.485 84.6723 37.485 85.4974 36.6599C85.8739 36.2834 86.1064 35.7661 86.1064 35.1964V34.738C86.1064 33.9228 86.7481 33.4284 87.5241 33.4284C88.2444 33.4284 88.8337 34.0177 88.8337 34.738V35.1964C88.8337 36.5192 88.2935 37.7208 87.4258 38.5884C85.5432 40.471 82.2822 40.471 80.3996 38.5884C79.5319 37.7208 78.9917 36.5192 78.9917 35.1964V17.061C78.9917 16.272 79.6171 15.7383 80.3832 15.7383C81.1493 15.7383 81.706 16.3342 81.706 17.061H81.7092Z"
|
||||
fill="url(#paint3_linear_3364_2463)"
|
||||
/>
|
||||
<path
|
||||
id="Vector_5"
|
||||
d="M75.4293 38.6377C75.4293 39.358 74.8399 39.9441 74.1196 39.9441C73.3436 39.9441 72.7019 39.453 72.7019 38.6377V34.2013C72.7019 33.4809 73.2912 32.8916 74.0116 32.8916C74.7875 32.8916 75.4293 33.3827 75.4293 34.2013V38.6377Z"
|
||||
fill="url(#paint4_linear_3364_2463)"
|
||||
/>
|
||||
{!hideText && (
|
||||
<path
|
||||
id="Vector_6"
|
||||
d="M11.7672 22.2907V31.6252H8.94164V26.9399H2.82557V31.6252H0V22.2907C0 14.5998 11.7672 14.4983 11.7672 22.2907ZM44.3808 31.6252C48.5618 31.6252 51.9506 28.2365 51.9506 24.0554C51.9506 19.8744 48.5618 16.4857 44.3808 16.4857C40.1997 16.4857 36.811 19.8744 36.811 24.0554C36.811 28.2365 40.1997 31.6252 44.3808 31.6252ZM44.3808 28.7309C41.8008 28.7309 39.7086 26.6387 39.7086 24.0587C39.7086 21.4787 41.8008 19.3865 44.3808 19.3865C46.9608 19.3865 49.053 21.4787 49.053 24.0587C49.053 26.6387 46.9608 28.7309 44.3808 28.7309ZM37.3218 16.4857V19.2097H33.2095V31.6252H30.4854V19.2097H26.3731V16.4857H37.3218ZM25.0111 25.8202V16.4857H22.1855V25.8202C22.1855 30.0242 16.0661 29.9489 16.0661 25.8202V16.4857H13.2406V25.8202C13.2406 33.5111 25.0078 33.6126 25.0078 25.8202H25.0111ZM8.94164 24.2159V22.294C8.94164 18.09 2.8223 18.1653 2.8223 22.294V24.2159H8.94164Z"
|
||||
fill="#000030"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
id="Vector_7"
|
||||
d="M87.4713 32.257C88.2434 32.257 88.8693 31.6311 88.8693 30.859C88.8693 30.0869 88.2434 29.4609 87.4713 29.4609C86.6992 29.4609 86.0732 30.0869 86.0732 30.859C86.0732 31.6311 86.6992 32.257 87.4713 32.257Z"
|
||||
fill="#669CF6"
|
||||
/>
|
||||
<path
|
||||
id="Vector_8"
|
||||
d="M49.2167 39.9475C49.9888 39.9475 50.6147 39.3215 50.6147 38.5494C50.6147 37.7773 49.9888 37.1514 49.2167 37.1514C48.4445 37.1514 47.8186 37.7773 47.8186 38.5494C47.8186 39.3215 48.4445 39.9475 49.2167 39.9475Z"
|
||||
fill="#669CF6"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_3364_2463"
|
||||
x1="62.7328"
|
||||
y1="20.9589"
|
||||
x2="62.7328"
|
||||
y2="33.2932"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#000030" />
|
||||
<stop offset="1" stopColor="#9900FF" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_3364_2463"
|
||||
x1="47.5336"
|
||||
y1="20.947"
|
||||
x2="47.5336"
|
||||
y2="33.2951"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#000030" />
|
||||
<stop offset="1" stopColor="#4285F4" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_3364_2463"
|
||||
x1="69.4138"
|
||||
y1="6.17402"
|
||||
x2="48.0898"
|
||||
y2="-3.94009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#4285F4" />
|
||||
<stop offset="1" stopColor="#9900FF" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_3364_2463"
|
||||
x1="74.2976"
|
||||
y1="15.7136"
|
||||
x2="74.2976"
|
||||
y2="34.5465"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#000030" />
|
||||
<stop offset="1" stopColor="#4285F4" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint4_linear_3364_2463"
|
||||
x1="64.3579"
|
||||
y1="24.1914"
|
||||
x2="65.0886"
|
||||
y2="30.9756"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#4285F4" />
|
||||
<stop offset="1" stopColor="#9900FF" />
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_3364_2463">
|
||||
<rect width="88.8696" height="40" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { Emoji } from "./Emoji";
|
||||
|
||||
const meta: Meta<typeof Emoji> = {
|
||||
title: "Atoms/Emoji",
|
||||
tags: ["autodocs"],
|
||||
component: Emoji,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Renders emoji text as cross-platform SVG images using Twemoji.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
text: {
|
||||
control: "text",
|
||||
description: "Emoji character(s) to render",
|
||||
},
|
||||
size: {
|
||||
control: { type: "number", min: 12, max: 96, step: 4 },
|
||||
description: "Size in pixels (width and height)",
|
||||
},
|
||||
},
|
||||
args: {
|
||||
text: "🚀",
|
||||
size: 24,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
text: "🚀",
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
text: "✨",
|
||||
size: 16,
|
||||
},
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
text: "🎉",
|
||||
size: 48,
|
||||
},
|
||||
};
|
||||
|
||||
export const AllSizes: Story = {
|
||||
render: renderAllSizes,
|
||||
};
|
||||
|
||||
function renderAllSizes() {
|
||||
const sizes = [16, 24, 32, 48, 64];
|
||||
return (
|
||||
<div className="flex items-end gap-4">
|
||||
{sizes.map((size) => (
|
||||
<div key={size} className="flex flex-col items-center gap-2">
|
||||
<Emoji text="🔥" size={size} />
|
||||
<span className="text-xs text-muted-foreground">{size}px</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const MultipleEmojis: Story = {
|
||||
render: renderMultipleEmojis,
|
||||
};
|
||||
|
||||
function renderMultipleEmojis() {
|
||||
const emojis = ["😀", "🎯", "⚡", "🌈", "🤖", "💡", "🔧", "📦"];
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{emojis.map((emoji) => (
|
||||
<Emoji key={emoji} text={emoji} size={32} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import twemoji from "twemoji";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function Emoji({ text, size = 24 }: Props) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center"
|
||||
style={{ width: size, height: size }}
|
||||
dangerouslySetInnerHTML={{
|
||||
// twemoji.parse only converts emoji codepoints to <img> tags
|
||||
// pointing to Twitter's CDN SVGs — it does not inject arbitrary HTML
|
||||
__html: twemoji.parse(text, {
|
||||
folder: "svg",
|
||||
ext: ".svg",
|
||||
attributes: () => ({
|
||||
width: String(size),
|
||||
height: String(size),
|
||||
}),
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { FadeIn } from "./FadeIn";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
|
||||
const meta: Meta<typeof FadeIn> = {
|
||||
title: "Atoms/FadeIn",
|
||||
tags: ["autodocs"],
|
||||
component: FadeIn,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A wrapper that fades in its children with a subtle upward slide animation using framer-motion.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<FadeIn>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-8 text-center">
|
||||
<p className="text-lg font-medium">This content fades in</p>
|
||||
<p className="text-sm text-zinc-500">
|
||||
With a subtle upward slide animation
|
||||
</p>
|
||||
</div>
|
||||
</FadeIn>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function FadeIn({ children, onComplete }: Props) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
onAnimationComplete={onComplete}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -225,7 +225,7 @@ export function Input({
|
||||
return hideLabel ? (
|
||||
inputWithError
|
||||
) : (
|
||||
<label htmlFor={props.id} className="flex flex-col gap-2">
|
||||
<label htmlFor={props.id} className="flex w-full flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Text variant="large-medium" as="span" className="text-black">
|
||||
{label}
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { IconAutoGPTLogo, IconType } from "@/components/__legacy__/ui/icons";
|
||||
import { IconType } from "@/components/__legacy__/ui/icons";
|
||||
import { AutoGPTLogo } from "@/components/atoms/AutoGPTLogo/AutoGPTLogo";
|
||||
import { PreviewBanner } from "@/components/layout/Navbar/components/PreviewBanner/PreviewBanner";
|
||||
import { isLogoutInProgress } from "@/lib/autogpt-server-api/helpers";
|
||||
import { NAVBAR_HEIGHT_PX } from "@/lib/constants";
|
||||
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { environment } from "@/services/environment";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { AccountMenu } from "./components/AccountMenu/AccountMenu";
|
||||
import { FeedbackButton } from "./components/FeedbackButton";
|
||||
import { AgentActivityDropdown } from "./components/AgentActivityDropdown/AgentActivityDropdown";
|
||||
@@ -25,7 +25,6 @@ export function Navbar() {
|
||||
const breakpoint = useBreakpoint();
|
||||
const isSmallScreen = breakpoint === "sm" || breakpoint === "base";
|
||||
const dynamicMenuItems = getAccountMenuItems(user?.role);
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
const previewBranchName = environment.getPreviewStealingDev();
|
||||
const logoutInProgress = isLogoutInProgress();
|
||||
|
||||
@@ -44,11 +43,9 @@ export function Navbar() {
|
||||
|
||||
const shouldShowPreviewBanner = Boolean(isLoggedIn && previewBranchName);
|
||||
|
||||
const homeHref = isChatEnabled === true ? "/copilot" : "/library";
|
||||
|
||||
const actualLoggedInLinks = [
|
||||
{ name: "Home", href: homeHref },
|
||||
...(isChatEnabled === true ? [{ name: "Agents", href: "/library" }] : []),
|
||||
{ name: "Home", href: "/copilot" },
|
||||
{ name: "Agents", href: "/library" },
|
||||
...loggedInLinks,
|
||||
];
|
||||
|
||||
@@ -88,8 +85,8 @@ export function Navbar() {
|
||||
) : null}
|
||||
|
||||
{/* Centered logo */}
|
||||
<div className="static h-auto w-[4.5rem] md:absolute md:left-1/2 md:top-1/2 md:w-[5.5rem] md:-translate-x-1/2 md:-translate-y-1/2">
|
||||
<IconAutoGPTLogo className="h-full w-full" />
|
||||
<div className="static md:absolute md:left-1/2 md:top-1/2 md:-translate-x-1/2 md:-translate-y-1/2">
|
||||
<AutoGPTLogo className="h-auto w-[4.5rem] md:w-[5.5rem]" />
|
||||
</div>
|
||||
|
||||
{/* Right section */}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import { Bell } from "@phosphor-icons/react";
|
||||
import { Pulse } from "@phosphor-icons/react";
|
||||
import { ActivityDropdown } from "./components/ActivityDropdown/ActivityDropdown";
|
||||
import { formatNotificationCount } from "./helpers";
|
||||
import { useAgentActivityDropdown } from "./useAgentActivityDropdown";
|
||||
@@ -30,7 +30,7 @@ export function AgentActivityDropdown() {
|
||||
data-testid="agent-activity-button"
|
||||
aria-label="View Agent Activity"
|
||||
>
|
||||
<Bell size={22} className="text-black" />
|
||||
<Pulse size={22} className="text-black" />
|
||||
|
||||
{activeCount > 0 && (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { Laptop, ListChecksIcon } from "@phosphor-icons/react/dist/ssr";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
@@ -22,12 +21,10 @@ interface Props {
|
||||
|
||||
export function NavbarLink({ name, href }: Props) {
|
||||
const pathname = usePathname();
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
const expectedHomeRoute = isChatEnabled ? "/copilot" : "/library";
|
||||
|
||||
const isActive =
|
||||
href === expectedHomeRoute
|
||||
? pathname === "/" || pathname.startsWith(expectedHomeRoute)
|
||||
href === "/copilot"
|
||||
? pathname === "/" || pathname.startsWith("/copilot")
|
||||
: pathname.includes(href);
|
||||
|
||||
return (
|
||||
@@ -77,24 +74,14 @@ export function NavbarLink({ name, href }: Props) {
|
||||
<HomepageIcon />
|
||||
</div>
|
||||
)}
|
||||
{href === "/library" &&
|
||||
(isChatEnabled ? (
|
||||
<ListChecksIcon
|
||||
className={cn(
|
||||
"h-5 w-5 shrink-0",
|
||||
isActive && "text-white dark:text-black",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
iconNudgedClass,
|
||||
isActive && "text-white dark:text-black",
|
||||
)}
|
||||
>
|
||||
<HomepageIcon />
|
||||
</div>
|
||||
))}
|
||||
{href === "/library" && (
|
||||
<ListChecksIcon
|
||||
className={cn(
|
||||
"h-5 w-5 shrink-0",
|
||||
isActive && "text-white dark:text-black",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
variant="h5"
|
||||
className={cn(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDialogCtx } from "../useDialogCtx";
|
||||
|
||||
interface Props {
|
||||
@@ -10,14 +11,14 @@ interface Props {
|
||||
export function BaseFooter({
|
||||
children,
|
||||
testId = "modal-footer",
|
||||
className = "",
|
||||
className,
|
||||
style,
|
||||
}: Props) {
|
||||
const ctx = useDialogCtx();
|
||||
|
||||
return ctx.isLargeScreen ? (
|
||||
<div
|
||||
className={`flex justify-end gap-4 pt-6 ${className}`}
|
||||
className={cn("flex justify-end gap-4 pt-6", className)}
|
||||
data-testid={testId}
|
||||
style={style}
|
||||
>
|
||||
@@ -25,7 +26,7 @@ export function BaseFooter({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`flex w-full items-end justify-end gap-4 pt-6 ${className}`}
|
||||
className={cn("flex w-full items-end justify-end gap-4 pt-6", className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { TypingText } from "./TypingText";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
|
||||
const meta: Meta<typeof TypingText> = {
|
||||
title: "Molecules/TypingText",
|
||||
tags: ["autodocs"],
|
||||
component: TypingText,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Animates text appearing character by character with a blinking cursor. Useful for loading states and onboarding flows.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
text: { control: "text" },
|
||||
active: { control: "boolean" },
|
||||
speed: { control: { type: "range", min: 10, max: 100, step: 5 } },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
text: "Personalizing your experience...",
|
||||
active: true,
|
||||
speed: 30,
|
||||
},
|
||||
};
|
||||
|
||||
export const Fast: Story = {
|
||||
args: {
|
||||
text: "This types out quickly!",
|
||||
active: true,
|
||||
speed: 15,
|
||||
},
|
||||
};
|
||||
|
||||
export const Slow: Story = {
|
||||
args: {
|
||||
text: "This types out slowly...",
|
||||
active: true,
|
||||
speed: 80,
|
||||
},
|
||||
};
|
||||
|
||||
export const Inactive: Story = {
|
||||
args: {
|
||||
text: "This text is hidden because active is false",
|
||||
active: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
active: boolean;
|
||||
speed?: number;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function TypingText({ text, active, speed = 30, delay = 0 }: Props) {
|
||||
const [charCount, setCharCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setCharCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setCharCount(0);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCharCount((prev) => {
|
||||
if (prev >= text.length) {
|
||||
clearInterval(interval);
|
||||
return prev;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, speed);
|
||||
|
||||
cleanupRef = () => clearInterval(interval);
|
||||
}, delay);
|
||||
|
||||
let cleanupRef = () => {};
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
cleanupRef();
|
||||
};
|
||||
}, [active, text, speed, delay]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, charCount)}
|
||||
{charCount < text.length && (
|
||||
<span className="inline-block h-4 w-[2px] animate-pulse bg-current" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export function shouldRedirectFromOnboarding(
|
||||
pathname: string,
|
||||
): boolean {
|
||||
return (
|
||||
completedSteps.includes("CONGRATS") &&
|
||||
completedSteps.includes("VISIT_COPILOT") &&
|
||||
!pathname.startsWith("/onboarding/reset")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import {
|
||||
getV1IsOnboardingEnabled,
|
||||
getV1CheckIfOnboardingIsCompleted,
|
||||
getV1OnboardingState,
|
||||
patchV1UpdateOnboardingState,
|
||||
postV1CompleteOnboardingStep,
|
||||
@@ -142,13 +142,16 @@ export default function OnboardingProvider({
|
||||
|
||||
async function initializeOnboarding() {
|
||||
try {
|
||||
// Check onboarding enabled only for onboarding routes
|
||||
if (isOnOnboardingRoute) {
|
||||
const enabled = await resolveResponse(getV1IsOnboardingEnabled());
|
||||
if (!enabled) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
const { is_completed } = await resolveResponse(
|
||||
getV1CheckIfOnboardingIsCompleted(),
|
||||
);
|
||||
|
||||
if (!is_completed && !isOnOnboardingRoute) {
|
||||
router.replace("/onboarding");
|
||||
return;
|
||||
} else if (is_completed && isOnOnboardingRoute) {
|
||||
router.replace("/copilot");
|
||||
return;
|
||||
}
|
||||
|
||||
const onboarding = await fetchOnboarding();
|
||||
@@ -158,7 +161,7 @@ export default function OnboardingProvider({
|
||||
isOnOnboardingRoute &&
|
||||
shouldRedirectFromOnboarding(onboarding.completedSteps, pathname)
|
||||
) {
|
||||
router.push("/");
|
||||
router.replace("/copilot");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize onboarding:", error);
|
||||
|
||||
@@ -13,7 +13,6 @@ export enum Flag {
|
||||
AGENT_FAVORITING = "agent-favoriting",
|
||||
MARKETPLACE_SEARCH_TERMS = "marketplace-search-terms",
|
||||
ENABLE_PLATFORM_PAYMENT = "enable-platform-payment",
|
||||
CHAT = "chat",
|
||||
}
|
||||
|
||||
const isPwMockEnabled = process.env.NEXT_PUBLIC_PW_TEST === "true";
|
||||
@@ -27,7 +26,6 @@ const defaultFlags = {
|
||||
[Flag.AGENT_FAVORITING]: false,
|
||||
[Flag.MARKETPLACE_SEARCH_TERMS]: DEFAULT_SEARCH_TERMS,
|
||||
[Flag.ENABLE_PLATFORM_PAYMENT]: false,
|
||||
[Flag.CHAT]: false,
|
||||
};
|
||||
|
||||
type FlagValues = typeof defaultFlags;
|
||||
|
||||
@@ -15,6 +15,7 @@ export enum Key {
|
||||
COPILOT_NOTIFICATIONS_ENABLED = "copilot-notifications-enabled",
|
||||
COPILOT_NOTIFICATION_BANNER_DISMISSED = "copilot-notification-banner-dismissed",
|
||||
COPILOT_NOTIFICATION_DIALOG_DISMISSED = "copilot-notification-dialog-dismissed",
|
||||
COPILOT_COMPLETED_SESSIONS = "copilot-completed-sessions",
|
||||
}
|
||||
|
||||
function get(key: Key) {
|
||||
|
||||
120
autogpt_platform/frontend/src/tests/onboarding.spec.ts
Normal file
120
autogpt_platform/frontend/src/tests/onboarding.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import test, { expect } from "@playwright/test";
|
||||
import { signupTestUser } from "./utils/signup";
|
||||
import { completeOnboardingWizard } from "./utils/onboarding";
|
||||
import { getSelectors } from "./utils/selectors";
|
||||
|
||||
test("new user completes full onboarding wizard", async ({ page }) => {
|
||||
// Signup WITHOUT skipping onboarding (ignoreOnboarding=false)
|
||||
await signupTestUser(page, undefined, undefined, false);
|
||||
|
||||
// Should be on onboarding
|
||||
await expect(page).toHaveURL(/\/onboarding/);
|
||||
|
||||
// Complete the wizard
|
||||
await completeOnboardingWizard(page, {
|
||||
name: "Alice",
|
||||
role: "Marketing",
|
||||
painPoints: ["Social media", "Email & outreach"],
|
||||
});
|
||||
|
||||
// Should have been redirected to /copilot
|
||||
await expect(page).toHaveURL(/\/copilot/);
|
||||
|
||||
// User should be authenticated
|
||||
await page
|
||||
.getByTestId("profile-popout-menu-trigger")
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
});
|
||||
|
||||
test("onboarding wizard step navigation works", async ({ page }) => {
|
||||
await signupTestUser(page, undefined, undefined, false);
|
||||
await expect(page).toHaveURL(/\/onboarding/);
|
||||
|
||||
// Step 1: Welcome
|
||||
await expect(page.getByText("Welcome to AutoGPT")).toBeVisible();
|
||||
await page.getByLabel("Your first name").fill("Bob");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Step 2: Role — verify we're here, then go back
|
||||
await expect(page.getByText("What best describes you")).toBeVisible();
|
||||
await page.getByText("Back").click();
|
||||
|
||||
// Should be back on step 1 with name preserved
|
||||
await expect(page.getByText("Welcome to AutoGPT")).toBeVisible();
|
||||
await expect(page.getByLabel("Your first name")).toHaveValue("Bob");
|
||||
});
|
||||
|
||||
test("onboarding wizard validates required fields", async ({ page }) => {
|
||||
await signupTestUser(page, undefined, undefined, false);
|
||||
await expect(page).toHaveURL(/\/onboarding/);
|
||||
|
||||
// Step 1: Continue should be disabled without a name
|
||||
const continueButton = page.getByRole("button", { name: "Continue" });
|
||||
await expect(continueButton).toBeDisabled();
|
||||
|
||||
// Fill name — continue should become enabled
|
||||
await page.getByLabel("Your first name").fill("Charlie");
|
||||
await expect(continueButton).toBeEnabled();
|
||||
await continueButton.click();
|
||||
|
||||
// Step 2: Continue should be disabled without a role
|
||||
const step2Continue = page.getByRole("button", { name: "Continue" });
|
||||
await expect(step2Continue).toBeDisabled();
|
||||
|
||||
// Select role — continue should become enabled
|
||||
await page.getByText("Engineering").click();
|
||||
await expect(step2Continue).toBeEnabled();
|
||||
await step2Continue.click();
|
||||
|
||||
// Step 3: Launch Autopilot should be disabled without any pain points
|
||||
const launchButton = page.getByRole("button", { name: "Launch Autopilot" });
|
||||
await expect(launchButton).toBeDisabled();
|
||||
|
||||
// Select a pain point — button should become enabled
|
||||
await page.getByText("Research", { exact: true }).click();
|
||||
await expect(launchButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("completed onboarding redirects away from /onboarding", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create user and complete onboarding
|
||||
await signupTestUser(page, undefined, undefined, false);
|
||||
await completeOnboardingWizard(page);
|
||||
|
||||
// Try to navigate back to onboarding — should be redirected to /copilot
|
||||
await page.goto("http://localhost:3000/onboarding");
|
||||
await page.waitForURL(/\/copilot/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test("onboarding URL params sync with steps", async ({ page }) => {
|
||||
await signupTestUser(page, undefined, undefined, false);
|
||||
await expect(page).toHaveURL(/\/onboarding/);
|
||||
|
||||
// Step 1: URL may or may not include step=1 on initial load (no param is equivalent to step 1)
|
||||
await expect(page.getByText("Welcome to AutoGPT")).toBeVisible();
|
||||
|
||||
// Fill name and go to step 2
|
||||
await page.getByLabel("Your first name").fill("Test");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// URL should show step=2
|
||||
await expect(page).toHaveURL(/step=2/);
|
||||
});
|
||||
|
||||
test("role-based pain point ordering works", async ({ page }) => {
|
||||
await signupTestUser(page, undefined, undefined, false);
|
||||
|
||||
// Complete step 1
|
||||
await page.getByLabel("Your first name").fill("Test");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Select Sales/BD role
|
||||
await page.getByText("Sales / BD").click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// On pain points step, "Finding leads" should be visible (top pick for Sales)
|
||||
await expect(page.getByText("What's eating your time?")).toBeVisible();
|
||||
const { getText } = getSelectors(page);
|
||||
await expect(getText("Finding leads")).toBeVisible();
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Page } from "@playwright/test";
|
||||
import { skipOnboardingIfPresent } from "../utils/onboarding";
|
||||
|
||||
export class LoginPage {
|
||||
constructor(private page: Page) {}
|
||||
@@ -64,6 +65,9 @@ export class LoginPage {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200)); // allow time for client-side redirect
|
||||
await this.page.waitForLoadState("load", { timeout: 10_000 });
|
||||
|
||||
// If redirected to onboarding, complete it via API so tests can proceed
|
||||
await skipOnboardingIfPresent(this.page, "/marketplace");
|
||||
|
||||
console.log("➡️ Navigating to /marketplace ...");
|
||||
await this.page.goto("/marketplace", { timeout: 20_000 });
|
||||
console.log("✅ Login process complete");
|
||||
|
||||
@@ -182,10 +182,10 @@ test("logged in user is redirected from /login to /copilot", async ({
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
await page.goto("/login");
|
||||
await hasUrl(page, "/library?sort=updatedAt");
|
||||
await hasUrl(page, "/copilot");
|
||||
});
|
||||
|
||||
test("logged in user is redirected from /signup to /library", async ({
|
||||
test("logged in user is redirected from /signup to /copilot", async ({
|
||||
page,
|
||||
}) => {
|
||||
const testUser = await getTestUser();
|
||||
@@ -195,5 +195,5 @@ test("logged in user is redirected from /signup to /library", async ({
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
await page.goto("/signup");
|
||||
await hasUrl(page, "/library?sort=updatedAt");
|
||||
await hasUrl(page, "/copilot");
|
||||
});
|
||||
|
||||
81
autogpt_platform/frontend/src/tests/utils/onboarding.ts
Normal file
81
autogpt_platform/frontend/src/tests/utils/onboarding.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Page, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Complete the onboarding wizard via API.
|
||||
* Use this when a test needs an authenticated user who has already finished onboarding
|
||||
* (e.g., tests that navigate to marketplace, library, or build pages).
|
||||
*
|
||||
* The function sends a POST request to the onboarding completion endpoint using
|
||||
* the page's request context, which inherits the browser's auth cookies.
|
||||
*/
|
||||
export async function completeOnboardingViaAPI(page: Page) {
|
||||
await page.request.post(
|
||||
"http://localhost:3000/api/proxy/api/onboarding/step?step=VISIT_COPILOT",
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the onboarding redirect that occurs after login/signup.
|
||||
* If the page is on /onboarding, completes onboarding via API and navigates
|
||||
* to the given destination. If already past onboarding, does nothing.
|
||||
*/
|
||||
export async function skipOnboardingIfPresent(
|
||||
page: Page,
|
||||
destination: string = "/marketplace",
|
||||
) {
|
||||
const url = page.url();
|
||||
if (!url.includes("/onboarding")) return;
|
||||
|
||||
await completeOnboardingViaAPI(page);
|
||||
await page.goto(`http://localhost:3000${destination}`);
|
||||
await page.waitForLoadState("domcontentloaded", { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk through the full 4-step onboarding wizard in the browser.
|
||||
* Returns the data that was entered so tests can verify it was submitted.
|
||||
*/
|
||||
export async function completeOnboardingWizard(
|
||||
page: Page,
|
||||
options?: {
|
||||
name?: string;
|
||||
role?: string;
|
||||
painPoints?: string[];
|
||||
},
|
||||
) {
|
||||
const name = options?.name ?? "TestUser";
|
||||
const role = options?.role ?? "Engineering";
|
||||
const painPoints = options?.painPoints ?? ["Research", "Reports & data"];
|
||||
|
||||
// Step 1: Welcome — enter name
|
||||
await expect(page.getByText("Welcome to AutoGPT")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByLabel("Your first name").fill(name);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Step 2: Role — select a role
|
||||
await expect(page.getByText("What best describes you")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
await page.getByText(role, { exact: false }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Step 3: Pain points — select tasks
|
||||
await expect(page.getByText("What's eating your time?")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
for (const point of painPoints) {
|
||||
await page.getByText(point, { exact: true }).click();
|
||||
}
|
||||
await page.getByRole("button", { name: "Launch Autopilot" }).click();
|
||||
|
||||
// Step 4: Preparing — wait for animation to complete and redirect to /copilot
|
||||
await expect(page.getByText("Preparing your workspace")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
await page.waitForURL(/\/copilot/, { timeout: 15000 });
|
||||
|
||||
return { name, role, painPoints };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Page } from "@playwright/test";
|
||||
import { LoginPage } from "../pages/login.page";
|
||||
import { skipOnboardingIfPresent } from "./onboarding";
|
||||
|
||||
type TestUser = {
|
||||
email: string;
|
||||
@@ -25,8 +26,15 @@ export class SigninUtils {
|
||||
await this.page.goto("/login");
|
||||
await this.loginPage.login(testUser.email, testUser.password);
|
||||
|
||||
// Verify we're on marketplace
|
||||
await this.page.waitForURL("/marketplace");
|
||||
// Wait for redirect — could land on /onboarding, /marketplace, or /copilot
|
||||
await this.page.waitForURL(
|
||||
(url: URL) =>
|
||||
/\/(onboarding|marketplace|copilot|library)/.test(url.pathname),
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
|
||||
// Skip onboarding if present
|
||||
await skipOnboardingIfPresent(this.page, "/marketplace");
|
||||
|
||||
// Verify profile menu is visible (user is authenticated)
|
||||
await this.page.getByTestId("profile-popout-menu-trigger").waitFor({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TestUser } from "./auth";
|
||||
import { getSelectors } from "./selectors";
|
||||
import { isVisible } from "./assertion";
|
||||
import { BuildPage } from "../pages/build.page";
|
||||
import { skipOnboardingIfPresent } from "./onboarding";
|
||||
|
||||
export async function signupTestUser(
|
||||
page: any,
|
||||
@@ -58,18 +59,15 @@ export async function signupTestUser(
|
||||
|
||||
// Handle onboarding redirect if needed
|
||||
if (currentUrl.includes("/onboarding") && ignoreOnboarding) {
|
||||
await page.goto("http://localhost:3000/marketplace");
|
||||
await page.waitForLoadState("domcontentloaded", { timeout: 10000 });
|
||||
await skipOnboardingIfPresent(page, "/marketplace");
|
||||
}
|
||||
|
||||
// Verify we're on an expected final page and user is authenticated
|
||||
if (currentUrl.includes("/copilot") || currentUrl.includes("/library")) {
|
||||
// For copilot/library landing pages, just verify user is authenticated
|
||||
await page
|
||||
.getByTestId("profile-popout-menu-trigger")
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
} else if (ignoreOnboarding || currentUrl.includes("/marketplace")) {
|
||||
// Verify we're on marketplace
|
||||
await page
|
||||
.getByText(
|
||||
"Bringing you AI agents designed by thinkers from around the world",
|
||||
@@ -77,7 +75,6 @@ export async function signupTestUser(
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
// Verify user is authenticated (profile menu visible)
|
||||
await page
|
||||
.getByTestId("profile-popout-menu-trigger")
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""Test for cursor reset bug when clearing episode history between tasks.
|
||||
"""Tests for EpisodicActionHistory cursor safety and task continuation.
|
||||
|
||||
Reproduces: IndexError in EpisodicActionHistory.current_episode when
|
||||
episodes.clear() is called without resetting cursor to 0.
|
||||
|
||||
This is the exact crash from run_interaction_loop when the user starts a
|
||||
second task after finishing the first one.
|
||||
Covers:
|
||||
- Cursor >= len guard in current_episode (prevents IndexError)
|
||||
- History preserved across task changes (no clearing)
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
@@ -16,42 +14,14 @@ def _make_history_with_episodes(n: int) -> EpisodicActionHistory:
|
||||
"""Create a history with n completed episodes (cursor advanced past all)."""
|
||||
history = EpisodicActionHistory()
|
||||
for i in range(n):
|
||||
# Directly append mock episodes and advance cursor,
|
||||
# simulating what register_action + register_result does
|
||||
ep = MagicMock()
|
||||
ep.result = MagicMock() # has a result = completed
|
||||
ep.result = MagicMock()
|
||||
history.episodes.append(ep)
|
||||
history.cursor += 1
|
||||
return history
|
||||
|
||||
|
||||
class TestEpisodicActionHistoryCursorReset:
|
||||
def test_current_episode_after_clear_without_cursor_reset_crashes(self):
|
||||
"""REPRODUCER: This is the exact bug.
|
||||
|
||||
After completing a task, the interaction loop clears episodes but
|
||||
doesn't reset cursor. On the next task, current_episode does
|
||||
`self[self.cursor]` where cursor > len(episodes) -> IndexError.
|
||||
"""
|
||||
history = _make_history_with_episodes(2)
|
||||
assert history.cursor == 2
|
||||
assert len(history.episodes) == 2
|
||||
|
||||
# This is what main.py line 759 does between tasks:
|
||||
history.episodes.clear()
|
||||
|
||||
# cursor is still 2, but episodes is empty
|
||||
assert history.cursor == 2
|
||||
assert len(history.episodes) == 0
|
||||
|
||||
# This is what main.py line 687 calls at the start of the next task.
|
||||
# BUG: cursor (2) != len(episodes) (0), so it falls through to
|
||||
# self.episodes[2] on an empty list -> IndexError
|
||||
#
|
||||
# After the fix, this should return None (no current episode).
|
||||
result = history.current_episode
|
||||
assert result is None
|
||||
|
||||
class TestEpisodicActionHistoryCursor:
|
||||
def test_current_episode_returns_none_on_empty_history(self):
|
||||
history = EpisodicActionHistory()
|
||||
assert history.current_episode is None
|
||||
@@ -64,26 +34,48 @@ class TestEpisodicActionHistoryCursorReset:
|
||||
def test_current_episode_returns_episode_when_cursor_valid(self):
|
||||
history = EpisodicActionHistory()
|
||||
ep = MagicMock()
|
||||
ep.result = None # not yet completed
|
||||
ep.result = None
|
||||
history.episodes.append(ep)
|
||||
history.cursor = 0
|
||||
assert history.current_episode is ep
|
||||
|
||||
def test_clear_and_reset_allows_new_task(self):
|
||||
"""After properly clearing episodes AND resetting cursor,
|
||||
the history should work correctly for a new task."""
|
||||
history = _make_history_with_episodes(3)
|
||||
|
||||
# Clean reset between tasks
|
||||
history.episodes.clear()
|
||||
history.cursor = 0
|
||||
|
||||
assert history.current_episode is None
|
||||
assert len(history) == 0
|
||||
|
||||
def test_cursor_beyond_episodes_returns_none(self):
|
||||
"""Any cursor value beyond the episode list should return None,
|
||||
not raise IndexError."""
|
||||
"""Any cursor value beyond the episode list should return None."""
|
||||
history = EpisodicActionHistory()
|
||||
history.cursor = 100 # way past empty list
|
||||
history.cursor = 100
|
||||
assert history.current_episode is None
|
||||
|
||||
def test_cursor_safe_after_clear(self):
|
||||
"""Even if episodes are cleared without resetting cursor,
|
||||
current_episode must not crash (>= guard)."""
|
||||
history = _make_history_with_episodes(2)
|
||||
history.episodes.clear()
|
||||
assert history.cursor == 2
|
||||
assert history.current_episode is None
|
||||
|
||||
|
||||
class TestHistoryPreservedAcrossTasks:
|
||||
def test_episodes_survive_task_change(self):
|
||||
"""When user starts a new task, episodes from the previous task
|
||||
should still be present — the compression system handles overflow."""
|
||||
history = _make_history_with_episodes(3)
|
||||
assert len(history.episodes) == 3
|
||||
assert history.cursor == 3
|
||||
|
||||
# Simulate what main.py does on task change (no clearing)
|
||||
# history is untouched — episodes remain
|
||||
|
||||
assert len(history.episodes) == 3
|
||||
assert history.current_episode is None # cursor at end
|
||||
|
||||
def test_new_episode_appends_after_previous(self):
|
||||
"""New task actions append to existing history."""
|
||||
history = _make_history_with_episodes(2)
|
||||
|
||||
# New task starts — add a new episode
|
||||
new_ep = MagicMock()
|
||||
new_ep.result = None
|
||||
history.episodes.append(new_ep)
|
||||
# cursor still at 2, which is now the new episode
|
||||
assert history.current_episode is new_ep
|
||||
assert len(history.episodes) == 3
|
||||
|
||||
@@ -754,10 +754,18 @@ async def run_interaction_loop(
|
||||
logger.info("User chose to exit after task completion.")
|
||||
return
|
||||
|
||||
# Start new task in same workspace
|
||||
# Close the finish episode so the loop doesn't reuse it.
|
||||
# AgentFinished is caught before execute() can register
|
||||
# a result, leaving result=None — which the loop
|
||||
# interprets as "episode in progress, reuse proposal".
|
||||
from forge.models.action import ActionSuccessResult
|
||||
|
||||
agent.event_history.register_result(
|
||||
ActionSuccessResult(outputs=e.message)
|
||||
)
|
||||
|
||||
# Start new task in same workspace, keeping prior context
|
||||
agent.state.task = next_task
|
||||
agent.event_history.episodes.clear() # Clear history for fresh context
|
||||
agent.event_history.cursor = 0
|
||||
|
||||
# Reset cycle budget for new task
|
||||
cycles_remaining = _get_cycle_budget(
|
||||
|
||||
Reference in New Issue
Block a user