Compare commits

...

6 Commits

Author SHA1 Message Date
Nicholas Tindle
6aed43d708 fix(classic): register finish result before task continuation
AgentFinished is caught before execute() registers a result, leaving
the finish episode with result=None. The interaction loop sees this as
"episode in progress" and reuses the old finish proposal instead of
calling the LLM for the new task. Register a success result before
continuing so the loop calls propose_action() for the new task.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:45:30 +02:00
Nicholas Tindle
17e1578c46 feat(classic): preserve action history across task continuations
Stop clearing episodes when the user enters a new task after finishing.
The compression system (4 recent episodes full, older ones summarized,
1024 token budget) already handles context overflow. Keeping history
lets the agent build on prior work instead of starting from zero.

Restart the process for a clean slate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:36:23 +02:00
Krzysztof Czerwinski
09e42041ce fix(frontend): AutoPilot notification follow-ups — branding, UX, persistence, and cross-tab sync (#12428)
AutoPilot (copilot) notifications had several follow-up issues after
initial implementation: old "Otto" branding, UX quirks, a service-worker
crash, notification state that didn't persist or sync across tabs, a
broken notification sound, and noisy Sentry alerts from SSR.

### Changes 🏗️

- **Rename "Otto" → "AutoPilot"** in all notification surfaces: browser
notifications, document title badge, permission dialog copy, and
notification banner copy
- **Agent Activity icon**: changed from `Bell` to `Pulse` (Phosphor) in
the navbar dropdown
- **Centered dialog buttons**: the "Stay in the loop" permission dialog
buttons are now centered instead of right-aligned
- **Service worker notification fix**: wrapped `new Notification()` in
try-catch so it degrades gracefully in service worker / PWA contexts
instead of throwing `TypeError: Illegal constructor`
- **Persist notification state**: `completedSessionIDs` is now stored in
localStorage (`copilot-completed-sessions`) so it survives page
refreshes and new tabs
- **Cross-tab sync**: a `storage` event listener keeps
`completedSessionIDs` and `document.title` in sync across all open tabs
— clearing a notification in one tab clears it everywhere
- **Fix notification sound**: corrected the sound file path from
`/sounds/notification.mp3` to `/notification.mp3` and added a
`.gitignore` exception (root `.gitignore` has a blanket `*.mp3` ignore
rule from legacy AutoGPT agent days)
- **Fix SSR Sentry noise**: guarded the Copilot Zustand store
initialization with a client-side check so `storage.get()` is never
called during SSR, eliminating spurious Sentry alerts (BUILDER-7CB, 7CC,
7C7) while keeping the Sentry reporting in `local-storage.ts` intact for
genuinely unexpected SSR access

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Verify "AutoPilot" appears (not "Otto") in browser notification,
document title, permission dialog, and banner
  - [x] Verify Pulse icon in navbar Agent Activity dropdown
  - [x] Verify "Stay in the loop" dialog buttons are centered
- [x] Open two tabs on copilot → trigger completion → both tabs show
badge/checkmark
  - [x] Click completed session in tab 1 → badge clears in both tabs
  - [x] Refresh a tab → completed session state is preserved
  - [x] Verify notification sound plays on completion
  - [x] Verify no Sentry alerts from SSR localStorage access

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:44:22 +00:00
Zamil Majdy
a50e95f210 feat(backend/copilot): add include_graph option to find_library_agent (#12622)
## Why

The copilot's `edit_agent` tool requires the LLM to provide a complete
agent JSON (all nodes + links), but the LLM had **no way to see the
current graph structure** before editing. It was editing blindly —
guessing/hallucinating the entire node+link structure and replacing the
graph wholesale.

## What

- Add `include_graph` boolean parameter (default `false`) to the
existing `find_library_agent` tool
- When `true`, each returned `AgentInfo` includes a `graph` field with
the full graph JSON (nodes, links, `input_default` values)
- Update the agent generation guide to instruct the LLM to always fetch
the current graph before editing

## How

- Added `graph: dict[str, Any] | None` field to `AgentInfo` model
- Added `_enrich_agents_with_graph()` helper in `agent_search.py` that
calls the existing `get_agent_as_json()` utility to fetch full graph
data
- Threaded `include_graph` parameter through `find_library_agent` →
`search_agents` → `_search_library`
- Updated `agent_generation_guide.md` to add an "if editing" step that
fetches the graph first

No new tools introduced — reuses existing `find_library_agent` with one
optional flag.

## Test plan

- [x] Unit tests: 2 new tests added
(`test_include_graph_fetches_nodes_and_links`,
`test_include_graph_false_does_not_fetch`)
- [x] All 7 `agent_search_test.py` tests pass
- [x] All pre-commit hooks pass (lint, format, typecheck)
- [ ] Verify copilot correctly uses `include_graph=true` before editing
an agent (manual test)
2026-04-03 11:20:57 +00:00
Zamil Majdy
92b395d82a fix(backend): use OpenRouter client for simulator to support non-OpenAI models (#12656)
## Why

Dry-run block simulation is failing in production with `404 - model
gemini-2.5-flash does not exist`. The simulator's default model
(`google/gemini-2.5-flash`) is a non-OpenAI model that requires
OpenRouter routing, but the shared `get_openai_client()` prefers the
direct OpenAI key, creating a client that can't handle non-OpenAI
models. The old code also stripped the provider prefix, sending
`gemini-2.5-flash` to OpenAI's API.

## What

- Added `prefer_openrouter` keyword parameter to `get_openai_client()` —
when True, prefers the OpenRouter key (returns None if unavailable,
rather than falling back to an incompatible direct OpenAI client)
- Simulator now calls `get_openai_client(prefer_openrouter=True)` so
`google/gemini-2.5-flash` routes correctly through OpenRouter
- Removed the redundant `SIMULATION_MODEL` env var override and the
now-unnecessary provider prefix stripping from `_simulator_model()`

## How

`get_openai_client()` is decorated with `@cached(ttl_seconds=3600)`
which keys by args, so `get_openai_client()` and
`get_openai_client(prefer_openrouter=True)` are cached independently.
When `prefer_openrouter=True` and no OpenRouter key exists, returns
`None` instead of falling back — the simulator already handles `None`
with a clear error message.

### Checklist
- [x] All 24 dry-run tests pass
- [x] Test asserts `get_openai_client` is called with
`prefer_openrouter=True`
- [x] Format, lint, and pyright pass
- [x] No changes to user-facing APIs
- [ ] Deploy to staging and verify simulation works

---------

Co-authored-by: Nicholas Tindle <nicholas.tindle@agpt.co>
2026-04-03 11:19:09 +00:00
Ubbe
86abfbd394 feat(frontend): redesign onboarding wizard with Autopilot-first flow (#12640)
### Why / What / How

<img width="800" height="827" alt="Screenshot 2026-04-02 at 15 40 24"
src="https://github.com/user-attachments/assets/69a381c1-2884-434b-9406-4a3f7eec87cf"
/>
<img width="800" height="825" alt="Screenshot 2026-04-02 at 15 40 41"
src="https://github.com/user-attachments/assets/c6191a68-a8ba-482b-ba47-c06c71d69f0c"
/>
<img width="800" height="825" alt="Screenshot 2026-04-02 at 15 40 48"
src="https://github.com/user-attachments/assets/31b632b9-59cb-4bf7-a6a0-6158846fcf9a"
/>
<img width="800" height="812" alt="Screenshot 2026-04-02 at 15 40 54"
src="https://github.com/user-attachments/assets/64e38a15-2e56-4c0e-bd84-987bf6076bf7"
/>



**Why:** The existing onboarding flow was outdated and didn't align with
the new Autopilot-first experience. New users need a streamlined,
visually polished wizard that collects their role and pain points to
personalize Autopilot suggestions.

**What:** Complete redesign of the onboarding wizard as a 4-step flow:
Welcome → Role selection → Pain points → Preparing workspace. Uses the
design system throughout (atoms/molecules), adds animations, and syncs
steps with URL search params.

**How:** 
- Zustand store manages wizard state (name, role, pain points, current
step)
- Steps synced to `?step=N` URL params for browser navigation support
- Pain points reordered based on selected role (e.g. Sales sees "Finding
leads" first)
- Design system components used exclusively (no raw shadcn `ui/`
imports)
- New reusable components: `FadeIn` (atom), `TypingText` (molecule) with
Storybook stories
- `AutoGPTLogo` made sizeable via Tailwind className prop, migrated in
Navbar
- Fixed `SetupAnalytics` crash (client component was rendered inside
`<head>`)

### Changes 🏗️

- **New onboarding wizard** (`steps/WelcomeStep`, `RoleStep`,
`PainPointsStep`, `PreparingStep`)
- **New shared components**: `ProgressBar`, `StepIndicator`,
`SelectableCard`, `CardCarousel`
- **New design system components**: `FadeIn` atom with stories,
`TypingText` molecule with stories
- **`AutoGPTLogo`** — size now controlled via `className` prop instead
of numeric `size`
- **Navbar** — migrated from legacy `IconAutoGPTLogo` to design system
`AutoGPTLogo`
- **Layout fix** — moved `SetupAnalytics` from `<head>` to `<body>` to
fix React hydration crash
- **Role-based pain point ordering** — top picks surfaced first based on
role selection
- **URL-synced steps** — `?step=N` search params for back/forward
navigation
- Removed old onboarding pages (1-welcome through 6-congrats, reset
page)
- Emoji/image assets for role selection cards

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Complete onboarding flow from step 1 through 4 as a new user
  - [x] Verify back button navigates to previous step
  - [x] Verify progress bar advances correctly (hidden on step 4)
  - [x] Verify step indicator dots show for steps 1-3
  - [x] Verify role selection reorders pain points on next step
  - [x] Verify "Other" role/pain point shows text input
  - [x] Verify typing animation on PreparingStep title
  - [x] Verify fade-in animations on all steps
  - [x] Verify URL updates with `?step=N` on navigation
  - [x] Verify browser back/forward works with step URLs
  - [x] Verify mobile horizontal scroll on card grids
  - [x] Verify `pnpm types` passes cleanly

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:06:57 +07:00
93 changed files with 2743 additions and 2067 deletions

1
.gitignore vendored
View File

@@ -17,6 +17,7 @@ log-ingestion.txt
/logs
*.log
*.mp3
!autogpt_platform/frontend/public/notification.mp3
mem.sqlite3
venvAutoGPT

View File

@@ -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

View File

@@ -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 ###########################
########################################################

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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):

View File

@@ -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

View File

@@ -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:
"""

View 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

View File

@@ -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 "

View File

@@ -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

View 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

View File

@@ -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": {

View File

@@ -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:

Binary file not shown.

View File

@@ -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&apos;s learn a bit about
you to
<br />
tailor your experience.
</OnboardingText>
<OnboardingButton href="/onboarding/2-reason">Continue</OnboardingButton>
</>
);
}

View File

@@ -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&apos;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>
);
}

View File

@@ -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&apos;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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
))}
</>
);
}

View File

@@ -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]));
}

View File

@@ -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 &apos;run&apos; 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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 workjust enter <br />
the key information and get started.
</span>
<span className="mt-4 text-base font-normal leading-normal text-zinc-600">
When you&apos;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>
);
}

View File

@@ -1,2 +0,0 @@
export type InputPrimitive = string | number;
export type InputValues = Record<string, InputPrimitive>;

View File

@@ -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),
};
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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)}
/>
);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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 />;
}

View File

@@ -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&apos;s eating your time?
</Text>
<Text variant="lead" className="!text-zinc-500">
Pick the tasks you&apos;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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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&apos;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&apos;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>
);
}

View File

@@ -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,
};
}

View File

@@ -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: "",
});
},
}),
);

View File

@@ -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,
};
}

View File

@@ -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);

View File

@@ -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 =

View File

@@ -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

View File

@@ -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>

View File

@@ -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"]));
});
});

View File

@@ -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.

View File

@@ -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 />;
}

View File

@@ -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;
}
},
}));

View File

@@ -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]);
}

View File

@@ -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);

View File

@@ -112,6 +112,7 @@ export function useLoginPage() {
: "Unexpected error during login",
variant: "destructive",
});
setIsLoading(false);
setIsLoggingIn(false);
}

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -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,
};
}

View File

@@ -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",

View File

@@ -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 />

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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),
}),
}),
}}
/>
);
}

View File

@@ -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>
),
};

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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 */}

View File

@@ -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 && (
<>

View File

@@ -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(

View File

@@ -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}

View File

@@ -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,
},
};

View File

@@ -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" />
)}
</>
);
}

View File

@@ -36,7 +36,7 @@ export function shouldRedirectFromOnboarding(
pathname: string,
): boolean {
return (
completedSteps.includes("CONGRATS") &&
completedSteps.includes("VISIT_COPILOT") &&
!pathname.startsWith("/onboarding/reset")
);
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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) {

View 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();
});

View File

@@ -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");

View File

@@ -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");
});

View 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 };
}

View File

@@ -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({

View File

@@ -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 });

View File

@@ -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

View File

@@ -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(