mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-13 17:18:08 -05:00
Compare commits
9 Commits
swiftyos/f
...
fix/execut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fc2e3bbd8 | ||
|
|
3b6dc48033 | ||
|
|
3cab0c1240 | ||
|
|
2416975c30 | ||
|
|
bb8aab7bd4 | ||
|
|
a04b891e1c | ||
|
|
a304332bea | ||
|
|
01cfac9d5a | ||
|
|
f482eb668b |
@@ -28,6 +28,7 @@ from backend.executor.manager import get_db_async_client
|
||||
from backend.util.settings import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = Settings()
|
||||
|
||||
|
||||
class ExecutionAnalyticsRequest(BaseModel):
|
||||
@@ -63,6 +64,8 @@ class ExecutionAnalyticsResult(BaseModel):
|
||||
score: Optional[float]
|
||||
status: str # "success", "failed", "skipped"
|
||||
error_message: Optional[str] = None
|
||||
started_at: Optional[datetime] = None
|
||||
ended_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class ExecutionAnalyticsResponse(BaseModel):
|
||||
@@ -224,11 +227,6 @@ async def generate_execution_analytics(
|
||||
)
|
||||
|
||||
try:
|
||||
# Validate model configuration
|
||||
settings = Settings()
|
||||
if not settings.secrets.openai_internal_api_key:
|
||||
raise HTTPException(status_code=500, detail="OpenAI API key not configured")
|
||||
|
||||
# Get database client
|
||||
db_client = get_db_async_client()
|
||||
|
||||
@@ -320,6 +318,8 @@ async def generate_execution_analytics(
|
||||
),
|
||||
status="skipped",
|
||||
error_message=None, # Not an error - just already processed
|
||||
started_at=execution.started_at,
|
||||
ended_at=execution.ended_at,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -349,6 +349,9 @@ async def _process_batch(
|
||||
) -> list[ExecutionAnalyticsResult]:
|
||||
"""Process a batch of executions concurrently."""
|
||||
|
||||
if not settings.secrets.openai_internal_api_key:
|
||||
raise HTTPException(status_code=500, detail="OpenAI API key not configured")
|
||||
|
||||
async def process_single_execution(execution) -> ExecutionAnalyticsResult:
|
||||
try:
|
||||
# Generate activity status and score using the specified model
|
||||
@@ -387,6 +390,8 @@ async def _process_batch(
|
||||
score=None,
|
||||
status="skipped",
|
||||
error_message="Activity generation returned None",
|
||||
started_at=execution.started_at,
|
||||
ended_at=execution.ended_at,
|
||||
)
|
||||
|
||||
# Update the execution stats
|
||||
@@ -416,6 +421,8 @@ async def _process_batch(
|
||||
summary_text=activity_response["activity_status"],
|
||||
score=activity_response["correctness_score"],
|
||||
status="success",
|
||||
started_at=execution.started_at,
|
||||
ended_at=execution.ended_at,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -429,6 +436,8 @@ async def _process_batch(
|
||||
score=None,
|
||||
status="failed",
|
||||
error_message=str(e),
|
||||
started_at=execution.started_at,
|
||||
ended_at=execution.ended_at,
|
||||
)
|
||||
|
||||
# Process all executions in the batch concurrently
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -50,13 +49,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=ProfileCreateInput(
|
||||
userId=user.id,
|
||||
username=username,
|
||||
name=f"Test User {username}",
|
||||
description="Test user profile",
|
||||
links=[], # Required field - empty array for test profiles
|
||||
)
|
||||
data={
|
||||
"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
|
||||
@@ -173,13 +172,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=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
|
||||
)
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
# 2. Create test OpenAI credentials for the user
|
||||
@@ -333,13 +332,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=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
|
||||
)
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
# NOTE: We deliberately do NOT create Firecrawl credentials for this user
|
||||
|
||||
@@ -817,16 +817,18 @@ async def add_store_agent_to_library(
|
||||
|
||||
# Create LibraryAgent entry
|
||||
added_agent = await prisma.models.LibraryAgent.prisma().create(
|
||||
data=prisma.types.LibraryAgentCreateInput(
|
||||
User={"connect": {"id": user_id}},
|
||||
AgentGraph={
|
||||
data={
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -27,13 +27,6 @@ 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
|
||||
|
||||
@@ -55,11 +48,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=UserCreateInput(
|
||||
id=test_user_id,
|
||||
email=f"oauth-test-{test_user_id}@example.com",
|
||||
name="OAuth Test User",
|
||||
)
|
||||
data={
|
||||
"id": test_user_id,
|
||||
"email": f"oauth-test-{test_user_id}@example.com",
|
||||
"name": "OAuth Test User",
|
||||
}
|
||||
)
|
||||
|
||||
yield test_user_id
|
||||
@@ -84,22 +77,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=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=[
|
||||
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": [
|
||||
"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 {
|
||||
@@ -303,19 +296,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=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!
|
||||
)
|
||||
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!
|
||||
}
|
||||
)
|
||||
|
||||
yield {
|
||||
@@ -706,14 +699,14 @@ async def test_token_authorization_code_expired(
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
await PrismaOAuthAuthorizationCode.prisma().create(
|
||||
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
|
||||
)
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
@@ -949,13 +942,13 @@ async def test_token_refresh_expired(
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
await PrismaOAuthRefreshToken.prisma().create(
|
||||
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
|
||||
)
|
||||
data={
|
||||
"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(
|
||||
@@ -987,14 +980,14 @@ async def test_token_refresh_revoked(
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
await PrismaOAuthRefreshToken.prisma().create(
|
||||
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
|
||||
)
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
@@ -1020,19 +1013,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=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,
|
||||
)
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
yield {
|
||||
@@ -1059,13 +1052,13 @@ async def test_token_refresh_wrong_application(
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
await PrismaOAuthRefreshToken.prisma().create(
|
||||
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),
|
||||
)
|
||||
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),
|
||||
}
|
||||
)
|
||||
|
||||
# Try to use it with `other_oauth_app`
|
||||
@@ -1274,19 +1267,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=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,
|
||||
)
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
# Create an access token directly in the database
|
||||
@@ -1295,13 +1288,13 @@ async def test_validate_access_token_fails_when_app_disabled(
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
await PrismaOAuthAccessToken.prisma().create(
|
||||
data=OAuthAccessTokenCreateInput(
|
||||
token=token_hash,
|
||||
applicationId=app_id,
|
||||
userId=test_user,
|
||||
scopes=[APIKeyPermission.EXECUTE_GRAPH],
|
||||
expiresAt=now + timedelta(hours=1),
|
||||
)
|
||||
data={
|
||||
"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
|
||||
@@ -1568,19 +1561,19 @@ async def test_revoke_token_from_different_app_fails_silently(
|
||||
)
|
||||
|
||||
await PrismaOAuthApplication.prisma().create(
|
||||
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,
|
||||
)
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
# App 2 tries to revoke App 1's access token
|
||||
|
||||
@@ -249,9 +249,7 @@ 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=prisma.types.SearchTermsCreateInput(
|
||||
searchTerm=search_query, createdDate=date
|
||||
)
|
||||
data={"searchTerm": search_query, "createdDate": date}
|
||||
)
|
||||
except Exception as e:
|
||||
# Fail silently here so that logging search terms doesn't break the app
|
||||
@@ -1458,9 +1456,11 @@ 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, next_version, listing.id
|
||||
)
|
||||
data={
|
||||
**_create_sub_agent_version_data(sub_graph, heading, main_agent_name),
|
||||
"version": next_version,
|
||||
"storeListingId": listing.id,
|
||||
}
|
||||
)
|
||||
await prisma.models.StoreListing.prisma(tx).update(
|
||||
where={"id": listing.id}, data={"hasApprovedVersion": True}
|
||||
@@ -1468,14 +1468,10 @@ async def _approve_sub_agent(
|
||||
|
||||
|
||||
def _create_sub_agent_version_data(
|
||||
sub_graph: prisma.models.AgentGraph,
|
||||
heading: str,
|
||||
main_agent_name: str,
|
||||
version: typing.Optional[int] = None,
|
||||
store_listing_id: typing.Optional[str] = None,
|
||||
sub_graph: prisma.models.AgentGraph, heading: str, main_agent_name: str
|
||||
) -> prisma.types.StoreListingVersionCreateInput:
|
||||
"""Create store listing version data for a sub-agent"""
|
||||
data = prisma.types.StoreListingVersionCreateInput(
|
||||
return prisma.types.StoreListingVersionCreateInput(
|
||||
agentGraphId=sub_graph.id,
|
||||
agentGraphVersion=sub_graph.version,
|
||||
name=sub_graph.name or heading,
|
||||
@@ -1490,11 +1486,6 @@ 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(
|
||||
|
||||
@@ -495,8 +495,14 @@ class SmartDecisionMakerBlock(Block):
|
||||
}
|
||||
|
||||
properties = {}
|
||||
field_mapping = {}
|
||||
|
||||
for link in links:
|
||||
field_name = link.sink_name
|
||||
|
||||
clean_field_name = SmartDecisionMakerBlock.cleanup(field_name)
|
||||
field_mapping[clean_field_name] = field_name
|
||||
|
||||
sink_block_input_schema = sink_node.input_default["input_schema"]
|
||||
sink_block_properties = sink_block_input_schema.get("properties", {}).get(
|
||||
link.sink_name, {}
|
||||
@@ -506,7 +512,7 @@ class SmartDecisionMakerBlock(Block):
|
||||
if "description" in sink_block_properties
|
||||
else f"The {link.sink_name} of the tool"
|
||||
)
|
||||
properties[link.sink_name] = {
|
||||
properties[clean_field_name] = {
|
||||
"type": "string",
|
||||
"description": description,
|
||||
"default": json.dumps(sink_block_properties.get("default", None)),
|
||||
@@ -519,7 +525,7 @@ class SmartDecisionMakerBlock(Block):
|
||||
"strict": True,
|
||||
}
|
||||
|
||||
# Store node info for later use in output processing
|
||||
tool_function["_field_mapping"] = field_mapping
|
||||
tool_function["_sink_node_id"] = sink_node.id
|
||||
|
||||
return {"type": "function", "function": tool_function}
|
||||
@@ -1129,8 +1135,9 @@ class SmartDecisionMakerBlock(Block):
|
||||
original_field_name = field_mapping.get(clean_arg_name, clean_arg_name)
|
||||
arg_value = tool_args.get(clean_arg_name)
|
||||
|
||||
sanitized_arg_name = self.cleanup(original_field_name)
|
||||
emit_key = f"tools_^_{sink_node_id}_~_{sanitized_arg_name}"
|
||||
# Use original_field_name directly (not sanitized) to match link sink_name
|
||||
# The field_mapping already translates from LLM's cleaned names to original names
|
||||
emit_key = f"tools_^_{sink_node_id}_~_{original_field_name}"
|
||||
|
||||
logger.debug(
|
||||
"[SmartDecisionMakerBlock|geid:%s|neid:%s] emit %s",
|
||||
|
||||
@@ -42,7 +42,6 @@ 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()
|
||||
|
||||
@@ -148,7 +147,7 @@ def format_sql_insert(creds: dict) -> str:
|
||||
|
||||
sql = f"""
|
||||
-- ============================================================
|
||||
-- OAuth Application: {creds["name"]}
|
||||
-- OAuth Application: {creds['name']}
|
||||
-- Generated: {now_iso} UTC
|
||||
-- ============================================================
|
||||
|
||||
@@ -168,14 +167,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"[],
|
||||
@@ -187,8 +186,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.
|
||||
@@ -201,7 +200,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
|
||||
|
||||
@@ -835,19 +834,19 @@ async def create_test_app_in_db(
|
||||
|
||||
# Insert into database
|
||||
app = await OAuthApplication.prisma().create(
|
||||
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,
|
||||
)
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
click.echo(f"✓ Created test OAuth application: {app.clientId}")
|
||||
|
||||
@@ -104,7 +104,7 @@ async def get_accuracy_trends_and_alerts(
|
||||
AND e."executionStatus" IN ('COMPLETED', 'FAILED', 'TERMINATED')
|
||||
{user_filter}
|
||||
GROUP BY DATE(e."createdAt")
|
||||
HAVING COUNT(*) >= 3 -- Need at least 3 executions per day
|
||||
HAVING COUNT(*) >= 1 -- Include all days with at least 1 execution
|
||||
),
|
||||
trends AS (
|
||||
SELECT
|
||||
|
||||
@@ -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 APIKeyCreateInput, APIKeyWhereUniqueInput
|
||||
from prisma.types import 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=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,
|
||||
)
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
return APIKeyInfo.from_db(saved_key_obj), generated_key.key
|
||||
|
||||
@@ -22,12 +22,7 @@ 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 (
|
||||
OAuthAccessTokenCreateInput,
|
||||
OAuthApplicationUpdateInput,
|
||||
OAuthAuthorizationCodeCreateInput,
|
||||
OAuthRefreshTokenCreateInput,
|
||||
)
|
||||
from prisma.types import OAuthApplicationUpdateInput
|
||||
from pydantic import BaseModel, Field, SecretStr
|
||||
|
||||
from .base import APIAuthorizationInfo
|
||||
@@ -364,17 +359,17 @@ async def create_authorization_code(
|
||||
expires_at = now + AUTHORIZATION_CODE_TTL
|
||||
|
||||
saved_code = await PrismaOAuthAuthorizationCode.prisma().create(
|
||||
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,
|
||||
)
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
return OAuthAuthorizationCodeInfo.from_db(saved_code)
|
||||
@@ -495,14 +490,14 @@ async def create_access_token(
|
||||
expires_at = now + ACCESS_TOKEN_TTL
|
||||
|
||||
saved_token = await PrismaOAuthAccessToken.prisma().create(
|
||||
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],
|
||||
)
|
||||
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],
|
||||
}
|
||||
)
|
||||
|
||||
return OAuthAccessToken.from_db(saved_token, plaintext_token=plaintext_token)
|
||||
@@ -612,14 +607,14 @@ async def create_refresh_token(
|
||||
expires_at = now + REFRESH_TOKEN_TTL
|
||||
|
||||
saved_token = await PrismaOAuthRefreshToken.prisma().create(
|
||||
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],
|
||||
)
|
||||
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],
|
||||
}
|
||||
)
|
||||
|
||||
return OAuthRefreshToken.from_db(saved_token, plaintext_token=plaintext_token)
|
||||
|
||||
@@ -11,7 +11,6 @@ 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
|
||||
@@ -22,11 +21,11 @@ async def create_test_user(user_id: str) -> None:
|
||||
"""Create a test user for ceiling tests."""
|
||||
try:
|
||||
await User.prisma().create(
|
||||
data=UserCreateInput(
|
||||
id=user_id,
|
||||
email=f"test-{user_id}@example.com",
|
||||
name=f"Test User {user_id[:8]}",
|
||||
)
|
||||
data={
|
||||
"id": user_id,
|
||||
"email": f"test-{user_id}@example.com",
|
||||
"name": f"Test User {user_id[:8]}",
|
||||
}
|
||||
)
|
||||
except UniqueViolationError:
|
||||
# User already exists, continue
|
||||
@@ -34,10 +33,7 @@ async def create_test_user(user_id: str) -> None:
|
||||
|
||||
await UserBalance.prisma().upsert(
|
||||
where={"userId": user_id},
|
||||
data=UserBalanceUpsertInput(
|
||||
create=UserBalanceCreateInput(userId=user_id, balance=0),
|
||||
update={"balance": 0},
|
||||
),
|
||||
data={"create": {"userId": user_id, "balance": 0}, "update": {"balance": 0}},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ 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
|
||||
@@ -29,11 +28,11 @@ async def create_test_user(user_id: str) -> None:
|
||||
"""Create a test user with initial balance."""
|
||||
try:
|
||||
await User.prisma().create(
|
||||
data=UserCreateInput(
|
||||
id=user_id,
|
||||
email=f"test-{user_id}@example.com",
|
||||
name=f"Test User {user_id[:8]}",
|
||||
)
|
||||
data={
|
||||
"id": user_id,
|
||||
"email": f"test-{user_id}@example.com",
|
||||
"name": f"Test User {user_id[:8]}",
|
||||
}
|
||||
)
|
||||
except UniqueViolationError:
|
||||
# User already exists, continue
|
||||
@@ -42,10 +41,7 @@ async def create_test_user(user_id: str) -> None:
|
||||
# Ensure UserBalance record exists
|
||||
await UserBalance.prisma().upsert(
|
||||
where={"userId": user_id},
|
||||
data=UserBalanceUpsertInput(
|
||||
create=UserBalanceCreateInput(userId=user_id, balance=0),
|
||||
update={"balance": 0},
|
||||
),
|
||||
data={"create": {"userId": user_id, "balance": 0}, "update": {"balance": 0}},
|
||||
)
|
||||
|
||||
|
||||
@@ -346,10 +342,10 @@ async def test_integer_overflow_protection(server: SpinTestServer):
|
||||
# First, set balance near max
|
||||
await UserBalance.prisma().upsert(
|
||||
where={"userId": user_id},
|
||||
data=UserBalanceUpsertInput(
|
||||
create=UserBalanceCreateInput(userId=user_id, balance=max_int - 100),
|
||||
update={"balance": max_int - 100},
|
||||
),
|
||||
data={
|
||||
"create": {"userId": user_id, "balance": max_int - 100},
|
||||
"update": {"balance": max_int - 100},
|
||||
},
|
||||
)
|
||||
|
||||
# Try to add more than possible - should clamp to POSTGRES_INT_MAX
|
||||
@@ -511,7 +507,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 = []
|
||||
@@ -550,7 +546,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)
|
||||
@@ -711,7 +707,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
|
||||
|
||||
@@ -8,7 +8,6 @@ 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,
|
||||
@@ -30,12 +29,12 @@ async def cleanup_test_user():
|
||||
# Create the user first
|
||||
try:
|
||||
await User.prisma().create(
|
||||
data=UserCreateInput(
|
||||
id=user_id,
|
||||
email=f"test-{user_id}@example.com",
|
||||
topUpConfig=SafeJson({}),
|
||||
timezone="UTC",
|
||||
)
|
||||
data={
|
||||
"id": user_id,
|
||||
"email": f"test-{user_id}@example.com",
|
||||
"topUpConfig": SafeJson({}),
|
||||
"timezone": "UTC",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
# User might already exist, that's fine
|
||||
|
||||
@@ -12,12 +12,6 @@ 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
|
||||
@@ -41,32 +35,32 @@ async def setup_test_user_with_topup():
|
||||
|
||||
# Create user
|
||||
await User.prisma().create(
|
||||
data=UserCreateInput(
|
||||
id=REFUND_TEST_USER_ID,
|
||||
email=f"{REFUND_TEST_USER_ID}@example.com",
|
||||
name="Refund Test User",
|
||||
)
|
||||
data={
|
||||
"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=UserBalanceCreateInput(
|
||||
userId=REFUND_TEST_USER_ID,
|
||||
balance=1000, # $10
|
||||
)
|
||||
data={
|
||||
"userId": REFUND_TEST_USER_ID,
|
||||
"balance": 1000, # $10
|
||||
}
|
||||
)
|
||||
|
||||
# Create a top-up transaction that can be refunded
|
||||
topup_tx = await CreditTransaction.prisma().create(
|
||||
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"}),
|
||||
)
|
||||
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"}),
|
||||
}
|
||||
)
|
||||
|
||||
return topup_tx
|
||||
@@ -99,12 +93,12 @@ async def test_deduct_credits_atomic(server: SpinTestServer):
|
||||
|
||||
# Create refund request record (simulating webhook flow)
|
||||
await CreditRefundRequest.prisma().create(
|
||||
data=CreditRefundRequestCreateInput(
|
||||
userId=REFUND_TEST_USER_ID,
|
||||
amount=500,
|
||||
transactionKey=topup_tx.transactionKey, # Should match the original transaction
|
||||
reason="Test refund",
|
||||
)
|
||||
data={
|
||||
"userId": REFUND_TEST_USER_ID,
|
||||
"amount": 500,
|
||||
"transactionKey": topup_tx.transactionKey, # Should match the original transaction
|
||||
"reason": "Test refund",
|
||||
}
|
||||
)
|
||||
|
||||
# Call deduct_credits
|
||||
@@ -292,12 +286,12 @@ async def test_concurrent_refunds(server: SpinTestServer):
|
||||
refund_requests = []
|
||||
for i in range(5):
|
||||
req = await CreditRefundRequest.prisma().create(
|
||||
data=CreditRefundRequestCreateInput(
|
||||
userId=REFUND_TEST_USER_ID,
|
||||
amount=100, # $1 each
|
||||
transactionKey=topup_tx.transactionKey,
|
||||
reason=f"Test refund {i}",
|
||||
)
|
||||
data={
|
||||
"userId": REFUND_TEST_USER_ID,
|
||||
"amount": 100, # $1 each
|
||||
"transactionKey": topup_tx.transactionKey,
|
||||
"reason": f"Test refund {i}",
|
||||
}
|
||||
)
|
||||
refund_requests.append(req)
|
||||
|
||||
|
||||
@@ -3,11 +3,6 @@ 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
|
||||
@@ -28,10 +23,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=UserBalanceUpsertInput(
|
||||
create=UserBalanceCreateInput(userId=DEFAULT_USER_ID, balance=0),
|
||||
update={"balance": 0, "updatedAt": old_date},
|
||||
),
|
||||
data={
|
||||
"create": {"userId": DEFAULT_USER_ID, "balance": 0},
|
||||
"update": {"balance": 0, "updatedAt": old_date},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -145,23 +140,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=CreditTransactionCreateInput(
|
||||
userId=DEFAULT_USER_ID,
|
||||
amount=100,
|
||||
type=CreditTransactionType.TOP_UP,
|
||||
runningBalance=1100,
|
||||
isActive=True,
|
||||
createdAt=month1, # Set specific timestamp
|
||||
)
|
||||
data={
|
||||
"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=UserBalanceUpsertInput(
|
||||
create=UserBalanceCreateInput(userId=DEFAULT_USER_ID, balance=1100),
|
||||
update={"balance": 1100},
|
||||
),
|
||||
data={
|
||||
"create": {"userId": DEFAULT_USER_ID, "balance": 1100},
|
||||
"update": {"balance": 1100},
|
||||
},
|
||||
)
|
||||
|
||||
# Now test month 2 behavior
|
||||
@@ -180,14 +175,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=CreditTransactionCreateInput(
|
||||
userId=DEFAULT_USER_ID,
|
||||
amount=-700, # Spent 700 to get to 400
|
||||
type=CreditTransactionType.USAGE,
|
||||
runningBalance=400,
|
||||
isActive=True,
|
||||
createdAt=month2,
|
||||
)
|
||||
data={
|
||||
"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
|
||||
|
||||
@@ -12,7 +12,6 @@ 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
|
||||
@@ -22,11 +21,11 @@ async def create_test_user(user_id: str) -> None:
|
||||
"""Create a test user for underflow tests."""
|
||||
try:
|
||||
await User.prisma().create(
|
||||
data=UserCreateInput(
|
||||
id=user_id,
|
||||
email=f"test-{user_id}@example.com",
|
||||
name=f"Test User {user_id[:8]}",
|
||||
)
|
||||
data={
|
||||
"id": user_id,
|
||||
"email": f"test-{user_id}@example.com",
|
||||
"name": f"Test User {user_id[:8]}",
|
||||
}
|
||||
)
|
||||
except UniqueViolationError:
|
||||
# User already exists, continue
|
||||
@@ -34,10 +33,7 @@ async def create_test_user(user_id: str) -> None:
|
||||
|
||||
await UserBalance.prisma().upsert(
|
||||
where={"userId": user_id},
|
||||
data=UserBalanceUpsertInput(
|
||||
create=UserBalanceCreateInput(userId=user_id, balance=0),
|
||||
update={"balance": 0},
|
||||
),
|
||||
data={"create": {"userId": user_id, "balance": 0}, "update": {"balance": 0}},
|
||||
)
|
||||
|
||||
|
||||
@@ -70,14 +66,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=UserBalanceUpsertInput(
|
||||
create=UserBalanceCreateInput(
|
||||
userId=user_id, balance=initial_balance_target
|
||||
),
|
||||
update={"balance": initial_balance_target},
|
||||
),
|
||||
data={
|
||||
"create": {"userId": user_id, "balance": initial_balance_target},
|
||||
"update": {"balance": initial_balance_target},
|
||||
},
|
||||
)
|
||||
|
||||
current_balance = await credit_system.get_credits(user_id)
|
||||
@@ -114,10 +110,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=UserBalanceUpsertInput(
|
||||
create=UserBalanceCreateInput(userId=user_id, balance=POSTGRES_INT_MIN),
|
||||
update={"balance": POSTGRES_INT_MIN},
|
||||
),
|
||||
data={
|
||||
"create": {"userId": user_id, "balance": POSTGRES_INT_MIN},
|
||||
"update": {"balance": POSTGRES_INT_MIN},
|
||||
},
|
||||
)
|
||||
|
||||
edge_balance = await credit_system.get_credits(user_id)
|
||||
@@ -151,13 +147,15 @@ 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=UserBalanceUpsertInput(
|
||||
create=UserBalanceCreateInput(userId=user_id, balance=test_balance),
|
||||
update={"balance": test_balance},
|
||||
),
|
||||
data={
|
||||
"create": {"userId": user_id, "balance": test_balance},
|
||||
"update": {"balance": test_balance},
|
||||
},
|
||||
)
|
||||
|
||||
current_balance = await credit_system.get_credits(user_id)
|
||||
@@ -214,13 +212,15 @@ 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=UserBalanceUpsertInput(
|
||||
create=UserBalanceCreateInput(userId=user_id, balance=initial_balance),
|
||||
update={"balance": initial_balance},
|
||||
),
|
||||
data={
|
||||
"create": {"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=UserBalanceUpsertInput(
|
||||
create=UserBalanceCreateInput(userId=user_id, balance=initial_balance),
|
||||
update={"balance": initial_balance},
|
||||
),
|
||||
data={
|
||||
"create": {"userId": user_id, "balance": initial_balance},
|
||||
"update": {"balance": initial_balance},
|
||||
},
|
||||
)
|
||||
|
||||
async def large_refund(amount: int, label: str):
|
||||
|
||||
@@ -14,7 +14,6 @@ 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
|
||||
@@ -25,11 +24,11 @@ async def create_test_user(user_id: str) -> None:
|
||||
"""Create a test user for migration tests."""
|
||||
try:
|
||||
await User.prisma().create(
|
||||
data=UserCreateInput(
|
||||
id=user_id,
|
||||
email=f"test-{user_id}@example.com",
|
||||
name=f"Test User {user_id[:8]}",
|
||||
)
|
||||
data={
|
||||
"id": user_id,
|
||||
"email": f"test-{user_id}@example.com",
|
||||
"name": f"Test User {user_id[:8]}",
|
||||
}
|
||||
)
|
||||
except UniqueViolationError:
|
||||
# User already exists, continue
|
||||
@@ -122,7 +121,7 @@ async def test_detect_stale_user_balance_queries(server: SpinTestServer):
|
||||
try:
|
||||
# Create UserBalance with specific value
|
||||
await UserBalance.prisma().create(
|
||||
data=UserBalanceCreateInput(userId=user_id, balance=5000) # $50
|
||||
data={"userId": user_id, "balance": 5000} # $50
|
||||
)
|
||||
|
||||
# Verify that get_credits returns UserBalance value (5000), not any stale User.balance value
|
||||
@@ -161,9 +160,7 @@ async def test_concurrent_operations_use_userbalance_only(server: SpinTestServer
|
||||
|
||||
try:
|
||||
# Set initial balance in UserBalance
|
||||
await UserBalance.prisma().create(
|
||||
data=UserBalanceCreateInput(userId=user_id, balance=1000)
|
||||
)
|
||||
await UserBalance.prisma().create(data={"userId": user_id, "balance": 1000})
|
||||
|
||||
# Run concurrent operations to ensure they all use UserBalance atomic operations
|
||||
async def concurrent_spend(amount: int, label: str):
|
||||
|
||||
@@ -28,7 +28,6 @@ from prisma.models import (
|
||||
AgentNodeExecutionKeyValueData,
|
||||
)
|
||||
from prisma.types import (
|
||||
AgentGraphExecutionCreateInput,
|
||||
AgentGraphExecutionUpdateManyMutationInput,
|
||||
AgentGraphExecutionWhereInput,
|
||||
AgentNodeExecutionCreateInput,
|
||||
@@ -36,6 +35,7 @@ from prisma.types import (
|
||||
AgentNodeExecutionKeyValueDataCreateInput,
|
||||
AgentNodeExecutionUpdateInput,
|
||||
AgentNodeExecutionWhereInput,
|
||||
AgentNodeExecutionWhereUniqueInput,
|
||||
)
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue, ValidationError
|
||||
from pydantic.fields import Field
|
||||
@@ -153,8 +153,14 @@ class GraphExecutionMeta(BaseDbModel):
|
||||
nodes_input_masks: Optional[dict[str, BlockInput]]
|
||||
preset_id: Optional[str]
|
||||
status: ExecutionStatus
|
||||
started_at: datetime
|
||||
ended_at: datetime
|
||||
started_at: Optional[datetime] = Field(
|
||||
None,
|
||||
description="When execution started running. Null if not yet started (QUEUED).",
|
||||
)
|
||||
ended_at: Optional[datetime] = Field(
|
||||
None,
|
||||
description="When execution finished. Null if not yet completed (QUEUED, RUNNING, INCOMPLETE, REVIEW).",
|
||||
)
|
||||
is_shared: bool = False
|
||||
share_token: Optional[str] = None
|
||||
|
||||
@@ -229,10 +235,8 @@ class GraphExecutionMeta(BaseDbModel):
|
||||
|
||||
@staticmethod
|
||||
def from_db(_graph_exec: AgentGraphExecution):
|
||||
now = datetime.now(timezone.utc)
|
||||
# TODO: make started_at and ended_at optional
|
||||
start_time = _graph_exec.startedAt or _graph_exec.createdAt
|
||||
end_time = _graph_exec.updatedAt or now
|
||||
start_time = _graph_exec.startedAt
|
||||
end_time = _graph_exec.endedAt
|
||||
|
||||
try:
|
||||
stats = GraphExecutionStats.model_validate(_graph_exec.stats)
|
||||
@@ -709,18 +713,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=AgentGraphExecutionCreateInput(
|
||||
agentGraphId=graph_id,
|
||||
agentGraphVersion=graph_version,
|
||||
executionStatus=ExecutionStatus.INCOMPLETE,
|
||||
inputs=SafeJson(inputs),
|
||||
credentialInputs=(
|
||||
data={
|
||||
"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 +740,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 +835,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)
|
||||
@@ -900,6 +904,14 @@ async def update_graph_execution_stats(
|
||||
|
||||
if status:
|
||||
update_data["executionStatus"] = status
|
||||
# Set endedAt when execution reaches a terminal status
|
||||
terminal_statuses = [
|
||||
ExecutionStatus.COMPLETED,
|
||||
ExecutionStatus.FAILED,
|
||||
ExecutionStatus.TERMINATED,
|
||||
]
|
||||
if status in terminal_statuses:
|
||||
update_data["endedAt"] = datetime.now(tz=timezone.utc)
|
||||
|
||||
where_clause: AgentGraphExecutionWhereInput = {"id": graph_exec_id}
|
||||
|
||||
@@ -964,12 +976,6 @@ 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.")
|
||||
|
||||
@@ -980,27 +986,25 @@ async def update_node_execution_status(
|
||||
f"Invalid status transition: {status} has no valid source statuses"
|
||||
)
|
||||
|
||||
# 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},
|
||||
if res := await AgentNodeExecution.prisma().update(
|
||||
where=cast(
|
||||
AgentNodeExecutionWhereUniqueInput,
|
||||
{
|
||||
"id": node_exec_id,
|
||||
"executionStatus": {"in": [s.value for s in allowed_from]},
|
||||
},
|
||||
),
|
||||
data=_get_update_status_data(status, execution_data, stats),
|
||||
include=EXECUTION_RESULT_INCLUDE,
|
||||
)
|
||||
if not res:
|
||||
raise ValueError(f"Failed to update execution {node_exec_id}.")
|
||||
return NodeExecutionResult.from_db(res)
|
||||
):
|
||||
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.")
|
||||
|
||||
|
||||
def _get_update_status_data(
|
||||
|
||||
@@ -10,11 +10,7 @@ from typing import Optional
|
||||
|
||||
from prisma.enums import ReviewStatus
|
||||
from prisma.models import PendingHumanReview
|
||||
from prisma.types import (
|
||||
PendingHumanReviewCreateInput,
|
||||
PendingHumanReviewUpdateInput,
|
||||
PendingHumanReviewUpsertInput,
|
||||
)
|
||||
from prisma.types import PendingHumanReviewUpdateInput
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.api.features.executions.review.model import (
|
||||
@@ -70,20 +66,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=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
|
||||
),
|
||||
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
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -7,11 +7,7 @@ import prisma
|
||||
import pydantic
|
||||
from prisma.enums import OnboardingStep
|
||||
from prisma.models import UserOnboarding
|
||||
from prisma.types import (
|
||||
UserOnboardingCreateInput,
|
||||
UserOnboardingUpdateInput,
|
||||
UserOnboardingUpsertInput,
|
||||
)
|
||||
from prisma.types import UserOnboardingCreateInput, UserOnboardingUpdateInput
|
||||
|
||||
from backend.api.features.store.model import StoreAgentDetails
|
||||
from backend.api.model import OnboardingNotificationPayload
|
||||
@@ -96,7 +92,6 @@ 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
|
||||
@@ -115,14 +110,12 @@ 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=UserOnboardingUpsertInput(
|
||||
create=UserOnboardingCreateInput(userId=user_id),
|
||||
update=update,
|
||||
),
|
||||
data={
|
||||
"create": {"userId": user_id, **update},
|
||||
"update": update,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -60,8 +60,10 @@ class LateExecutionMonitor:
|
||||
if not all_late_executions:
|
||||
return "No late executions detected."
|
||||
|
||||
# Sort by created time (oldest first)
|
||||
all_late_executions.sort(key=lambda x: x.started_at)
|
||||
# Sort by started time (oldest first), with None values (unstarted) first
|
||||
all_late_executions.sort(
|
||||
key=lambda x: x.started_at or datetime.min.replace(tzinfo=timezone.utc)
|
||||
)
|
||||
|
||||
num_total_late = len(all_late_executions)
|
||||
num_queued = len(queued_late_executions)
|
||||
@@ -74,7 +76,7 @@ class LateExecutionMonitor:
|
||||
was_truncated = num_total_late > tuncate_size
|
||||
|
||||
late_execution_details = [
|
||||
f"* `Execution ID: {exec.id}, Graph ID: {exec.graph_id}v{exec.graph_version}, User ID: {exec.user_id}, Status: {exec.status}, Created At: {exec.started_at.isoformat()}`"
|
||||
f"* `Execution ID: {exec.id}, Graph ID: {exec.graph_id}v{exec.graph_version}, User ID: {exec.user_id}, Status: {exec.status}, Started At: {exec.started_at.isoformat() if exec.started_at else 'Not started'}`"
|
||||
for exec in truncated_executions
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "AgentGraphExecution" ADD COLUMN "endedAt" TIMESTAMP(3);
|
||||
|
||||
-- Set endedAt to updatedAt for existing records with terminal status only
|
||||
UPDATE "AgentGraphExecution"
|
||||
SET "endedAt" = "updatedAt"
|
||||
WHERE "endedAt" IS NULL
|
||||
AND "executionStatus" IN ('COMPLETED', 'FAILED', 'TERMINATED');
|
||||
@@ -383,6 +383,7 @@ model AgentGraphExecution {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
startedAt DateTime?
|
||||
endedAt DateTime?
|
||||
|
||||
isDeleted Boolean @default(false)
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ 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
|
||||
@@ -180,12 +179,12 @@ class TestDataCreator:
|
||||
for block in blocks_to_create:
|
||||
try:
|
||||
await prisma.agentblock.create(
|
||||
data=AgentBlockCreateInput(
|
||||
id=block.id,
|
||||
name=block.name,
|
||||
inputSchema="{}",
|
||||
outputSchema="{}",
|
||||
)
|
||||
data={
|
||||
"id": block.id,
|
||||
"name": block.name,
|
||||
"inputSchema": "{}",
|
||||
"outputSchema": "{}",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error creating block {block.name}: {e}")
|
||||
|
||||
@@ -30,19 +30,13 @@ from prisma.types import (
|
||||
AgentGraphCreateInput,
|
||||
AgentNodeCreateInput,
|
||||
AgentNodeLinkCreateInput,
|
||||
AgentPresetCreateInput,
|
||||
AnalyticsDetailsCreateInput,
|
||||
AnalyticsMetricsCreateInput,
|
||||
APIKeyCreateInput,
|
||||
CreditTransactionCreateInput,
|
||||
IntegrationWebhookCreateInput,
|
||||
LibraryAgentCreateInput,
|
||||
ProfileCreateInput,
|
||||
StoreListingCreateInput,
|
||||
StoreListingReviewCreateInput,
|
||||
StoreListingVersionCreateInput,
|
||||
UserCreateInput,
|
||||
UserOnboardingCreateInput,
|
||||
)
|
||||
|
||||
faker = Faker()
|
||||
@@ -178,14 +172,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=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,
|
||||
)
|
||||
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,
|
||||
}
|
||||
)
|
||||
agent_presets.append(preset)
|
||||
|
||||
@@ -226,18 +220,18 @@ async def main():
|
||||
)
|
||||
|
||||
library_agent = await db.libraryagent.create(
|
||||
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]),
|
||||
)
|
||||
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]),
|
||||
}
|
||||
)
|
||||
library_agents.append(library_agent)
|
||||
|
||||
@@ -398,13 +392,13 @@ async def main():
|
||||
user = random.choice(users)
|
||||
slug = faker.slug()
|
||||
listing = await db.storelisting.create(
|
||||
data=StoreListingCreateInput(
|
||||
agentGraphId=graph.id,
|
||||
agentGraphVersion=graph.version,
|
||||
owningUserId=user.id,
|
||||
hasApprovedVersion=random.choice([True, False]),
|
||||
slug=slug,
|
||||
)
|
||||
data={
|
||||
"agentGraphId": graph.id,
|
||||
"agentGraphVersion": graph.version,
|
||||
"owningUserId": user.id,
|
||||
"hasApprovedVersion": random.choice([True, False]),
|
||||
"slug": slug,
|
||||
}
|
||||
)
|
||||
store_listings.append(listing)
|
||||
|
||||
@@ -414,26 +408,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=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(
|
||||
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(
|
||||
[
|
||||
prisma.enums.SubmissionStatus.PENDING,
|
||||
prisma.enums.SubmissionStatus.APPROVED,
|
||||
prisma.enums.SubmissionStatus.REJECTED,
|
||||
]
|
||||
),
|
||||
)
|
||||
}
|
||||
)
|
||||
store_listing_versions.append(version)
|
||||
|
||||
@@ -475,49 +469,51 @@ async def main():
|
||||
|
||||
try:
|
||||
await db.useronboarding.create(
|
||||
data=UserOnboardingCreateInput(
|
||||
userId=user.id,
|
||||
completedSteps=completed_steps,
|
||||
walletShown=random.choice([True, False]),
|
||||
notified=(
|
||||
data={
|
||||
"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=UserOnboardingCreateInput(
|
||||
userId=user.id,
|
||||
)
|
||||
data={
|
||||
"userId": user.id,
|
||||
}
|
||||
)
|
||||
|
||||
# Insert IntegrationWebhooks for some users
|
||||
@@ -548,20 +544,20 @@ async def main():
|
||||
for user in users:
|
||||
api_key = APIKeySmith().generate_key()
|
||||
await db.apikey.create(
|
||||
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=[
|
||||
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": [
|
||||
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
|
||||
|
||||
@@ -16,7 +16,6 @@ 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()
|
||||
|
||||
@@ -167,16 +166,16 @@ async def main():
|
||||
score = random.choices([1, 2, 3, 4, 5], weights=[5, 10, 20, 40, 25])[0]
|
||||
|
||||
await db.storelistingreview.create(
|
||||
data=StoreListingReviewCreateInput(
|
||||
storeListingVersionId=version.id,
|
||||
reviewByUserId=reviewer.id,
|
||||
score=score,
|
||||
comments=(
|
||||
data={
|
||||
"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
|
||||
|
||||
@@ -245,17 +244,17 @@ async def main():
|
||||
)
|
||||
|
||||
await db.credittransaction.create(
|
||||
data=CreditTransactionCreateInput(
|
||||
userId=user.id,
|
||||
amount=amount,
|
||||
type=transaction_type,
|
||||
metadata=Json(
|
||||
data={
|
||||
"userId": user.id,
|
||||
"amount": amount,
|
||||
"type": transaction_type,
|
||||
"metadata": Json(
|
||||
{
|
||||
"source": "test_updater",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
)
|
||||
transaction_count += 1
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"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",
|
||||
|
||||
112
autogpt_platform/frontend/pnpm-lock.yaml
generated
112
autogpt_platform/frontend/pnpm-lock.yaml
generated
@@ -200,6 +200,9 @@ 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)
|
||||
@@ -1001,6 +1004,9 @@ 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==}
|
||||
|
||||
@@ -3116,6 +3122,9 @@ 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==}
|
||||
|
||||
@@ -3772,6 +3781,9 @@ 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==}
|
||||
|
||||
@@ -3985,6 +3997,10 @@ 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'}
|
||||
@@ -4000,6 +4016,9 @@ 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'}
|
||||
@@ -6112,6 +6131,10 @@ 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}
|
||||
@@ -6283,6 +6306,12 @@ 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'}
|
||||
@@ -6649,6 +6678,9 @@ 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}
|
||||
@@ -6862,6 +6894,13 @@ 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'}
|
||||
@@ -6888,6 +6927,9 @@ 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'}
|
||||
@@ -7054,6 +7096,9 @@ 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==}
|
||||
|
||||
@@ -8290,10 +8335,10 @@ snapshots:
|
||||
'@emotion/is-prop-valid@1.2.2':
|
||||
dependencies:
|
||||
'@emotion/memoize': 0.8.1
|
||||
optional: true
|
||||
|
||||
'@emotion/memoize@0.8.1':
|
||||
optional: true
|
||||
'@emotion/memoize@0.8.1': {}
|
||||
|
||||
'@emotion/unitless@0.8.1': {}
|
||||
|
||||
'@epic-web/invariant@1.0.0': {}
|
||||
|
||||
@@ -10689,6 +10734,8 @@ snapshots:
|
||||
|
||||
'@types/statuses@2.0.6': {}
|
||||
|
||||
'@types/stylis@4.2.7': {}
|
||||
|
||||
'@types/tedious@4.0.14':
|
||||
dependencies:
|
||||
'@types/node': 24.10.0
|
||||
@@ -11385,6 +11432,8 @@ snapshots:
|
||||
|
||||
camelcase-css@2.0.1: {}
|
||||
|
||||
camelize@1.0.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001762: {}
|
||||
|
||||
case-sensitive-paths-webpack-plugin@2.4.0: {}
|
||||
@@ -11596,6 +11645,8 @@ 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)
|
||||
@@ -11617,6 +11668,12 @@ 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: {}
|
||||
@@ -12070,8 +12127,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)(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-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-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)
|
||||
@@ -12090,7 +12147,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
|
||||
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):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3
|
||||
@@ -12101,22 +12158,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@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)
|
||||
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@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-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):
|
||||
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)(eslint@8.57.1)
|
||||
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)
|
||||
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@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):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
@@ -12127,7 +12184,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@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-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)
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
@@ -14202,6 +14259,12 @@ 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
|
||||
@@ -14323,6 +14386,13 @@ 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
|
||||
@@ -14816,6 +14886,8 @@ 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
|
||||
@@ -15106,6 +15178,20 @@ 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
|
||||
@@ -15120,6 +15206,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.28.5
|
||||
|
||||
stylis@4.3.6: {}
|
||||
|
||||
sucrase@3.35.1:
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
@@ -15302,6 +15390,8 @@ snapshots:
|
||||
|
||||
tslib@1.14.1: {}
|
||||
|
||||
tslib@2.6.2: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tty-browserify@0.0.1: {}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
@@ -51,6 +51,8 @@ export function AnalyticsResultsTable({ results }: Props) {
|
||||
"Execution ID",
|
||||
"Status",
|
||||
"Score",
|
||||
"Started At",
|
||||
"Ended At",
|
||||
"Summary Text",
|
||||
"Error Message",
|
||||
];
|
||||
@@ -62,6 +64,8 @@ export function AnalyticsResultsTable({ results }: Props) {
|
||||
result.exec_id,
|
||||
result.status,
|
||||
result.score?.toString() || "",
|
||||
result.started_at ? new Date(result.started_at).toLocaleString() : "",
|
||||
result.ended_at ? new Date(result.ended_at).toLocaleString() : "",
|
||||
`"${(result.summary_text || "").replace(/"/g, '""')}"`, // Escape quotes in summary
|
||||
`"${(result.error_message || "").replace(/"/g, '""')}"`, // Escape quotes in error
|
||||
]);
|
||||
@@ -248,15 +252,13 @@ export function AnalyticsResultsTable({ results }: Props) {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{(result.summary_text || result.error_message) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => toggleRowExpansion(result.exec_id)}
|
||||
>
|
||||
<EyeIcon size={16} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => toggleRowExpansion(result.exec_id)}
|
||||
>
|
||||
<EyeIcon size={16} />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -264,6 +266,44 @@ export function AnalyticsResultsTable({ results }: Props) {
|
||||
<tr>
|
||||
<td colSpan={7} className="bg-gray-50 px-4 py-3">
|
||||
<div className="space-y-3">
|
||||
{/* Timestamps section */}
|
||||
<div className="grid grid-cols-2 gap-4 border-b border-gray-200 pb-3">
|
||||
<div>
|
||||
<Text
|
||||
variant="body"
|
||||
className="text-xs font-medium text-gray-600"
|
||||
>
|
||||
Started At:
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
className="text-sm text-gray-700"
|
||||
>
|
||||
{result.started_at
|
||||
? new Date(
|
||||
result.started_at,
|
||||
).toLocaleString()
|
||||
: "—"}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text
|
||||
variant="body"
|
||||
className="text-xs font-medium text-gray-600"
|
||||
>
|
||||
Ended At:
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
className="text-sm text-gray-700"
|
||||
>
|
||||
{result.ended_at
|
||||
? new Date(result.ended_at).toLocaleString()
|
||||
: "—"}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.summary_text && (
|
||||
<div>
|
||||
<Text
|
||||
|
||||
@@ -541,7 +541,19 @@ export function ExecutionAnalyticsForm() {
|
||||
{/* Accuracy Trends Display */}
|
||||
{trendsData && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Execution Accuracy Trends</h3>
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="text-lg font-semibold">Execution Accuracy Trends</h3>
|
||||
<div className="rounded-md bg-blue-50 px-3 py-2 text-xs text-blue-700">
|
||||
<p className="font-medium">
|
||||
Chart Filters (matches monitoring system):
|
||||
</p>
|
||||
<ul className="mt-1 list-inside list-disc space-y-1">
|
||||
<li>Only days with ≥1 execution with correctness score</li>
|
||||
<li>Last 30 days</li>
|
||||
<li>Averages calculated from scored executions only</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alert Section */}
|
||||
{trendsData.alert && (
|
||||
|
||||
@@ -97,9 +97,6 @@ export const Flow = () => {
|
||||
onConnect={onConnect}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onNodeContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
maxZoom={2}
|
||||
minZoom={0.1}
|
||||
onDragOver={onDragOver}
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
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 { 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 { Node as XYNode, NodeProps } from "@xyflow/react";
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
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 { BlockInfoCategoriesItem } from "@/app/api/__generated__/models/blockInfoCategoriesItem";
|
||||
import { BlockCost } from "@/app/api/__generated__/models/blockCost";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
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";
|
||||
|
||||
export type CustomNodeData = {
|
||||
hardcodedValues: {
|
||||
@@ -89,7 +88,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
|
||||
const node = (
|
||||
return (
|
||||
<NodeContainer selected={selected} nodeId={nodeId} hasErrors={hasErrors}>
|
||||
<div className="rounded-xlarge bg-white">
|
||||
<NodeHeader data={data} nodeId={nodeId} />
|
||||
@@ -118,15 +117,6 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
||||
<NodeExecutionBadge nodeId={nodeId} />
|
||||
</NodeContainer>
|
||||
);
|
||||
|
||||
return (
|
||||
<NodeRightClickMenu
|
||||
nodeId={nodeId}
|
||||
subGraphID={data.hardcodedValues?.graph_id}
|
||||
>
|
||||
{node}
|
||||
</NodeRightClickMenu>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,31 +1,26 @@
|
||||
import { useCopyPasteStore } from "@/app/(platform)/build/stores/copyPasteStore";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/molecules/DropdownMenu/DropdownMenu";
|
||||
import {
|
||||
SecondaryDropdownMenuContent,
|
||||
SecondaryDropdownMenuItem,
|
||||
SecondaryDropdownMenuSeparator,
|
||||
} from "@/components/molecules/SecondaryMenu/SecondaryMenu";
|
||||
import {
|
||||
ArrowSquareOutIcon,
|
||||
CopyIcon,
|
||||
DotsThreeOutlineVerticalIcon,
|
||||
TrashIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
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 { useReactFlow } from "@xyflow/react";
|
||||
|
||||
type Props = {
|
||||
export const NodeContextMenu = ({
|
||||
nodeId,
|
||||
subGraphID,
|
||||
}: {
|
||||
nodeId: string;
|
||||
subGraphID?: string;
|
||||
};
|
||||
|
||||
export const NodeContextMenu = ({ nodeId, subGraphID }: Props) => {
|
||||
}) => {
|
||||
const { deleteElements } = useReactFlow();
|
||||
|
||||
function handleCopy() {
|
||||
const handleCopy = () => {
|
||||
useNodeStore.setState((state) => ({
|
||||
nodes: state.nodes.map((node) => ({
|
||||
...node,
|
||||
@@ -35,47 +30,47 @@ export const NodeContextMenu = ({ nodeId, subGraphID }: Props) => {
|
||||
|
||||
useCopyPasteStore.getState().copySelectedNodes();
|
||||
useCopyPasteStore.getState().pasteNodes();
|
||||
}
|
||||
};
|
||||
|
||||
function handleDelete() {
|
||||
const handleDelete = () => {
|
||||
deleteElements({ nodes: [{ id: nodeId }] });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="py-2">
|
||||
<DotsThreeOutlineVerticalIcon size={16} weight="fill" />
|
||||
</DropdownMenuTrigger>
|
||||
<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 />
|
||||
<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>
|
||||
|
||||
{subGraphID && (
|
||||
<>
|
||||
<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 />
|
||||
</>
|
||||
<DropdownMenuItem
|
||||
onClick={() => window.open(`/build?flowID=${subGraphID}`)}
|
||||
className="hover:rounded-xlarge"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Agent
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
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 {
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
export const NodeHeader = ({
|
||||
data,
|
||||
nodeId,
|
||||
}: {
|
||||
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);
|
||||
|
||||
@@ -151,7 +151,7 @@ export const NodeDataViewer: FC<NodeDataViewerProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
{outputItems.length > 1 && (
|
||||
{outputItems.length > 0 && (
|
||||
<OutputActions
|
||||
items={outputItems.map((item) => ({
|
||||
value: item.value,
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
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" },
|
||||
];
|
||||
@@ -1,26 +0,0 @@
|
||||
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>;
|
||||
@@ -1,14 +1,111 @@
|
||||
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 { BlockMenuFilters } from "../BlockMenuFilters/BlockMenuFilters";
|
||||
import { BlockMenuSearchContent } from "../BlockMenuSearchContent/BlockMenuSearchContent";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NoSearchResult } from "../NoSearchResult";
|
||||
|
||||
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>
|
||||
<BlockMenuSearchContent />
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,19 +23,9 @@ 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();
|
||||
@@ -67,8 +57,6 @@ export const useBlockMenuSearchContent = () => {
|
||||
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 },
|
||||
@@ -110,26 +98,6 @@ export const useBlockMenuSearchContent = () => {
|
||||
}
|
||||
}, [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();
|
||||
@@ -1,108 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { XIcon } from "@phosphor-icons/react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import React, { ButtonHTMLAttributes, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import React, { ButtonHTMLAttributes } from "react";
|
||||
|
||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
selected?: boolean;
|
||||
@@ -18,51 +16,39 @@ export const FilterChip: React.FC<Props> = ({
|
||||
className,
|
||||
...rest
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<Button
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
<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
|
||||
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",
|
||||
"hover:border-violet-500 hover:bg-transparent focus:ring-0 disabled:cursor-not-allowed",
|
||||
selected && "border-0 bg-violet-700 hover:border",
|
||||
className,
|
||||
"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",
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<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",
|
||||
{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>
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export const INITIAL_CREATORS_TO_SHOW = 5;
|
||||
@@ -1,100 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,30 +1,12 @@
|
||||
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;
|
||||
@@ -37,44 +19,11 @@ 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: "",
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
"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";
|
||||
@@ -38,41 +30,6 @@ 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 {
|
||||
@@ -190,50 +147,9 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,9 +83,7 @@ function renderCode(
|
||||
</div>
|
||||
)}
|
||||
<pre className="overflow-x-auto rounded-md bg-muted p-3">
|
||||
<code className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
{codeValue}
|
||||
</code>
|
||||
<code className="font-mono text-sm">{codeValue}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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/SelectedScheduleActions";
|
||||
import { SelectedScheduleActions } from "./components/SelectedScheduleActions";
|
||||
import { useSelectedScheduleView } from "./useSelectedScheduleView";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
"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,
|
||||
};
|
||||
}
|
||||
@@ -173,8 +173,9 @@ export function OldAgentLibraryView() {
|
||||
if (agentRuns.length > 0) {
|
||||
// select latest run
|
||||
const latestRun = agentRuns.reduce((latest, current) => {
|
||||
if (latest.started_at && !current.started_at) return current;
|
||||
else if (!latest.started_at) return latest;
|
||||
if (!latest.started_at && !current.started_at) return latest;
|
||||
if (!latest.started_at) return current;
|
||||
if (!current.started_at) return latest;
|
||||
return latest.started_at > current.started_at ? latest : current;
|
||||
}, agentRuns[0]);
|
||||
selectRun(latestRun.id as GraphExecutionID);
|
||||
|
||||
@@ -184,9 +184,11 @@ export function AgentRunsSelectorList({
|
||||
))}
|
||||
{agentPresets.length > 0 && <Separator className="my-1" />}
|
||||
{agentRuns
|
||||
.toSorted(
|
||||
(a, b) => b.started_at.getTime() - a.started_at.getTime(),
|
||||
)
|
||||
.toSorted((a, b) => {
|
||||
const aTime = a.started_at?.getTime() ?? 0;
|
||||
const bTime = b.started_at?.getTime() ?? 0;
|
||||
return bTime - aTime;
|
||||
})
|
||||
.map((run) => (
|
||||
<AgentRunSummaryCard
|
||||
className={listItemClasses}
|
||||
@@ -199,7 +201,7 @@ export function AgentRunsSelectorList({
|
||||
?.name
|
||||
: null) ?? agent.name
|
||||
}
|
||||
timestamp={run.started_at}
|
||||
timestamp={run.started_at ?? undefined}
|
||||
selected={selectedView.id === run.id}
|
||||
onClick={() => onSelectRun(run.id)}
|
||||
onDelete={() => doDeleteRun(run as GraphExecutionMeta)}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
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 { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
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";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
|
||||
export function FavoritesSection() {
|
||||
export default function FavoritesSection() {
|
||||
const isAgentFavoritingEnabled = useGetFlag(Flag.AGENT_FAVORITING);
|
||||
const {
|
||||
allAgents: favoriteAgents,
|
||||
@@ -32,7 +33,7 @@ export function FavoritesSection() {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-[10px] p-2 pb-[10px]">
|
||||
<HeartIcon className="h-5 w-5 fill-red-500 text-red-500" />
|
||||
<Heart 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>
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
import { LibrarySearchBar } from "../LibrarySearchBar/LibrarySearchBar";
|
||||
// import LibraryNotificationDropdown from "./library-notification-dropdown";
|
||||
import LibraryUploadAgentDialog from "../LibraryUploadAgentDialog/LibraryUploadAgentDialog";
|
||||
import LibrarySearchBar from "../LibrarySearchBar/LibrarySearchBar";
|
||||
|
||||
interface Props {
|
||||
setSearchTerm: (value: string) => void;
|
||||
}
|
||||
type LibraryActionHeaderProps = Record<string, never>;
|
||||
|
||||
export function LibraryActionHeader({ setSearchTerm }: Props) {
|
||||
/**
|
||||
* LibraryActionHeader component - Renders a header with search, notifications and filters
|
||||
*/
|
||||
const LibraryActionHeader: React.FC<LibraryActionHeaderProps> = ({}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-[32px] hidden items-center justify-center gap-4 md:flex">
|
||||
<LibrarySearchBar setSearchTerm={setSearchTerm} />
|
||||
<div className="mb-[32px] hidden items-start justify-between md:flex">
|
||||
{/* <LibraryNotificationDropdown /> */}
|
||||
<LibrarySearchBar />
|
||||
<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 setSearchTerm={setSearchTerm} />
|
||||
<LibrarySearchBar />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default LibraryActionHeader;
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { LibrarySortMenu } from "../LibrarySortMenu/LibrarySortMenu";
|
||||
import LibrarySortMenu from "../LibrarySortMenu/LibrarySortMenu";
|
||||
|
||||
interface Props {
|
||||
interface LibraryActionSubHeaderProps {
|
||||
agentCount: number;
|
||||
setLibrarySort: (value: LibraryAgentSort) => void;
|
||||
}
|
||||
|
||||
export function LibraryActionSubHeader({ agentCount, setLibrarySort }: Props) {
|
||||
export default function LibraryActionSubHeader({
|
||||
agentCount,
|
||||
}: LibraryActionSubHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-baseline gap-4">
|
||||
<Text variant="h4">My agents</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
<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"
|
||||
data-testid="agents-count"
|
||||
className="text-zinc-500"
|
||||
>
|
||||
{agentCount}
|
||||
</Text>
|
||||
{agentCount} agents
|
||||
</span>
|
||||
</div>
|
||||
<LibrarySortMenu setLibrarySort={setLibrarySort} />
|
||||
<LibrarySortMenu />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,128 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { CaretCircleRightIcon } from "@phosphor-icons/react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import NextLink from "next/link";
|
||||
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 { 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 Props {
|
||||
interface LibraryAgentCardProps {
|
||||
agent: LibraryAgent;
|
||||
}
|
||||
|
||||
export function LibraryAgentCard({ agent }: Props) {
|
||||
const { id, name, graph_id, can_access_graph, image_url } = agent;
|
||||
|
||||
const {
|
||||
isFromMarketplace,
|
||||
isAgentFavoritingEnabled,
|
||||
isFavorite,
|
||||
profile,
|
||||
export default function LibraryAgentCard({
|
||||
agent: {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
graph_id,
|
||||
can_access_graph,
|
||||
creator_image_url,
|
||||
handleToggleFavorite,
|
||||
} = useLibraryAgentCard({ agent });
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="library-agent-card"
|
||||
data-agent-id={id}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<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">
|
||||
<AvatarImage
|
||||
src={
|
||||
isFromMarketplace
|
||||
? creator_image_url || "/avatar-placeholder.png"
|
||||
: profile?.avatar_url || "/avatar-placeholder.png"
|
||||
creator_image_url
|
||||
? creator_image_url
|
||||
: "/avatar-placeholder.png"
|
||||
}
|
||||
alt={`${name} creator avatar`}
|
||||
/>
|
||||
<AvatarFallback size={48}>{name.charAt(0)}</AvatarFallback>
|
||||
<AvatarFallback size={64}>{name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<Text
|
||||
variant="small-medium"
|
||||
className="uppercase tracking-wide text-zinc-400"
|
||||
>
|
||||
{isFromMarketplace ? "FROM MARKETPLACE" : "Built by you"}
|
||||
</Text>
|
||||
</div>
|
||||
{isAgentFavoritingEnabled && (
|
||||
<FavoriteButton
|
||||
isFavorite={isFavorite}
|
||||
onClick={handleToggleFavorite}
|
||||
/>
|
||||
)}
|
||||
</NextLink>
|
||||
</Link>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
{name}
|
||||
</Text>
|
||||
</h3>
|
||||
|
||||
{!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"
|
||||
/>
|
||||
)}
|
||||
<p className="line-clamp-3 flex-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<div className="mt-auto flex w-full justify-start gap-6 border-t border-zinc-100 pb-1 pt-3">
|
||||
<div className="flex-grow" />
|
||||
{/* Spacer */}
|
||||
|
||||
<div className="items-between mt-4 flex w-full justify-between gap-3">
|
||||
<Link
|
||||
href={`/library/agents/${id}`}
|
||||
data-testid="library-agent-card-see-runs-link"
|
||||
className="flex items-center gap-1 text-[13px]"
|
||||
className="text-lg font-semibold text-neutral-800 hover:underline dark:text-neutral-200"
|
||||
>
|
||||
See runs <CaretCircleRightIcon size={20} />
|
||||
See runs
|
||||
</Link>
|
||||
|
||||
{can_access_graph && (
|
||||
<Link
|
||||
href={`/build?flowID=${graph_id}`}
|
||||
data-testid="library-agent-card-open-in-builder-link"
|
||||
className="flex items-center gap-1 text-[13px]"
|
||||
isExternal
|
||||
className="text-lg font-semibold text-neutral-800 hover:underline dark:text-neutral-200"
|
||||
>
|
||||
Open in builder <CaretCircleRightIcon size={20} />
|
||||
Open in builder
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
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;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
"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,
|
||||
};
|
||||
}
|
||||
@@ -1,22 +1,10 @@
|
||||
"use client";
|
||||
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import LibraryActionSubHeader from "../LibraryActionSubHeader/LibraryActionSubHeader";
|
||||
import LibraryAgentCard from "../LibraryAgentCard/LibraryAgentCard";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
import { LibraryActionSubHeader } from "../LibraryActionSubHeader/LibraryActionSubHeader";
|
||||
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
|
||||
import { useLibraryAgentList } from "./useLibraryAgentList";
|
||||
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
librarySort: LibraryAgentSort;
|
||||
setLibrarySort: (value: LibraryAgentSort) => void;
|
||||
}
|
||||
|
||||
export function LibraryAgentList({
|
||||
searchTerm,
|
||||
librarySort,
|
||||
setLibrarySort,
|
||||
}: Props) {
|
||||
export default function LibraryAgentList() {
|
||||
const {
|
||||
agentLoading,
|
||||
agentCount,
|
||||
@@ -24,27 +12,28 @@ export function LibraryAgentList({
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = useLibraryAgentList({ searchTerm, librarySort });
|
||||
} = useLibraryAgentList();
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-t-2 border-neutral-800" />
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LibraryActionSubHeader
|
||||
agentCount={agentCount}
|
||||
setLibrarySort={setLibrarySort}
|
||||
/>
|
||||
<LibraryActionSubHeader agentCount={agentCount} />
|
||||
<div className="px-2">
|
||||
{agentLoading ? (
|
||||
<div className="flex h-[200px] items-center justify-center">
|
||||
<LoadingSpinner size="large" />
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : (
|
||||
<InfiniteScroll
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
loader={<LoadingSpinner size="medium" />}
|
||||
loader={<LoadingSpinner />}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div className="grid grid-cols-1 gap-3 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} />
|
||||
))}
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
"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 { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useGetV2ListLibraryAgentsInfinite } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { useLibraryPageContext } from "../state-provider";
|
||||
import { useLibraryAgentsStore } from "@/hooks/useLibraryAgents/store";
|
||||
import { getInitialData } from "./helpers";
|
||||
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
librarySort: LibraryAgentSort;
|
||||
}
|
||||
|
||||
export function useLibraryAgentList({ searchTerm, librarySort }: Props) {
|
||||
const queryClient = getQueryClient();
|
||||
const prevSortRef = useRef<LibraryAgentSort | null>(null);
|
||||
export const useLibraryAgentList = () => {
|
||||
const { searchTerm, librarySort } = useLibraryPageContext();
|
||||
const { agents: cachedAgents } = useLibraryAgentsStore();
|
||||
|
||||
const {
|
||||
data: agentsQueryData,
|
||||
@@ -28,28 +23,18 @@ export function useLibraryAgentList({ searchTerm, librarySort }: Props) {
|
||||
} = useGetV2ListLibraryAgentsInfinite(
|
||||
{
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
page_size: 8,
|
||||
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")
|
||||
: [];
|
||||
@@ -63,4 +48,4 @@ export function useLibraryAgentList({ searchTerm, librarySort }: Props) {
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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;
|
||||
@@ -0,0 +1,132 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { MagnifyingGlassIcon } from "@phosphor-icons/react";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useLibrarySearchbar } from "./useLibrarySearchbar";
|
||||
|
||||
interface Props {
|
||||
setSearchTerm: (value: string) => void;
|
||||
}
|
||||
|
||||
export function LibrarySearchBar({ setSearchTerm }: Props) {
|
||||
const { handleSearchInput } = useLibrarySearchbar({ setSearchTerm });
|
||||
|
||||
export default function LibrarySearchBar(): React.ReactNode {
|
||||
const { handleSearchInput, handleClear, setIsFocused, isFocused, inputRef } =
|
||||
useLibrarySearchbar();
|
||||
return (
|
||||
<div
|
||||
data-testid="search-bar"
|
||||
className="relative z-[21] -mb-6 flex w-full items-center md:w-auto"
|
||||
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]"
|
||||
>
|
||||
<MagnifyingGlassIcon
|
||||
width={18}
|
||||
height={18}
|
||||
className="absolute left-4 top-[34%] z-20 -translate-y-1/2 text-zinc-800"
|
||||
<Search
|
||||
className="mr-2 h-[29px] w-[29px] text-neutral-900"
|
||||
strokeWidth={1.25}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Search agents"
|
||||
id="library-search-bar"
|
||||
hideLabel
|
||||
ref={inputRef}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => !inputRef.current?.value && setIsFocused(false)}
|
||||
onChange={handleSearchInput}
|
||||
className="min-w-[18rem] pl-12 lg:min-w-[30rem]"
|
||||
className="flex-1 border-none font-sans text-[16px] font-normal leading-7 shadow-none focus:shadow-none focus:ring-0"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,36 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useLibraryPageContext } from "../state-provider";
|
||||
import { debounce } from "lodash";
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
interface Props {
|
||||
setSearchTerm: (value: string) => void;
|
||||
}
|
||||
export const useLibrarySearchbar = () => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const { setSearchTerm } = useLibraryPageContext();
|
||||
|
||||
export function useLibrarySearchbar({ setSearchTerm }: Props) {
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((value: string) => {
|
||||
setSearchTerm(value);
|
||||
}, 300),
|
||||
[setSearchTerm],
|
||||
);
|
||||
const debouncedSearch = debounce((value: string) => {
|
||||
setSearchTerm(value);
|
||||
}, 300);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedSearch.cancel();
|
||||
};
|
||||
}, [debouncedSearch]);
|
||||
|
||||
function handleSearchInput(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const 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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
|
||||
import { ArrowDownNarrowWideIcon } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -8,15 +8,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/__legacy__/ui/select";
|
||||
import { ArrowDownNarrowWideIcon } from "lucide-react";
|
||||
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
|
||||
import { useLibrarySortMenu } from "./useLibrarySortMenu";
|
||||
|
||||
interface Props {
|
||||
setLibrarySort: (value: LibraryAgentSort) => void;
|
||||
}
|
||||
|
||||
export function LibrarySortMenu({ setLibrarySort }: Props) {
|
||||
const { handleSortChange } = useLibrarySortMenu({ setLibrarySort });
|
||||
export default function LibrarySortMenu(): React.ReactNode {
|
||||
const { handleSortChange } = useLibrarySortMenu();
|
||||
return (
|
||||
<div className="flex items-center" data-testid="sort-by-dropdown">
|
||||
<span className="hidden whitespace-nowrap sm:inline">sort by</span>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
|
||||
import { useLibraryPageContext } from "../state-provider";
|
||||
|
||||
interface Props {
|
||||
setLibrarySort: (value: LibraryAgentSort) => void;
|
||||
}
|
||||
export const useLibrarySortMenu = () => {
|
||||
const { setLibrarySort } = useLibraryPageContext();
|
||||
|
||||
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 function useLibrarySortMenu({ setLibrarySort }: Props) {
|
||||
handleSortChange,
|
||||
getSortLabel,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,134 +1,192 @@
|
||||
"use client";
|
||||
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 { 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 {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/molecules/Form/Form";
|
||||
import { UploadSimpleIcon } from "@phosphor-icons/react";
|
||||
import { z } from "zod";
|
||||
} from "@/components/__legacy__/ui/form";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import { Textarea } from "@/components/__legacy__/ui/textarea";
|
||||
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: z.string().min(1, "Agent file is required"),
|
||||
agentFile: fileSchema,
|
||||
agentName: z.string().min(1, "Agent name is required"),
|
||||
agentDescription: z.string(),
|
||||
});
|
||||
|
||||
export default function LibraryUploadAgentDialog() {
|
||||
const { onSubmit, isUploading, isOpen, setIsOpen, form, agentObject } =
|
||||
useLibraryUploadAgentDialog();
|
||||
|
||||
export default function LibraryUploadAgentDialog(): React.ReactNode {
|
||||
const {
|
||||
onSubmit,
|
||||
isUploading,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
isDroped,
|
||||
handleChange,
|
||||
form,
|
||||
setisDroped,
|
||||
agentObject,
|
||||
clearAgentFile,
|
||||
} = useLibraryUploadAgentDialog();
|
||||
return (
|
||||
<Dialog
|
||||
title="Upload Agent"
|
||||
styling={{ maxWidth: "30rem" }}
|
||||
controlled={{
|
||||
isOpen,
|
||||
set: setIsOpen,
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<Dialog.Trigger>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
data-testid="upload-agent-button"
|
||||
variant="primary"
|
||||
className="h-[2.78rem] w-full md:w-[12rem]"
|
||||
size="small"
|
||||
className="w-fit sm:w-[177px]"
|
||||
>
|
||||
<UploadSimpleIcon width={18} height={18} />
|
||||
<span className="">Upload agent</span>
|
||||
<Upload className="h-5 w-5 sm:mr-2" />
|
||||
<span className="hidden items-center sm:inline-flex">
|
||||
Upload an agent
|
||||
</span>
|
||||
</Button>
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
<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="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="agentDescription"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} className="w-full rounded-[10px]" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</Form>
|
||||
</Dialog.Content>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
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 function useLibraryUploadAgentDialog() {
|
||||
export const useLibraryUploadAgentDialog = () => {
|
||||
const [isDroped, setisDroped] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const [agentObject, setAgentObject] = useState<Graph | null>(null);
|
||||
@@ -42,78 +43,9 @@ export function 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" });
|
||||
@@ -135,6 +67,69 @@ export function 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,
|
||||
@@ -142,5 +137,9 @@ export function useLibraryUploadAgentDialog() {
|
||||
setIsOpen,
|
||||
form,
|
||||
agentObject,
|
||||
isDroped,
|
||||
handleChange,
|
||||
setisDroped,
|
||||
clearAgentFile,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"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;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"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],
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,23 @@
|
||||
"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 { useLibraryListPage } from "./components/useLibraryListPage";
|
||||
import FavoritesSection from "./components/FavoritesSection/FavoritesSection";
|
||||
import LibraryActionHeader from "./components/LibraryActionHeader/LibraryActionHeader";
|
||||
import LibraryAgentList from "./components/LibraryAgentList/LibraryAgentList";
|
||||
import { LibraryPageStateProvider } from "./components/state-provider";
|
||||
|
||||
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">
|
||||
<LibraryActionHeader setSearchTerm={setSearchTerm} />
|
||||
<FavoritesSection />
|
||||
<LibraryAgentList
|
||||
searchTerm={searchTerm}
|
||||
librarySort={librarySort}
|
||||
setLibrarySort={setLibrarySort}
|
||||
/>
|
||||
<LibraryPageStateProvider>
|
||||
<LibraryActionHeader />
|
||||
<FavoritesSection />
|
||||
<LibraryAgentList />
|
||||
</LibraryPageStateProvider>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,9 +120,11 @@ export const AgentFlowList = ({
|
||||
lastRun =
|
||||
runCount == 0
|
||||
? null
|
||||
: _flowRuns.reduce((a, c) =>
|
||||
a.started_at > c.started_at ? a : c,
|
||||
);
|
||||
: _flowRuns.reduce((a, c) => {
|
||||
const aTime = a.started_at?.getTime() ?? 0;
|
||||
const cTime = c.started_at?.getTime() ?? 0;
|
||||
return aTime > cTime ? a : c;
|
||||
});
|
||||
}
|
||||
return { flow, runCount, lastRun };
|
||||
})
|
||||
@@ -130,10 +132,9 @@ export const AgentFlowList = ({
|
||||
if (!a.lastRun && !b.lastRun) return 0;
|
||||
if (!a.lastRun) return 1;
|
||||
if (!b.lastRun) return -1;
|
||||
return (
|
||||
b.lastRun.started_at.getTime() -
|
||||
a.lastRun.started_at.getTime()
|
||||
);
|
||||
const bTime = b.lastRun.started_at?.getTime() ?? 0;
|
||||
const aTime = a.lastRun.started_at?.getTime() ?? 0;
|
||||
return bTime - aTime;
|
||||
})
|
||||
.map(({ flow, runCount, lastRun }) => (
|
||||
<TableRow
|
||||
|
||||
@@ -29,7 +29,10 @@ export const FlowRunsStatus: React.FC<{
|
||||
: statsSince;
|
||||
const filteredFlowRuns =
|
||||
statsSinceTimestamp != null
|
||||
? executions.filter((fr) => fr.started_at.getTime() > statsSinceTimestamp)
|
||||
? executions.filter(
|
||||
(fr) =>
|
||||
fr.started_at && fr.started_at.getTime() > statsSinceTimestamp,
|
||||
)
|
||||
: executions;
|
||||
|
||||
return (
|
||||
|
||||
@@ -98,40 +98,43 @@ export const FlowRunsTimeline = ({
|
||||
<Scatter
|
||||
key={flow.id}
|
||||
data={executions
|
||||
.filter((e) => e.graph_id == flow.graph_id)
|
||||
.filter((e) => e.graph_id == flow.graph_id && e.started_at)
|
||||
.map((e) => ({
|
||||
...e,
|
||||
time:
|
||||
e.started_at.getTime() + (e.stats?.node_exec_time ?? 0) * 1000,
|
||||
(e.started_at?.getTime() ?? 0) +
|
||||
(e.stats?.node_exec_time ?? 0) * 1000,
|
||||
_duration: e.stats?.node_exec_time ?? 0,
|
||||
}))}
|
||||
name={flow.name}
|
||||
fill={`hsl(${(hashString(flow.id) * 137.5) % 360}, 70%, 50%)`}
|
||||
/>
|
||||
))}
|
||||
{executions.map((execution) => (
|
||||
<Line
|
||||
key={execution.id}
|
||||
type="linear"
|
||||
dataKey="_duration"
|
||||
data={[
|
||||
{
|
||||
...execution,
|
||||
time: execution.started_at.getTime(),
|
||||
_duration: 0,
|
||||
},
|
||||
{
|
||||
...execution,
|
||||
time: execution.ended_at.getTime(),
|
||||
_duration: execution.stats?.node_exec_time ?? 0,
|
||||
},
|
||||
]}
|
||||
stroke={`hsl(${(hashString(execution.graph_id) * 137.5) % 360}, 70%, 50%)`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
legendType="none"
|
||||
/>
|
||||
))}
|
||||
{executions
|
||||
.filter((e) => e.started_at && e.ended_at)
|
||||
.map((execution) => (
|
||||
<Line
|
||||
key={execution.id}
|
||||
type="linear"
|
||||
dataKey="_duration"
|
||||
data={[
|
||||
{
|
||||
...execution,
|
||||
time: execution.started_at!.getTime(),
|
||||
_duration: 0,
|
||||
},
|
||||
{
|
||||
...execution,
|
||||
time: execution.ended_at!.getTime(),
|
||||
_duration: execution.stats?.node_exec_time ?? 0,
|
||||
},
|
||||
]}
|
||||
stroke={`hsl(${(hashString(execution.graph_id) * 137.5) % 360}, 70%, 50%)`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
legendType="none"
|
||||
/>
|
||||
))}
|
||||
<Legend
|
||||
content={<ScrollableLegend />}
|
||||
wrapperStyle={{
|
||||
|
||||
@@ -98,7 +98,11 @@ const Monitor = () => {
|
||||
...(selectedFlow
|
||||
? executions.filter((v) => v.graph_id == selectedFlow.graph_id)
|
||||
: executions),
|
||||
].sort((a, b) => b.started_at.getTime() - a.started_at.getTime())}
|
||||
].sort((a, b) => {
|
||||
const aTime = a.started_at?.getTime() ?? 0;
|
||||
const bTime = b.started_at?.getTime() ?? 0;
|
||||
return bTime - aTime;
|
||||
})}
|
||||
selectedRun={selectedRun}
|
||||
onSelectRun={(r) => setSelectedRun(r.id == selectedRun?.id ? null : r)}
|
||||
/>
|
||||
|
||||
@@ -41,9 +41,11 @@ export const customMutator = async <
|
||||
T extends { data: any; status: number; headers: Headers },
|
||||
>(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
options: RequestInit & {
|
||||
params?: any;
|
||||
} = {},
|
||||
): Promise<T> => {
|
||||
const requestOptions = options;
|
||||
const { params, ...requestOptions } = options;
|
||||
const method = (requestOptions.method || "GET") as
|
||||
| "GET"
|
||||
| "POST"
|
||||
@@ -85,11 +87,14 @@ 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.
|
||||
// here url also contains encoded query params
|
||||
const fullUrl = `${baseUrl}${url}`;
|
||||
const fullUrl = `${baseUrl}${url}${queryString}`;
|
||||
|
||||
if (environment.isServerSide()) {
|
||||
try {
|
||||
|
||||
@@ -6968,6 +6968,20 @@
|
||||
"error_message": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Error Message"
|
||||
},
|
||||
"started_at": {
|
||||
"anyOf": [
|
||||
{ "type": "string", "format": "date-time" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "Started At"
|
||||
},
|
||||
"ended_at": {
|
||||
"anyOf": [
|
||||
{ "type": "string", "format": "date-time" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "Ended At"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -7074,14 +7088,20 @@
|
||||
},
|
||||
"status": { "$ref": "#/components/schemas/AgentExecutionStatus" },
|
||||
"started_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Started At"
|
||||
"anyOf": [
|
||||
{ "type": "string", "format": "date-time" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "Started At",
|
||||
"description": "When execution started running. Null if not yet started (QUEUED)."
|
||||
},
|
||||
"ended_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Ended At"
|
||||
"anyOf": [
|
||||
{ "type": "string", "format": "date-time" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "Ended At",
|
||||
"description": "When execution finished. Null if not yet completed (QUEUED, RUNNING, INCOMPLETE, REVIEW)."
|
||||
},
|
||||
"is_shared": {
|
||||
"type": "boolean",
|
||||
@@ -7115,8 +7135,6 @@
|
||||
"nodes_input_masks",
|
||||
"preset_id",
|
||||
"status",
|
||||
"started_at",
|
||||
"ended_at",
|
||||
"stats",
|
||||
"outputs"
|
||||
],
|
||||
@@ -7215,14 +7233,20 @@
|
||||
},
|
||||
"status": { "$ref": "#/components/schemas/AgentExecutionStatus" },
|
||||
"started_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Started At"
|
||||
"anyOf": [
|
||||
{ "type": "string", "format": "date-time" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "Started At",
|
||||
"description": "When execution started running. Null if not yet started (QUEUED)."
|
||||
},
|
||||
"ended_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Ended At"
|
||||
"anyOf": [
|
||||
{ "type": "string", "format": "date-time" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "Ended At",
|
||||
"description": "When execution finished. Null if not yet completed (QUEUED, RUNNING, INCOMPLETE, REVIEW)."
|
||||
},
|
||||
"is_shared": {
|
||||
"type": "boolean",
|
||||
@@ -7251,8 +7275,6 @@
|
||||
"nodes_input_masks",
|
||||
"preset_id",
|
||||
"status",
|
||||
"started_at",
|
||||
"ended_at",
|
||||
"stats"
|
||||
],
|
||||
"title": "GraphExecutionMeta"
|
||||
@@ -7299,14 +7321,20 @@
|
||||
},
|
||||
"status": { "$ref": "#/components/schemas/AgentExecutionStatus" },
|
||||
"started_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Started At"
|
||||
"anyOf": [
|
||||
{ "type": "string", "format": "date-time" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "Started At",
|
||||
"description": "When execution started running. Null if not yet started (QUEUED)."
|
||||
},
|
||||
"ended_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Ended At"
|
||||
"anyOf": [
|
||||
{ "type": "string", "format": "date-time" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "Ended At",
|
||||
"description": "When execution finished. Null if not yet completed (QUEUED, RUNNING, INCOMPLETE, REVIEW)."
|
||||
},
|
||||
"is_shared": {
|
||||
"type": "boolean",
|
||||
@@ -7345,8 +7373,6 @@
|
||||
"nodes_input_masks",
|
||||
"preset_id",
|
||||
"status",
|
||||
"started_at",
|
||||
"ended_at",
|
||||
"stats",
|
||||
"outputs",
|
||||
"node_executions"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 = <LoadingSpinner size="medium" />;
|
||||
const defaultLoader = <LoadingBox className="w-full py-4" spinnerSize={12} />;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -49,26 +49,7 @@ 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]);
|
||||
@@ -77,7 +58,7 @@ export const useInfiniteScroll = ({
|
||||
if (isInView && hasNextPage && !isLoadingRef.current) {
|
||||
loadMore();
|
||||
}
|
||||
}, [isInView, hasNextPage, loadMore]);
|
||||
}, [isInView, hasNextPage]);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
|
||||
@@ -50,7 +50,9 @@ export function ActivityItem({ execution }: Props) {
|
||||
execution.status === AgentExecutionStatus.QUEUED;
|
||||
|
||||
if (isActiveStatus) {
|
||||
const timeAgo = formatTimeAgo(execution.started_at.toString());
|
||||
const timeAgo = execution.started_at
|
||||
? formatTimeAgo(execution.started_at.toString())
|
||||
: "recently";
|
||||
const statusText =
|
||||
execution.status === AgentExecutionStatus.QUEUED ? "queued" : "running";
|
||||
return [
|
||||
@@ -61,7 +63,9 @@ export function ActivityItem({ execution }: Props) {
|
||||
// Handle all other statuses with time display
|
||||
const timeAgo = execution.ended_at
|
||||
? formatTimeAgo(execution.ended_at.toString())
|
||||
: formatTimeAgo(execution.started_at.toString());
|
||||
: execution.started_at
|
||||
? formatTimeAgo(execution.started_at.toString())
|
||||
: "recently";
|
||||
|
||||
let statusText = "ended";
|
||||
switch (execution.status) {
|
||||
|
||||
@@ -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, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import {
|
||||
NotificationState,
|
||||
categorizeExecutions,
|
||||
@@ -47,22 +47,10 @@ 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(() => {
|
||||
const executionKey = executions
|
||||
? `${executions.length}-${executionsSuccess}`
|
||||
: null;
|
||||
|
||||
if (
|
||||
executions &&
|
||||
executionsSuccess &&
|
||||
agentInfoMap.size > 0 &&
|
||||
processedExecutionsRef.current !== executionKey
|
||||
) {
|
||||
if (executions && executionsSuccess && agentInfoMap.size > 0) {
|
||||
const notifications = categorizeExecutions(executions, agentInfoMap);
|
||||
setNotifications(notifications);
|
||||
processedExecutionsRef.current = executionKey;
|
||||
}
|
||||
}, [executions, executionsSuccess, agentInfoMap]);
|
||||
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
"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,
|
||||
};
|
||||
@@ -1,99 +0,0 @@
|
||||
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>
|
||||
),
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
"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";
|
||||
@@ -4,7 +4,6 @@ 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;
|
||||
@@ -25,11 +24,6 @@ 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
|
||||
@@ -39,7 +33,7 @@ export const FormRenderer = ({
|
||||
schema={preprocessedSchema}
|
||||
validator={customValidator}
|
||||
onChange={handleChange}
|
||||
uiSchema={mergedUiSchema}
|
||||
uiSchema={uiSchema}
|
||||
formData={initialValues}
|
||||
liveValidate={false}
|
||||
/>
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
import { findCustomFieldId } from "../custom/custom-registry";
|
||||
|
||||
/**
|
||||
* Pre-processes the input schema to ensure all properties have a type defined.
|
||||
@@ -20,6 +21,12 @@ 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 &&
|
||||
|
||||
@@ -1,7 +1,114 @@
|
||||
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,
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
import { useGetV2ListLibraryAgentsInfinite } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { getPaginationNextPageNumber, unpaginate } from "@/app/api/helpers";
|
||||
import { useMemo } from "react";
|
||||
import { buildAgentInfoMap } from "./store";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { buildAgentInfoMap, useLibraryAgentsStore } from "./store";
|
||||
|
||||
let initialized = false;
|
||||
|
||||
export function useLibraryAgents() {
|
||||
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
|
||||
},
|
||||
},
|
||||
);
|
||||
const { agents, isRefreshing, lastUpdatedAt, loadFromCache, refreshAll } =
|
||||
useLibraryAgentsStore();
|
||||
|
||||
const agents = agentsQueryData ? unpaginate(agentsQueryData, "agents") : [];
|
||||
useEffect(() => {
|
||||
if (!initialized) {
|
||||
loadFromCache();
|
||||
void refreshAll();
|
||||
initialized = true;
|
||||
}
|
||||
}, [loadFromCache, refreshAll]);
|
||||
|
||||
// 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(",")],
|
||||
);
|
||||
const agentInfoMap = useMemo(() => buildAgentInfoMap(agents), [agents]);
|
||||
|
||||
return { agents, agentInfoMap, isRefreshing, lastUpdatedAt: undefined };
|
||||
return { agents, agentInfoMap, isRefreshing, lastUpdatedAt };
|
||||
}
|
||||
|
||||
@@ -327,8 +327,8 @@ export type GraphExecutionMeta = {
|
||||
| "FAILED"
|
||||
| "INCOMPLETE"
|
||||
| "REVIEW";
|
||||
started_at: Date;
|
||||
ended_at: Date;
|
||||
started_at: Date | null;
|
||||
ended_at: Date | null;
|
||||
stats: {
|
||||
error: string | null;
|
||||
cost: number;
|
||||
|
||||
@@ -18,7 +18,6 @@ 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();
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import test, { expect } from "@playwright/test";
|
||||
import path from "path";
|
||||
import { TEST_CREDENTIALS } from "./credentials";
|
||||
import { LibraryPage } from "./pages/library.page";
|
||||
import path from "path";
|
||||
import test, { expect } from "@playwright/test";
|
||||
import { TEST_CREDENTIALS } from "./credentials";
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import { hasUrl } from "./utils/assertion";
|
||||
import { getSelectors } from "./utils/selectors";
|
||||
import { hasUrl } from "./utils/assertion";
|
||||
|
||||
test.describe("Library", () => {
|
||||
let libraryPage: LibraryPage;
|
||||
@@ -47,18 +47,13 @@ test.describe("Library", () => {
|
||||
);
|
||||
|
||||
if (agentWithBuilder) {
|
||||
const [newPage] = await Promise.all([
|
||||
page.context().waitForEvent("page"),
|
||||
libraryPage.clickOpenInBuilder(agentWithBuilder),
|
||||
]);
|
||||
await newPage.waitForLoadState();
|
||||
test.expect(newPage.url()).toContain(`/build`);
|
||||
await newPage.close();
|
||||
await libraryPage.clickOpenInBuilder(agentWithBuilder);
|
||||
await page.waitForURL("**/build**");
|
||||
test.expect(page.url()).toContain(`/build`);
|
||||
}
|
||||
});
|
||||
|
||||
test("pagination works correctly", async ({ page }, testInfo) => {
|
||||
test.setTimeout(testInfo.timeout * 3); // Increase timeout for pagination operations
|
||||
test("pagination works correctly", async ({ page }) => {
|
||||
await page.goto("/library");
|
||||
|
||||
const paginationResult = await libraryPage.testPagination();
|
||||
@@ -85,9 +80,6 @@ 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();
|
||||
@@ -125,8 +117,8 @@ test.describe("Library", () => {
|
||||
await libraryPage.clearSearch();
|
||||
await libraryPage.waitForAgentsToLoad();
|
||||
|
||||
const clearedSearchCount = await libraryPage.getAgentCount();
|
||||
test.expect(clearedSearchCount).toEqual(initialAgentCount);
|
||||
const clearedSearchResults = await libraryPage.getAgents();
|
||||
test.expect(clearedSearchResults.length).toEqual(allAgents.length);
|
||||
|
||||
const clearedSearchValue = await libraryPage.getSearchValue();
|
||||
test.expect(clearedSearchValue).toBe("");
|
||||
@@ -208,14 +200,11 @@ test.describe("Library", () => {
|
||||
);
|
||||
await fileInput.setInputFiles(testAgentPath);
|
||||
|
||||
// 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 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
expect(await libraryPage.isUploadButtonEnabled()).toBeTruthy();
|
||||
|
||||
await page.getByRole("button", { name: "Upload" }).click();
|
||||
await page.getByRole("button", { name: "Upload Agent" }).click();
|
||||
|
||||
await page.waitForURL("**/build**", { timeout: 10000 });
|
||||
expect(page.url()).toContain("/build");
|
||||
@@ -235,10 +224,28 @@ 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();
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user