Compare commits

...

11 Commits

Author SHA1 Message Date
Swifty
402bec4595 pr comments 2026-01-08 14:43:07 +01:00
Swifty
1c8cba9c5f fix more linting issues 2026-01-08 13:30:51 +01:00
Swifty
072c647baa fix addintal formatting issues 2026-01-08 13:24:30 +01:00
Swifty
d5f490b85d Merge branch 'dev' into swiftyos/fix-linting-errors 2026-01-08 13:07:05 +01:00
Swifty
6686de1701 fix linter errors 2026-01-08 13:04:02 +01:00
Ubbe
fc25e008b3 feat(frontend): update library agent cards to use DS (#11720)
## Changes 🏗️

<img width="700" height="838" alt="Screenshot 2026-01-07 at 16 11 04"
src="https://github.com/user-attachments/assets/0b38d2e1-d4a8-4036-862c-b35c82c496c2"
/>

- Update the agent library cards to new designs
- Update page to use Design System components
- Allow to edit/delete/duplicate agents on the library list page
- Add missing actions on library agent detail page

## 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] Run locally and test the above


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Marketplace info shown on agent cards and improved favoriting with
optimistic UI and feedback.
  * Delete agent and delete schedule flows with confirmation dialogs.

* **Refactor**
* New composable form system, modernized upload dialog, streamlined
search bar, and multiple library components converted to named exports
with layout tweaks.
  * New agent card menu and favorite button UI.

* **Chores**
  * Removed notification UI and dropped a drag-drop dependency.

* **Tests**
  * Increased timeouts and stabilized upload/pagination flows.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-08 18:28:27 +07:00
Abhimanyu Yadav
a81ac150da fix(frontend): add word wrapping to CodeRenderer and improve output actions visibility (#11724)
## Changes 🏗️
- Updated the `CodeRenderer` component to add `whitespace-pre-wrap` and
`break-words` CSS classes to the `<code>` element
- This enables proper wrapping of long code lines while preserving
whitespace formatting

Before


![image.png](https://app.graphite.com/user-attachments/assets/aca769cc-0f6f-4e25-8cdd-c491fcbf21bb.png)

After

![Screenshot 2026-01-08 at
3.02.53 PM.png](https://app.graphite.com/user-attachments/assets/99e23efa-be2a-441b-b0d6-50fa2a08cdb0.png)

### 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] Verified code with long lines wraps correctly
  - [x] Confirmed whitespace and indentation are preserved
  - [x] Tested code display in various viewport sizes

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Code blocks now preserve whitespace and wrap long lines for improved
readability.
* Output action controls are hidden when there is only a single output
item, reducing unnecessary UI elements.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-08 11:13:47 +00:00
Abhimanyu Yadav
49ee087496 feat(frontend): add new integration images for Webshare and WordPress (#11725)
### Changes 🏗️

Added two new integration icons to the frontend:
- `webshare_proxy.png` - Icon for WebShare Proxy integration
- `wordpress.png` - Icon for WordPress integration

### 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] Verified both icons display correctly in the integrations section
  - [x] Confirmed icons render properly at different screen sizes
  - [x] Checked that the icons maintain quality when scaled

#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
2026-01-08 11:13:34 +00:00
Ubbe
b0855e8cf2 feat(frontend): context menu right click new builder (#11703)
## Changes 🏗️

<img width="250" height="504" alt="Screenshot 2026-01-06 at 17 53 26"
src="https://github.com/user-attachments/assets/52013448-f49c-46b6-b86a-39f98270cbc3"
/>

<img width="300" height="544" alt="Screenshot 2026-01-06 at 17 53 29"
src="https://github.com/user-attachments/assets/e6334034-68e4-4346-9092-3774ab3e8445"
/>

On the **New Builder**:
- right-click on a node menu make it show the context menu
- use the same menu for right-click and when clicking on `...`

## 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] Run locally and test the above



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a custom right-click context menu for nodes with Copy, Open
agent (when available), and Delete actions; browser default menu is
suppressed while preserving zoom/drag/wiring.
* Introduced reusable SecondaryMenu primitives for context and dropdown
menus.

* **Documentation**
* Added Storybook examples demonstrating the context menu and dropdown
menu usage.

* **Style**
* Updated menu styling and icons with improved consistency and dark-mode
support.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-08 17:35:49 +07:00
Abhimanyu Yadav
5e2146dd76 feat(frontend): add CustomSchemaField wrapper for dynamic form field routing
(#11722)

### Changes 🏗️

This PR introduces automatic UI schema generation for custom form
fields, eliminating manual field mapping.

#### 1. **generateUiSchemaForCustomFields Utility**
(`generate-ui-schema.ts`) - New File
   - Auto-generates `ui:field` settings for custom fields
   - Detects custom fields using `findCustomFieldId()` matcher
   - Handles nested objects and array items recursively
   - Merges with existing UI schema without overwriting

#### 2. **FormRenderer Integration** (`FormRenderer.tsx`)
   - Imports and uses `generateUiSchemaForCustomFields`
   - Creates merged UI schema with `useMemo`
   - Passes merged schema to Form component
   - Enables automatic custom field detection

#### 3. **Preprocessor Cleanup** (`input-schema-pre-processor.ts`)
   - Removed manual `$id` assignment for custom fields
   - Removed unused `findCustomFieldId` import
   - Simplified to focus only on type validation

### Why these changes?

- Custom fields now auto-detect without manual `ui:field` configuration
- Uses standard RJSF approach (UI schema) for field routing
- Centralized custom field detection logic improves maintainability

### 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 custom fields render correctly when present in schema
- [x] Verify standard fields continue to render with default SchemaField
- [x] Verify multiple instances of same custom field type have unique
IDs
  - [x] Test form submission with custom fields

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved custom field rendering in forms by optimizing the UI schema
generation process.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-08 08:47:52 +00:00
Abhimanyu Yadav
103a62c9da feat(frontend/builder): add filters to blocks menu (#11654)
### Changes 🏗️

This PR adds filtering functionality to the new blocks menu, allowing
users to filter search results by category and creator.

**New Components:**
- `BlockMenuFilters`: Main filter component displaying active filters
and filter chips
- `FilterSheet`: Slide-out panel for selecting filters with categories
and creators
- `BlockMenuSearchContent`: Refactored search results display component

**Features Added:**
- Filter by categories: Blocks, Integrations, Marketplace agents, My
agents
- Filter by creator: Shows all available creators from search results
- Category counts: Display number of results per category
- Interactive filter chips with animations (using framer-motion)
- Hover states showing result counts on filter chips
- "All filters" sheet with apply/clear functionality

**State Management:**
- Extended `blockMenuStore` with filter state management
- Added `filters`, `creators`, `creators_list`, and `categoryCounts` to
store
- Integrated filters with search API (`filter` and `by_creator`
parameters)

**Refactoring:**
- Moved search logic from `BlockMenuSearch` to `BlockMenuSearchContent`
- Renamed `useBlockMenuSearch` to `useBlockMenuSearchContent`
- Moved helper functions to `BlockMenuSearchContent` directory

**API Changes:**
- Updated `custom-mutator.ts` to properly handle query parameter
encoding


### 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] Search for blocks and verify filter chips appear
- [x] Click "All filters" and verify filter sheet opens with categories
- [x] Select/deselect category filters and verify results update
accordingly
  - [x] Filter by creator and verify only blocks from that creator show
  - [x] Clear all filters and verify reset to default state
  - [x] Verify filter counts display correctly
  - [x] Test filter chip hover animations
2026-01-08 08:02:21 +00:00
84 changed files with 3131 additions and 2045 deletions

View File

@@ -3,6 +3,7 @@ from datetime import UTC, datetime
from os import getenv
import pytest
from prisma.types import ProfileCreateInput
from pydantic import SecretStr
from backend.api.features.chat.model import ChatSession
@@ -49,13 +50,13 @@ async def setup_test_data():
# 1b. Create a profile with username for the user (required for store agent lookup)
username = user.email.split("@")[0]
await prisma.profile.create(
data={
"userId": user.id,
"username": username,
"name": f"Test User {username}",
"description": "Test user profile",
"links": [], # Required field - empty array for test profiles
}
data=ProfileCreateInput(
userId=user.id,
username=username,
name=f"Test User {username}",
description="Test user profile",
links=[], # Required field - empty array for test profiles
)
)
# 2. Create a test graph with agent input -> agent output
@@ -172,13 +173,13 @@ async def setup_llm_test_data():
# 1b. Create a profile with username for the user (required for store agent lookup)
username = user.email.split("@")[0]
await prisma.profile.create(
data={
"userId": user.id,
"username": username,
"name": f"Test User {username}",
"description": "Test user profile for LLM tests",
"links": [], # Required field - empty array for test profiles
}
data=ProfileCreateInput(
userId=user.id,
username=username,
name=f"Test User {username}",
description="Test user profile for LLM tests",
links=[], # Required field - empty array for test profiles
)
)
# 2. Create test OpenAI credentials for the user
@@ -332,13 +333,13 @@ async def setup_firecrawl_test_data():
# 1b. Create a profile with username for the user (required for store agent lookup)
username = user.email.split("@")[0]
await prisma.profile.create(
data={
"userId": user.id,
"username": username,
"name": f"Test User {username}",
"description": "Test user profile for Firecrawl tests",
"links": [], # Required field - empty array for test profiles
}
data=ProfileCreateInput(
userId=user.id,
username=username,
name=f"Test User {username}",
description="Test user profile for Firecrawl tests",
links=[], # Required field - empty array for test profiles
)
)
# NOTE: We deliberately do NOT create Firecrawl credentials for this user

View File

@@ -817,18 +817,16 @@ async def add_store_agent_to_library(
# Create LibraryAgent entry
added_agent = await prisma.models.LibraryAgent.prisma().create(
data={
"User": {"connect": {"id": user_id}},
"AgentGraph": {
data=prisma.types.LibraryAgentCreateInput(
User={"connect": {"id": user_id}},
AgentGraph={
"connect": {
"graphVersionId": {"id": graph.id, "version": graph.version}
}
},
"isCreatedByUser": False,
"settings": SafeJson(
_initialize_graph_settings(graph_model).model_dump()
),
},
isCreatedByUser=False,
settings=SafeJson(_initialize_graph_settings(graph_model).model_dump()),
),
include=library_agent_include(
user_id, include_nodes=False, include_executions=False
),

View File

@@ -27,6 +27,13 @@ from prisma.models import OAuthApplication as PrismaOAuthApplication
from prisma.models import OAuthAuthorizationCode as PrismaOAuthAuthorizationCode
from prisma.models import OAuthRefreshToken as PrismaOAuthRefreshToken
from prisma.models import User as PrismaUser
from prisma.types import (
OAuthAccessTokenCreateInput,
OAuthApplicationCreateInput,
OAuthAuthorizationCodeCreateInput,
OAuthRefreshTokenCreateInput,
UserCreateInput,
)
from backend.api.rest_api import app
@@ -48,11 +55,11 @@ def test_user_id() -> str:
async def test_user(server, test_user_id: str):
"""Create a test user in the database."""
await PrismaUser.prisma().create(
data={
"id": test_user_id,
"email": f"oauth-test-{test_user_id}@example.com",
"name": "OAuth Test User",
}
data=UserCreateInput(
id=test_user_id,
email=f"oauth-test-{test_user_id}@example.com",
name="OAuth Test User",
)
)
yield test_user_id
@@ -77,22 +84,22 @@ async def test_oauth_app(test_user: str):
client_secret_hash, client_secret_salt = keysmith.hash_key(client_secret_plaintext)
await PrismaOAuthApplication.prisma().create(
data={
"id": app_id,
"name": "Test OAuth App",
"description": "Test application for integration tests",
"clientId": client_id,
"clientSecret": client_secret_hash,
"clientSecretSalt": client_secret_salt,
"redirectUris": [
data=OAuthApplicationCreateInput(
id=app_id,
name="Test OAuth App",
description="Test application for integration tests",
clientId=client_id,
clientSecret=client_secret_hash,
clientSecretSalt=client_secret_salt,
redirectUris=[
"https://example.com/callback",
"http://localhost:3000/callback",
],
"grantTypes": ["authorization_code", "refresh_token"],
"scopes": [APIKeyPermission.EXECUTE_GRAPH, APIKeyPermission.READ_GRAPH],
"ownerId": test_user,
"isActive": True,
}
grantTypes=["authorization_code", "refresh_token"],
scopes=[APIKeyPermission.EXECUTE_GRAPH, APIKeyPermission.READ_GRAPH],
ownerId=test_user,
isActive=True,
)
)
yield {
@@ -296,19 +303,19 @@ async def inactive_oauth_app(test_user: str):
client_secret_hash, client_secret_salt = keysmith.hash_key(client_secret_plaintext)
await PrismaOAuthApplication.prisma().create(
data={
"id": app_id,
"name": "Inactive OAuth App",
"description": "Inactive test application",
"clientId": client_id,
"clientSecret": client_secret_hash,
"clientSecretSalt": client_secret_salt,
"redirectUris": ["https://example.com/callback"],
"grantTypes": ["authorization_code", "refresh_token"],
"scopes": [APIKeyPermission.EXECUTE_GRAPH],
"ownerId": test_user,
"isActive": False, # Inactive!
}
data=OAuthApplicationCreateInput(
id=app_id,
name="Inactive OAuth App",
description="Inactive test application",
clientId=client_id,
clientSecret=client_secret_hash,
clientSecretSalt=client_secret_salt,
redirectUris=["https://example.com/callback"],
grantTypes=["authorization_code", "refresh_token"],
scopes=[APIKeyPermission.EXECUTE_GRAPH],
ownerId=test_user,
isActive=False, # Inactive!
)
)
yield {
@@ -699,14 +706,14 @@ async def test_token_authorization_code_expired(
now = datetime.now(timezone.utc)
await PrismaOAuthAuthorizationCode.prisma().create(
data={
"code": expired_code,
"applicationId": test_oauth_app["id"],
"userId": test_user,
"scopes": [APIKeyPermission.EXECUTE_GRAPH],
"redirectUri": test_oauth_app["redirect_uri"],
"expiresAt": now - timedelta(hours=1), # Already expired
}
data=OAuthAuthorizationCodeCreateInput(
code=expired_code,
applicationId=test_oauth_app["id"],
userId=test_user,
scopes=[APIKeyPermission.EXECUTE_GRAPH],
redirectUri=test_oauth_app["redirect_uri"],
expiresAt=now - timedelta(hours=1), # Already expired
)
)
response = await client.post(
@@ -942,13 +949,13 @@ async def test_token_refresh_expired(
now = datetime.now(timezone.utc)
await PrismaOAuthRefreshToken.prisma().create(
data={
"token": expired_token_hash,
"applicationId": test_oauth_app["id"],
"userId": test_user,
"scopes": [APIKeyPermission.EXECUTE_GRAPH],
"expiresAt": now - timedelta(days=1), # Already expired
}
data=OAuthRefreshTokenCreateInput(
token=expired_token_hash,
applicationId=test_oauth_app["id"],
userId=test_user,
scopes=[APIKeyPermission.EXECUTE_GRAPH],
expiresAt=now - timedelta(days=1), # Already expired
)
)
response = await client.post(
@@ -980,14 +987,14 @@ async def test_token_refresh_revoked(
now = datetime.now(timezone.utc)
await PrismaOAuthRefreshToken.prisma().create(
data={
"token": revoked_token_hash,
"applicationId": test_oauth_app["id"],
"userId": test_user,
"scopes": [APIKeyPermission.EXECUTE_GRAPH],
"expiresAt": now + timedelta(days=30), # Not expired
"revokedAt": now - timedelta(hours=1), # But revoked
}
data=OAuthRefreshTokenCreateInput(
token=revoked_token_hash,
applicationId=test_oauth_app["id"],
userId=test_user,
scopes=[APIKeyPermission.EXECUTE_GRAPH],
expiresAt=now + timedelta(days=30), # Not expired
revokedAt=now - timedelta(hours=1), # But revoked
)
)
response = await client.post(
@@ -1013,19 +1020,19 @@ async def other_oauth_app(test_user: str):
client_secret_hash, client_secret_salt = keysmith.hash_key(client_secret_plaintext)
await PrismaOAuthApplication.prisma().create(
data={
"id": app_id,
"name": "Other OAuth App",
"description": "Second test application",
"clientId": client_id,
"clientSecret": client_secret_hash,
"clientSecretSalt": client_secret_salt,
"redirectUris": ["https://other.example.com/callback"],
"grantTypes": ["authorization_code", "refresh_token"],
"scopes": [APIKeyPermission.EXECUTE_GRAPH],
"ownerId": test_user,
"isActive": True,
}
data=OAuthApplicationCreateInput(
id=app_id,
name="Other OAuth App",
description="Second test application",
clientId=client_id,
clientSecret=client_secret_hash,
clientSecretSalt=client_secret_salt,
redirectUris=["https://other.example.com/callback"],
grantTypes=["authorization_code", "refresh_token"],
scopes=[APIKeyPermission.EXECUTE_GRAPH],
ownerId=test_user,
isActive=True,
)
)
yield {
@@ -1052,13 +1059,13 @@ async def test_token_refresh_wrong_application(
now = datetime.now(timezone.utc)
await PrismaOAuthRefreshToken.prisma().create(
data={
"token": token_hash,
"applicationId": test_oauth_app["id"], # Belongs to test_oauth_app
"userId": test_user,
"scopes": [APIKeyPermission.EXECUTE_GRAPH],
"expiresAt": now + timedelta(days=30),
}
data=OAuthRefreshTokenCreateInput(
token=token_hash,
applicationId=test_oauth_app["id"], # Belongs to test_oauth_app
userId=test_user,
scopes=[APIKeyPermission.EXECUTE_GRAPH],
expiresAt=now + timedelta(days=30),
)
)
# Try to use it with `other_oauth_app`
@@ -1267,19 +1274,19 @@ async def test_validate_access_token_fails_when_app_disabled(
client_secret_hash, client_secret_salt = keysmith.hash_key(client_secret_plaintext)
await PrismaOAuthApplication.prisma().create(
data={
"id": app_id,
"name": "App To Be Disabled",
"description": "Test app for disabled validation",
"clientId": client_id,
"clientSecret": client_secret_hash,
"clientSecretSalt": client_secret_salt,
"redirectUris": ["https://example.com/callback"],
"grantTypes": ["authorization_code"],
"scopes": [APIKeyPermission.EXECUTE_GRAPH],
"ownerId": test_user,
"isActive": True,
}
data=OAuthApplicationCreateInput(
id=app_id,
name="App To Be Disabled",
description="Test app for disabled validation",
clientId=client_id,
clientSecret=client_secret_hash,
clientSecretSalt=client_secret_salt,
redirectUris=["https://example.com/callback"],
grantTypes=["authorization_code"],
scopes=[APIKeyPermission.EXECUTE_GRAPH],
ownerId=test_user,
isActive=True,
)
)
# Create an access token directly in the database
@@ -1288,13 +1295,13 @@ async def test_validate_access_token_fails_when_app_disabled(
now = datetime.now(timezone.utc)
await PrismaOAuthAccessToken.prisma().create(
data={
"token": token_hash,
"applicationId": app_id,
"userId": test_user,
"scopes": [APIKeyPermission.EXECUTE_GRAPH],
"expiresAt": now + timedelta(hours=1),
}
data=OAuthAccessTokenCreateInput(
token=token_hash,
applicationId=app_id,
userId=test_user,
scopes=[APIKeyPermission.EXECUTE_GRAPH],
expiresAt=now + timedelta(hours=1),
)
)
# Token should be valid while app is active
@@ -1561,19 +1568,19 @@ async def test_revoke_token_from_different_app_fails_silently(
)
await PrismaOAuthApplication.prisma().create(
data={
"id": app2_id,
"name": "Second Test OAuth App",
"description": "Second test application for cross-app revocation test",
"clientId": app2_client_id,
"clientSecret": app2_client_secret_hash,
"clientSecretSalt": app2_client_secret_salt,
"redirectUris": ["https://other-app.com/callback"],
"grantTypes": ["authorization_code", "refresh_token"],
"scopes": [APIKeyPermission.EXECUTE_GRAPH, APIKeyPermission.READ_GRAPH],
"ownerId": test_user,
"isActive": True,
}
data=OAuthApplicationCreateInput(
id=app2_id,
name="Second Test OAuth App",
description="Second test application for cross-app revocation test",
clientId=app2_client_id,
clientSecret=app2_client_secret_hash,
clientSecretSalt=app2_client_secret_salt,
redirectUris=["https://other-app.com/callback"],
grantTypes=["authorization_code", "refresh_token"],
scopes=[APIKeyPermission.EXECUTE_GRAPH, APIKeyPermission.READ_GRAPH],
ownerId=test_user,
isActive=True,
)
)
# App 2 tries to revoke App 1's access token

View File

@@ -249,7 +249,9 @@ async def log_search_term(search_query: str):
date = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
try:
await prisma.models.SearchTerms.prisma().create(
data={"searchTerm": search_query, "createdDate": date}
data=prisma.types.SearchTermsCreateInput(
searchTerm=search_query, createdDate=date
)
)
except Exception as e:
# Fail silently here so that logging search terms doesn't break the app
@@ -1456,11 +1458,9 @@ async def _approve_sub_agent(
# Create new version if no matching version found
next_version = max((v.version for v in listing.Versions or []), default=0) + 1
await prisma.models.StoreListingVersion.prisma(tx).create(
data={
**_create_sub_agent_version_data(sub_graph, heading, main_agent_name),
"version": next_version,
"storeListingId": listing.id,
}
data=_create_sub_agent_version_data(
sub_graph, heading, main_agent_name, next_version, listing.id
)
)
await prisma.models.StoreListing.prisma(tx).update(
where={"id": listing.id}, data={"hasApprovedVersion": True}
@@ -1468,10 +1468,14 @@ async def _approve_sub_agent(
def _create_sub_agent_version_data(
sub_graph: prisma.models.AgentGraph, heading: str, main_agent_name: str
sub_graph: prisma.models.AgentGraph,
heading: str,
main_agent_name: str,
version: typing.Optional[int] = None,
store_listing_id: typing.Optional[str] = None,
) -> prisma.types.StoreListingVersionCreateInput:
"""Create store listing version data for a sub-agent"""
return prisma.types.StoreListingVersionCreateInput(
data = prisma.types.StoreListingVersionCreateInput(
agentGraphId=sub_graph.id,
agentGraphVersion=sub_graph.version,
name=sub_graph.name or heading,
@@ -1486,6 +1490,11 @@ def _create_sub_agent_version_data(
imageUrls=[], # Sub-agents don't need images
categories=[], # Sub-agents don't need categories
)
if version is not None:
data["version"] = version
if store_listing_id is not None:
data["storeListingId"] = store_listing_id
return data
async def review_store_submission(

View File

@@ -42,6 +42,7 @@ from urllib.parse import urlparse
import click
from autogpt_libs.api_key.keysmith import APIKeySmith
from prisma.enums import APIKeyPermission
from prisma.types import OAuthApplicationCreateInput
keysmith = APIKeySmith()
@@ -147,7 +148,7 @@ def format_sql_insert(creds: dict) -> str:
sql = f"""
-- ============================================================
-- OAuth Application: {creds['name']}
-- OAuth Application: {creds["name"]}
-- Generated: {now_iso} UTC
-- ============================================================
@@ -167,14 +168,14 @@ INSERT INTO "OAuthApplication" (
"isActive"
)
VALUES (
'{creds['id']}',
'{creds["id"]}',
NOW(),
NOW(),
'{creds['name']}',
{f"'{creds['description']}'" if creds['description'] else 'NULL'},
'{creds['client_id']}',
'{creds['client_secret_hash']}',
'{creds['client_secret_salt']}',
'{creds["name"]}',
{f"'{creds['description']}'" if creds["description"] else "NULL"},
'{creds["client_id"]}',
'{creds["client_secret_hash"]}',
'{creds["client_secret_salt"]}',
ARRAY{redirect_uris_pg}::TEXT[],
ARRAY{grant_types_pg}::TEXT[],
ARRAY{scopes_pg}::"APIKeyPermission"[],
@@ -186,8 +187,8 @@ VALUES (
-- ⚠️ IMPORTANT: Save these credentials securely!
-- ============================================================
--
-- Client ID: {creds['client_id']}
-- Client Secret: {creds['client_secret_plaintext']}
-- Client ID: {creds["client_id"]}
-- Client Secret: {creds["client_secret_plaintext"]}
--
-- ⚠️ The client secret is shown ONLY ONCE!
-- ⚠️ Store it securely and share only with the application developer.
@@ -200,7 +201,7 @@ VALUES (
-- To verify the application was created:
-- SELECT "clientId", name, scopes, "redirectUris", "isActive"
-- FROM "OAuthApplication"
-- WHERE "clientId" = '{creds['client_id']}';
-- WHERE "clientId" = '{creds["client_id"]}';
"""
return sql
@@ -834,19 +835,19 @@ async def create_test_app_in_db(
# Insert into database
app = await OAuthApplication.prisma().create(
data={
"id": creds["id"],
"name": creds["name"],
"description": creds["description"],
"clientId": creds["client_id"],
"clientSecret": creds["client_secret_hash"],
"clientSecretSalt": creds["client_secret_salt"],
"redirectUris": creds["redirect_uris"],
"grantTypes": creds["grant_types"],
"scopes": creds["scopes"],
"ownerId": owner_id,
"isActive": True,
}
data=OAuthApplicationCreateInput(
id=creds["id"],
name=creds["name"],
description=creds["description"],
clientId=creds["client_id"],
clientSecret=creds["client_secret_hash"],
clientSecretSalt=creds["client_secret_salt"],
redirectUris=creds["redirect_uris"],
grantTypes=creds["grant_types"],
scopes=creds["scopes"],
ownerId=owner_id,
isActive=True,
)
)
click.echo(f"✓ Created test OAuth application: {app.clientId}")

View File

@@ -6,7 +6,7 @@ from typing import Literal, Optional
from autogpt_libs.api_key.keysmith import APIKeySmith
from prisma.enums import APIKeyPermission, APIKeyStatus
from prisma.models import APIKey as PrismaAPIKey
from prisma.types import APIKeyWhereUniqueInput
from prisma.types import APIKeyCreateInput, APIKeyWhereUniqueInput
from pydantic import Field
from backend.data.includes import MAX_USER_API_KEYS_FETCH
@@ -82,17 +82,17 @@ async def create_api_key(
generated_key = keysmith.generate_key()
saved_key_obj = await PrismaAPIKey.prisma().create(
data={
"id": str(uuid.uuid4()),
"name": name,
"head": generated_key.head,
"tail": generated_key.tail,
"hash": generated_key.hash,
"salt": generated_key.salt,
"permissions": [p for p in permissions],
"description": description,
"userId": user_id,
}
data=APIKeyCreateInput(
id=str(uuid.uuid4()),
name=name,
head=generated_key.head,
tail=generated_key.tail,
hash=generated_key.hash,
salt=generated_key.salt,
permissions=[p for p in permissions],
description=description,
userId=user_id,
)
)
return APIKeyInfo.from_db(saved_key_obj), generated_key.key

View File

@@ -22,7 +22,12 @@ from prisma.models import OAuthAccessToken as PrismaOAuthAccessToken
from prisma.models import OAuthApplication as PrismaOAuthApplication
from prisma.models import OAuthAuthorizationCode as PrismaOAuthAuthorizationCode
from prisma.models import OAuthRefreshToken as PrismaOAuthRefreshToken
from prisma.types import OAuthApplicationUpdateInput
from prisma.types import (
OAuthAccessTokenCreateInput,
OAuthApplicationUpdateInput,
OAuthAuthorizationCodeCreateInput,
OAuthRefreshTokenCreateInput,
)
from pydantic import BaseModel, Field, SecretStr
from .base import APIAuthorizationInfo
@@ -359,17 +364,17 @@ async def create_authorization_code(
expires_at = now + AUTHORIZATION_CODE_TTL
saved_code = await PrismaOAuthAuthorizationCode.prisma().create(
data={
"id": str(uuid.uuid4()),
"code": code,
"expiresAt": expires_at,
"applicationId": application_id,
"userId": user_id,
"scopes": [s for s in scopes],
"redirectUri": redirect_uri,
"codeChallenge": code_challenge,
"codeChallengeMethod": code_challenge_method,
}
data=OAuthAuthorizationCodeCreateInput(
id=str(uuid.uuid4()),
code=code,
expiresAt=expires_at,
applicationId=application_id,
userId=user_id,
scopes=[s for s in scopes],
redirectUri=redirect_uri,
codeChallenge=code_challenge,
codeChallengeMethod=code_challenge_method,
)
)
return OAuthAuthorizationCodeInfo.from_db(saved_code)
@@ -490,14 +495,14 @@ async def create_access_token(
expires_at = now + ACCESS_TOKEN_TTL
saved_token = await PrismaOAuthAccessToken.prisma().create(
data={
"id": str(uuid.uuid4()),
"token": token_hash, # SHA256 hash for direct lookup
"expiresAt": expires_at,
"applicationId": application_id,
"userId": user_id,
"scopes": [s for s in scopes],
}
data=OAuthAccessTokenCreateInput(
id=str(uuid.uuid4()),
token=token_hash, # SHA256 hash for direct lookup
expiresAt=expires_at,
applicationId=application_id,
userId=user_id,
scopes=[s for s in scopes],
)
)
return OAuthAccessToken.from_db(saved_token, plaintext_token=plaintext_token)
@@ -607,14 +612,14 @@ async def create_refresh_token(
expires_at = now + REFRESH_TOKEN_TTL
saved_token = await PrismaOAuthRefreshToken.prisma().create(
data={
"id": str(uuid.uuid4()),
"token": token_hash, # SHA256 hash for direct lookup
"expiresAt": expires_at,
"applicationId": application_id,
"userId": user_id,
"scopes": [s for s in scopes],
}
data=OAuthRefreshTokenCreateInput(
id=str(uuid.uuid4()),
token=token_hash, # SHA256 hash for direct lookup
expiresAt=expires_at,
applicationId=application_id,
userId=user_id,
scopes=[s for s in scopes],
)
)
return OAuthRefreshToken.from_db(saved_token, plaintext_token=plaintext_token)

View File

@@ -11,6 +11,7 @@ import pytest
from prisma.enums import CreditTransactionType
from prisma.errors import UniqueViolationError
from prisma.models import CreditTransaction, User, UserBalance
from prisma.types import UserBalanceCreateInput, UserBalanceUpsertInput, UserCreateInput
from backend.data.credit import UserCredit
from backend.util.json import SafeJson
@@ -21,11 +22,11 @@ async def create_test_user(user_id: str) -> None:
"""Create a test user for ceiling tests."""
try:
await User.prisma().create(
data={
"id": user_id,
"email": f"test-{user_id}@example.com",
"name": f"Test User {user_id[:8]}",
}
data=UserCreateInput(
id=user_id,
email=f"test-{user_id}@example.com",
name=f"Test User {user_id[:8]}",
)
)
except UniqueViolationError:
# User already exists, continue
@@ -33,7 +34,10 @@ async def create_test_user(user_id: str) -> None:
await UserBalance.prisma().upsert(
where={"userId": user_id},
data={"create": {"userId": user_id, "balance": 0}, "update": {"balance": 0}},
data=UserBalanceUpsertInput(
create=UserBalanceCreateInput(userId=user_id, balance=0),
update={"balance": 0},
),
)

View File

@@ -14,6 +14,7 @@ import pytest
from prisma.enums import CreditTransactionType
from prisma.errors import UniqueViolationError
from prisma.models import CreditTransaction, User, UserBalance
from prisma.types import UserBalanceCreateInput, UserBalanceUpsertInput, UserCreateInput
from backend.data.credit import POSTGRES_INT_MAX, UsageTransactionMetadata, UserCredit
from backend.util.exceptions import InsufficientBalanceError
@@ -28,11 +29,11 @@ async def create_test_user(user_id: str) -> None:
"""Create a test user with initial balance."""
try:
await User.prisma().create(
data={
"id": user_id,
"email": f"test-{user_id}@example.com",
"name": f"Test User {user_id[:8]}",
}
data=UserCreateInput(
id=user_id,
email=f"test-{user_id}@example.com",
name=f"Test User {user_id[:8]}",
)
)
except UniqueViolationError:
# User already exists, continue
@@ -41,7 +42,10 @@ async def create_test_user(user_id: str) -> None:
# Ensure UserBalance record exists
await UserBalance.prisma().upsert(
where={"userId": user_id},
data={"create": {"userId": user_id, "balance": 0}, "update": {"balance": 0}},
data=UserBalanceUpsertInput(
create=UserBalanceCreateInput(userId=user_id, balance=0),
update={"balance": 0},
),
)
@@ -342,10 +346,10 @@ async def test_integer_overflow_protection(server: SpinTestServer):
# First, set balance near max
await UserBalance.prisma().upsert(
where={"userId": user_id},
data={
"create": {"userId": user_id, "balance": max_int - 100},
"update": {"balance": max_int - 100},
},
data=UserBalanceUpsertInput(
create=UserBalanceCreateInput(userId=user_id, balance=max_int - 100),
update={"balance": max_int - 100},
),
)
# Try to add more than possible - should clamp to POSTGRES_INT_MAX
@@ -507,7 +511,7 @@ async def test_concurrent_multiple_spends_sufficient_balance(server: SpinTestSer
sorted_timings = sorted(timings.items(), key=lambda x: x[1]["start"])
print("\nExecution order by start time:")
for i, (label, timing) in enumerate(sorted_timings):
print(f" {i+1}. {label}: {timing['start']:.4f} -> {timing['end']:.4f}")
print(f" {i + 1}. {label}: {timing['start']:.4f} -> {timing['end']:.4f}")
# Check for overlap (true concurrency) vs serialization
overlaps = []
@@ -546,7 +550,7 @@ async def test_concurrent_multiple_spends_sufficient_balance(server: SpinTestSer
print("\nDatabase transaction order (by createdAt):")
for i, tx in enumerate(transactions):
print(
f" {i+1}. Amount {tx.amount}, Running balance: {tx.runningBalance}, Created: {tx.createdAt}"
f" {i + 1}. Amount {tx.amount}, Running balance: {tx.runningBalance}, Created: {tx.createdAt}"
)
# Verify running balances are chronologically consistent (ordered by createdAt)
@@ -707,7 +711,7 @@ async def test_prove_database_locking_behavior(server: SpinTestServer):
for i, result in enumerate(sorted_results):
print(
f" {i+1}. {result['label']}: DB operation took {result['db_duration']:.4f}s"
f" {i + 1}. {result['label']}: DB operation took {result['db_duration']:.4f}s"
)
# Check if any operations overlapped at the database level

View File

@@ -8,6 +8,7 @@ which would have caught the CreditTransactionType enum casting bug.
import pytest
from prisma.enums import CreditTransactionType
from prisma.models import CreditTransaction, User, UserBalance
from prisma.types import UserCreateInput
from backend.data.credit import (
AutoTopUpConfig,
@@ -29,12 +30,12 @@ async def cleanup_test_user():
# Create the user first
try:
await User.prisma().create(
data={
"id": user_id,
"email": f"test-{user_id}@example.com",
"topUpConfig": SafeJson({}),
"timezone": "UTC",
}
data=UserCreateInput(
id=user_id,
email=f"test-{user_id}@example.com",
topUpConfig=SafeJson({}),
timezone="UTC",
)
)
except Exception:
# User might already exist, that's fine

View File

@@ -12,6 +12,12 @@ import pytest
import stripe
from prisma.enums import CreditTransactionType
from prisma.models import CreditRefundRequest, CreditTransaction, User, UserBalance
from prisma.types import (
CreditRefundRequestCreateInput,
CreditTransactionCreateInput,
UserBalanceCreateInput,
UserCreateInput,
)
from backend.data.credit import UserCredit
from backend.util.json import SafeJson
@@ -35,32 +41,32 @@ async def setup_test_user_with_topup():
# Create user
await User.prisma().create(
data={
"id": REFUND_TEST_USER_ID,
"email": f"{REFUND_TEST_USER_ID}@example.com",
"name": "Refund Test User",
}
data=UserCreateInput(
id=REFUND_TEST_USER_ID,
email=f"{REFUND_TEST_USER_ID}@example.com",
name="Refund Test User",
)
)
# Create user balance
await UserBalance.prisma().create(
data={
"userId": REFUND_TEST_USER_ID,
"balance": 1000, # $10
}
data=UserBalanceCreateInput(
userId=REFUND_TEST_USER_ID,
balance=1000, # $10
)
)
# Create a top-up transaction that can be refunded
topup_tx = await CreditTransaction.prisma().create(
data={
"userId": REFUND_TEST_USER_ID,
"amount": 1000,
"type": CreditTransactionType.TOP_UP,
"transactionKey": "pi_test_12345",
"runningBalance": 1000,
"isActive": True,
"metadata": SafeJson({"stripe_payment_intent": "pi_test_12345"}),
}
data=CreditTransactionCreateInput(
userId=REFUND_TEST_USER_ID,
amount=1000,
type=CreditTransactionType.TOP_UP,
transactionKey="pi_test_12345",
runningBalance=1000,
isActive=True,
metadata=SafeJson({"stripe_payment_intent": "pi_test_12345"}),
)
)
return topup_tx
@@ -93,12 +99,12 @@ async def test_deduct_credits_atomic(server: SpinTestServer):
# Create refund request record (simulating webhook flow)
await CreditRefundRequest.prisma().create(
data={
"userId": REFUND_TEST_USER_ID,
"amount": 500,
"transactionKey": topup_tx.transactionKey, # Should match the original transaction
"reason": "Test refund",
}
data=CreditRefundRequestCreateInput(
userId=REFUND_TEST_USER_ID,
amount=500,
transactionKey=topup_tx.transactionKey, # Should match the original transaction
reason="Test refund",
)
)
# Call deduct_credits
@@ -286,12 +292,12 @@ async def test_concurrent_refunds(server: SpinTestServer):
refund_requests = []
for i in range(5):
req = await CreditRefundRequest.prisma().create(
data={
"userId": REFUND_TEST_USER_ID,
"amount": 100, # $1 each
"transactionKey": topup_tx.transactionKey,
"reason": f"Test refund {i}",
}
data=CreditRefundRequestCreateInput(
userId=REFUND_TEST_USER_ID,
amount=100, # $1 each
transactionKey=topup_tx.transactionKey,
reason=f"Test refund {i}",
)
)
refund_requests.append(req)

View File

@@ -3,6 +3,11 @@ from datetime import datetime, timedelta, timezone
import pytest
from prisma.enums import CreditTransactionType
from prisma.models import CreditTransaction, UserBalance
from prisma.types import (
CreditTransactionCreateInput,
UserBalanceCreateInput,
UserBalanceUpsertInput,
)
from backend.blocks.llm import AITextGeneratorBlock
from backend.data.block import get_block
@@ -23,10 +28,10 @@ async def disable_test_user_transactions():
old_date = datetime.now(timezone.utc) - timedelta(days=35) # More than a month ago
await UserBalance.prisma().upsert(
where={"userId": DEFAULT_USER_ID},
data={
"create": {"userId": DEFAULT_USER_ID, "balance": 0},
"update": {"balance": 0, "updatedAt": old_date},
},
data=UserBalanceUpsertInput(
create=UserBalanceCreateInput(userId=DEFAULT_USER_ID, balance=0),
update={"balance": 0, "updatedAt": old_date},
),
)
@@ -140,23 +145,23 @@ async def test_block_credit_reset(server: SpinTestServer):
# Manually create a transaction with month 1 timestamp to establish history
await CreditTransaction.prisma().create(
data={
"userId": DEFAULT_USER_ID,
"amount": 100,
"type": CreditTransactionType.TOP_UP,
"runningBalance": 1100,
"isActive": True,
"createdAt": month1, # Set specific timestamp
}
data=CreditTransactionCreateInput(
userId=DEFAULT_USER_ID,
amount=100,
type=CreditTransactionType.TOP_UP,
runningBalance=1100,
isActive=True,
createdAt=month1, # Set specific timestamp
)
)
# Update user balance to match
await UserBalance.prisma().upsert(
where={"userId": DEFAULT_USER_ID},
data={
"create": {"userId": DEFAULT_USER_ID, "balance": 1100},
"update": {"balance": 1100},
},
data=UserBalanceUpsertInput(
create=UserBalanceCreateInput(userId=DEFAULT_USER_ID, balance=1100),
update={"balance": 1100},
),
)
# Now test month 2 behavior
@@ -175,14 +180,14 @@ async def test_block_credit_reset(server: SpinTestServer):
# Create a month 2 transaction to update the last transaction time
await CreditTransaction.prisma().create(
data={
"userId": DEFAULT_USER_ID,
"amount": -700, # Spent 700 to get to 400
"type": CreditTransactionType.USAGE,
"runningBalance": 400,
"isActive": True,
"createdAt": month2,
}
data=CreditTransactionCreateInput(
userId=DEFAULT_USER_ID,
amount=-700, # Spent 700 to get to 400
type=CreditTransactionType.USAGE,
runningBalance=400,
isActive=True,
createdAt=month2,
)
)
# Move to month 3

View File

@@ -12,6 +12,7 @@ import pytest
from prisma.enums import CreditTransactionType
from prisma.errors import UniqueViolationError
from prisma.models import CreditTransaction, User, UserBalance
from prisma.types import UserBalanceCreateInput, UserBalanceUpsertInput, UserCreateInput
from backend.data.credit import POSTGRES_INT_MIN, UserCredit
from backend.util.test import SpinTestServer
@@ -21,11 +22,11 @@ async def create_test_user(user_id: str) -> None:
"""Create a test user for underflow tests."""
try:
await User.prisma().create(
data={
"id": user_id,
"email": f"test-{user_id}@example.com",
"name": f"Test User {user_id[:8]}",
}
data=UserCreateInput(
id=user_id,
email=f"test-{user_id}@example.com",
name=f"Test User {user_id[:8]}",
)
)
except UniqueViolationError:
# User already exists, continue
@@ -33,7 +34,10 @@ async def create_test_user(user_id: str) -> None:
await UserBalance.prisma().upsert(
where={"userId": user_id},
data={"create": {"userId": user_id, "balance": 0}, "update": {"balance": 0}},
data=UserBalanceUpsertInput(
create=UserBalanceCreateInput(userId=user_id, balance=0),
update={"balance": 0},
),
)
@@ -66,14 +70,14 @@ async def test_debug_underflow_step_by_step(server: SpinTestServer):
initial_balance_target = POSTGRES_INT_MIN + 100
# Use direct database update to set the balance close to underflow
from prisma.models import UserBalance
await UserBalance.prisma().upsert(
where={"userId": user_id},
data={
"create": {"userId": user_id, "balance": initial_balance_target},
"update": {"balance": initial_balance_target},
},
data=UserBalanceUpsertInput(
create=UserBalanceCreateInput(
userId=user_id, balance=initial_balance_target
),
update={"balance": initial_balance_target},
),
)
current_balance = await credit_system.get_credits(user_id)
@@ -110,10 +114,10 @@ async def test_debug_underflow_step_by_step(server: SpinTestServer):
# Set balance to exactly POSTGRES_INT_MIN
await UserBalance.prisma().upsert(
where={"userId": user_id},
data={
"create": {"userId": user_id, "balance": POSTGRES_INT_MIN},
"update": {"balance": POSTGRES_INT_MIN},
},
data=UserBalanceUpsertInput(
create=UserBalanceCreateInput(userId=user_id, balance=POSTGRES_INT_MIN),
update={"balance": POSTGRES_INT_MIN},
),
)
edge_balance = await credit_system.get_credits(user_id)
@@ -147,15 +151,13 @@ async def test_underflow_protection_large_refunds(server: SpinTestServer):
# Set up balance close to underflow threshold to test the protection
# Set balance to POSTGRES_INT_MIN + 1000, then try to subtract 2000
# This should trigger underflow protection
from prisma.models import UserBalance
test_balance = POSTGRES_INT_MIN + 1000
await UserBalance.prisma().upsert(
where={"userId": user_id},
data={
"create": {"userId": user_id, "balance": test_balance},
"update": {"balance": test_balance},
},
data=UserBalanceUpsertInput(
create=UserBalanceCreateInput(userId=user_id, balance=test_balance),
update={"balance": test_balance},
),
)
current_balance = await credit_system.get_credits(user_id)
@@ -212,15 +214,13 @@ async def test_multiple_large_refunds_cumulative_underflow(server: SpinTestServe
try:
# Set up balance close to underflow threshold
from prisma.models import UserBalance
initial_balance = POSTGRES_INT_MIN + 500 # Close to minimum but with some room
await UserBalance.prisma().upsert(
where={"userId": user_id},
data={
"create": {"userId": user_id, "balance": initial_balance},
"update": {"balance": initial_balance},
},
data=UserBalanceUpsertInput(
create=UserBalanceCreateInput(userId=user_id, balance=initial_balance),
update={"balance": initial_balance},
),
)
# Apply multiple refunds that would cumulatively underflow
@@ -295,10 +295,10 @@ async def test_concurrent_large_refunds_no_underflow(server: SpinTestServer):
initial_balance = POSTGRES_INT_MIN + 1000 # Close to minimum
await UserBalance.prisma().upsert(
where={"userId": user_id},
data={
"create": {"userId": user_id, "balance": initial_balance},
"update": {"balance": initial_balance},
},
data=UserBalanceUpsertInput(
create=UserBalanceCreateInput(userId=user_id, balance=initial_balance),
update={"balance": initial_balance},
),
)
async def large_refund(amount: int, label: str):

View File

@@ -14,6 +14,7 @@ import pytest
from prisma.enums import CreditTransactionType
from prisma.errors import UniqueViolationError
from prisma.models import CreditTransaction, User, UserBalance
from prisma.types import UserBalanceCreateInput, UserCreateInput
from backend.data.credit import UsageTransactionMetadata, UserCredit
from backend.util.json import SafeJson
@@ -24,11 +25,11 @@ async def create_test_user(user_id: str) -> None:
"""Create a test user for migration tests."""
try:
await User.prisma().create(
data={
"id": user_id,
"email": f"test-{user_id}@example.com",
"name": f"Test User {user_id[:8]}",
}
data=UserCreateInput(
id=user_id,
email=f"test-{user_id}@example.com",
name=f"Test User {user_id[:8]}",
)
)
except UniqueViolationError:
# User already exists, continue
@@ -121,7 +122,7 @@ async def test_detect_stale_user_balance_queries(server: SpinTestServer):
try:
# Create UserBalance with specific value
await UserBalance.prisma().create(
data={"userId": user_id, "balance": 5000} # $50
data=UserBalanceCreateInput(userId=user_id, balance=5000) # $50
)
# Verify that get_credits returns UserBalance value (5000), not any stale User.balance value
@@ -160,7 +161,9 @@ async def test_concurrent_operations_use_userbalance_only(server: SpinTestServer
try:
# Set initial balance in UserBalance
await UserBalance.prisma().create(data={"userId": user_id, "balance": 1000})
await UserBalance.prisma().create(
data=UserBalanceCreateInput(userId=user_id, balance=1000)
)
# Run concurrent operations to ensure they all use UserBalance atomic operations
async def concurrent_spend(amount: int, label: str):

View File

@@ -28,6 +28,7 @@ from prisma.models import (
AgentNodeExecutionKeyValueData,
)
from prisma.types import (
AgentGraphExecutionCreateInput,
AgentGraphExecutionUpdateManyMutationInput,
AgentGraphExecutionWhereInput,
AgentNodeExecutionCreateInput,
@@ -35,7 +36,6 @@ from prisma.types import (
AgentNodeExecutionKeyValueDataCreateInput,
AgentNodeExecutionUpdateInput,
AgentNodeExecutionWhereInput,
AgentNodeExecutionWhereUniqueInput,
)
from pydantic import BaseModel, ConfigDict, JsonValue, ValidationError
from pydantic.fields import Field
@@ -709,18 +709,18 @@ async def create_graph_execution(
The id of the AgentGraphExecution and the list of ExecutionResult for each node.
"""
result = await AgentGraphExecution.prisma().create(
data={
"agentGraphId": graph_id,
"agentGraphVersion": graph_version,
"executionStatus": ExecutionStatus.INCOMPLETE,
"inputs": SafeJson(inputs),
"credentialInputs": (
data=AgentGraphExecutionCreateInput(
agentGraphId=graph_id,
agentGraphVersion=graph_version,
executionStatus=ExecutionStatus.INCOMPLETE,
inputs=SafeJson(inputs),
credentialInputs=(
SafeJson(credential_inputs) if credential_inputs else Json({})
),
"nodesInputMasks": (
nodesInputMasks=(
SafeJson(nodes_input_masks) if nodes_input_masks else Json({})
),
"NodeExecutions": {
NodeExecutions={
"create": [
AgentNodeExecutionCreateInput(
agentNodeId=node_id,
@@ -736,10 +736,10 @@ async def create_graph_execution(
for node_id, node_input in starting_nodes_input
]
},
"userId": user_id,
"agentPresetId": preset_id,
"parentGraphExecutionId": parent_graph_exec_id,
},
userId=user_id,
agentPresetId=preset_id,
parentGraphExecutionId=parent_graph_exec_id,
),
include=GRAPH_EXECUTION_INCLUDE_WITH_NODES,
)
@@ -831,10 +831,10 @@ async def upsert_execution_output(
"""
Insert AgentNodeExecutionInputOutput record for as one of AgentNodeExecution.Output.
"""
data: AgentNodeExecutionInputOutputCreateInput = {
"name": output_name,
"referencedByOutputExecId": node_exec_id,
}
data = AgentNodeExecutionInputOutputCreateInput(
name=output_name,
referencedByOutputExecId=node_exec_id,
)
if output_data is not None:
data["data"] = SafeJson(output_data)
await AgentNodeExecutionInputOutput.prisma().create(data=data)
@@ -964,6 +964,12 @@ async def update_node_execution_status(
execution_data: BlockInput | None = None,
stats: dict[str, Any] | None = None,
) -> NodeExecutionResult:
"""
Update a node execution's status with validation of allowed transitions.
⚠️ Internal executor use only - no user_id check. Callers (executor/manager.py)
are responsible for validating user authorization before invoking this function.
"""
if status == ExecutionStatus.QUEUED and execution_data is None:
raise ValueError("Execution data must be provided when queuing an execution.")
@@ -974,25 +980,27 @@ async def update_node_execution_status(
f"Invalid status transition: {status} has no valid source statuses"
)
if res := await AgentNodeExecution.prisma().update(
where=cast(
AgentNodeExecutionWhereUniqueInput,
{
"id": node_exec_id,
"executionStatus": {"in": [s.value for s in allowed_from]},
},
),
# Fetch current execution to validate status transition
current = await AgentNodeExecution.prisma().find_unique(
where={"id": node_exec_id}, include=EXECUTION_RESULT_INCLUDE
)
if not current:
raise ValueError(f"Execution {node_exec_id} not found.")
# Validate current status allows transition to the new status
if current.executionStatus not in allowed_from:
# Return current state without updating if transition is not allowed
return NodeExecutionResult.from_db(current)
# Perform the update with only the unique identifier
res = await AgentNodeExecution.prisma().update(
where={"id": node_exec_id},
data=_get_update_status_data(status, execution_data, stats),
include=EXECUTION_RESULT_INCLUDE,
):
return NodeExecutionResult.from_db(res)
if res := await AgentNodeExecution.prisma().find_unique(
where={"id": node_exec_id}, include=EXECUTION_RESULT_INCLUDE
):
return NodeExecutionResult.from_db(res)
raise ValueError(f"Execution {node_exec_id} not found.")
)
if not res:
raise ValueError(f"Failed to update execution {node_exec_id}.")
return NodeExecutionResult.from_db(res)
def _get_update_status_data(

View File

@@ -10,7 +10,11 @@ from typing import Optional
from prisma.enums import ReviewStatus
from prisma.models import PendingHumanReview
from prisma.types import PendingHumanReviewUpdateInput
from prisma.types import (
PendingHumanReviewCreateInput,
PendingHumanReviewUpdateInput,
PendingHumanReviewUpsertInput,
)
from pydantic import BaseModel
from backend.api.features.executions.review.model import (
@@ -66,20 +70,20 @@ async def get_or_create_human_review(
# Upsert - get existing or create new review
review = await PendingHumanReview.prisma().upsert(
where={"nodeExecId": node_exec_id},
data={
"create": {
"userId": user_id,
"nodeExecId": node_exec_id,
"graphExecId": graph_exec_id,
"graphId": graph_id,
"graphVersion": graph_version,
"payload": SafeJson(input_data),
"instructions": message,
"editable": editable,
"status": ReviewStatus.WAITING,
},
"update": {}, # Do nothing on update - keep existing review as is
},
data=PendingHumanReviewUpsertInput(
create=PendingHumanReviewCreateInput(
userId=user_id,
nodeExecId=node_exec_id,
graphExecId=graph_exec_id,
graphId=graph_id,
graphVersion=graph_version,
payload=SafeJson(input_data),
instructions=message,
editable=editable,
status=ReviewStatus.WAITING,
),
update={}, # Do nothing on update - keep existing review as is
),
)
logger.info(

View File

@@ -7,7 +7,11 @@ import prisma
import pydantic
from prisma.enums import OnboardingStep
from prisma.models import UserOnboarding
from prisma.types import UserOnboardingCreateInput, UserOnboardingUpdateInput
from prisma.types import (
UserOnboardingCreateInput,
UserOnboardingUpdateInput,
UserOnboardingUpsertInput,
)
from backend.api.features.store.model import StoreAgentDetails
from backend.api.model import OnboardingNotificationPayload
@@ -92,6 +96,7 @@ async def reset_user_onboarding(user_id: str):
async def update_user_onboarding(user_id: str, data: UserOnboardingUpdate):
update: UserOnboardingUpdateInput = {}
# get_user_onboarding guarantees the record exists via upsert
onboarding = await get_user_onboarding(user_id)
if data.walletShown:
update["walletShown"] = data.walletShown
@@ -110,12 +115,14 @@ async def update_user_onboarding(user_id: str, data: UserOnboardingUpdate):
if data.onboardingAgentExecutionId is not None:
update["onboardingAgentExecutionId"] = data.onboardingAgentExecutionId
# The create branch is never taken since get_user_onboarding ensures the record exists,
# but upsert requires a create payload so we provide a minimal one
return await UserOnboarding.prisma().upsert(
where={"userId": user_id},
data={
"create": {"userId": user_id, **update},
"update": update,
},
data=UserOnboardingUpsertInput(
create=UserOnboardingCreateInput(userId=user_id),
update=update,
),
)

View File

@@ -22,6 +22,7 @@ import random
from typing import Any, Dict, List
from faker import Faker
from prisma.types import AgentBlockCreateInput
# Import API functions from the backend
from backend.api.features.library.db import create_library_agent, create_preset
@@ -179,12 +180,12 @@ class TestDataCreator:
for block in blocks_to_create:
try:
await prisma.agentblock.create(
data={
"id": block.id,
"name": block.name,
"inputSchema": "{}",
"outputSchema": "{}",
}
data=AgentBlockCreateInput(
id=block.id,
name=block.name,
inputSchema="{}",
outputSchema="{}",
)
)
except Exception as e:
print(f"Error creating block {block.name}: {e}")

View File

@@ -30,13 +30,19 @@ from prisma.types import (
AgentGraphCreateInput,
AgentNodeCreateInput,
AgentNodeLinkCreateInput,
AgentPresetCreateInput,
AnalyticsDetailsCreateInput,
AnalyticsMetricsCreateInput,
APIKeyCreateInput,
CreditTransactionCreateInput,
IntegrationWebhookCreateInput,
LibraryAgentCreateInput,
ProfileCreateInput,
StoreListingCreateInput,
StoreListingReviewCreateInput,
StoreListingVersionCreateInput,
UserCreateInput,
UserOnboardingCreateInput,
)
faker = Faker()
@@ -172,14 +178,14 @@ async def main():
for _ in range(num_presets): # Create 1 AgentPreset per user
graph = random.choice(agent_graphs)
preset = await db.agentpreset.create(
data={
"name": faker.sentence(nb_words=3),
"description": faker.text(max_nb_chars=200),
"userId": user.id,
"agentGraphId": graph.id,
"agentGraphVersion": graph.version,
"isActive": True,
}
data=AgentPresetCreateInput(
name=faker.sentence(nb_words=3),
description=faker.text(max_nb_chars=200),
userId=user.id,
agentGraphId=graph.id,
agentGraphVersion=graph.version,
isActive=True,
)
)
agent_presets.append(preset)
@@ -220,18 +226,18 @@ async def main():
)
library_agent = await db.libraryagent.create(
data={
"userId": user.id,
"agentGraphId": graph.id,
"agentGraphVersion": graph.version,
"creatorId": creator_profile.id if creator_profile else None,
"imageUrl": get_image() if random.random() < 0.5 else None,
"useGraphIsActiveVersion": random.choice([True, False]),
"isFavorite": random.choice([True, False]),
"isCreatedByUser": random.choice([True, False]),
"isArchived": random.choice([True, False]),
"isDeleted": random.choice([True, False]),
}
data=LibraryAgentCreateInput(
userId=user.id,
agentGraphId=graph.id,
agentGraphVersion=graph.version,
creatorId=creator_profile.id if creator_profile else None,
imageUrl=get_image() if random.random() < 0.5 else None,
useGraphIsActiveVersion=random.choice([True, False]),
isFavorite=random.choice([True, False]),
isCreatedByUser=random.choice([True, False]),
isArchived=random.choice([True, False]),
isDeleted=random.choice([True, False]),
)
)
library_agents.append(library_agent)
@@ -392,13 +398,13 @@ async def main():
user = random.choice(users)
slug = faker.slug()
listing = await db.storelisting.create(
data={
"agentGraphId": graph.id,
"agentGraphVersion": graph.version,
"owningUserId": user.id,
"hasApprovedVersion": random.choice([True, False]),
"slug": slug,
}
data=StoreListingCreateInput(
agentGraphId=graph.id,
agentGraphVersion=graph.version,
owningUserId=user.id,
hasApprovedVersion=random.choice([True, False]),
slug=slug,
)
)
store_listings.append(listing)
@@ -408,26 +414,26 @@ async def main():
for listing in store_listings:
graph = [g for g in agent_graphs if g.id == listing.agentGraphId][0]
version = await db.storelistingversion.create(
data={
"agentGraphId": graph.id,
"agentGraphVersion": graph.version,
"name": graph.name or faker.sentence(nb_words=3),
"subHeading": faker.sentence(),
"videoUrl": get_video_url() if random.random() < 0.3 else None,
"imageUrls": [get_image() for _ in range(3)],
"description": faker.text(),
"categories": [faker.word() for _ in range(3)],
"isFeatured": random.choice([True, False]),
"isAvailable": True,
"storeListingId": listing.id,
"submissionStatus": random.choice(
data=StoreListingVersionCreateInput(
agentGraphId=graph.id,
agentGraphVersion=graph.version,
name=graph.name or faker.sentence(nb_words=3),
subHeading=faker.sentence(),
videoUrl=get_video_url() if random.random() < 0.3 else None,
imageUrls=[get_image() for _ in range(3)],
description=faker.text(),
categories=[faker.word() for _ in range(3)],
isFeatured=random.choice([True, False]),
isAvailable=True,
storeListingId=listing.id,
submissionStatus=random.choice(
[
prisma.enums.SubmissionStatus.PENDING,
prisma.enums.SubmissionStatus.APPROVED,
prisma.enums.SubmissionStatus.REJECTED,
]
),
}
)
)
store_listing_versions.append(version)
@@ -469,51 +475,49 @@ async def main():
try:
await db.useronboarding.create(
data={
"userId": user.id,
"completedSteps": completed_steps,
"walletShown": random.choice([True, False]),
"notified": (
data=UserOnboardingCreateInput(
userId=user.id,
completedSteps=completed_steps,
walletShown=random.choice([True, False]),
notified=(
random.sample(completed_steps, k=min(3, len(completed_steps)))
if completed_steps
else []
),
"rewardedFor": (
rewardedFor=(
random.sample(completed_steps, k=min(2, len(completed_steps)))
if completed_steps
else []
),
"usageReason": (
usageReason=(
random.choice(["personal", "business", "research", "learning"])
if random.random() < 0.7
else None
),
"integrations": random.sample(
integrations=random.sample(
["github", "google", "discord", "slack"], k=random.randint(0, 2)
),
"otherIntegrations": (
faker.word() if random.random() < 0.2 else None
),
"selectedStoreListingVersionId": (
otherIntegrations=(faker.word() if random.random() < 0.2 else None),
selectedStoreListingVersionId=(
random.choice(store_listing_versions).id
if store_listing_versions and random.random() < 0.5
else None
),
"onboardingAgentExecutionId": (
onboardingAgentExecutionId=(
random.choice(agent_graph_executions).id
if agent_graph_executions and random.random() < 0.3
else None
),
"agentRuns": random.randint(0, 10),
}
agentRuns=random.randint(0, 10),
)
)
except Exception as e:
print(f"Error creating onboarding for user {user.id}: {e}")
# Try simpler version
await db.useronboarding.create(
data={
"userId": user.id,
}
data=UserOnboardingCreateInput(
userId=user.id,
)
)
# Insert IntegrationWebhooks for some users
@@ -544,20 +548,20 @@ async def main():
for user in users:
api_key = APIKeySmith().generate_key()
await db.apikey.create(
data={
"name": faker.word(),
"head": api_key.head,
"tail": api_key.tail,
"hash": api_key.hash,
"salt": api_key.salt,
"status": prisma.enums.APIKeyStatus.ACTIVE,
"permissions": [
data=APIKeyCreateInput(
name=faker.word(),
head=api_key.head,
tail=api_key.tail,
hash=api_key.hash,
salt=api_key.salt,
status=prisma.enums.APIKeyStatus.ACTIVE,
permissions=[
prisma.enums.APIKeyPermission.EXECUTE_GRAPH,
prisma.enums.APIKeyPermission.READ_GRAPH,
],
"description": faker.text(),
"userId": user.id,
}
description=faker.text(),
userId=user.id,
)
)
# Refresh materialized views

View File

@@ -16,6 +16,7 @@ from datetime import datetime, timedelta
import prisma.enums
from faker import Faker
from prisma import Json, Prisma
from prisma.types import CreditTransactionCreateInput, StoreListingReviewCreateInput
faker = Faker()
@@ -166,16 +167,16 @@ async def main():
score = random.choices([1, 2, 3, 4, 5], weights=[5, 10, 20, 40, 25])[0]
await db.storelistingreview.create(
data={
"storeListingVersionId": version.id,
"reviewByUserId": reviewer.id,
"score": score,
"comments": (
data=StoreListingReviewCreateInput(
storeListingVersionId=version.id,
reviewByUserId=reviewer.id,
score=score,
comments=(
faker.text(max_nb_chars=200)
if random.random() < 0.7
else None
),
}
)
)
new_reviews_count += 1
@@ -244,17 +245,17 @@ async def main():
)
await db.credittransaction.create(
data={
"userId": user.id,
"amount": amount,
"type": transaction_type,
"metadata": Json(
data=CreditTransactionCreateInput(
userId=user.id,
amount=amount,
type=transaction_type,
metadata=Json(
{
"source": "test_updater",
"timestamp": datetime.now().isoformat(),
}
),
}
)
)
transaction_count += 1

View File

@@ -92,7 +92,6 @@
"react-currency-input-field": "4.0.3",
"react-day-picker": "9.11.1",
"react-dom": "18.3.1",
"react-drag-drop-files": "2.4.0",
"react-hook-form": "7.66.0",
"react-icons": "5.5.0",
"react-markdown": "9.0.3",

View File

@@ -200,9 +200,6 @@ importers:
react-dom:
specifier: 18.3.1
version: 18.3.1(react@18.3.1)
react-drag-drop-files:
specifier: 2.4.0
version: 2.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-hook-form:
specifier: 7.66.0
version: 7.66.0(react@18.3.1)
@@ -1004,9 +1001,6 @@ packages:
'@emotion/memoize@0.8.1':
resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==}
'@emotion/unitless@0.8.1':
resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==}
'@epic-web/invariant@1.0.0':
resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
@@ -3122,9 +3116,6 @@ packages:
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
'@types/stylis@4.2.7':
resolution: {integrity: sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==}
'@types/tedious@4.0.14':
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
@@ -3781,9 +3772,6 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
camelize@1.0.1:
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
caniuse-lite@1.0.30001762:
resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==}
@@ -3997,10 +3985,6 @@ packages:
resolution: {integrity: sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==}
engines: {node: '>= 0.10'}
css-color-keywords@1.0.0:
resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
engines: {node: '>=4'}
css-loader@6.11.0:
resolution: {integrity: sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==}
engines: {node: '>= 12.13.0'}
@@ -4016,9 +4000,6 @@ packages:
css-select@4.3.0:
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
css-to-react-native@3.2.0:
resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
css-what@6.2.2:
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
engines: {node: '>= 6'}
@@ -6131,10 +6112,6 @@ packages:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.4.49:
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@@ -6306,12 +6283,6 @@ packages:
peerDependencies:
react: ^18.3.1
react-drag-drop-files@2.4.0:
resolution: {integrity: sha512-MGPV3HVVnwXEXq3gQfLtSU3jz5j5jrabvGedokpiSEMoONrDHgYl/NpIOlfsqGQ4zBv1bzzv7qbKURZNOX32PA==}
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
react-hook-form@7.66.0:
resolution: {integrity: sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==}
engines: {node: '>=18.0.0'}
@@ -6678,9 +6649,6 @@ packages:
engines: {node: '>= 0.10'}
hasBin: true
shallowequal@1.1.0:
resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -6894,13 +6862,6 @@ packages:
style-to-object@1.0.14:
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
styled-components@6.2.0:
resolution: {integrity: sha512-ryFCkETE++8jlrBmC+BoGPUN96ld1/Yp0s7t5bcXDobrs4XoXroY1tN+JbFi09hV6a5h3MzbcVi8/BGDP0eCgQ==}
engines: {node: '>= 16'}
peerDependencies:
react: '>= 16.8.0'
react-dom: '>= 16.8.0'
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -6927,9 +6888,6 @@ packages:
babel-plugin-macros:
optional: true
stylis@4.3.6:
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
sucrase@3.35.1:
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -7096,9 +7054,6 @@ packages:
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.6.2:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -8335,10 +8290,10 @@ snapshots:
'@emotion/is-prop-valid@1.2.2':
dependencies:
'@emotion/memoize': 0.8.1
optional: true
'@emotion/memoize@0.8.1': {}
'@emotion/unitless@0.8.1': {}
'@emotion/memoize@0.8.1':
optional: true
'@epic-web/invariant@1.0.0': {}
@@ -10734,8 +10689,6 @@ snapshots:
'@types/statuses@2.0.6': {}
'@types/stylis@4.2.7': {}
'@types/tedious@4.0.14':
dependencies:
'@types/node': 24.10.0
@@ -11432,8 +11385,6 @@ snapshots:
camelcase-css@2.0.1: {}
camelize@1.0.1: {}
caniuse-lite@1.0.30001762: {}
case-sensitive-paths-webpack-plugin@2.4.0: {}
@@ -11645,8 +11596,6 @@ snapshots:
randombytes: 2.1.0
randomfill: 1.0.4
css-color-keywords@1.0.0: {}
css-loader@6.11.0(webpack@5.104.1(esbuild@0.25.12)):
dependencies:
icss-utils: 5.1.0(postcss@8.5.6)
@@ -11668,12 +11617,6 @@ snapshots:
domutils: 2.8.0
nth-check: 2.1.1
css-to-react-native@3.2.0:
dependencies:
camelize: 1.0.1
css-color-keywords: 1.0.0
postcss-value-parser: 4.2.0
css-what@6.2.2: {}
css.escape@1.5.1: {}
@@ -12127,8 +12070,8 @@ snapshots:
'@typescript-eslint/parser': 8.52.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1)
@@ -12147,7 +12090,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
@@ -12158,22 +12101,22 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.52.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -12184,7 +12127,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -14259,12 +14202,6 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.4.49:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
@@ -14386,13 +14323,6 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
react-drag-drop-files@2.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
prop-types: 15.8.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
styled-components: 6.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-hook-form@7.66.0(react@18.3.1):
dependencies:
react: 18.3.1
@@ -14886,8 +14816,6 @@ snapshots:
safe-buffer: 5.2.1
to-buffer: 1.2.2
shallowequal@1.1.0: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.0.0
@@ -15178,20 +15106,6 @@ snapshots:
dependencies:
inline-style-parser: 0.2.7
styled-components@6.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@emotion/is-prop-valid': 1.2.2
'@emotion/unitless': 0.8.1
'@types/stylis': 4.2.7
css-to-react-native: 3.2.0
csstype: 3.2.3
postcss: 8.4.49
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
shallowequal: 1.1.0
stylis: 4.3.6
tslib: 2.6.2
styled-jsx@5.1.6(@babel/core@7.28.5)(react@18.3.1):
dependencies:
client-only: 0.0.1
@@ -15206,8 +15120,6 @@ snapshots:
optionalDependencies:
'@babel/core': 7.28.5
stylis@4.3.6: {}
sucrase@3.35.1:
dependencies:
'@jridgewell/gen-mapping': 0.3.13
@@ -15390,8 +15302,6 @@ snapshots:
tslib@1.14.1: {}
tslib@2.6.2: {}
tslib@2.8.1: {}
tty-browserify@0.0.1: {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -97,6 +97,9 @@ export const Flow = () => {
onConnect={onConnect}
onEdgesChange={onEdgesChange}
onNodeDragStop={onNodeDragStop}
onNodeContextMenu={(event) => {
event.preventDefault();
}}
maxZoom={2}
minZoom={0.1}
onDragOver={onDragOver}

View File

@@ -1,24 +1,25 @@
import React from "react";
import { Node as XYNode, NodeProps } from "@xyflow/react";
import { RJSFSchema } from "@rjsf/utils";
import { BlockUIType } from "../../../types";
import { StickyNoteBlock } from "./components/StickyNoteBlock";
import { BlockInfoCategoriesItem } from "@/app/api/__generated__/models/blockInfoCategoriesItem";
import { BlockCost } from "@/app/api/__generated__/models/blockCost";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { BlockCost } from "@/app/api/__generated__/models/blockCost";
import { BlockInfoCategoriesItem } from "@/app/api/__generated__/models/blockInfoCategoriesItem";
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
import { NodeContainer } from "./components/NodeContainer";
import { NodeHeader } from "./components/NodeHeader";
import { FormCreator } from "../FormCreator";
import { preprocessInputSchema } from "@/components/renderers/InputRenderer/utils/input-schema-pre-processor";
import { OutputHandler } from "../OutputHandler";
import { NodeAdvancedToggle } from "./components/NodeAdvancedToggle";
import { NodeDataRenderer } from "./components/NodeOutput/NodeOutput";
import { NodeExecutionBadge } from "./components/NodeExecutionBadge";
import { cn } from "@/lib/utils";
import { WebhookDisclaimer } from "./components/WebhookDisclaimer";
import { AyrshareConnectButton } from "./components/AyrshareConnectButton";
import { NodeModelMetadata } from "@/app/api/__generated__/models/nodeModelMetadata";
import { preprocessInputSchema } from "@/components/renderers/InputRenderer/utils/input-schema-pre-processor";
import { cn } from "@/lib/utils";
import { RJSFSchema } from "@rjsf/utils";
import { NodeProps, Node as XYNode } from "@xyflow/react";
import React from "react";
import { BlockUIType } from "../../../types";
import { FormCreator } from "../FormCreator";
import { OutputHandler } from "../OutputHandler";
import { AyrshareConnectButton } from "./components/AyrshareConnectButton";
import { NodeAdvancedToggle } from "./components/NodeAdvancedToggle";
import { NodeContainer } from "./components/NodeContainer";
import { NodeExecutionBadge } from "./components/NodeExecutionBadge";
import { NodeHeader } from "./components/NodeHeader";
import { NodeDataRenderer } from "./components/NodeOutput/NodeOutput";
import { NodeRightClickMenu } from "./components/NodeRightClickMenu";
import { StickyNoteBlock } from "./components/StickyNoteBlock";
import { WebhookDisclaimer } from "./components/WebhookDisclaimer";
export type CustomNodeData = {
hardcodedValues: {
@@ -88,7 +89,7 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
// Currently all blockTypes design are similar - that's why i am using the same component for all of them
// If in future - if we need some drastic change in some blockTypes design - we can create separate components for them
return (
const node = (
<NodeContainer selected={selected} nodeId={nodeId} hasErrors={hasErrors}>
<div className="rounded-xlarge bg-white">
<NodeHeader data={data} nodeId={nodeId} />
@@ -117,6 +118,15 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
<NodeExecutionBadge nodeId={nodeId} />
</NodeContainer>
);
return (
<NodeRightClickMenu
nodeId={nodeId}
subGraphID={data.hardcodedValues?.graph_id}
>
{node}
</NodeRightClickMenu>
);
},
);

View File

@@ -1,26 +1,31 @@
import { Separator } from "@/components/__legacy__/ui/separator";
import { useCopyPasteStore } from "@/app/(platform)/build/stores/copyPasteStore";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import { DotsThreeOutlineVerticalIcon } from "@phosphor-icons/react";
import { Copy, Trash2, ExternalLink } from "lucide-react";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { useCopyPasteStore } from "@/app/(platform)/build/stores/copyPasteStore";
import {
SecondaryDropdownMenuContent,
SecondaryDropdownMenuItem,
SecondaryDropdownMenuSeparator,
} from "@/components/molecules/SecondaryMenu/SecondaryMenu";
import {
ArrowSquareOutIcon,
CopyIcon,
DotsThreeOutlineVerticalIcon,
TrashIcon,
} from "@phosphor-icons/react";
import { useReactFlow } from "@xyflow/react";
export const NodeContextMenu = ({
nodeId,
subGraphID,
}: {
type Props = {
nodeId: string;
subGraphID?: string;
}) => {
};
export const NodeContextMenu = ({ nodeId, subGraphID }: Props) => {
const { deleteElements } = useReactFlow();
const handleCopy = () => {
function handleCopy() {
useNodeStore.setState((state) => ({
nodes: state.nodes.map((node) => ({
...node,
@@ -30,47 +35,47 @@ export const NodeContextMenu = ({
useCopyPasteStore.getState().copySelectedNodes();
useCopyPasteStore.getState().pasteNodes();
};
}
const handleDelete = () => {
function handleDelete() {
deleteElements({ nodes: [{ id: nodeId }] });
};
}
return (
<DropdownMenu>
<DropdownMenuTrigger className="py-2">
<DotsThreeOutlineVerticalIcon size={16} weight="fill" />
</DropdownMenuTrigger>
<DropdownMenuContent
side="right"
align="start"
className="rounded-xlarge"
>
<DropdownMenuItem onClick={handleCopy} className="hover:rounded-xlarge">
<Copy className="mr-2 h-4 w-4" />
Copy Node
</DropdownMenuItem>
<SecondaryDropdownMenuContent side="right" align="start">
<SecondaryDropdownMenuItem onClick={handleCopy}>
<CopyIcon size={20} className="mr-2 dark:text-gray-100" />
<span className="dark:text-gray-100">Copy</span>
</SecondaryDropdownMenuItem>
<SecondaryDropdownMenuSeparator />
{subGraphID && (
<DropdownMenuItem
onClick={() => window.open(`/build?flowID=${subGraphID}`)}
className="hover:rounded-xlarge"
>
<ExternalLink className="mr-2 h-4 w-4" />
Open Agent
</DropdownMenuItem>
<>
<SecondaryDropdownMenuItem
onClick={() => window.open(`/build?flowID=${subGraphID}`)}
>
<ArrowSquareOutIcon
size={20}
className="mr-2 dark:text-gray-100"
/>
<span className="dark:text-gray-100">Open agent</span>
</SecondaryDropdownMenuItem>
<SecondaryDropdownMenuSeparator />
</>
)}
<Separator className="my-2" />
<DropdownMenuItem
onClick={handleDelete}
className="text-red-600 hover:rounded-xlarge"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
<SecondaryDropdownMenuItem variant="destructive" onClick={handleDelete}>
<TrashIcon
size={20}
className="mr-2 text-red-500 dark:text-red-400"
/>
<span className="dark:text-red-400">Delete</span>
</SecondaryDropdownMenuItem>
</SecondaryDropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -1,25 +1,24 @@
import { Text } from "@/components/atoms/Text/Text";
import { beautifyString, cn } from "@/lib/utils";
import { NodeCost } from "./NodeCost";
import { NodeBadges } from "./NodeBadges";
import { NodeContextMenu } from "./NodeContextMenu";
import { CustomNodeData } from "../CustomNode";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { useState } from "react";
import { Text } from "@/components/atoms/Text/Text";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { beautifyString, cn } from "@/lib/utils";
import { useState } from "react";
import { CustomNodeData } from "../CustomNode";
import { NodeBadges } from "./NodeBadges";
import { NodeContextMenu } from "./NodeContextMenu";
import { NodeCost } from "./NodeCost";
export const NodeHeader = ({
data,
nodeId,
}: {
type Props = {
data: CustomNodeData;
nodeId: string;
}) => {
};
export const NodeHeader = ({ data, nodeId }: Props) => {
const updateNodeData = useNodeStore((state) => state.updateNodeData);
const title = (data.metadata?.customized_name as string) || data.title;
const [isEditingTitle, setIsEditingTitle] = useState(false);

View File

@@ -151,7 +151,7 @@ export const NodeDataViewer: FC<NodeDataViewerProps> = ({
</div>
<div className="flex justify-end pt-4">
{outputItems.length > 0 && (
{outputItems.length > 1 && (
<OutputActions
items={outputItems.map((item) => ({
value: item.value,

View File

@@ -0,0 +1,104 @@
import { useCopyPasteStore } from "@/app/(platform)/build/stores/copyPasteStore";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import {
SecondaryMenuContent,
SecondaryMenuItem,
SecondaryMenuSeparator,
} from "@/components/molecules/SecondaryMenu/SecondaryMenu";
import { ArrowSquareOutIcon, CopyIcon, TrashIcon } from "@phosphor-icons/react";
import * as ContextMenu from "@radix-ui/react-context-menu";
import { useReactFlow } from "@xyflow/react";
import { useEffect, useRef } from "react";
import { CustomNode } from "../CustomNode";
type Props = {
nodeId: string;
subGraphID?: string;
children: React.ReactNode;
};
const DOUBLE_CLICK_TIMEOUT = 300;
export function NodeRightClickMenu({ nodeId, subGraphID, children }: Props) {
const { deleteElements } = useReactFlow<CustomNode>();
const lastRightClickTime = useRef<number>(0);
const containerRef = useRef<HTMLDivElement>(null);
function copyNode() {
useNodeStore.setState((state) => ({
nodes: state.nodes.map((node) => ({
...node,
selected: node.id === nodeId,
})),
}));
useCopyPasteStore.getState().copySelectedNodes();
useCopyPasteStore.getState().pasteNodes();
}
function deleteNode() {
deleteElements({ nodes: [{ id: nodeId }] });
}
useEffect(() => {
const container = containerRef.current;
if (!container) return;
function handleContextMenu(e: MouseEvent) {
const now = Date.now();
const timeSinceLastClick = now - lastRightClickTime.current;
if (timeSinceLastClick < DOUBLE_CLICK_TIMEOUT) {
e.stopImmediatePropagation();
lastRightClickTime.current = 0;
return;
}
lastRightClickTime.current = now;
}
container.addEventListener("contextmenu", handleContextMenu, true);
return () => {
container.removeEventListener("contextmenu", handleContextMenu, true);
};
}, []);
return (
<ContextMenu.Root>
<ContextMenu.Trigger asChild>
<div ref={containerRef}>{children}</div>
</ContextMenu.Trigger>
<SecondaryMenuContent>
<SecondaryMenuItem onSelect={copyNode}>
<CopyIcon size={20} className="mr-2 dark:text-gray-100" />
<span className="dark:text-gray-100">Copy</span>
</SecondaryMenuItem>
<SecondaryMenuSeparator />
{subGraphID && (
<>
<SecondaryMenuItem
onClick={() => window.open(`/build?flowID=${subGraphID}`)}
>
<ArrowSquareOutIcon
size={20}
className="mr-2 dark:text-gray-100"
/>
<span className="dark:text-gray-100">Open agent</span>
</SecondaryMenuItem>
<SecondaryMenuSeparator />
</>
)}
<SecondaryMenuItem variant="destructive" onSelect={deleteNode}>
<TrashIcon
size={20}
className="mr-2 text-red-500 dark:text-red-400"
/>
<span className="dark:text-red-400">Delete</span>
</SecondaryMenuItem>
</SecondaryMenuContent>
</ContextMenu.Root>
);
}

View File

@@ -0,0 +1,57 @@
import { useBlockMenuStore } from "@/app/(platform)/build/stores/blockMenuStore";
import { FilterChip } from "../FilterChip";
import { categories } from "./constants";
import { FilterSheet } from "../FilterSheet/FilterSheet";
import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
export const BlockMenuFilters = () => {
const {
filters,
addFilter,
removeFilter,
categoryCounts,
creators,
addCreator,
removeCreator,
} = useBlockMenuStore();
const handleFilterClick = (filter: GetV2BuilderSearchFilterAnyOfItem) => {
if (filters.includes(filter)) {
removeFilter(filter);
} else {
addFilter(filter);
}
};
const handleCreatorClick = (creator: string) => {
if (creators.includes(creator)) {
removeCreator(creator);
} else {
addCreator(creator);
}
};
return (
<div className="flex flex-wrap gap-2">
<FilterSheet categories={categories} />
{creators.length > 0 &&
creators.map((creator) => (
<FilterChip
key={creator}
name={"Created by " + creator.slice(0, 10) + "..."}
selected={creators.includes(creator)}
onClick={() => handleCreatorClick(creator)}
/>
))}
{categories.map((category) => (
<FilterChip
key={category.key}
name={category.name}
selected={filters.includes(category.key)}
onClick={() => handleFilterClick(category.key)}
number={categoryCounts[category.key] ?? 0}
/>
))}
</div>
);
};

View File

@@ -0,0 +1,15 @@
import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
import { CategoryKey } from "./types";
export const categories: Array<{ key: CategoryKey; name: string }> = [
{ key: GetV2BuilderSearchFilterAnyOfItem.blocks, name: "Blocks" },
{
key: GetV2BuilderSearchFilterAnyOfItem.integrations,
name: "Integrations",
},
{
key: GetV2BuilderSearchFilterAnyOfItem.marketplace_agents,
name: "Marketplace agents",
},
{ key: GetV2BuilderSearchFilterAnyOfItem.my_agents, name: "My agents" },
];

View File

@@ -0,0 +1,26 @@
import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
export type DefaultStateType =
| "suggestion"
| "all_blocks"
| "input_blocks"
| "action_blocks"
| "output_blocks"
| "integrations"
| "marketplace_agents"
| "my_agents";
export type CategoryKey = GetV2BuilderSearchFilterAnyOfItem;
export interface Filters {
categories: {
blocks: boolean;
integrations: boolean;
marketplace_agents: boolean;
my_agents: boolean;
providers: boolean;
};
createdBy: string[];
}
export type CategoryCounts = Record<CategoryKey, number>;

View File

@@ -1,111 +1,14 @@
import { Text } from "@/components/atoms/Text/Text";
import { useBlockMenuSearch } from "./useBlockMenuSearch";
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
import { LoadingSpinner } from "@/components/__legacy__/ui/loading";
import { SearchResponseItemsItem } from "@/app/api/__generated__/models/searchResponseItemsItem";
import { MarketplaceAgentBlock } from "../MarketplaceAgentBlock";
import { Block } from "../Block";
import { UGCAgentBlock } from "../UGCAgentBlock";
import { getSearchItemType } from "./helper";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
import { blockMenuContainerStyle } from "../style";
import { cn } from "@/lib/utils";
import { NoSearchResult } from "../NoSearchResult";
import { BlockMenuFilters } from "../BlockMenuFilters/BlockMenuFilters";
import { BlockMenuSearchContent } from "../BlockMenuSearchContent/BlockMenuSearchContent";
export const BlockMenuSearch = () => {
const {
searchResults,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
searchLoading,
handleAddLibraryAgent,
handleAddMarketplaceAgent,
addingLibraryAgentId,
addingMarketplaceAgentSlug,
} = useBlockMenuSearch();
const { searchQuery } = useBlockMenuStore();
if (searchLoading) {
return (
<div
className={cn(
blockMenuContainerStyle,
"flex items-center justify-center",
)}
>
<LoadingSpinner className="size-13" />
</div>
);
}
if (searchResults.length === 0) {
return <NoSearchResult />;
}
return (
<div className={blockMenuContainerStyle}>
<BlockMenuFilters />
<Text variant="body-medium">Search results</Text>
<InfiniteScroll
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
loader={<LoadingSpinner className="size-13" />}
className="space-y-2.5"
>
{searchResults.map((item: SearchResponseItemsItem, index: number) => {
const { type, data } = getSearchItemType(item);
// backend give support to these 3 types only [right now] - we need to give support to integration and ai agent types in follow up PRs
switch (type) {
case "store_agent":
return (
<MarketplaceAgentBlock
key={index}
slug={data.slug}
highlightedText={searchQuery}
title={data.agent_name}
image_url={data.agent_image}
creator_name={data.creator}
number_of_runs={data.runs}
loading={addingMarketplaceAgentSlug === data.slug}
onClick={() =>
handleAddMarketplaceAgent({
creator_name: data.creator,
slug: data.slug,
})
}
/>
);
case "block":
return (
<Block
key={index}
title={data.name}
highlightedText={searchQuery}
description={data.description}
blockData={data}
/>
);
case "library_agent":
return (
<UGCAgentBlock
key={index}
title={data.name}
highlightedText={searchQuery}
image_url={data.image_url}
version={data.graph_version}
edited_time={data.updated_at}
isLoading={addingLibraryAgentId === data.id}
onClick={() => handleAddLibraryAgent(data)}
/>
);
default:
return null;
}
})}
</InfiniteScroll>
<BlockMenuSearchContent />
</div>
);
};

View File

@@ -0,0 +1,108 @@
import { SearchResponseItemsItem } from "@/app/api/__generated__/models/searchResponseItemsItem";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
import { getSearchItemType } from "./helper";
import { MarketplaceAgentBlock } from "../MarketplaceAgentBlock";
import { Block } from "../Block";
import { UGCAgentBlock } from "../UGCAgentBlock";
import { useBlockMenuSearchContent } from "./useBlockMenuSearchContent";
import { useBlockMenuStore } from "@/app/(platform)/build/stores/blockMenuStore";
import { cn } from "@/lib/utils";
import { blockMenuContainerStyle } from "../style";
import { NoSearchResult } from "../NoSearchResult";
export const BlockMenuSearchContent = () => {
const {
searchResults,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
searchLoading,
handleAddLibraryAgent,
handleAddMarketplaceAgent,
addingLibraryAgentId,
addingMarketplaceAgentSlug,
} = useBlockMenuSearchContent();
const { searchQuery } = useBlockMenuStore();
if (searchLoading) {
return (
<div
className={cn(
blockMenuContainerStyle,
"flex items-center justify-center",
)}
>
<LoadingSpinner className="size-13" />
</div>
);
}
if (searchResults.length === 0) {
return <NoSearchResult />;
}
return (
<InfiniteScroll
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
loader={<LoadingSpinner className="size-13" />}
className="space-y-2.5"
>
{searchResults.map((item: SearchResponseItemsItem, index: number) => {
const { type, data } = getSearchItemType(item);
// backend give support to these 3 types only [right now] - we need to give support to integration and ai agent types in follow up PRs
switch (type) {
case "store_agent":
return (
<MarketplaceAgentBlock
key={index}
slug={data.slug}
highlightedText={searchQuery}
title={data.agent_name}
image_url={data.agent_image}
creator_name={data.creator}
number_of_runs={data.runs}
loading={addingMarketplaceAgentSlug === data.slug}
onClick={() =>
handleAddMarketplaceAgent({
creator_name: data.creator,
slug: data.slug,
})
}
/>
);
case "block":
return (
<Block
key={index}
title={data.name}
highlightedText={searchQuery}
description={data.description}
blockData={data}
/>
);
case "library_agent":
return (
<UGCAgentBlock
key={index}
title={data.name}
highlightedText={searchQuery}
image_url={data.image_url}
version={data.graph_version}
edited_time={data.updated_at}
isLoading={addingLibraryAgentId === data.id}
onClick={() => handleAddLibraryAgent(data)}
/>
);
default:
return null;
}
})}
</InfiniteScroll>
);
};

View File

@@ -23,9 +23,19 @@ import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { useToast } from "@/components/molecules/Toast/use-toast";
import * as Sentry from "@sentry/nextjs";
import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
export const useBlockMenuSearchContent = () => {
const {
searchQuery,
searchId,
setSearchId,
filters,
setCreatorsList,
creators,
setCategoryCounts,
} = useBlockMenuStore();
export const useBlockMenuSearch = () => {
const { searchQuery, searchId, setSearchId } = useBlockMenuStore();
const { toast } = useToast();
const { addAgentToBuilder, addLibraryAgentToBuilder } =
useAddAgentToBuilder();
@@ -57,6 +67,8 @@ export const useBlockMenuSearch = () => {
page_size: 8,
search_query: searchQuery,
search_id: searchId,
filter: filters.length > 0 ? filters : undefined,
by_creator: creators.length > 0 ? creators : undefined,
},
{
query: { getNextPageParam: getPaginationNextPageNumber },
@@ -98,6 +110,26 @@ export const useBlockMenuSearch = () => {
}
}, [searchQueryData, searchId, setSearchId]);
// from all the results, we need to get all the unique creators
useEffect(() => {
if (!searchQueryData?.pages?.length) {
return;
}
const latestData = okData(searchQueryData.pages.at(-1));
setCategoryCounts(
(latestData?.total_items as Record<
GetV2BuilderSearchFilterAnyOfItem,
number
>) || {
blocks: 0,
integrations: 0,
marketplace_agents: 0,
my_agents: 0,
},
);
setCreatorsList(latestData?.items || []);
}, [searchQueryData]);
useEffect(() => {
if (searchId && !searchQuery) {
resetSearchSession();

View File

@@ -1,7 +1,9 @@
import { Button } from "@/components/__legacy__/ui/button";
import { cn } from "@/lib/utils";
import { X } from "lucide-react";
import React, { ButtonHTMLAttributes } from "react";
import { XIcon } from "@phosphor-icons/react";
import { AnimatePresence, motion } from "framer-motion";
import React, { ButtonHTMLAttributes, useState } from "react";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
selected?: boolean;
@@ -16,39 +18,51 @@ export const FilterChip: React.FC<Props> = ({
className,
...rest
}) => {
const [isHovered, setIsHovered] = useState(false);
return (
<Button
className={cn(
"group w-fit space-x-1 rounded-[1.5rem] border border-zinc-300 bg-transparent px-[0.625rem] py-[0.375rem] shadow-none transition-transform duration-300 ease-in-out",
"hover:border-violet-500 hover:bg-transparent focus:ring-0 disabled:cursor-not-allowed",
selected && "border-0 bg-violet-700 hover:border",
className,
)}
{...rest}
>
<span
<AnimatePresence mode="wait">
<Button
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={cn(
"font-sans text-sm font-medium leading-[1.375rem] text-zinc-600 group-hover:text-zinc-600 group-disabled:text-zinc-400",
selected && "text-zinc-50",
"group w-fit space-x-1 rounded-[1.5rem] border border-zinc-300 bg-transparent px-[0.625rem] py-[0.375rem] shadow-none",
"hover:border-violet-500 hover:bg-transparent focus:ring-0 disabled:cursor-not-allowed",
selected && "border-0 bg-violet-700 hover:border",
className,
)}
{...rest}
>
{name}
</span>
{selected && (
<>
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-zinc-50 transition-all duration-300 ease-in-out group-hover:hidden">
<X
className="h-3 w-3 rounded-full text-violet-700"
strokeWidth={2}
/>
</span>
{number !== undefined && (
<span className="hidden h-[1.375rem] items-center rounded-[1.25rem] bg-violet-700 p-[0.375rem] text-zinc-50 transition-all duration-300 ease-in-out animate-in fade-in zoom-in group-hover:flex">
{number > 100 ? "100+" : number}
</span>
<span
className={cn(
"font-sans text-sm font-medium leading-[1.375rem] text-zinc-600 group-hover:text-zinc-600 group-disabled:text-zinc-400",
selected && "text-zinc-50",
)}
</>
)}
</Button>
>
{name}
</span>
{selected && !isHovered && (
<motion.span
initial={{ opacity: 0.5, scale: 0.5, filter: "blur(20px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0.5, scale: 0.5, filter: "blur(20px)" }}
transition={{ duration: 0.3, type: "spring", bounce: 0.2 }}
className="flex h-4 w-4 items-center justify-center rounded-full bg-zinc-50"
>
<XIcon size={12} weight="bold" className="text-violet-700" />
</motion.span>
)}
{number !== undefined && isHovered && (
<motion.span
initial={{ opacity: 0.5, scale: 0.5, filter: "blur(10px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0.5, scale: 0.5, filter: "blur(10px)" }}
transition={{ duration: 0.3, type: "spring", bounce: 0.2 }}
className="flex h-[1.375rem] items-center rounded-[1.25rem] bg-violet-700 p-[0.375rem] text-zinc-50"
>
{number > 100 ? "100+" : number}
</motion.span>
)}
</Button>
</AnimatePresence>
);
};

View File

@@ -0,0 +1,156 @@
import { FilterChip } from "../FilterChip";
import { cn } from "@/lib/utils";
import { CategoryKey } from "../BlockMenuFilters/types";
import { AnimatePresence, motion } from "framer-motion";
import { XIcon } from "@phosphor-icons/react";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Separator } from "@/components/__legacy__/ui/separator";
import { Checkbox } from "@/components/__legacy__/ui/checkbox";
import { useFilterSheet } from "./useFilterSheet";
import { INITIAL_CREATORS_TO_SHOW } from "./constant";
export function FilterSheet({
categories,
}: {
categories: Array<{ key: CategoryKey; name: string }>;
}) {
const {
isOpen,
localCategories,
localCreators,
displayedCreatorsCount,
handleLocalCategoryChange,
handleToggleShowMoreCreators,
handleLocalCreatorChange,
handleClearFilters,
handleCloseButton,
handleApplyFilters,
hasLocalActiveFilters,
visibleCreators,
creators,
handleOpenFilters,
hasActiveFilters,
} = useFilterSheet();
return (
<div className="m-0 inline w-fit p-0">
<FilterChip
name={hasActiveFilters() ? "Edit filters" : "All filters"}
onClick={handleOpenFilters}
/>
<AnimatePresence>
{isOpen && (
<motion.div
className={cn(
"absolute bottom-2 left-2 top-2 z-20 w-3/4 max-w-[22.5rem] space-y-4 overflow-hidden rounded-[0.75rem] bg-white pb-4 shadow-[0_4px_12px_2px_rgba(0,0,0,0.1)]",
)}
initial={{ x: "-100%", filter: "blur(10px)" }}
animate={{ x: 0, filter: "blur(0px)" }}
exit={{ x: "-110%", filter: "blur(10px)" }}
transition={{ duration: 0.4, type: "spring", bounce: 0.2 }}
>
{/* Top section */}
<div className="flex items-center justify-between px-5 pt-4">
<Text variant="body">Filters</Text>
<Button
className="p-0"
variant="ghost"
size="icon"
onClick={handleCloseButton}
>
<XIcon size={20} />
</Button>
</div>
<Separator className="h-[1px] w-full text-zinc-300" />
{/* Category section */}
<div className="space-y-4 px-5">
<Text variant="large">Categories</Text>
<div className="space-y-2">
{categories.map((category) => (
<div
key={category.key}
className="flex items-center space-x-2"
>
<Checkbox
id={category.key}
checked={localCategories.includes(category.key)}
onCheckedChange={() =>
handleLocalCategoryChange(category.key)
}
className="border border-[#D4D4D4] shadow-none data-[state=checked]:border-none data-[state=checked]:bg-violet-700 data-[state=checked]:text-white"
/>
<label
htmlFor={category.key}
className="font-sans text-sm leading-[1.375rem] text-zinc-600"
>
{category.name}
</label>
</div>
))}
</div>
</div>
{/* Created by section */}
<div className="space-y-4 px-5">
<p className="font-sans text-base font-medium text-zinc-800">
Created by
</p>
<div className="space-y-2">
{visibleCreators.map((creator, i) => (
<div key={i} className="flex items-center space-x-2">
<Checkbox
id={`creator-${creator}`}
checked={localCreators.includes(creator)}
onCheckedChange={() => handleLocalCreatorChange(creator)}
className="border border-[#D4D4D4] shadow-none data-[state=checked]:border-none data-[state=checked]:bg-violet-700 data-[state=checked]:text-white"
/>
<label
htmlFor={`creator-${creator}`}
className="font-sans text-sm leading-[1.375rem] text-zinc-600"
>
{creator}
</label>
</div>
))}
</div>
{creators.length > INITIAL_CREATORS_TO_SHOW && (
<Button
variant={"link"}
className="m-0 p-0 font-sans text-sm font-medium leading-[1.375rem] text-zinc-800 underline hover:text-zinc-600"
onClick={handleToggleShowMoreCreators}
>
{displayedCreatorsCount < creators.length ? "More" : "Less"}
</Button>
)}
</div>
{/* Footer section */}
<div className="fixed bottom-0 flex w-full justify-between gap-3 border-t border-zinc-200 bg-white px-5 py-3">
<Button
size="small"
variant={"outline"}
onClick={handleClearFilters}
className="rounded-[8px] px-2 py-1.5"
>
Clear
</Button>
<Button
size="small"
onClick={handleApplyFilters}
disabled={!hasLocalActiveFilters()}
className="rounded-[8px] px-2 py-1.5"
>
Apply filters
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1 @@
export const INITIAL_CREATORS_TO_SHOW = 5;

View File

@@ -0,0 +1,100 @@
import { useBlockMenuStore } from "@/app/(platform)/build/stores/blockMenuStore";
import { useState } from "react";
import { INITIAL_CREATORS_TO_SHOW } from "./constant";
import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
export const useFilterSheet = () => {
const { filters, creators_list, creators, setFilters, setCreators } =
useBlockMenuStore();
const [isOpen, setIsOpen] = useState(false);
const [localCategories, setLocalCategories] =
useState<GetV2BuilderSearchFilterAnyOfItem[]>(filters);
const [localCreators, setLocalCreators] = useState<string[]>(creators);
const [displayedCreatorsCount, setDisplayedCreatorsCount] = useState(
INITIAL_CREATORS_TO_SHOW,
);
const handleLocalCategoryChange = (
category: GetV2BuilderSearchFilterAnyOfItem,
) => {
setLocalCategories((prev) => {
if (prev.includes(category)) {
return prev.filter((c) => c !== category);
}
return [...prev, category];
});
};
const hasActiveFilters = () => {
return filters.length > 0 || creators.length > 0;
};
const handleToggleShowMoreCreators = () => {
if (displayedCreatorsCount < creators.length) {
setDisplayedCreatorsCount(creators.length);
} else {
setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW);
}
};
const handleLocalCreatorChange = (creator: string) => {
setLocalCreators((prev) => {
if (prev.includes(creator)) {
return prev.filter((c) => c !== creator);
}
return [...prev, creator];
});
};
const handleClearFilters = () => {
setLocalCategories([]);
setLocalCreators([]);
setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW);
};
const handleCloseButton = () => {
setIsOpen(false);
setLocalCategories(filters);
setLocalCreators(creators);
setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW);
};
const handleApplyFilters = () => {
setFilters(localCategories);
setCreators(localCreators);
setIsOpen(false);
};
const handleOpenFilters = () => {
setIsOpen(true);
setLocalCategories(filters);
setLocalCreators(creators);
};
const hasLocalActiveFilters = () => {
return localCategories.length > 0 || localCreators.length > 0;
};
const visibleCreators = creators_list.slice(0, displayedCreatorsCount);
return {
creators,
isOpen,
setIsOpen,
localCategories,
localCreators,
displayedCreatorsCount,
setDisplayedCreatorsCount,
handleLocalCategoryChange,
handleToggleShowMoreCreators,
handleLocalCreatorChange,
handleClearFilters,
handleCloseButton,
handleOpenFilters,
handleApplyFilters,
hasLocalActiveFilters,
visibleCreators,
hasActiveFilters,
};
};

View File

@@ -1,12 +1,30 @@
import { create } from "zustand";
import { DefaultStateType } from "../components/NewControlPanel/NewBlockMenu/types";
import { SearchResponseItemsItem } from "@/app/api/__generated__/models/searchResponseItemsItem";
import { getSearchItemType } from "../components/NewControlPanel/NewBlockMenu/BlockMenuSearchContent/helper";
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
type BlockMenuStore = {
searchQuery: string;
searchId: string | undefined;
defaultState: DefaultStateType;
integration: string | undefined;
filters: GetV2BuilderSearchFilterAnyOfItem[];
creators: string[];
creators_list: string[];
categoryCounts: Record<GetV2BuilderSearchFilterAnyOfItem, number>;
setCategoryCounts: (
counts: Record<GetV2BuilderSearchFilterAnyOfItem, number>,
) => void;
setCreatorsList: (searchData: SearchResponseItemsItem[]) => void;
addCreator: (creator: string) => void;
setCreators: (creators: string[]) => void;
removeCreator: (creator: string) => void;
addFilter: (filter: GetV2BuilderSearchFilterAnyOfItem) => void;
setFilters: (filters: GetV2BuilderSearchFilterAnyOfItem[]) => void;
removeFilter: (filter: GetV2BuilderSearchFilterAnyOfItem) => void;
setSearchQuery: (query: string) => void;
setSearchId: (id: string | undefined) => void;
setDefaultState: (state: DefaultStateType) => void;
@@ -19,11 +37,44 @@ export const useBlockMenuStore = create<BlockMenuStore>((set) => ({
searchId: undefined,
defaultState: DefaultStateType.SUGGESTION,
integration: undefined,
filters: [],
creators: [], // creator filters that are applied to the search results
creators_list: [], // all creators that are available to filter by
categoryCounts: {
blocks: 0,
integrations: 0,
marketplace_agents: 0,
my_agents: 0,
},
setCategoryCounts: (counts) => set({ categoryCounts: counts }),
setCreatorsList: (searchData) => {
const marketplaceAgents = searchData.filter((item) => {
return getSearchItemType(item).type === "store_agent";
}) as StoreAgent[];
const newCreators = marketplaceAgents.map((agent) => agent.creator);
set((state) => ({
creators_list: Array.from(
new Set([...state.creators_list, ...newCreators]),
),
}));
},
setCreators: (creators) => set({ creators }),
setFilters: (filters) => set({ filters }),
setSearchQuery: (query) => set({ searchQuery: query }),
setSearchId: (id) => set({ searchId: id }),
setDefaultState: (state) => set({ defaultState: state }),
setIntegration: (integration) => set({ integration }),
addFilter: (filter) =>
set((state) => ({ filters: [...state.filters, filter] })),
removeFilter: (filter) =>
set((state) => ({ filters: state.filters.filter((f) => f !== filter) })),
addCreator: (creator) =>
set((state) => ({ creators: [...state.creators, creator] })),
removeCreator: (creator) =>
set((state) => ({ creators: state.creators.filter((c) => c !== creator) })),
reset: () =>
set({
searchQuery: "",

View File

@@ -1,17 +1,25 @@
"use client";
import { getV1GetGraphVersion } from "@/app/api/__generated__/endpoints/graphs/graphs";
import {
getGetV2ListLibraryAgentsQueryKey,
useDeleteV2DeleteLibraryAgent,
} from "@/app/api/__generated__/endpoints/library/library";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { ShowMoreText } from "@/components/molecules/ShowMoreText/ShowMoreText";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { exportAsJSONFile } from "@/lib/utils";
import { formatDate } from "@/lib/utils/time";
import { useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { RunAgentModal } from "../modals/RunAgentModal/RunAgentModal";
import { RunDetailCard } from "../selected-views/RunDetailCard/RunDetailCard";
import { EmptyTasksIllustration } from "./EmptyTasksIllustration";
@@ -30,6 +38,41 @@ export function EmptyTasks({
onScheduleCreated,
}: Props) {
const { toast } = useToast();
const queryClient = useQueryClient();
const router = useRouter();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isDeletingAgent, setIsDeletingAgent] = useState(false);
const { mutateAsync: deleteAgent } = useDeleteV2DeleteLibraryAgent();
async function handleDeleteAgent() {
if (!agent.id) return;
setIsDeletingAgent(true);
try {
await deleteAgent({ libraryAgentId: agent.id });
await queryClient.refetchQueries({
queryKey: getGetV2ListLibraryAgentsQueryKey(),
});
toast({ title: "Agent deleted" });
setShowDeleteDialog(false);
router.push("/library");
} catch (error: unknown) {
toast({
title: "Failed to delete agent",
description:
error instanceof Error
? error.message
: "An unexpected error occurred.",
variant: "destructive",
});
} finally {
setIsDeletingAgent(false);
}
}
async function handleExport() {
try {
@@ -147,9 +190,50 @@ export function EmptyTasks({
<Button variant="secondary" size="small" onClick={handleExport}>
Export agent to file
</Button>
<Button
variant="secondary"
size="small"
onClick={() => setShowDeleteDialog(true)}
>
Delete agent
</Button>
</div>
</div>
</div>
<Dialog
controlled={{
isOpen: showDeleteDialog,
set: setShowDeleteDialog,
}}
styling={{ maxWidth: "32rem" }}
title="Delete agent"
>
<Dialog.Content>
<div>
<Text variant="large">
Are you sure you want to delete this agent? This action cannot be
undone.
</Text>
<Dialog.Footer>
<Button
variant="secondary"
disabled={isDeletingAgent}
onClick={() => setShowDeleteDialog(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteAgent}
loading={isDeletingAgent}
>
Delete Agent
</Button>
</Dialog.Footer>
</div>
</Dialog.Content>
</Dialog>
</div>
);
}

View File

@@ -83,7 +83,9 @@ function renderCode(
</div>
)}
<pre className="overflow-x-auto rounded-md bg-muted p-3">
<code className="font-mono text-sm">{codeValue}</code>
<code className="whitespace-pre-wrap break-words font-mono text-sm">
{codeValue}
</code>
</pre>
</div>
);

View File

@@ -13,7 +13,7 @@ import { LoadingSelectedContent } from "../LoadingSelectedContent";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
import { SelectedViewLayout } from "../SelectedViewLayout";
import { SelectedScheduleActions } from "./components/SelectedScheduleActions";
import { SelectedScheduleActions } from "./components/SelectedScheduleActions/SelectedScheduleActions";
import { useSelectedScheduleView } from "./useSelectedScheduleView";
interface Props {

View File

@@ -1,40 +0,0 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { EyeIcon } from "@phosphor-icons/react";
import { AgentActionsDropdown } from "../../AgentActionsDropdown";
import { useScheduleDetailHeader } from "../../RunDetailHeader/useScheduleDetailHeader";
import { SelectedActionsWrap } from "../../SelectedActionsWrap";
type Props = {
agent: LibraryAgent;
scheduleId: string;
onDeleted?: () => void;
};
export function SelectedScheduleActions({ agent, scheduleId }: Props) {
const { openInBuilderHref } = useScheduleDetailHeader(
agent.graph_id,
scheduleId,
agent.graph_version,
);
return (
<>
<SelectedActionsWrap>
{openInBuilderHref && (
<Button
variant="icon"
size="icon"
as="NextLink"
href={openInBuilderHref}
target="_blank"
aria-label="View scheduled task details"
>
<EyeIcon weight="bold" size={18} className="text-zinc-700" />
</Button>
)}
<AgentActionsDropdown agent={agent} scheduleId={scheduleId} />
</SelectedActionsWrap>
</>
);
}

View File

@@ -0,0 +1,96 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { EyeIcon, TrashIcon } from "@phosphor-icons/react";
import { AgentActionsDropdown } from "../../../AgentActionsDropdown";
import { SelectedActionsWrap } from "../../../SelectedActionsWrap";
import { useSelectedScheduleActions } from "./useSelectedScheduleActions";
type Props = {
agent: LibraryAgent;
scheduleId: string;
onDeleted?: () => void;
};
export function SelectedScheduleActions({
agent,
scheduleId,
onDeleted,
}: Props) {
const {
openInBuilderHref,
showDeleteDialog,
setShowDeleteDialog,
handleDelete,
isDeleting,
} = useSelectedScheduleActions({ agent, scheduleId, onDeleted });
return (
<>
<SelectedActionsWrap>
{openInBuilderHref && (
<Button
variant="icon"
size="icon"
as="NextLink"
href={openInBuilderHref}
target="_blank"
aria-label="View scheduled task details"
>
<EyeIcon weight="bold" size={18} className="text-zinc-700" />
</Button>
)}
<Button
variant="icon"
size="icon"
aria-label="Delete schedule"
onClick={() => setShowDeleteDialog(true)}
disabled={isDeleting}
>
{isDeleting ? (
<LoadingSpinner size="small" />
) : (
<TrashIcon weight="bold" size={18} />
)}
</Button>
<AgentActionsDropdown agent={agent} scheduleId={scheduleId} />
</SelectedActionsWrap>
<Dialog
controlled={{
isOpen: showDeleteDialog,
set: setShowDeleteDialog,
}}
styling={{ maxWidth: "32rem" }}
title="Delete schedule"
>
<Dialog.Content>
<Text variant="large">
Are you sure you want to delete this schedule? This action cannot be
undone.
</Text>
<Dialog.Footer>
<Button
variant="secondary"
onClick={() => setShowDeleteDialog(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
loading={isDeleting}
>
Delete Schedule
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import {
getGetV1ListExecutionSchedulesForAGraphQueryOptions,
useDeleteV1DeleteExecutionSchedule,
} from "@/app/api/__generated__/endpoints/schedules/schedules";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
interface UseSelectedScheduleActionsProps {
agent: LibraryAgent;
scheduleId: string;
onDeleted?: () => void;
}
export function useSelectedScheduleActions({
agent,
scheduleId,
onDeleted,
}: UseSelectedScheduleActionsProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const deleteMutation = useDeleteV1DeleteExecutionSchedule({
mutation: {
onSuccess: () => {
toast({ title: "Schedule deleted" });
queryClient.invalidateQueries({
queryKey: getGetV1ListExecutionSchedulesForAGraphQueryOptions(
agent.graph_id,
).queryKey,
});
setShowDeleteDialog(false);
onDeleted?.();
},
onError: (error: unknown) =>
toast({
title: "Failed to delete schedule",
description:
error instanceof Error
? error.message
: "An unexpected error occurred.",
variant: "destructive",
}),
},
});
function handleDelete() {
if (!scheduleId) return;
deleteMutation.mutate({ scheduleId });
}
const openInBuilderHref = `/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}`;
return {
openInBuilderHref,
showDeleteDialog,
setShowDeleteDialog,
handleDelete,
isDeleting: deleteMutation.isPending,
};
}

View File

@@ -1,15 +1,14 @@
"use client";
import React from "react";
import { useFavoriteAgents } from "../../hooks/useFavoriteAgents";
import LibraryAgentCard from "../LibraryAgentCard/LibraryAgentCard";
import { useGetFlag, Flag } from "@/services/feature-flags/use-get-flag";
import { Heart } from "lucide-react";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { HeartIcon } from "@phosphor-icons/react";
import { useFavoriteAgents } from "../../hooks/useFavoriteAgents";
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
export default function FavoritesSection() {
export function FavoritesSection() {
const isAgentFavoritingEnabled = useGetFlag(Flag.AGENT_FAVORITING);
const {
allAgents: favoriteAgents,
@@ -33,7 +32,7 @@ export default function FavoritesSection() {
return (
<div className="mb-8">
<div className="flex items-center gap-[10px] p-2 pb-[10px]">
<Heart className="h-5 w-5 fill-red-500 text-red-500" />
<HeartIcon className="h-5 w-5 fill-red-500 text-red-500" />
<span className="font-poppin text-[18px] font-semibold leading-[28px] text-neutral-800">
Favorites
</span>

View File

@@ -1,34 +1,28 @@
// import LibraryNotificationDropdown from "./library-notification-dropdown";
import { LibrarySearchBar } from "../LibrarySearchBar/LibrarySearchBar";
import LibraryUploadAgentDialog from "../LibraryUploadAgentDialog/LibraryUploadAgentDialog";
import LibrarySearchBar from "../LibrarySearchBar/LibrarySearchBar";
type LibraryActionHeaderProps = Record<string, never>;
interface Props {
setSearchTerm: (value: string) => void;
}
/**
* LibraryActionHeader component - Renders a header with search, notifications and filters
*/
const LibraryActionHeader: React.FC<LibraryActionHeaderProps> = ({}) => {
export function LibraryActionHeader({ setSearchTerm }: Props) {
return (
<>
<div className="mb-[32px] hidden items-start justify-between md:flex">
{/* <LibraryNotificationDropdown /> */}
<LibrarySearchBar />
<div className="mb-[32px] hidden items-center justify-center gap-4 md:flex">
<LibrarySearchBar setSearchTerm={setSearchTerm} />
<LibraryUploadAgentDialog />
</div>
{/* Mobile and tablet */}
<div className="flex flex-col gap-4 p-4 pt-[52px] md:hidden">
<div className="flex w-full justify-between">
{/* <LibraryNotificationDropdown /> */}
<LibraryUploadAgentDialog />
</div>
<div className="flex items-center justify-center">
<LibrarySearchBar />
<LibrarySearchBar setSearchTerm={setSearchTerm} />
</div>
</div>
</>
);
};
export default LibraryActionHeader;
}

View File

@@ -1,28 +1,28 @@
"use client";
import LibrarySortMenu from "../LibrarySortMenu/LibrarySortMenu";
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
import { Text } from "@/components/atoms/Text/Text";
import { LibrarySortMenu } from "../LibrarySortMenu/LibrarySortMenu";
interface LibraryActionSubHeaderProps {
interface Props {
agentCount: number;
setLibrarySort: (value: LibraryAgentSort) => void;
}
export default function LibraryActionSubHeader({
agentCount,
}: LibraryActionSubHeaderProps) {
export function LibraryActionSubHeader({ agentCount, setLibrarySort }: Props) {
return (
<div className="flex items-center justify-between pb-[10px]">
<div className="flex items-center gap-[10px] p-2">
<span className="font-poppin w-[96px] text-[18px] font-semibold leading-[28px] text-neutral-800">
My agents
</span>
<span
className="w-[70px] font-sans text-[14px] font-normal leading-6"
<div className="flex items-baseline justify-between">
<div className="flex items-baseline gap-4">
<Text variant="h4">My agents</Text>
<Text
variant="body"
data-testid="agents-count"
className="text-zinc-500"
>
{agentCount} agents
</span>
{agentCount}
</Text>
</div>
<LibrarySortMenu />
<LibrarySortMenu setLibrarySort={setLibrarySort} />
</div>
);
}

View File

@@ -1,332 +1,128 @@
"use client";
import Link from "next/link";
import { Text } from "@/components/atoms/Text/Text";
import { CaretCircleRightIcon } from "@phosphor-icons/react";
import Image from "next/image";
import { Heart } from "@phosphor-icons/react";
import { useState, useEffect } from "react";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { InfiniteData } from "@tanstack/react-query";
import NextLink from "next/link";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import {
getV2ListLibraryAgentsResponse,
getV2ListFavoriteLibraryAgentsResponse,
} from "@/app/api/__generated__/endpoints/library/library";
import BackendAPI, { LibraryAgentID } from "@/lib/autogpt-server-api";
import { cn } from "@/lib/utils";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import Avatar, {
AvatarFallback,
AvatarImage,
} from "@/components/atoms/Avatar/Avatar";
import { Link } from "@/components/atoms/Link/Link";
import { AgentCardMenu } from "./components/AgentCardMenu";
import { FavoriteButton } from "./components/FavoriteButton";
import { useLibraryAgentCard } from "./useLibraryAgentCard";
interface LibraryAgentCardProps {
interface Props {
agent: LibraryAgent;
}
export default function LibraryAgentCard({
agent: {
id,
name,
description,
graph_id,
can_access_graph,
export function LibraryAgentCard({ agent }: Props) {
const { id, name, graph_id, can_access_graph, image_url } = agent;
const {
isFromMarketplace,
isAgentFavoritingEnabled,
isFavorite,
profile,
creator_image_url,
image_url,
is_favorite,
},
}: LibraryAgentCardProps) {
const isAgentFavoritingEnabled = useGetFlag(Flag.AGENT_FAVORITING);
const [isFavorite, setIsFavorite] = useState(is_favorite);
const [isUpdating, setIsUpdating] = useState(false);
const { toast } = useToast();
const api = new BackendAPI();
const queryClient = getQueryClient();
// Sync local state with prop when it changes (e.g., after query invalidation)
useEffect(() => {
setIsFavorite(is_favorite);
}, [is_favorite]);
const updateQueryData = (newIsFavorite: boolean) => {
// Update the agent in all library agent queries
queryClient.setQueriesData(
{ queryKey: ["/api/library/agents"] },
(
oldData:
| InfiniteData<getV2ListLibraryAgentsResponse, number | undefined>
| undefined,
) => {
if (!oldData?.pages) return oldData;
return {
...oldData,
pages: oldData.pages.map((page) => {
if (page.status !== 200) return page;
return {
...page,
data: {
...page.data,
agents: page.data.agents.map((agent: LibraryAgent) =>
agent.id === id
? { ...agent, is_favorite: newIsFavorite }
: agent,
),
},
};
}),
};
},
);
// Update or remove from favorites query based on new state
queryClient.setQueriesData(
{ queryKey: ["/api/library/agents/favorites"] },
(
oldData:
| InfiniteData<
getV2ListFavoriteLibraryAgentsResponse,
number | undefined
>
| undefined,
) => {
if (!oldData?.pages) return oldData;
if (newIsFavorite) {
// Add to favorites if not already there
const exists = oldData.pages.some(
(page) =>
page.status === 200 &&
page.data.agents.some((agent: LibraryAgent) => agent.id === id),
);
if (!exists) {
const firstPage = oldData.pages[0];
if (firstPage?.status === 200) {
const updatedAgent = {
id,
name,
description,
graph_id,
can_access_graph,
creator_image_url,
image_url,
is_favorite: true,
};
return {
...oldData,
pages: [
{
...firstPage,
data: {
...firstPage.data,
agents: [updatedAgent, ...firstPage.data.agents],
pagination: {
...firstPage.data.pagination,
total_items: firstPage.data.pagination.total_items + 1,
},
},
},
...oldData.pages.slice(1).map((page) =>
page.status === 200
? {
...page,
data: {
...page.data,
pagination: {
...page.data.pagination,
total_items: page.data.pagination.total_items + 1,
},
},
}
: page,
),
],
};
}
}
} else {
// Remove from favorites
let removedCount = 0;
return {
...oldData,
pages: oldData.pages.map((page) => {
if (page.status !== 200) return page;
const filteredAgents = page.data.agents.filter(
(agent: LibraryAgent) => agent.id !== id,
);
if (filteredAgents.length < page.data.agents.length) {
removedCount = 1;
}
return {
...page,
data: {
...page.data,
agents: filteredAgents,
pagination: {
...page.data.pagination,
total_items:
page.data.pagination.total_items - removedCount,
},
},
};
}),
};
}
return oldData;
},
);
};
const handleToggleFavorite = async (e: React.MouseEvent) => {
e.preventDefault(); // Prevent navigation when clicking the heart
e.stopPropagation();
if (isUpdating || !isAgentFavoritingEnabled) return;
const newIsFavorite = !isFavorite;
// Optimistic update
setIsFavorite(newIsFavorite);
updateQueryData(newIsFavorite);
setIsUpdating(true);
try {
await api.updateLibraryAgent(id as LibraryAgentID, {
is_favorite: newIsFavorite,
});
toast({
title: newIsFavorite ? "Added to favorites" : "Removed from favorites",
description: `${name} has been ${newIsFavorite ? "added to" : "removed from"} your favorites.`,
});
} catch (error) {
// Revert on error
console.error("Failed to update favorite status:", error);
setIsFavorite(!newIsFavorite);
updateQueryData(!newIsFavorite);
toast({
title: "Error",
description: "Failed to update favorite status. Please try again.",
variant: "destructive",
});
} finally {
setIsUpdating(false);
}
};
handleToggleFavorite,
} = useLibraryAgentCard({ agent });
return (
<div
data-testid="library-agent-card"
data-agent-id={id}
className="group inline-flex w-full max-w-[434px] flex-col items-start justify-start gap-2.5 rounded-[26px] bg-white transition-all duration-300 hover:shadow-lg dark:bg-transparent dark:hover:shadow-gray-700"
className="group relative inline-flex h-[10.625rem] w-full max-w-[25rem] flex-col items-start justify-start gap-2.5 rounded-medium border border-zinc-100 bg-white transition-all duration-300 hover:shadow-md"
>
<Link
href={`/library/agents/${id}`}
className="relative h-[200px] w-full overflow-hidden rounded-[20px]"
>
{!image_url ? (
<div
className={`h-full w-full ${
[
"bg-gradient-to-r from-green-200 to-blue-200",
"bg-gradient-to-r from-pink-200 to-purple-200",
"bg-gradient-to-r from-yellow-200 to-orange-200",
"bg-gradient-to-r from-blue-200 to-cyan-200",
"bg-gradient-to-r from-indigo-200 to-purple-200",
][parseInt(id.slice(0, 8), 16) % 5]
}`}
style={{
backgroundSize: "200% 200%",
animation: "gradient 15s ease infinite",
}}
/>
) : (
<Image
src={image_url}
alt={`${name} preview image`}
fill
className="object-cover"
/>
)}
{isAgentFavoritingEnabled && (
<button
onClick={handleToggleFavorite}
className={cn(
"absolute right-4 top-4 rounded-full bg-white/90 p-2 backdrop-blur-sm transition-all duration-200",
"hover:scale-110 hover:bg-white",
"focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2",
isUpdating && "cursor-not-allowed opacity-50",
!isFavorite && "opacity-0 group-hover:opacity-100",
)}
disabled={isUpdating}
aria-label={
isFavorite ? "Remove from favorites" : "Add to favorites"
}
>
<Heart
size={20}
weight={isFavorite ? "fill" : "regular"}
className={cn(
"transition-colors duration-200",
isFavorite
? "text-red-500"
: "text-gray-600 hover:text-red-500",
)}
/>
</button>
)}
<div className="absolute bottom-4 left-4">
<Avatar className="h-16 w-16">
<AgentCardMenu agent={agent} />
<NextLink href={`/library/agents/${id}`} className="w-full flex-shrink-0">
<div className="flex items-center gap-2 px-4 pt-3">
<Avatar className="h-4 w-4 rounded-full">
<AvatarImage
src={
creator_image_url
? creator_image_url
: "/avatar-placeholder.png"
isFromMarketplace
? creator_image_url || "/avatar-placeholder.png"
: profile?.avatar_url || "/avatar-placeholder.png"
}
alt={`${name} creator avatar`}
/>
<AvatarFallback size={64}>{name.charAt(0)}</AvatarFallback>
<AvatarFallback size={48}>{name.charAt(0)}</AvatarFallback>
</Avatar>
<Text
variant="small-medium"
className="uppercase tracking-wide text-zinc-400"
>
{isFromMarketplace ? "FROM MARKETPLACE" : "Built by you"}
</Text>
</div>
</Link>
{isAgentFavoritingEnabled && (
<FavoriteButton
isFavorite={isFavorite}
onClick={handleToggleFavorite}
/>
)}
</NextLink>
<div className="flex w-full flex-1 flex-col px-4 py-4">
<Link href={`/library/agents/${id}`}>
<h3 className="mb-2 line-clamp-2 font-poppins text-2xl font-semibold leading-tight text-[#272727] dark:text-neutral-100">
<div className="flex w-full flex-1 flex-col px-4 pb-2">
<Link
href={`/library/agents/${id}`}
className="flex w-full items-start justify-between gap-2 no-underline hover:no-underline"
>
<Text
variant="h5"
data-testid="library-agent-card-name"
className="line-clamp-3 hyphens-auto break-words no-underline hover:no-underline"
>
{name}
</h3>
</Text>
<p className="line-clamp-3 flex-1 text-sm text-gray-600 dark:text-gray-400">
{description}
</p>
{!image_url ? (
<div
className={`h-[3.64rem] w-[6.70rem] flex-shrink-0 rounded-small ${
[
"bg-gradient-to-r from-green-200 to-blue-200",
"bg-gradient-to-r from-pink-200 to-purple-200",
"bg-gradient-to-r from-yellow-200 to-orange-200",
"bg-gradient-to-r from-blue-200 to-cyan-200",
"bg-gradient-to-r from-indigo-200 to-purple-200",
][parseInt(id.slice(0, 8), 16) % 5]
}`}
style={{
backgroundSize: "200% 200%",
animation: "gradient 15s ease infinite",
}}
/>
) : (
<Image
src={image_url}
alt={`${name} preview image`}
width={107}
height={58}
className="flex-shrink-0 rounded-small object-cover"
/>
)}
</Link>
<div className="flex-grow" />
{/* Spacer */}
<div className="items-between mt-4 flex w-full justify-between gap-3">
<div className="mt-auto flex w-full justify-start gap-6 border-t border-zinc-100 pb-1 pt-3">
<Link
href={`/library/agents/${id}`}
className="text-lg font-semibold text-neutral-800 hover:underline dark:text-neutral-200"
data-testid="library-agent-card-see-runs-link"
className="flex items-center gap-1 text-[13px]"
>
See runs
See runs <CaretCircleRightIcon size={20} />
</Link>
{can_access_graph && (
<Link
href={`/build?flowID=${graph_id}`}
className="text-lg font-semibold text-neutral-800 hover:underline dark:text-neutral-200"
data-testid="library-agent-card-open-in-builder-link"
className="flex items-center gap-1 text-[13px]"
isExternal
>
Open in builder
Open in builder <CaretCircleRightIcon size={20} />
</Link>
)}
</div>

View File

@@ -0,0 +1,188 @@
"use client";
import {
getGetV2ListLibraryAgentsQueryKey,
useDeleteV2DeleteLibraryAgent,
usePostV2ForkLibraryAgent,
} from "@/app/api/__generated__/endpoints/library/library";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { DotsThree } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
interface AgentCardMenuProps {
agent: LibraryAgent;
}
export function AgentCardMenu({ agent }: AgentCardMenuProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const router = useRouter();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isDeletingAgent, setIsDeletingAgent] = useState(false);
const [isDuplicatingAgent, setIsDuplicatingAgent] = useState(false);
const { mutateAsync: deleteAgent } = useDeleteV2DeleteLibraryAgent();
const { mutateAsync: forkAgent } = usePostV2ForkLibraryAgent();
async function handleDuplicateAgent() {
if (!agent.id) return;
setIsDuplicatingAgent(true);
try {
const result = await forkAgent({ libraryAgentId: agent.id });
if (result.status === 200) {
await queryClient.refetchQueries({
queryKey: getGetV2ListLibraryAgentsQueryKey(),
});
toast({
title: "Agent duplicated",
description: `${result.data.name} has been created.`,
});
}
} catch (error: unknown) {
toast({
title: "Failed to duplicate agent",
description:
error instanceof Error
? error.message
: "An unexpected error occurred.",
variant: "destructive",
});
} finally {
setIsDuplicatingAgent(false);
}
}
async function handleDeleteAgent() {
if (!agent.id) return;
setIsDeletingAgent(true);
try {
await deleteAgent({ libraryAgentId: agent.id });
await queryClient.refetchQueries({
queryKey: getGetV2ListLibraryAgentsQueryKey(),
});
toast({ title: "Agent deleted" });
setShowDeleteDialog(false);
router.push("/library");
} catch (error: unknown) {
toast({
title: "Failed to delete agent",
description:
error instanceof Error
? error.message
: "An unexpected error occurred.",
variant: "destructive",
});
} finally {
setIsDeletingAgent(false);
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="absolute right-2 top-1 rounded p-1.5 transition-opacity hover:bg-neutral-100"
onClick={(e) => e.stopPropagation()}
aria-label="More actions"
>
<DotsThree className="h-5 w-5 text-neutral-600" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{agent.can_access_graph && (
<>
<DropdownMenuItem asChild>
<Link
href={`/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}`}
target="_blank"
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
Edit agent
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleDuplicateAgent();
}}
disabled={isDuplicatingAgent}
className="flex items-center gap-2"
>
Duplicate agent
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowDeleteDialog(true);
}}
className="flex items-center gap-2 text-red-600 focus:bg-red-50 focus:text-red-600"
>
Delete agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog
controlled={{
isOpen: showDeleteDialog,
set: setShowDeleteDialog,
}}
styling={{ maxWidth: "32rem" }}
title="Delete agent"
>
<Dialog.Content>
<div>
<Text variant="large">
Are you sure you want to delete this agent? This action cannot be
undone.
</Text>
<Dialog.Footer>
<Button
variant="secondary"
disabled={isDeletingAgent}
onClick={() => setShowDeleteDialog(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteAgent}
loading={isDeletingAgent}
>
Delete Agent
</Button>
</Dialog.Footer>
</div>
</Dialog.Content>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
import { cn } from "@/lib/utils";
import { HeartIcon } from "@phosphor-icons/react";
interface FavoriteButtonProps {
isFavorite: boolean;
onClick: (e: React.MouseEvent) => void;
}
export function FavoriteButton({ isFavorite, onClick }: FavoriteButtonProps) {
return (
<button
onClick={onClick}
className={cn(
"rounded-full bg-white/90 p-2 backdrop-blur-sm transition-all duration-200",
"hover:scale-110 hover:bg-white",
"focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2",
!isFavorite && "opacity-0 group-hover:opacity-100",
)}
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
>
<HeartIcon
size={20}
weight={isFavorite ? "fill" : "regular"}
className={cn(
"transition-colors duration-200",
isFavorite ? "text-red-500" : "text-gray-600 hover:text-red-500",
)}
/>
</button>
);
}

View File

@@ -0,0 +1,150 @@
import { InfiniteData, QueryClient } from "@tanstack/react-query";
import {
getV2ListFavoriteLibraryAgentsResponse,
getV2ListLibraryAgentsResponse,
} from "@/app/api/__generated__/endpoints/library/library";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
interface UpdateFavoriteInQueriesParams {
queryClient: QueryClient;
agentId: string;
agent: LibraryAgent;
newIsFavorite: boolean;
}
export function updateFavoriteInQueries({
queryClient,
agentId,
agent,
newIsFavorite,
}: UpdateFavoriteInQueriesParams) {
queryClient.setQueriesData(
{ queryKey: ["/api/library/agents"] },
(
oldData:
| InfiniteData<getV2ListLibraryAgentsResponse, number | undefined>
| undefined,
) => {
if (!oldData?.pages) return oldData;
return {
...oldData,
pages: oldData.pages.map((page) => {
if (page.status !== 200) return page;
return {
...page,
data: {
...page.data,
agents: page.data.agents.map((currentAgent: LibraryAgent) =>
currentAgent.id === agentId
? { ...currentAgent, is_favorite: newIsFavorite }
: currentAgent,
),
},
};
}),
};
},
);
queryClient.setQueriesData(
{ queryKey: ["/api/library/agents/favorites"] },
(
oldData:
| InfiniteData<
getV2ListFavoriteLibraryAgentsResponse,
number | undefined
>
| undefined,
) => {
if (!oldData?.pages) return oldData;
if (newIsFavorite) {
const exists = oldData.pages.some(
(page) =>
page.status === 200 &&
page.data.agents.some(
(currentAgent: LibraryAgent) => currentAgent.id === agentId,
),
);
if (!exists) {
const firstPage = oldData.pages[0];
if (firstPage?.status === 200) {
const updatedAgent = {
id: agent.id,
name: agent.name,
description: agent.description,
graph_id: agent.graph_id,
can_access_graph: agent.can_access_graph,
creator_image_url: agent.creator_image_url,
image_url: agent.image_url,
is_favorite: true,
};
return {
...oldData,
pages: [
{
...firstPage,
data: {
...firstPage.data,
agents: [updatedAgent, ...firstPage.data.agents],
pagination: {
...firstPage.data.pagination,
total_items: firstPage.data.pagination.total_items + 1,
},
},
},
...oldData.pages.slice(1).map((page) =>
page.status === 200
? {
...page,
data: {
...page.data,
pagination: {
...page.data.pagination,
total_items: page.data.pagination.total_items + 1,
},
},
}
: page,
),
],
};
}
}
} else {
return {
...oldData,
pages: oldData.pages.map((page) => {
if (page.status !== 200) return page;
const filteredAgents = page.data.agents.filter(
(currentAgent: LibraryAgent) => currentAgent.id !== agentId,
);
const removedCount =
filteredAgents.length < page.data.agents.length ? 1 : 0;
return {
...page,
data: {
...page.data,
agents: filteredAgents,
pagination: {
...page.data.pagination,
total_items: page.data.pagination.total_items - removedCount,
},
},
};
}),
};
}
return oldData;
},
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { useEffect, useState } from "react";
import { usePatchV2UpdateLibraryAgent } from "@/app/api/__generated__/endpoints/library/library";
import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { okData } from "@/app/api/helpers";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { updateFavoriteInQueries } from "./helpers";
interface Props {
agent: LibraryAgent;
}
export function useLibraryAgentCard({ agent }: Props) {
const { id, name, is_favorite, creator_image_url, marketplace_listing } =
agent;
const isFromMarketplace = Boolean(marketplace_listing);
const isAgentFavoritingEnabled = useGetFlag(Flag.AGENT_FAVORITING);
const [isFavorite, setIsFavorite] = useState(is_favorite);
const { toast } = useToast();
const queryClient = getQueryClient();
const { mutateAsync: updateLibraryAgent } = usePatchV2UpdateLibraryAgent();
const { data: profile } = useGetV2GetUserProfile({
query: {
select: okData,
},
});
useEffect(() => {
setIsFavorite(is_favorite);
}, [is_favorite]);
function updateQueryData(newIsFavorite: boolean) {
updateFavoriteInQueries({
queryClient,
agentId: id,
agent,
newIsFavorite,
});
}
async function handleToggleFavorite(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (!isAgentFavoritingEnabled) return;
const newIsFavorite = !isFavorite;
setIsFavorite(newIsFavorite);
updateQueryData(newIsFavorite);
try {
await updateLibraryAgent({
libraryAgentId: id,
data: { is_favorite: newIsFavorite },
});
toast({
title: newIsFavorite ? "Added to favorites" : "Removed from favorites",
description: `${name} has been ${newIsFavorite ? "added to" : "removed from"} your favorites.`,
});
} catch {
setIsFavorite(!newIsFavorite);
updateQueryData(!newIsFavorite);
toast({
title: "Error",
description: "Failed to update favorite status. Please try again.",
variant: "destructive",
});
}
}
return {
isFromMarketplace,
isAgentFavoritingEnabled,
isFavorite,
profile,
creator_image_url,
handleToggleFavorite,
};
}

View File

@@ -1,10 +1,22 @@
"use client";
import LibraryActionSubHeader from "../LibraryActionSubHeader/LibraryActionSubHeader";
import LibraryAgentCard from "../LibraryAgentCard/LibraryAgentCard";
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
import { LibraryActionSubHeader } from "../LibraryActionSubHeader/LibraryActionSubHeader";
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
import { useLibraryAgentList } from "./useLibraryAgentList";
export default function LibraryAgentList() {
interface Props {
searchTerm: string;
librarySort: LibraryAgentSort;
setLibrarySort: (value: LibraryAgentSort) => void;
}
export function LibraryAgentList({
searchTerm,
librarySort,
setLibrarySort,
}: Props) {
const {
agentLoading,
agentCount,
@@ -12,28 +24,27 @@ export default function LibraryAgentList() {
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useLibraryAgentList();
const LoadingSpinner = () => (
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-t-2 border-neutral-800" />
);
} = useLibraryAgentList({ searchTerm, librarySort });
return (
<>
<LibraryActionSubHeader agentCount={agentCount} />
<LibraryActionSubHeader
agentCount={agentCount}
setLibrarySort={setLibrarySort}
/>
<div className="px-2">
{agentLoading ? (
<div className="flex h-[200px] items-center justify-center">
<LoadingSpinner />
<LoadingSpinner size="large" />
</div>
) : (
<InfiniteScroll
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
loader={<LoadingSpinner />}
loader={<LoadingSpinner size="medium" />}
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{agents.map((agent) => (
<LibraryAgentCard key={agent.id} agent={agent} />
))}

View File

@@ -1,18 +1,23 @@
"use client";
import { useGetV2ListLibraryAgentsInfinite } from "@/app/api/__generated__/endpoints/library/library";
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
import {
getPaginatedTotalCount,
getPaginationNextPageNumber,
unpaginate,
} from "@/app/api/helpers";
import { useGetV2ListLibraryAgentsInfinite } from "@/app/api/__generated__/endpoints/library/library";
import { useLibraryPageContext } from "../state-provider";
import { useLibraryAgentsStore } from "@/hooks/useLibraryAgents/store";
import { getInitialData } from "./helpers";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { useEffect, useRef } from "react";
export const useLibraryAgentList = () => {
const { searchTerm, librarySort } = useLibraryPageContext();
const { agents: cachedAgents } = useLibraryAgentsStore();
interface Props {
searchTerm: string;
librarySort: LibraryAgentSort;
}
export function useLibraryAgentList({ searchTerm, librarySort }: Props) {
const queryClient = getQueryClient();
const prevSortRef = useRef<LibraryAgentSort | null>(null);
const {
data: agentsQueryData,
@@ -23,18 +28,28 @@ export const useLibraryAgentList = () => {
} = useGetV2ListLibraryAgentsInfinite(
{
page: 1,
page_size: 8,
page_size: 20,
search_term: searchTerm || undefined,
sort_by: librarySort,
},
{
query: {
initialData: getInitialData(cachedAgents, searchTerm, 8),
getNextPageParam: getPaginationNextPageNumber,
},
},
);
// Reset queries when sort changes to ensure fresh data with correct sorting
useEffect(() => {
if (prevSortRef.current !== null && prevSortRef.current !== librarySort) {
// Reset all library agent queries to ensure fresh fetch with new sort
queryClient.resetQueries({
queryKey: ["/api/library/agents"],
});
}
prevSortRef.current = librarySort;
}, [librarySort, queryClient]);
const allAgents = agentsQueryData
? unpaginate(agentsQueryData, "agents")
: [];
@@ -48,4 +63,4 @@ export const useLibraryAgentList = () => {
isFetchingNextPage,
fetchNextPage,
};
};
}

View File

@@ -1,175 +0,0 @@
import Image from "next/image";
import { Button } from "@/components/__legacy__/ui/button";
import { Separator } from "@/components/__legacy__/ui/separator";
import {
CirclePlayIcon,
ClipboardCopy,
ImageIcon,
PlayCircle,
Share2,
X,
} from "lucide-react";
export interface NotificationCardData {
type: "text" | "image" | "video" | "audio";
title: string;
id: string;
content?: string;
mediaUrl?: string;
}
interface NotificationCardProps {
notification: NotificationCardData;
onClose: () => void;
}
const NotificationCard = ({
notification: { type, title, content, mediaUrl },
onClose,
}: NotificationCardProps) => {
const barHeights = Array.from({ length: 60 }, () =>
Math.floor(Math.random() * (34 - 20 + 1) + 20),
);
const handleClose = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
onClose();
};
return (
<div className="w-[430px] space-y-[22px] rounded-[14px] border border-neutral-100 bg-neutral-50 p-[16px] pt-[12px]">
<div className="flex items-center justify-between">
{/* count */}
<div className="flex items-center gap-[10px]">
<p className="font-sans text-[12px] font-medium text-neutral-500">
1/4
</p>
<p className="h-[26px] rounded-[45px] bg-green-100 px-[9px] py-[3px] font-sans text-[12px] font-medium text-green-800">
Success
</p>
</div>
{/* cross icon */}
<Button
variant="ghost"
className="p-0 hover:bg-transparent"
onClick={handleClose}
>
<X
className="h-6 w-6 text-[#020617] hover:scale-105"
strokeWidth={1.25}
/>
</Button>
</div>
<div className="space-y-[6px] p-0">
<p className="font-sans text-[14px] font-medium leading-[20px] text-neutral-500">
New Output Ready!
</p>
<h2 className="font-poppin text-[20px] font-medium leading-7 text-neutral-800">
{title}
</h2>
{type === "text" && <Separator />}
</div>
<div className="p-0">
{type === "text" && (
// Maybe in future we give markdown support
<div className="mt-[-8px] line-clamp-6 font-sans text-sm font-[400px] text-neutral-600">
{content}
</div>
)}
{type === "image" &&
(mediaUrl ? (
<div className="relative h-[200px] w-full">
<Image
src={mediaUrl}
alt={title}
fill
className="rounded-lg object-cover"
/>
</div>
) : (
<div className="flex h-[244px] w-full items-center justify-center rounded-lg bg-[#D9D9D9]">
<ImageIcon
className="h-[138px] w-[138px] text-neutral-400"
strokeWidth={1}
/>
</div>
))}
{type === "video" && (
<div className="space-y-4">
{mediaUrl ? (
<video src={mediaUrl} controls className="w-full rounded-lg" />
) : (
<div className="flex h-[219px] w-[398px] items-center justify-center rounded-lg bg-[#D9D9D9]">
<PlayCircle
className="h-16 w-16 text-neutral-500"
strokeWidth={1}
/>
</div>
)}
</div>
)}
{type === "audio" && (
<div className="flex gap-2">
<CirclePlayIcon
className="h-10 w-10 rounded-full bg-neutral-800 text-white"
strokeWidth={1}
/>
<div className="flex flex-1 items-center justify-between">
{/* <audio src={mediaUrl} controls className="w-full" /> */}
{barHeights.map((h, i) => {
return (
<div
key={i}
className={`rounded-[8px] bg-neutral-500`}
style={{
height: `${h}px`,
width: "3px",
}}
/>
);
})}
</div>
</div>
)}
</div>
<div className="flex justify-between gap-2 p-0">
<div className="space-x-3">
<Button
variant="outline"
onClick={() => {
navigator.share({
title,
text: content,
url: mediaUrl,
});
}}
className="h-10 w-10 rounded-full border-neutral-800 p-0"
>
<Share2 className="h-5 w-5" strokeWidth={1} />
</Button>
<Button
variant="outline"
onClick={() =>
navigator.clipboard.writeText(content || mediaUrl || "")
}
className="h-10 w-10 rounded-full border-neutral-800 p-0"
>
<ClipboardCopy className="h-5 w-5" strokeWidth={1} />
</Button>
</div>
<Button className="h-[40px] rounded-[52px] bg-neutral-800 px-4 py-2">
See run
</Button>
</div>
</div>
);
};
export default NotificationCard;

View File

@@ -1,132 +0,0 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { motion, useAnimationControls } from "framer-motion";
import { BellIcon, X } from "lucide-react";
import { Button } from "@/components/__legacy__/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/__legacy__/ui/dropdown-menu";
import NotificationCard, {
NotificationCardData,
} from "../LibraryNotificationCard/LibraryNotificationCard";
export default function LibraryNotificationDropdown(): React.ReactNode {
const controls = useAnimationControls();
const [open, setOpen] = useState(false);
const [notifications, setNotifications] = useState<
NotificationCardData[] | null
>(null);
const initialNotificationData = useMemo(
() =>
[
{
type: "audio",
title: "Audio Processing Complete",
id: "4",
},
{
type: "text",
title: "LinkedIn Post Generator: YouTube to Professional Content",
id: "1",
content:
"As artificial intelligence (AI) continues to evolve, it's increasingly clear that AI isn't just a trend—it's reshaping the way we work, innovate, and solve complex problems. However, for many professionals, the question remains: How can I leverage AI to drive meaningful results in my own field? In this article, we'll explore how AI can empower businesses and individuals alike to be more efficient, make better decisions, and unlock new opportunities. Whether you're in tech, finance, healthcare, or any other industry, understanding the potential of AI can set you apart.",
},
{
type: "image",
title: "New Image Upload",
id: "2",
},
{
type: "video",
title: "Video Processing Complete",
id: "3",
},
] as NotificationCardData[],
[],
);
useEffect(() => {
if (initialNotificationData) {
setNotifications(initialNotificationData);
}
}, [initialNotificationData]);
const handleHoverStart = () => {
controls.start({
rotate: [0, -10, 10, -10, 10, 0],
transition: { duration: 0.5 },
});
};
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger className="sm:flex-1" asChild>
<Button
variant={open ? "primary" : "outline"}
onMouseEnter={handleHoverStart}
onMouseLeave={handleHoverStart}
className="w-fit max-w-[161px] transition-all duration-200 ease-in-out sm:w-[161px]"
>
<motion.div animate={controls}>
<BellIcon
className="h-5 w-5 transition-all duration-200 ease-in-out sm:mr-2"
strokeWidth={2}
/>
</motion.div>
<motion.div
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="hidden items-center transition-opacity duration-300 sm:inline-flex"
>
Your updates
<span className="ml-2 text-[14px]">
{notifications?.length || 0}
</span>
</motion.div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
sideOffset={22}
className="relative left-[16px] h-[80vh] w-fit overflow-y-auto rounded-[26px] bg-[#C5C5CA] p-5"
>
<DropdownMenuLabel className="z-10 mb-4 font-sans text-[18px] text-white">
Agent run updates
</DropdownMenuLabel>
<button
className="absolute right-[10px] top-[20px] h-fit w-fit"
onClick={() => setOpen(false)}
>
<X className="h-6 w-6 text-white hover:text-white/60" />
</button>
<div className="space-y-[12px]">
{notifications && notifications.length ? (
notifications.map((notification) => (
<DropdownMenuItem key={notification.id} className="p-0">
<NotificationCard
notification={notification}
onClose={() =>
setNotifications((prev) => {
if (!prev) return null;
return prev.filter((n) => n.id !== notification.id);
})
}
/>
</DropdownMenuItem>
))
) : (
<div className="w-[464px] py-4 text-center text-white">
No notifications present
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,40 +1,37 @@
"use client";
import { Input } from "@/components/__legacy__/ui/input";
import { Search, X } from "lucide-react";
import { Input } from "@/components/atoms/Input/Input";
import { MagnifyingGlassIcon } from "@phosphor-icons/react";
import { useLibrarySearchbar } from "./useLibrarySearchbar";
export default function LibrarySearchBar(): React.ReactNode {
const { handleSearchInput, handleClear, setIsFocused, isFocused, inputRef } =
useLibrarySearchbar();
interface Props {
setSearchTerm: (value: string) => void;
}
export function LibrarySearchBar({ setSearchTerm }: Props) {
const { handleSearchInput } = useLibrarySearchbar({ setSearchTerm });
return (
<div
data-testid="search-bar"
onClick={() => inputRef.current?.focus()}
className="relative z-[21] mx-auto flex h-[50px] w-full max-w-[500px] flex-1 cursor-pointer items-center rounded-[45px] bg-[#EDEDED] px-[24px] py-[10px]"
className="relative z-[21] -mb-6 flex w-full items-center md:w-auto"
>
<Search
className="mr-2 h-[29px] w-[29px] text-neutral-900"
strokeWidth={1.25}
<MagnifyingGlassIcon
width={18}
height={18}
className="absolute left-4 top-[34%] z-20 -translate-y-1/2 text-zinc-800"
/>
<Input
ref={inputRef}
onFocus={() => setIsFocused(true)}
onBlur={() => !inputRef.current?.value && setIsFocused(false)}
label="Search agents"
id="library-search-bar"
hideLabel
onChange={handleSearchInput}
className="flex-1 border-none font-sans text-[16px] font-normal leading-7 shadow-none focus:shadow-none focus:ring-0"
className="min-w-[18rem] pl-12 lg:min-w-[30rem]"
type="text"
data-testid="library-textbox"
placeholder="Search agents"
/>
{isFocused && inputRef.current?.value && (
<X
className="ml-2 h-[29px] w-[29px] cursor-pointer text-neutral-900"
strokeWidth={1.25}
onClick={handleClear}
/>
)}
</div>
);
}

View File

@@ -1,36 +1,30 @@
import { useRef, useState } from "react";
import { useLibraryPageContext } from "../state-provider";
import { debounce } from "lodash";
import { useCallback, useEffect } from "react";
export const useLibrarySearchbar = () => {
const inputRef = useRef<HTMLInputElement>(null);
const [isFocused, setIsFocused] = useState(false);
const { setSearchTerm } = useLibraryPageContext();
interface Props {
setSearchTerm: (value: string) => void;
}
const debouncedSearch = debounce((value: string) => {
setSearchTerm(value);
}, 300);
export function useLibrarySearchbar({ setSearchTerm }: Props) {
const debouncedSearch = useCallback(
debounce((value: string) => {
setSearchTerm(value);
}, 300),
[setSearchTerm],
);
const handleSearchInput = (e: React.ChangeEvent<HTMLInputElement>) => {
useEffect(() => {
return () => {
debouncedSearch.cancel();
};
}, [debouncedSearch]);
function handleSearchInput(e: React.ChangeEvent<HTMLInputElement>) {
const searchTerm = e.target.value;
debouncedSearch(searchTerm);
};
const handleClear = (e: React.MouseEvent) => {
if (inputRef.current) {
inputRef.current.value = "";
inputRef.current.blur();
setSearchTerm("");
e.preventDefault();
}
setIsFocused(false);
};
}
return {
handleClear,
handleSearchInput,
isFocused,
inputRef,
setIsFocused,
};
};
}

View File

@@ -1,5 +1,5 @@
"use client";
import { ArrowDownNarrowWideIcon } from "lucide-react";
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
import {
Select,
SelectContent,
@@ -8,11 +8,15 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/__legacy__/ui/select";
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
import { ArrowDownNarrowWideIcon } from "lucide-react";
import { useLibrarySortMenu } from "./useLibrarySortMenu";
export default function LibrarySortMenu(): React.ReactNode {
const { handleSortChange } = useLibrarySortMenu();
interface Props {
setLibrarySort: (value: LibraryAgentSort) => void;
}
export function LibrarySortMenu({ setLibrarySort }: Props) {
const { handleSortChange } = useLibrarySortMenu({ setLibrarySort });
return (
<div className="flex items-center" data-testid="sort-by-dropdown">
<span className="hidden whitespace-nowrap sm:inline">sort by</span>

View File

@@ -1,11 +1,11 @@
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
import { useLibraryPageContext } from "../state-provider";
export const useLibrarySortMenu = () => {
const { setLibrarySort } = useLibraryPageContext();
interface Props {
setLibrarySort: (value: LibraryAgentSort) => void;
}
export function useLibrarySortMenu({ setLibrarySort }: Props) {
const handleSortChange = (value: LibraryAgentSort) => {
// Simply updating the sort state - React Query will handle the rest
setLibrarySort(value);
};
@@ -24,4 +24,4 @@ export const useLibrarySortMenu = () => {
handleSortChange,
getSortLabel,
};
};
}

View File

@@ -1,192 +1,134 @@
"use client";
import { Upload, X } from "lucide-react";
import { Button } from "@/components/__legacy__/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/__legacy__/ui/dialog";
import { z } from "zod";
import { FileUploader } from "react-drag-drop-files";
import { Button } from "@/components/atoms/Button/Button";
import { FileInput } from "@/components/atoms/FileInput/FileInput";
import { Input } from "@/components/atoms/Input/Input";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/__legacy__/ui/form";
import { Input } from "@/components/__legacy__/ui/input";
import { Textarea } from "@/components/__legacy__/ui/textarea";
} from "@/components/molecules/Form/Form";
import { UploadSimpleIcon } from "@phosphor-icons/react";
import { z } from "zod";
import { useLibraryUploadAgentDialog } from "./useLibraryUploadAgentDialog";
const fileTypes = ["JSON"];
const fileSchema = z.custom<File>((val) => val instanceof File, {
message: "Must be a File object",
});
export const uploadAgentFormSchema = z.object({
agentFile: fileSchema,
agentFile: z.string().min(1, "Agent file is required"),
agentName: z.string().min(1, "Agent name is required"),
agentDescription: z.string(),
});
export default function LibraryUploadAgentDialog(): React.ReactNode {
const {
onSubmit,
isUploading,
isOpen,
setIsOpen,
isDroped,
handleChange,
form,
setisDroped,
agentObject,
clearAgentFile,
} = useLibraryUploadAgentDialog();
export default function LibraryUploadAgentDialog() {
const { onSubmit, isUploading, isOpen, setIsOpen, form, agentObject } =
useLibraryUploadAgentDialog();
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Dialog
title="Upload Agent"
styling={{ maxWidth: "30rem" }}
controlled={{
isOpen,
set: setIsOpen,
}}
onClose={() => {
setIsOpen(false);
}}
>
<Dialog.Trigger>
<Button
data-testid="upload-agent-button"
variant="primary"
className="w-fit sm:w-[177px]"
className="h-[2.78rem] w-full md:w-[12rem]"
size="small"
>
<Upload className="h-5 w-5 sm:mr-2" />
<span className="hidden items-center sm:inline-flex">
Upload an agent
</span>
<UploadSimpleIcon width={18} height={18} />
<span className="">Upload agent</span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className="mb-8 text-center">Upload Agent</DialogTitle>
<DialogDescription>
Upload your agent by providing a name, description, and JSON file.
</DialogDescription>
</DialogHeader>
</Dialog.Trigger>
<Dialog.Content>
<Form
form={form}
onSubmit={onSubmit}
className="flex flex-col justify-center gap-0 px-1"
>
<FormField
control={form.control}
name="agentName"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
id={field.name}
label="Agent name"
className="w-full rounded-[10px]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="agentName"
render={({ field }) => (
<FormItem>
<FormLabel>Agent name</FormLabel>
<FormControl>
<Input {...field} className="w-full rounded-[10px]" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="agentDescription"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
id={field.name}
label="Agent description"
type="textarea"
className="w-full rounded-[10px]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="agentDescription"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} className="w-full rounded-[10px]" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="agentFile"
render={({ field }) => (
<FormItem>
<FormControl>
<FileInput
mode="base64"
value={field.value}
onChange={field.onChange}
accept=".json,application/json"
placeholder="Agent file"
maxFileSize={10 * 1024 * 1024}
showStorageNote={false}
className="mb-8 mt-4"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="agentFile"
render={({ field }) => (
<FormItem className="rounded-xl border-2 border-dashed border-neutral-300 hover:border-neutral-600">
<FormControl>
{field.value ? (
<div className="relative flex rounded-[10px] border p-2 font-sans text-sm font-medium text-[#525252] outline-none">
<span className="line-clamp-1">{field.value.name}</span>
<Button
onClick={clearAgentFile}
className="absolute left-[-10px] top-[-16px] mt-2 h-fit border-none bg-red-200 p-1"
>
<X
className="m-0 h-[12px] w-[12px] text-red-600"
strokeWidth={3}
/>
</Button>
</div>
) : (
<FileUploader
handleChange={handleChange}
name="file"
types={fileTypes}
label={"Upload your agent here..!!"}
uploadedLabel={"Uploading Successful"}
required={true}
hoverTitle={"Drop your agent here...!!"}
maxSize={10}
classes={"drop-style"}
onDrop={() => {
setisDroped(true);
}}
onSelect={() => setisDroped(true)}
>
<div
style={{
minHeight: "150px",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
outline: "none",
color: "#525252",
fontSize: "14px",
fontWeight: "500",
borderWidth: "0px",
}}
>
{isDroped ? (
<div className="flex items-center justify-center py-4">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-t-2 border-neutral-800"></div>
</div>
) : (
<>
<span>Drop your agent here</span>
<span>or</span>
<span>Click to upload</span>
</>
)}
</div>
</FileUploader>
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
variant="primary"
className="mt-2 self-end"
disabled={!agentObject || isUploading}
>
{isUploading ? (
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-t-2 border-white"></div>
<span>Uploading...</span>
</div>
) : (
"Upload Agent"
)}
</Button>
</form>
<Button
type="submit"
variant="primary"
className="min-w-[18rem]"
disabled={!agentObject || isUploading}
>
{isUploading ? (
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-t-2 border-white"></div>
<span>Uploading...</span>
</div>
) : (
"Upload"
)}
</Button>
</Form>
</DialogContent>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -1,16 +1,15 @@
import { usePostV1CreateNewGraph } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { Graph } from "@/app/api/__generated__/models/graph";
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { sanitizeImportedGraph } from "@/lib/autogpt-server-api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { uploadAgentFormSchema } from "./LibraryUploadAgentDialog";
import { usePostV1CreateNewGraph } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useState } from "react";
import { Graph } from "@/app/api/__generated__/models/graph";
import { sanitizeImportedGraph } from "@/lib/autogpt-server-api";
export const useLibraryUploadAgentDialog = () => {
const [isDroped, setisDroped] = useState(false);
export function useLibraryUploadAgentDialog() {
const [isOpen, setIsOpen] = useState(false);
const { toast } = useToast();
const [agentObject, setAgentObject] = useState<Graph | null>(null);
@@ -43,9 +42,78 @@ export const useLibraryUploadAgentDialog = () => {
defaultValues: {
agentName: "",
agentDescription: "",
agentFile: "",
},
});
const agentFileValue = form.watch("agentFile");
const prevAgentObjectRef = useRef<Graph | null>(null);
useEffect(() => {
if (!agentFileValue) {
const prevAgent = prevAgentObjectRef.current;
if (prevAgent) {
const currentName = form.getValues("agentName");
const currentDescription = form.getValues("agentDescription");
if (currentName === prevAgent.name) {
form.setValue("agentName", "");
}
if (currentDescription === prevAgent.description) {
form.setValue("agentDescription", "");
}
}
setAgentObject(null);
prevAgentObjectRef.current = null;
return;
}
try {
const base64Match = agentFileValue.match(/^data:[^;]+;base64,(.+)$/);
if (!base64Match) {
throw new Error("Invalid base64 data URL format");
}
const base64String = base64Match[1];
const jsonString = atob(base64String);
const obj = JSON.parse(jsonString);
if (
!["name", "description", "nodes", "links"].every(
(key) => key in obj && obj[key] != null,
)
) {
throw new Error(
"Invalid agent file. Please upload a valid agent.json file that has been previously exported from the AutoGPT platform. The file must contain the required fields: name, description, nodes, and links.",
);
}
const agent = obj as Graph;
sanitizeImportedGraph(agent);
setAgentObject(agent);
prevAgentObjectRef.current = agent;
if (!form.getValues("agentName")) {
form.setValue("agentName", agent.name);
}
if (!form.getValues("agentDescription")) {
form.setValue("agentDescription", agent.description);
}
} catch (error) {
console.error("Error loading agent file:", error);
toast({
title: "Invalid Agent File",
description:
"Please upload a valid agent.json file that has been previously exported from the AutoGPT platform. The file must contain the required fields: name, description, nodes, and links.",
duration: 5000,
variant: "destructive",
});
form.resetField("agentFile");
setAgentObject(null);
}
}, [agentFileValue, form, toast]);
const onSubmit = async (values: z.infer<typeof uploadAgentFormSchema>) => {
if (!agentObject) {
form.setError("root", { message: "No Agent object to save" });
@@ -67,69 +135,6 @@ export const useLibraryUploadAgentDialog = () => {
});
};
const handleChange = (file: File) => {
setTimeout(() => {
setisDroped(false);
}, 2000);
form.setValue("agentFile", file);
const reader = new FileReader();
reader.onload = (event) => {
try {
const obj = JSON.parse(event.target?.result as string);
if (
!["name", "description", "nodes", "links"].every(
(key) => key in obj && obj[key] != null,
)
) {
throw new Error(
"Invalid agent file. Please upload a valid agent.json file that has been previously exported from the AutoGPT platform. The file must contain the required fields: name, description, nodes, and links.",
);
}
const agent = obj as Graph;
sanitizeImportedGraph(agent);
setAgentObject(agent);
if (!form.getValues("agentName")) {
form.setValue("agentName", agent.name);
}
if (!form.getValues("agentDescription")) {
form.setValue("agentDescription", agent.description);
}
} catch (error) {
console.error("Error loading agent file:", error);
toast({
title: "Invalid Agent File",
description:
"Please upload a valid agent.json file that has been previously exported from the AutoGPT platform. The file must contain the required fields: name, description, nodes, and links.",
duration: 5000,
variant: "destructive",
});
form.resetField("agentFile");
setAgentObject(null);
}
};
reader.readAsText(file);
setisDroped(false);
};
const clearAgentFile = () => {
const currentName = form.getValues("agentName");
const currentDescription = form.getValues("agentDescription");
const prevAgent = agentObject;
form.setValue("agentFile", undefined as any);
if (prevAgent && currentName === prevAgent.name) {
form.setValue("agentName", "");
}
if (prevAgent && currentDescription === prevAgent.description) {
form.setValue("agentDescription", "");
}
setAgentObject(null);
};
return {
onSubmit,
isUploading,
@@ -137,9 +142,5 @@ export const useLibraryUploadAgentDialog = () => {
setIsOpen,
form,
agentObject,
isDroped,
handleChange,
setisDroped,
clearAgentFile,
};
};
}

View File

@@ -1,59 +0,0 @@
"use client";
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
import {
createContext,
useState,
ReactNode,
useContext,
Dispatch,
SetStateAction,
} from "react";
interface LibraryPageContextType {
searchTerm: string;
setSearchTerm: Dispatch<SetStateAction<string>>;
uploadedFile: File | null;
setUploadedFile: Dispatch<SetStateAction<File | null>>;
librarySort: LibraryAgentSort;
setLibrarySort: Dispatch<SetStateAction<LibraryAgentSort>>;
}
export const LibraryPageContext = createContext<LibraryPageContextType>(
{} as LibraryPageContextType,
);
export function LibraryPageStateProvider({
children,
}: {
children: ReactNode;
}) {
const [searchTerm, setSearchTerm] = useState<string>("");
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [librarySort, setLibrarySort] = useState<LibraryAgentSort>(
LibraryAgentSort.updatedAt,
);
return (
<LibraryPageContext.Provider
value={{
searchTerm,
setSearchTerm,
uploadedFile,
setUploadedFile,
librarySort,
setLibrarySort,
}}
>
{children}
</LibraryPageContext.Provider>
);
}
export function useLibraryPageContext(): LibraryPageContextType {
const context = useContext(LibraryPageContext);
if (!context) {
throw new Error("Error in context of Library page");
}
return context;
}

View File

@@ -0,0 +1,41 @@
"use client";
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
import { parseAsStringEnum, useQueryState } from "nuqs";
import { useCallback, useEffect, useMemo, useState } from "react";
const sortParser = parseAsStringEnum(Object.values(LibraryAgentSort));
export function useLibraryListPage() {
const [searchTerm, setSearchTerm] = useState<string>("");
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [librarySortRaw, setLibrarySortRaw] = useQueryState("sort", sortParser);
// Ensure sort param is always present in URL (even if default)
useEffect(() => {
if (!librarySortRaw) {
setLibrarySortRaw(LibraryAgentSort.updatedAt, { shallow: false });
}
}, [librarySortRaw, setLibrarySortRaw]);
const librarySort = librarySortRaw || LibraryAgentSort.updatedAt;
const setLibrarySort = useCallback(
(value: LibraryAgentSort) => {
setLibrarySortRaw(value, { shallow: false });
},
[setLibrarySortRaw],
);
return useMemo(
() => ({
searchTerm,
setSearchTerm,
uploadedFile,
setUploadedFile,
librarySort,
setLibrarySort,
}),
[searchTerm, uploadedFile, librarySort, setLibrarySort],
);
}

View File

@@ -1,23 +1,28 @@
"use client";
import { useEffect } from "react";
import FavoritesSection from "./components/FavoritesSection/FavoritesSection";
import LibraryActionHeader from "./components/LibraryActionHeader/LibraryActionHeader";
import LibraryAgentList from "./components/LibraryAgentList/LibraryAgentList";
import { LibraryPageStateProvider } from "./components/state-provider";
import { FavoritesSection } from "./components/FavoritesSection/FavoritesSection";
import { LibraryActionHeader } from "./components/LibraryActionHeader/LibraryActionHeader";
import { LibraryAgentList } from "./components/LibraryAgentList/LibraryAgentList";
import { useLibraryListPage } from "./components/useLibraryListPage";
export default function LibraryPage() {
const { searchTerm, setSearchTerm, librarySort, setLibrarySort } =
useLibraryListPage();
useEffect(() => {
document.title = "Library AutoGPT Platform";
}, []);
return (
<main className="pt-160 container min-h-screen space-y-4 pb-20 pt-16 sm:px-8 md:px-12">
<LibraryPageStateProvider>
<LibraryActionHeader />
<FavoritesSection />
<LibraryAgentList />
</LibraryPageStateProvider>
<LibraryActionHeader setSearchTerm={setSearchTerm} />
<FavoritesSection />
<LibraryAgentList
searchTerm={searchTerm}
librarySort={librarySort}
setLibrarySort={setLibrarySort}
/>
</main>
);
}

View File

@@ -41,11 +41,9 @@ export const customMutator = async <
T extends { data: any; status: number; headers: Headers },
>(
url: string,
options: RequestInit & {
params?: any;
} = {},
options: RequestInit,
): Promise<T> => {
const { params, ...requestOptions } = options;
const requestOptions = options;
const method = (requestOptions.method || "GET") as
| "GET"
| "POST"
@@ -87,14 +85,11 @@ export const customMutator = async <
headers["Content-Type"] = "application/json";
}
const queryString = params
? "?" + new URLSearchParams(params).toString()
: "";
const baseUrl = getBaseUrl();
// The caching in React Query in our system depends on the url, so the base_url could be different for the server and client sides.
const fullUrl = `${baseUrl}${url}${queryString}`;
// here url also contains encoded query params
const fullUrl = `${baseUrl}${url}`;
if (environment.isServerSide()) {
try {

View File

@@ -1,9 +1,9 @@
"use client";
import React from "react";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { cn } from "@/lib/utils";
import React from "react";
import { useInfiniteScroll } from "./useInfiniteScroll";
import LoadingBox from "@/components/__legacy__/ui/loading";
type InfiniteScrollProps = {
children: React.ReactNode;
@@ -47,7 +47,7 @@ export const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
hasNextPage,
});
const defaultLoader = <LoadingBox className="w-full py-4" spinnerSize={12} />;
const defaultLoader = <LoadingSpinner size="medium" />;
return (
<div

View File

@@ -49,7 +49,26 @@ export const useInfiniteScroll = ({
observer.observe(endOfListRef.current);
// Check if element is initially in view after a short delay to ensure DOM is ready
const checkInitialView = () => {
if (endOfListRef.current) {
const rect = endOfListRef.current.getBoundingClientRect();
const isInitiallyInView =
rect.top <= window.innerHeight + scrollThreshold &&
rect.bottom >= -scrollThreshold;
if (isInitiallyInView) {
setIsInView(true);
}
}
};
// Check immediately and after a short delay to catch cases where DOM updates
checkInitialView();
const timeoutId = setTimeout(checkInitialView, 100);
return () => {
clearTimeout(timeoutId);
observer.disconnect();
};
}, [hasNextPage, scrollThreshold]);
@@ -58,7 +77,7 @@ export const useInfiniteScroll = ({
if (isInView && hasNextPage && !isLoadingRef.current) {
loadMore();
}
}, [isInView, hasNextPage]);
}, [isInView, hasNextPage, loadMore]);
return {
containerRef,

View File

@@ -1,10 +1,10 @@
import { useGetV1ListAllExecutions } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { okData } from "@/app/api/helpers";
import { useExecutionEvents } from "@/hooks/useExecutionEvents";
import { useLibraryAgents } from "@/hooks/useLibraryAgents/useLibraryAgents";
import type { GraphExecution } from "@/lib/autogpt-server-api/types";
import { useCallback, useEffect, useMemo, useState } from "react";
import { okData } from "@/app/api/helpers";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
NotificationState,
categorizeExecutions,
@@ -47,10 +47,22 @@ export function useAgentActivityDropdown() {
);
// Process initial execution state when data loads
// Use a ref to track if we've already processed to avoid infinite loops
const processedExecutionsRef = useRef<string | null>(null);
useEffect(() => {
if (executions && executionsSuccess && agentInfoMap.size > 0) {
const executionKey = executions
? `${executions.length}-${executionsSuccess}`
: null;
if (
executions &&
executionsSuccess &&
agentInfoMap.size > 0 &&
processedExecutionsRef.current !== executionKey
) {
const notifications = categorizeExecutions(executions, agentInfoMap);
setNotifications(notifications);
processedExecutionsRef.current = executionKey;
}
}, [executions, executionsSuccess, agentInfoMap]);

View File

@@ -0,0 +1,209 @@
"use client";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form";
import { cn } from "@/lib/utils";
import type { UseFormReturn } from "react-hook-form";
type FormProps<TFieldValues extends FieldValues = FieldValues> = {
form: UseFormReturn<TFieldValues>;
onSubmit: (values: TFieldValues) => void | Promise<void>;
className?: string;
} & Omit<React.FormHTMLAttributes<HTMLFormElement>, "onSubmit">;
function Form<TFieldValues extends FieldValues = FieldValues>({
form,
onSubmit,
className,
children,
...props
}: FormProps<TFieldValues>) {
return (
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn("space-y-4", className)}
{...props}
>
{children}
</form>
</FormProvider>
);
}
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<LabelPrimitive.Root
ref={ref}
className={cn(error && "text-red-500 dark:text-red-900", className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn(
"font-sans text-[0.75rem] font-[400] leading-[1.125rem] text-neutral-500 dark:text-neutral-400",
className,
)}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn(
"font-sans text-[0.75rem] font-[500] leading-[1.125rem] text-red-500 dark:text-red-900",
className,
)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = "FormMessage";
export {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
};

View File

@@ -0,0 +1,99 @@
import { Button } from "@/components/atoms/Button/Button";
import {
DropdownMenu,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import {
ArrowSquareOutIcon,
CopyIcon,
DotsThreeOutlineVerticalIcon,
TrashIcon,
} from "@phosphor-icons/react";
import * as ContextMenu from "@radix-ui/react-context-menu";
import type { Meta, StoryObj } from "@storybook/nextjs";
import {
SecondaryDropdownMenuContent,
SecondaryDropdownMenuItem,
SecondaryDropdownMenuSeparator,
SecondaryMenuContent,
SecondaryMenuItem,
SecondaryMenuSeparator,
} from "./SecondaryMenu";
const meta: Meta = {
title: "Molecules/SecondaryMenu",
component: SecondaryMenuContent,
};
export default meta;
type Story = StoryObj<typeof SecondaryMenuContent>;
export const ContextMenuExample: Story = {
render: () => (
<div className="flex h-96 items-center justify-center">
<ContextMenu.Root>
<ContextMenu.Trigger asChild>
<div className="flex h-32 w-64 cursor-pointer items-center justify-center rounded-lg border border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800">
Right-click me
</div>
</ContextMenu.Trigger>
<SecondaryMenuContent>
<SecondaryMenuItem onSelect={() => alert("Copy")}>
<CopyIcon size={20} className="mr-2 dark:text-gray-100" />
<span className="dark:text-gray-100">Copy</span>
</SecondaryMenuItem>
<SecondaryMenuItem onSelect={() => alert("Open agent")}>
<ArrowSquareOutIcon size={20} className="mr-2 dark:text-gray-100" />
<span className="dark:text-gray-100">Open agent</span>
</SecondaryMenuItem>
<SecondaryMenuSeparator />
<SecondaryMenuItem
variant="destructive"
onSelect={() => alert("Delete")}
>
<TrashIcon
size={20}
className="mr-2 text-red-500 dark:text-red-400"
/>
<span className="dark:text-red-400">Delete</span>
</SecondaryMenuItem>
</SecondaryMenuContent>
</ContextMenu.Root>
</div>
),
};
export const DropdownMenuExample: Story = {
render: () => (
<div className="flex h-96 items-center justify-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="small">
<DotsThreeOutlineVerticalIcon size={16} weight="fill" />
</Button>
</DropdownMenuTrigger>
<SecondaryDropdownMenuContent side="right" align="start">
<SecondaryDropdownMenuItem onClick={() => alert("Copy")}>
<CopyIcon size={20} className="mr-2 dark:text-gray-100" />
<span className="dark:text-gray-100">Copy</span>
</SecondaryDropdownMenuItem>
<SecondaryDropdownMenuItem onClick={() => alert("Open agent")}>
<ArrowSquareOutIcon size={20} className="mr-2 dark:text-gray-100" />
<span className="dark:text-gray-100">Open agent</span>
</SecondaryDropdownMenuItem>
<SecondaryDropdownMenuSeparator />
<SecondaryDropdownMenuItem
variant="destructive"
onClick={() => alert("Delete")}
>
<TrashIcon
size={20}
className="mr-2 text-red-500 dark:text-red-400"
/>
<span className="dark:text-red-400">Delete</span>
</SecondaryDropdownMenuItem>
</SecondaryDropdownMenuContent>
</DropdownMenu>
</div>
),
};

View File

@@ -0,0 +1,103 @@
"use client";
import { cn } from "@/lib/utils";
import * as ContextMenu from "@radix-ui/react-context-menu";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import React from "react";
const secondaryMenuContentClassName =
"z-10 rounded-xl border bg-white p-1 shadow-md dark:bg-gray-800";
const secondaryMenuItemClassName =
"flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700";
const secondaryMenuSeparatorClassName =
"my-1 h-px bg-gray-300 dark:bg-gray-600";
export const SecondaryMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenu.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenu.Content>
>(({ className, ...props }, ref) => (
<ContextMenu.Content
ref={ref}
className={cn(secondaryMenuContentClassName, className)}
{...props}
/>
));
SecondaryMenuContent.displayName = "SecondaryMenuContent";
export const SecondaryMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenu.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenu.Item> & {
variant?: "default" | "destructive";
}
>(({ className, variant = "default", ...props }, ref) => (
<ContextMenu.Item
ref={ref}
className={cn(
secondaryMenuItemClassName,
variant === "destructive" &&
"text-red-500 hover:bg-gray-100 dark:hover:bg-gray-700",
className,
)}
{...props}
/>
));
SecondaryMenuItem.displayName = "SecondaryMenuItem";
export const SecondaryMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenu.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenu.Separator>
>(({ className, ...props }, ref) => (
<ContextMenu.Separator
ref={ref}
className={cn(secondaryMenuSeparatorClassName, className)}
{...props}
/>
));
SecondaryMenuSeparator.displayName = "SecondaryMenuSeparator";
export const SecondaryDropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
className={cn(secondaryMenuContentClassName, className)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
SecondaryDropdownMenuContent.displayName = "SecondaryDropdownMenuContent";
export const SecondaryDropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
variant?: "default" | "destructive";
}
>(({ className, variant = "default", ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
secondaryMenuItemClassName,
variant === "destructive" &&
"text-red-500 hover:bg-gray-100 dark:hover:bg-gray-700",
className,
)}
{...props}
/>
));
SecondaryDropdownMenuItem.displayName = "SecondaryDropdownMenuItem";
export const SecondaryDropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn(secondaryMenuSeparatorClassName, className)}
{...props}
/>
));
SecondaryDropdownMenuSeparator.displayName = "SecondaryDropdownMenuSeparator";

View File

@@ -4,6 +4,7 @@ import { useMemo } from "react";
import { customValidator } from "./utils/custom-validator";
import Form from "./registry";
import { ExtendedFormContextType } from "./types";
import { generateUiSchemaForCustomFields } from "./utils/generate-ui-schema";
type FormRendererProps = {
jsonSchema: RJSFSchema;
@@ -24,6 +25,11 @@ export const FormRenderer = ({
return preprocessInputSchema(jsonSchema);
}, [jsonSchema]);
// Merge custom field ui:field settings with existing uiSchema
const mergedUiSchema = useMemo(() => {
return generateUiSchemaForCustomFields(preprocessedSchema, uiSchema);
}, [preprocessedSchema, uiSchema]);
return (
<div className={"mb-6 mt-4"}>
<Form
@@ -33,7 +39,7 @@ export const FormRenderer = ({
schema={preprocessedSchema}
validator={customValidator}
onChange={handleChange}
uiSchema={uiSchema}
uiSchema={mergedUiSchema}
formData={initialValues}
liveValidate={false}
/>

View File

@@ -0,0 +1,71 @@
import { RJSFSchema, UiSchema } from "@rjsf/utils";
import { findCustomFieldId } from "../custom/custom-registry";
/**
* Generates uiSchema with ui:field settings for custom fields based on schema matchers.
* This is the standard RJSF way to route fields to custom components.
*/
export function generateUiSchemaForCustomFields(
schema: RJSFSchema,
existingUiSchema: UiSchema = {},
): UiSchema {
const uiSchema: UiSchema = { ...existingUiSchema };
if (schema.properties) {
for (const [key, propSchema] of Object.entries(schema.properties)) {
if (propSchema && typeof propSchema === "object") {
const customFieldId = findCustomFieldId(propSchema);
if (customFieldId) {
uiSchema[key] = {
...(uiSchema[key] as object),
"ui:field": customFieldId,
};
}
if (
propSchema.type === "object" &&
propSchema.properties &&
typeof propSchema.properties === "object"
) {
const nestedUiSchema = generateUiSchemaForCustomFields(
propSchema as RJSFSchema,
(uiSchema[key] as UiSchema) || {},
);
uiSchema[key] = {
...(uiSchema[key] as object),
...nestedUiSchema,
};
}
if (propSchema.type === "array" && propSchema.items) {
const itemsSchema = propSchema.items as RJSFSchema;
if (itemsSchema && typeof itemsSchema === "object") {
const itemsCustomFieldId = findCustomFieldId(itemsSchema);
if (itemsCustomFieldId) {
uiSchema[key] = {
...(uiSchema[key] as object),
items: {
"ui:field": itemsCustomFieldId,
},
};
} else if (itemsSchema.properties) {
const itemsUiSchema = generateUiSchemaForCustomFields(
itemsSchema,
((uiSchema[key] as UiSchema)?.items as UiSchema) || {},
);
if (Object.keys(itemsUiSchema).length > 0) {
uiSchema[key] = {
...(uiSchema[key] as object),
items: itemsUiSchema,
};
}
}
}
}
}
}
}
return uiSchema;
}

View File

@@ -1,5 +1,4 @@
import { RJSFSchema } from "@rjsf/utils";
import { findCustomFieldId } from "../custom/custom-registry";
/**
* Pre-processes the input schema to ensure all properties have a type defined.
@@ -21,12 +20,6 @@ export function preprocessInputSchema(schema: RJSFSchema): RJSFSchema {
if (property && typeof property === "object") {
const processedProperty = { ...property };
// adding $id for custom field
const customFieldId = findCustomFieldId(processedProperty);
if (customFieldId) {
processedProperty.$id = customFieldId;
}
// Only add type if no type is defined AND no anyOf/oneOf/allOf is present
if (
!processedProperty.type &&

View File

@@ -1,114 +1,7 @@
import { create } from "zustand";
import * as Sentry from "@sentry/nextjs";
import { storage, Key } from "@/services/storage/local-storage";
import {
getV2ListLibraryAgents,
type getV2ListLibraryAgentsResponse,
} from "@/app/api/__generated__/endpoints/library/library";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
export type AgentInfo = LibraryAgent;
type AgentStore = {
agents: AgentInfo[];
lastUpdatedAt?: number;
isRefreshing: boolean;
error?: unknown;
loadFromCache: () => void;
refreshAll: () => Promise<void>;
};
type CachedAgents = {
agents: LibraryAgent[];
lastUpdatedAt: number;
};
async function fetchAllLibraryAgents() {
const pageSize = 100;
let page = 1;
const all: LibraryAgent[] = [];
let res: getV2ListLibraryAgentsResponse | undefined;
try {
res = await getV2ListLibraryAgents({ page, page_size: pageSize });
} catch (err) {
Sentry.captureException(err, { tags: { context: "library_agents_fetch" } });
throw err;
}
if (!res || res.status !== 200) return all;
const { agents, pagination } = res.data;
all.push(...agents);
const totalPages = pagination?.total_pages ?? 1;
for (page = 2; page <= totalPages; page += 1) {
try {
const next = await getV2ListLibraryAgents({ page, page_size: pageSize });
if (next.status === 200) {
all.push(...next.data.agents);
}
} catch (err) {
Sentry.captureException(err, {
tags: { context: "library_agents_fetch" },
});
}
}
return all;
}
function persistCache(cached: CachedAgents) {
try {
storage.set(Key.LIBRARY_AGENTS_CACHE, JSON.stringify(cached));
} catch (error) {
// Ignore cache failures
// eslint-disable-next-line no-console
console.error("Failed to persist library agents cache", error);
Sentry.captureException(error, {
tags: { context: "library_agents_cache_persist" },
});
}
}
function readCache(): CachedAgents | undefined {
try {
const raw = storage.get(Key.LIBRARY_AGENTS_CACHE);
if (!raw) return;
return JSON.parse(raw) as CachedAgents;
} catch {
return;
}
}
export const useLibraryAgentsStore = create<AgentStore>((set, get) => ({
agents: [],
lastUpdatedAt: undefined,
isRefreshing: false,
error: undefined,
loadFromCache: () => {
const cached = readCache();
if (cached?.agents?.length) {
set({ agents: cached.agents, lastUpdatedAt: cached.lastUpdatedAt });
}
},
refreshAll: async () => {
if (get().isRefreshing) return;
set({ isRefreshing: true, error: undefined });
try {
const agents = await fetchAllLibraryAgents();
const snapshot: CachedAgents = { agents, lastUpdatedAt: Date.now() };
persistCache(snapshot);
set({ agents, lastUpdatedAt: snapshot.lastUpdatedAt });
} catch (error) {
set({ error });
} finally {
set({ isRefreshing: false });
}
},
}));
export function buildAgentInfoMap(agents: AgentInfo[]) {
const map = new Map<
string,

View File

@@ -1,21 +1,34 @@
import { useEffect, useMemo } from "react";
import { buildAgentInfoMap, useLibraryAgentsStore } from "./store";
let initialized = false;
import { useGetV2ListLibraryAgentsInfinite } from "@/app/api/__generated__/endpoints/library/library";
import { getPaginationNextPageNumber, unpaginate } from "@/app/api/helpers";
import { useMemo } from "react";
import { buildAgentInfoMap } from "./store";
export function useLibraryAgents() {
const { agents, isRefreshing, lastUpdatedAt, loadFromCache, refreshAll } =
useLibraryAgentsStore();
const { data: agentsQueryData, isLoading: isRefreshing } =
useGetV2ListLibraryAgentsInfinite(
{
page: 1,
page_size: 100,
},
{
query: {
getNextPageParam: getPaginationNextPageNumber,
// Don't block rendering - fetch in background
refetchOnMount: false,
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000, // 5 minutes
},
},
);
useEffect(() => {
if (!initialized) {
loadFromCache();
void refreshAll();
initialized = true;
}
}, [loadFromCache, refreshAll]);
const agents = agentsQueryData ? unpaginate(agentsQueryData, "agents") : [];
const agentInfoMap = useMemo(() => buildAgentInfoMap(agents), [agents]);
// Use agents.length as dependency to avoid recreating map unnecessarily
const agentInfoMap = useMemo(
() => buildAgentInfoMap(agents),
// eslint-disable-next-line react-hooks/exhaustive-deps
[agents.length, agents.map((a) => a.id).join(",")],
);
return { agents, agentInfoMap, isRefreshing, lastUpdatedAt };
return { agents, agentInfoMap, isRefreshing, lastUpdatedAt: undefined };
}

View File

@@ -18,6 +18,7 @@ test.describe("Build", () => { //(1)!
// Reason Ignore: admonishment is in the wrong place visually with correct prettier rules
// prettier-ignore
test.beforeEach(async ({ page }) => { //(3)! ts-ignore
test.setTimeout(25000);
const loginPage = new LoginPage(page);
const testUser = await getTestUser();

View File

@@ -1,10 +1,10 @@
import { LibraryPage } from "./pages/library.page";
import path from "path";
import test, { expect } from "@playwright/test";
import path from "path";
import { TEST_CREDENTIALS } from "./credentials";
import { LibraryPage } from "./pages/library.page";
import { LoginPage } from "./pages/login.page";
import { getSelectors } from "./utils/selectors";
import { hasUrl } from "./utils/assertion";
import { getSelectors } from "./utils/selectors";
test.describe("Library", () => {
let libraryPage: LibraryPage;
@@ -47,13 +47,18 @@ test.describe("Library", () => {
);
if (agentWithBuilder) {
await libraryPage.clickOpenInBuilder(agentWithBuilder);
await page.waitForURL("**/build**");
test.expect(page.url()).toContain(`/build`);
const [newPage] = await Promise.all([
page.context().waitForEvent("page"),
libraryPage.clickOpenInBuilder(agentWithBuilder),
]);
await newPage.waitForLoadState();
test.expect(newPage.url()).toContain(`/build`);
await newPage.close();
}
});
test("pagination works correctly", async ({ page }) => {
test("pagination works correctly", async ({ page }, testInfo) => {
test.setTimeout(testInfo.timeout * 3); // Increase timeout for pagination operations
await page.goto("/library");
const paginationResult = await libraryPage.testPagination();
@@ -80,6 +85,9 @@ test.describe("Library", () => {
const allAgents = await libraryPage.getAgents();
expect(allAgents.length).toBeGreaterThan(0);
const initialAgentCount = await libraryPage.getAgentCount();
expect(initialAgentCount).toBeGreaterThan(0);
const firstAgent = allAgents[0];
await libraryPage.searchAgents(firstAgent.name);
await libraryPage.waitForAgentsToLoad();
@@ -117,8 +125,8 @@ test.describe("Library", () => {
await libraryPage.clearSearch();
await libraryPage.waitForAgentsToLoad();
const clearedSearchResults = await libraryPage.getAgents();
test.expect(clearedSearchResults.length).toEqual(allAgents.length);
const clearedSearchCount = await libraryPage.getAgentCount();
test.expect(clearedSearchCount).toEqual(initialAgentCount);
const clearedSearchValue = await libraryPage.getSearchValue();
test.expect(clearedSearchValue).toBe("");
@@ -200,11 +208,14 @@ test.describe("Library", () => {
);
await fileInput.setInputFiles(testAgentPath);
await page.waitForTimeout(1000);
// Wait for file to be processed and upload button to be enabled
const uploadButton = page.getByRole("button", { name: "Upload" });
await uploadButton.waitFor({ state: "visible", timeout: 10000 });
await expect(uploadButton).toBeEnabled({ timeout: 10000 });
expect(await libraryPage.isUploadButtonEnabled()).toBeTruthy();
await page.getByRole("button", { name: "Upload Agent" }).click();
await page.getByRole("button", { name: "Upload" }).click();
await page.waitForURL("**/build**", { timeout: 10000 });
expect(page.url()).toContain("/build");
@@ -224,28 +235,10 @@ test.describe("Library", () => {
if (uploadedAgent) {
test.expect(uploadedAgent.name).toContain(testAgentName);
test.expect(uploadedAgent.description).toContain(testAgentDescription);
test.expect(uploadedAgent.seeRunsUrl).toBeTruthy();
test.expect(uploadedAgent.openInBuilderUrl).toBeTruthy();
await libraryPage.clickAgent(uploadedAgent);
await page.waitForURL(`**/library/agents/${uploadedAgent.id}**`, {
timeout: 10000,
});
await page.getByRole("button", { name: "Delete agent" }).click();
await page.waitForTimeout(500);
await page.getByRole("button", { name: "Delete" }).click();
await page.waitForTimeout(1000);
await libraryPage.navigateToLibrary();
await libraryPage.waitForAgentsToLoad();
await libraryPage.searchAgents(testAgentName);
await libraryPage.waitForAgentsToLoad();
const deletedSearchResults = await libraryPage.getAgentCount();
expect(deletedSearchResults).toBe(0);
}
await libraryPage.clearSearch();
await libraryPage.waitForAgentsToLoad();
});

View File

@@ -1,6 +1,6 @@
import { BasePage } from "./base.page";
import { Locator, Page } from "@playwright/test";
import { getSelectors } from "../utils/selectors";
import { BasePage } from "./base.page";
export interface Agent {
id: string;
@@ -109,7 +109,7 @@ export class LibraryPage extends BasePage {
async openUploadDialog(): Promise<void> {
console.log(`opening upload dialog`);
await this.page.getByRole("button", { name: "Upload an agent" }).click();
await this.page.getByRole("button", { name: "Upload agent" }).click();
// Wait for dialog to appear
await this.page.getByRole("dialog", { name: "Upload Agent" }).waitFor({
@@ -149,7 +149,7 @@ export class LibraryPage extends BasePage {
// Fill description
await this.page
.getByRole("textbox", { name: "Description" })
.getByRole("textbox", { name: "Agent description" })
.fill(description);
}
@@ -157,7 +157,7 @@ export class LibraryPage extends BasePage {
console.log(`checking if upload button is enabled`);
try {
const uploadButton = this.page.getByRole("button", {
name: "Upload Agent",
name: "Upload",
});
return await uploadButton.isEnabled();
} catch {
@@ -175,26 +175,32 @@ export class LibraryPage extends BasePage {
const agentCards = await getId("library-agent-card").all();
for (const card of agentCards) {
const name = await card.locator("h3").textContent();
const description = await card.locator("p").textContent();
const seeRunsLink = card.locator("a", { hasText: "See runs" });
const openInBuilderLink = card.locator("a", {
hasText: "Open in builder",
});
const name = await getId("library-agent-card-name", card).textContent();
const seeRunsLink = getId("library-agent-card-see-runs-link", card);
const openInBuilderLink = getId(
"library-agent-card-open-in-builder-link",
card,
);
const seeRunsUrl = await seeRunsLink.getAttribute("href");
const openInBuilderUrl = await openInBuilderLink.getAttribute("href");
if (name && description && seeRunsUrl && openInBuilderUrl) {
// Check if the "Open in builder" link exists before getting its href
const openInBuilderLinkCount = await openInBuilderLink.count();
const openInBuilderUrl =
openInBuilderLinkCount > 0
? await openInBuilderLink.getAttribute("href")
: null;
if (name && seeRunsUrl) {
const idMatch = seeRunsUrl.match(/\/library\/agents\/([^\/]+)/);
const id = idMatch ? idMatch[1] : "";
agents.push({
id,
name: name.trim(),
description: description.trim(),
description: "", // Description is not currently rendered in the card
seeRunsUrl,
openInBuilderUrl,
openInBuilderUrl: openInBuilderUrl || "",
});
}
}
@@ -204,28 +210,36 @@ export class LibraryPage extends BasePage {
}
async clickAgent(agent: Agent): Promise<void> {
await this.page
.getByRole("heading", { name: agent.name, level: 3 })
.first()
.click();
const { getId } = getSelectors(this.page);
const nameElement = getId("library-agent-card-name").filter({
hasText: agent.name,
});
await nameElement.first().click();
}
async clickSeeRuns(agent: Agent): Promise<void> {
console.log(`clicking see runs for agent: ${agent.name}`);
// Find the "See runs" link for this specific agent
const agentCard = this.page.locator(`[href="${agent.seeRunsUrl}"]`).first();
await agentCard.click();
const { getId } = getSelectors(this.page);
const agentCard = getId("library-agent-card").filter({
hasText: agent.name,
});
const seeRunsLink = getId("library-agent-card-see-runs-link", agentCard);
await seeRunsLink.first().click();
}
async clickOpenInBuilder(agent: Agent): Promise<void> {
console.log(`clicking open in builder for agent: ${agent.name}`);
// Find the "Open in builder" link for this specific agent
const builderLink = this.page
.locator(`[href="${agent.openInBuilderUrl}"]`)
.first();
await builderLink.click();
const { getId } = getSelectors(this.page);
const agentCard = getId("library-agent-card").filter({
hasText: agent.name,
});
const builderLink = getId(
"library-agent-card-open-in-builder-link",
agentCard,
);
await builderLink.first().click();
}
async waitForAgentsToLoad(): Promise<void> {
@@ -359,9 +373,9 @@ export class LibraryPage extends BasePage {
let previousCount = 0;
let currentCount = 0;
let stableChecks = 0;
const maxChecks = 10;
const maxChecks = 5; // Reduced from 10 to prevent excessive waiting
while (stableChecks < 3 && stableChecks < maxChecks) {
while (stableChecks < 2 && stableChecks < maxChecks) {
currentCount = await this.getAgentCount();
if (currentCount === previousCount) {
@@ -371,7 +385,10 @@ export class LibraryPage extends BasePage {
}
previousCount = currentCount;
await this.page.waitForTimeout(500);
if (stableChecks < 2) {
// Only wait if we haven't stabilized yet
await this.page.waitForTimeout(500);
}
}
console.log(`Pagination load stabilized with ${currentCount} agents`);