mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-12 16:48:06 -05:00
Compare commits
1 Commits
ntindle/wa
...
typing-che
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f135a33b3a |
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"worktreeCopyPatterns": [
|
||||
".env*",
|
||||
".vscode/**",
|
||||
".auth/**",
|
||||
".claude/**",
|
||||
"autogpt_platform/.env*",
|
||||
"autogpt_platform/backend/.env*",
|
||||
"autogpt_platform/frontend/.env*",
|
||||
"autogpt_platform/frontend/.auth/**",
|
||||
"autogpt_platform/db/docker/.env*"
|
||||
],
|
||||
"worktreeCopyIgnores": [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/.git/**",
|
||||
"**/Thumbs.db",
|
||||
"**/.DS_Store",
|
||||
"**/.next/**",
|
||||
"**/__pycache__/**",
|
||||
"**/.ruff_cache/**",
|
||||
"**/.pytest_cache/**",
|
||||
"**/*.pyc",
|
||||
"**/playwright-report/**",
|
||||
"**/logs/**",
|
||||
"**/site/**"
|
||||
],
|
||||
"worktreePathTemplate": "$BASE_PATH.worktree",
|
||||
"postCreateCmd": [
|
||||
"cd autogpt_platform/autogpt_libs && poetry install",
|
||||
"cd autogpt_platform/backend && poetry install && poetry run prisma generate",
|
||||
"cd autogpt_platform/frontend && pnpm install",
|
||||
"cd docs && pip install -r requirements.txt"
|
||||
],
|
||||
"terminalCommand": "code .",
|
||||
"deleteBranchWithWorktree": false
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
!autogpt_platform/backend/poetry.lock
|
||||
!autogpt_platform/backend/README.md
|
||||
!autogpt_platform/backend/.env
|
||||
!autogpt_platform/backend/gen_prisma_types_stub.py
|
||||
|
||||
# Platform - Market
|
||||
!autogpt_platform/market/market/
|
||||
|
||||
2
.github/workflows/claude-dependabot.yml
vendored
2
.github/workflows/claude-dependabot.yml
vendored
@@ -74,7 +74,7 @@ jobs:
|
||||
|
||||
- name: Generate Prisma Client
|
||||
working-directory: autogpt_platform/backend
|
||||
run: poetry run prisma generate && poetry run gen-prisma-stub
|
||||
run: poetry run prisma generate
|
||||
|
||||
# Frontend Node.js/pnpm setup (mirrors platform-frontend-ci.yml)
|
||||
- name: Set up Node.js
|
||||
|
||||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
@@ -90,7 +90,7 @@ jobs:
|
||||
|
||||
- name: Generate Prisma Client
|
||||
working-directory: autogpt_platform/backend
|
||||
run: poetry run prisma generate && poetry run gen-prisma-stub
|
||||
run: poetry run prisma generate
|
||||
|
||||
# Frontend Node.js/pnpm setup (mirrors platform-frontend-ci.yml)
|
||||
- name: Set up Node.js
|
||||
|
||||
12
.github/workflows/copilot-setup-steps.yml
vendored
12
.github/workflows/copilot-setup-steps.yml
vendored
@@ -72,7 +72,7 @@ jobs:
|
||||
|
||||
- name: Generate Prisma Client
|
||||
working-directory: autogpt_platform/backend
|
||||
run: poetry run prisma generate && poetry run gen-prisma-stub
|
||||
run: poetry run prisma generate
|
||||
|
||||
# Frontend Node.js/pnpm setup (mirrors platform-frontend-ci.yml)
|
||||
- name: Set up Node.js
|
||||
@@ -108,16 +108,6 @@ jobs:
|
||||
# run: pnpm playwright install --with-deps chromium
|
||||
|
||||
# Docker setup for development environment
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
# Remove large unused tools to free disk space for Docker builds
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||
sudo docker system prune -af
|
||||
df -h
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
||||
2
.github/workflows/platform-backend-ci.yml
vendored
2
.github/workflows/platform-backend-ci.yml
vendored
@@ -134,7 +134,7 @@ jobs:
|
||||
run: poetry install
|
||||
|
||||
- name: Generate Prisma Client
|
||||
run: poetry run prisma generate && poetry run gen-prisma-stub
|
||||
run: poetry run prisma generate
|
||||
|
||||
- id: supabase
|
||||
name: Start Supabase
|
||||
|
||||
@@ -12,7 +12,6 @@ reset-db:
|
||||
rm -rf db/docker/volumes/db/data
|
||||
cd backend && poetry run prisma migrate deploy
|
||||
cd backend && poetry run prisma generate
|
||||
cd backend && poetry run gen-prisma-stub
|
||||
|
||||
# View logs for core services
|
||||
logs-core:
|
||||
@@ -34,7 +33,6 @@ init-env:
|
||||
migrate:
|
||||
cd backend && poetry run prisma migrate deploy
|
||||
cd backend && poetry run prisma generate
|
||||
cd backend && poetry run gen-prisma-stub
|
||||
|
||||
run-backend:
|
||||
cd backend && poetry run app
|
||||
|
||||
@@ -48,8 +48,7 @@ RUN poetry install --no-ansi --no-root
|
||||
# Generate Prisma client
|
||||
COPY autogpt_platform/backend/schema.prisma ./
|
||||
COPY autogpt_platform/backend/backend/data/partial_types.py ./backend/data/partial_types.py
|
||||
COPY autogpt_platform/backend/gen_prisma_types_stub.py ./
|
||||
RUN poetry run prisma generate && poetry run gen-prisma-stub
|
||||
RUN poetry run prisma generate
|
||||
|
||||
FROM debian:13-slim AS server_dependencies
|
||||
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
import logging
|
||||
|
||||
import autogpt_libs.auth
|
||||
import fastapi
|
||||
import fastapi.responses
|
||||
|
||||
import backend.api.features.store.db as store_db
|
||||
import backend.api.features.store.model as store_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = fastapi.APIRouter(
|
||||
prefix="/admin/waitlist",
|
||||
tags=["store", "admin", "waitlist"],
|
||||
dependencies=[fastapi.Security(autogpt_libs.auth.requires_admin_user)],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
summary="Create Waitlist",
|
||||
response_model=store_model.WaitlistAdminResponse,
|
||||
)
|
||||
async def create_waitlist(
|
||||
request: store_model.WaitlistCreateRequest,
|
||||
user_id: str = fastapi.Security(autogpt_libs.auth.get_user_id),
|
||||
):
|
||||
"""
|
||||
Create a new waitlist (admin only).
|
||||
|
||||
Args:
|
||||
request: Waitlist creation details
|
||||
user_id: Authenticated admin user creating the waitlist
|
||||
|
||||
Returns:
|
||||
WaitlistAdminResponse with the created waitlist details
|
||||
"""
|
||||
try:
|
||||
waitlist = await store_db.create_waitlist_admin(
|
||||
admin_user_id=user_id,
|
||||
data=request,
|
||||
)
|
||||
return waitlist
|
||||
except Exception as e:
|
||||
logger.exception("Error creating waitlist: %s", e)
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "An error occurred while creating the waitlist"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
summary="List All Waitlists",
|
||||
response_model=store_model.WaitlistAdminListResponse,
|
||||
)
|
||||
async def list_waitlists():
|
||||
"""
|
||||
Get all waitlists with admin details (admin only).
|
||||
|
||||
Returns:
|
||||
WaitlistAdminListResponse with all waitlists
|
||||
"""
|
||||
try:
|
||||
return await store_db.get_waitlists_admin()
|
||||
except Exception as e:
|
||||
logger.exception("Error listing waitlists: %s", e)
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "An error occurred while fetching waitlists"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{waitlist_id}",
|
||||
summary="Get Waitlist Details",
|
||||
response_model=store_model.WaitlistAdminResponse,
|
||||
)
|
||||
async def get_waitlist(
|
||||
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
|
||||
):
|
||||
"""
|
||||
Get a single waitlist with admin details (admin only).
|
||||
|
||||
Args:
|
||||
waitlist_id: ID of the waitlist to retrieve
|
||||
|
||||
Returns:
|
||||
WaitlistAdminResponse with waitlist details
|
||||
"""
|
||||
try:
|
||||
return await store_db.get_waitlist_admin(waitlist_id)
|
||||
except ValueError:
|
||||
logger.warning("Waitlist not found: %s", waitlist_id)
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": "Waitlist not found"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Error fetching waitlist: %s", e)
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "An error occurred while fetching the waitlist"},
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{waitlist_id}",
|
||||
summary="Update Waitlist",
|
||||
response_model=store_model.WaitlistAdminResponse,
|
||||
)
|
||||
async def update_waitlist(
|
||||
request: store_model.WaitlistUpdateRequest,
|
||||
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
|
||||
):
|
||||
"""
|
||||
Update a waitlist (admin only).
|
||||
|
||||
Args:
|
||||
waitlist_id: ID of the waitlist to update
|
||||
request: Fields to update
|
||||
|
||||
Returns:
|
||||
WaitlistAdminResponse with updated waitlist details
|
||||
"""
|
||||
try:
|
||||
return await store_db.update_waitlist_admin(waitlist_id, request)
|
||||
except ValueError:
|
||||
logger.warning("Waitlist not found for update: %s", waitlist_id)
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": "Waitlist not found"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Error updating waitlist: %s", e)
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "An error occurred while updating the waitlist"},
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{waitlist_id}",
|
||||
summary="Delete Waitlist",
|
||||
)
|
||||
async def delete_waitlist(
|
||||
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
|
||||
):
|
||||
"""
|
||||
Soft delete a waitlist (admin only).
|
||||
|
||||
Args:
|
||||
waitlist_id: ID of the waitlist to delete
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
try:
|
||||
deleted = await store_db.delete_waitlist_admin(waitlist_id)
|
||||
if deleted:
|
||||
return {"message": "Waitlist deleted successfully"}
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": "Waitlist not found"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Error deleting waitlist: %s", e)
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "An error occurred while deleting the waitlist"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{waitlist_id}/signups",
|
||||
summary="Get Waitlist Signups",
|
||||
response_model=store_model.WaitlistSignupListResponse,
|
||||
)
|
||||
async def get_waitlist_signups(
|
||||
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
|
||||
):
|
||||
"""
|
||||
Get all signups for a waitlist (admin only).
|
||||
|
||||
Args:
|
||||
waitlist_id: ID of the waitlist
|
||||
|
||||
Returns:
|
||||
WaitlistSignupListResponse with all signups
|
||||
"""
|
||||
try:
|
||||
return await store_db.get_waitlist_signups_admin(waitlist_id)
|
||||
except ValueError:
|
||||
logger.warning("Waitlist not found for signups: %s", waitlist_id)
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": "Waitlist not found"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Error fetching waitlist signups: %s", e)
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "An error occurred while fetching waitlist signups"},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{waitlist_id}/link",
|
||||
summary="Link Waitlist to Store Listing",
|
||||
response_model=store_model.WaitlistAdminResponse,
|
||||
)
|
||||
async def link_waitlist_to_listing(
|
||||
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
|
||||
store_listing_id: str = fastapi.Body(
|
||||
..., embed=True, description="The ID of the store listing"
|
||||
),
|
||||
):
|
||||
"""
|
||||
Link a waitlist to a store listing (admin only).
|
||||
|
||||
When the linked store listing is approved/published, waitlist users
|
||||
will be automatically notified.
|
||||
|
||||
Args:
|
||||
waitlist_id: ID of the waitlist
|
||||
store_listing_id: ID of the store listing to link
|
||||
|
||||
Returns:
|
||||
WaitlistAdminResponse with updated waitlist details
|
||||
"""
|
||||
try:
|
||||
return await store_db.link_waitlist_to_listing_admin(
|
||||
waitlist_id, store_listing_id
|
||||
)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Link failed - waitlist or listing not found: %s, %s",
|
||||
waitlist_id,
|
||||
store_listing_id,
|
||||
)
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": "Waitlist or store listing not found"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Error linking waitlist to listing: %s", e)
|
||||
return fastapi.responses.JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "An error occurred while linking the waitlist"},
|
||||
)
|
||||
@@ -489,7 +489,7 @@ async def update_agent_version_in_library(
|
||||
agent_graph_version: int,
|
||||
) -> library_model.LibraryAgent:
|
||||
"""
|
||||
Updates the agent version in the library for any agent owned by the user.
|
||||
Updates the agent version in the library if useGraphIsActiveVersion is True.
|
||||
|
||||
Args:
|
||||
user_id: Owner of the LibraryAgent.
|
||||
@@ -498,31 +498,20 @@ async def update_agent_version_in_library(
|
||||
|
||||
Raises:
|
||||
DatabaseError: If there's an error with the update.
|
||||
NotFoundError: If no library agent is found for this user and agent.
|
||||
"""
|
||||
logger.debug(
|
||||
f"Updating agent version in library for user #{user_id}, "
|
||||
f"agent #{agent_graph_id} v{agent_graph_version}"
|
||||
)
|
||||
async with transaction() as tx:
|
||||
library_agent = await prisma.models.LibraryAgent.prisma(tx).find_first_or_raise(
|
||||
try:
|
||||
library_agent = await prisma.models.LibraryAgent.prisma().find_first_or_raise(
|
||||
where={
|
||||
"userId": user_id,
|
||||
"agentGraphId": agent_graph_id,
|
||||
"useGraphIsActiveVersion": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Delete any conflicting LibraryAgent for the target version
|
||||
await prisma.models.LibraryAgent.prisma(tx).delete_many(
|
||||
where={
|
||||
"userId": user_id,
|
||||
"agentGraphId": agent_graph_id,
|
||||
"agentGraphVersion": agent_graph_version,
|
||||
"id": {"not": library_agent.id},
|
||||
}
|
||||
)
|
||||
|
||||
lib = await prisma.models.LibraryAgent.prisma(tx).update(
|
||||
lib = await prisma.models.LibraryAgent.prisma().update(
|
||||
where={"id": library_agent.id},
|
||||
data={
|
||||
"AgentGraph": {
|
||||
@@ -536,13 +525,13 @@ async def update_agent_version_in_library(
|
||||
},
|
||||
include={"AgentGraph": True},
|
||||
)
|
||||
if lib is None:
|
||||
raise NotFoundError(f"Library agent {library_agent.id} not found")
|
||||
|
||||
if lib is None:
|
||||
raise NotFoundError(
|
||||
f"Failed to update library agent for {agent_graph_id} v{agent_graph_version}"
|
||||
)
|
||||
|
||||
return library_model.LibraryAgent.from_db(lib)
|
||||
return library_model.LibraryAgent.from_db(lib)
|
||||
except prisma.errors.PrismaError as e:
|
||||
logger.error(f"Database error updating agent version in library: {e}")
|
||||
raise DatabaseError("Failed to update agent version in library") from e
|
||||
|
||||
|
||||
async def update_library_agent(
|
||||
@@ -836,7 +825,6 @@ async def add_store_agent_to_library(
|
||||
}
|
||||
},
|
||||
"isCreatedByUser": False,
|
||||
"useGraphIsActiveVersion": False,
|
||||
"settings": SafeJson(
|
||||
_initialize_graph_settings(graph_model).model_dump()
|
||||
),
|
||||
|
||||
@@ -48,7 +48,6 @@ class LibraryAgent(pydantic.BaseModel):
|
||||
id: str
|
||||
graph_id: str
|
||||
graph_version: int
|
||||
owner_user_id: str # ID of user who owns/created this agent graph
|
||||
|
||||
image_url: str | None
|
||||
|
||||
@@ -164,7 +163,6 @@ class LibraryAgent(pydantic.BaseModel):
|
||||
id=agent.id,
|
||||
graph_id=agent.agentGraphId,
|
||||
graph_version=agent.agentGraphVersion,
|
||||
owner_user_id=agent.userId,
|
||||
image_url=agent.imageUrl,
|
||||
creator_name=creator_name,
|
||||
creator_image_url=creator_image_url,
|
||||
|
||||
@@ -42,7 +42,6 @@ async def test_get_library_agents_success(
|
||||
id="test-agent-1",
|
||||
graph_id="test-agent-1",
|
||||
graph_version=1,
|
||||
owner_user_id=test_user_id,
|
||||
name="Test Agent 1",
|
||||
description="Test Description 1",
|
||||
image_url=None,
|
||||
@@ -65,7 +64,6 @@ async def test_get_library_agents_success(
|
||||
id="test-agent-2",
|
||||
graph_id="test-agent-2",
|
||||
graph_version=1,
|
||||
owner_user_id=test_user_id,
|
||||
name="Test Agent 2",
|
||||
description="Test Description 2",
|
||||
image_url=None,
|
||||
@@ -140,7 +138,6 @@ async def test_get_favorite_library_agents_success(
|
||||
id="test-agent-1",
|
||||
graph_id="test-agent-1",
|
||||
graph_version=1,
|
||||
owner_user_id=test_user_id,
|
||||
name="Favorite Agent 1",
|
||||
description="Test Favorite Description 1",
|
||||
image_url=None,
|
||||
@@ -208,7 +205,6 @@ def test_add_agent_to_library_success(
|
||||
id="test-library-agent-id",
|
||||
graph_id="test-agent-1",
|
||||
graph_version=1,
|
||||
owner_user_id=test_user_id,
|
||||
name="Test Agent 1",
|
||||
description="Test Description 1",
|
||||
image_url=None,
|
||||
|
||||
@@ -23,7 +23,6 @@ from backend.data.notifications import (
|
||||
AgentApprovalData,
|
||||
AgentRejectionData,
|
||||
NotificationEventModel,
|
||||
WaitlistLaunchData,
|
||||
)
|
||||
from backend.notifications.notifications import queue_notification_async
|
||||
from backend.util.exceptions import DatabaseError
|
||||
@@ -615,7 +614,6 @@ async def get_store_submissions(
|
||||
submission_models = []
|
||||
for sub in submissions:
|
||||
submission_model = store_model.StoreSubmission(
|
||||
listing_id=sub.listing_id,
|
||||
agent_id=sub.agent_id,
|
||||
agent_version=sub.agent_version,
|
||||
name=sub.name,
|
||||
@@ -669,48 +667,35 @@ async def delete_store_submission(
|
||||
submission_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a store submission version as the submitting user.
|
||||
Delete a store listing submission as the submitting user.
|
||||
|
||||
Args:
|
||||
user_id: ID of the authenticated user
|
||||
submission_id: StoreListingVersion ID to delete
|
||||
submission_id: ID of the submission to be deleted
|
||||
|
||||
Returns:
|
||||
bool: True if successfully deleted
|
||||
bool: True if the submission was successfully deleted, False otherwise
|
||||
"""
|
||||
logger.debug(f"Deleting store submission {submission_id} for user {user_id}")
|
||||
|
||||
try:
|
||||
# Find the submission version with ownership check
|
||||
version = await prisma.models.StoreListingVersion.prisma().find_first(
|
||||
where={"id": submission_id}, include={"StoreListing": True}
|
||||
# Verify the submission belongs to this user
|
||||
submission = await prisma.models.StoreListing.prisma().find_first(
|
||||
where={"agentGraphId": submission_id, "owningUserId": user_id}
|
||||
)
|
||||
|
||||
if (
|
||||
not version
|
||||
or not version.StoreListing
|
||||
or version.StoreListing.owningUserId != user_id
|
||||
):
|
||||
raise store_exceptions.SubmissionNotFoundError("Submission not found")
|
||||
|
||||
# Prevent deletion of approved submissions
|
||||
if version.submissionStatus == prisma.enums.SubmissionStatus.APPROVED:
|
||||
raise store_exceptions.InvalidOperationError(
|
||||
"Cannot delete approved submissions"
|
||||
if not submission:
|
||||
logger.warning(f"Submission not found for user {user_id}: {submission_id}")
|
||||
raise store_exceptions.SubmissionNotFoundError(
|
||||
f"Submission not found for this user. User ID: {user_id}, Submission ID: {submission_id}"
|
||||
)
|
||||
|
||||
# Delete the version
|
||||
await prisma.models.StoreListingVersion.prisma().delete(
|
||||
where={"id": version.id}
|
||||
)
|
||||
# Delete the submission
|
||||
await prisma.models.StoreListing.prisma().delete(where={"id": submission.id})
|
||||
|
||||
# Clean up empty listing if this was the last version
|
||||
remaining = await prisma.models.StoreListingVersion.prisma().count(
|
||||
where={"storeListingId": version.storeListingId}
|
||||
logger.debug(
|
||||
f"Successfully deleted submission {submission_id} for user {user_id}"
|
||||
)
|
||||
if remaining == 0:
|
||||
await prisma.models.StoreListing.prisma().delete(
|
||||
where={"id": version.storeListingId}
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -774,15 +759,9 @@ async def create_store_submission(
|
||||
logger.warning(
|
||||
f"Agent not found for user {user_id}: {agent_id} v{agent_version}"
|
||||
)
|
||||
# Provide more user-friendly error message when agent_id is empty
|
||||
if not agent_id or agent_id.strip() == "":
|
||||
raise store_exceptions.AgentNotFoundError(
|
||||
"No agent selected. Please select an agent before submitting to the store."
|
||||
)
|
||||
else:
|
||||
raise store_exceptions.AgentNotFoundError(
|
||||
f"Agent not found for this user. User ID: {user_id}, Agent ID: {agent_id}, Version: {agent_version}"
|
||||
)
|
||||
raise store_exceptions.AgentNotFoundError(
|
||||
f"Agent not found for this user. User ID: {user_id}, Agent ID: {agent_id}, Version: {agent_version}"
|
||||
)
|
||||
|
||||
# Check if listing already exists for this agent
|
||||
existing_listing = await prisma.models.StoreListing.prisma().find_first(
|
||||
@@ -854,7 +833,6 @@ async def create_store_submission(
|
||||
logger.debug(f"Created store listing for agent {agent_id}")
|
||||
# Return submission details
|
||||
return store_model.StoreSubmission(
|
||||
listing_id=listing.id,
|
||||
agent_id=agent_id,
|
||||
agent_version=agent_version,
|
||||
name=name,
|
||||
@@ -966,56 +944,81 @@ async def edit_store_submission(
|
||||
# Currently we are not allowing user to update the agent associated with a submission
|
||||
# If we allow it in future, then we need a check here to verify the agent belongs to this user.
|
||||
|
||||
# Only allow editing of PENDING submissions
|
||||
if current_version.submissionStatus != prisma.enums.SubmissionStatus.PENDING:
|
||||
# Check if we can edit this submission
|
||||
if current_version.submissionStatus == prisma.enums.SubmissionStatus.REJECTED:
|
||||
raise store_exceptions.InvalidOperationError(
|
||||
f"Cannot edit a {current_version.submissionStatus.value.lower()} submission. Only pending submissions can be edited."
|
||||
"Cannot edit a rejected submission"
|
||||
)
|
||||
|
||||
# For APPROVED submissions, we need to create a new version
|
||||
if current_version.submissionStatus == prisma.enums.SubmissionStatus.APPROVED:
|
||||
# Create a new version for the existing listing
|
||||
return await create_store_version(
|
||||
user_id=user_id,
|
||||
agent_id=current_version.agentGraphId,
|
||||
agent_version=current_version.agentGraphVersion,
|
||||
store_listing_id=current_version.storeListingId,
|
||||
name=name,
|
||||
video_url=video_url,
|
||||
agent_output_demo_url=agent_output_demo_url,
|
||||
image_urls=image_urls,
|
||||
description=description,
|
||||
sub_heading=sub_heading,
|
||||
categories=categories,
|
||||
changes_summary=changes_summary,
|
||||
recommended_schedule_cron=recommended_schedule_cron,
|
||||
instructions=instructions,
|
||||
)
|
||||
|
||||
# For PENDING submissions, we can update the existing version
|
||||
# Update the existing version
|
||||
updated_version = await prisma.models.StoreListingVersion.prisma().update(
|
||||
where={"id": store_listing_version_id},
|
||||
data=prisma.types.StoreListingVersionUpdateInput(
|
||||
elif current_version.submissionStatus == prisma.enums.SubmissionStatus.PENDING:
|
||||
# Update the existing version
|
||||
updated_version = await prisma.models.StoreListingVersion.prisma().update(
|
||||
where={"id": store_listing_version_id},
|
||||
data=prisma.types.StoreListingVersionUpdateInput(
|
||||
name=name,
|
||||
videoUrl=video_url,
|
||||
agentOutputDemoUrl=agent_output_demo_url,
|
||||
imageUrls=image_urls,
|
||||
description=description,
|
||||
categories=categories,
|
||||
subHeading=sub_heading,
|
||||
changesSummary=changes_summary,
|
||||
recommendedScheduleCron=recommended_schedule_cron,
|
||||
instructions=instructions,
|
||||
),
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Updated existing version {store_listing_version_id} for agent {current_version.agentGraphId}"
|
||||
)
|
||||
|
||||
if not updated_version:
|
||||
raise DatabaseError("Failed to update store listing version")
|
||||
return store_model.StoreSubmission(
|
||||
agent_id=current_version.agentGraphId,
|
||||
agent_version=current_version.agentGraphVersion,
|
||||
name=name,
|
||||
videoUrl=video_url,
|
||||
agentOutputDemoUrl=agent_output_demo_url,
|
||||
imageUrls=image_urls,
|
||||
sub_heading=sub_heading,
|
||||
slug=current_version.StoreListing.slug,
|
||||
description=description,
|
||||
categories=categories,
|
||||
subHeading=sub_heading,
|
||||
changesSummary=changes_summary,
|
||||
recommendedScheduleCron=recommended_schedule_cron,
|
||||
instructions=instructions,
|
||||
),
|
||||
)
|
||||
image_urls=image_urls,
|
||||
date_submitted=updated_version.submittedAt or updated_version.createdAt,
|
||||
status=updated_version.submissionStatus,
|
||||
runs=0,
|
||||
rating=0.0,
|
||||
store_listing_version_id=updated_version.id,
|
||||
changes_summary=changes_summary,
|
||||
video_url=video_url,
|
||||
categories=categories,
|
||||
version=updated_version.version,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Updated existing version {store_listing_version_id} for agent {current_version.agentGraphId}"
|
||||
)
|
||||
|
||||
if not updated_version:
|
||||
raise DatabaseError("Failed to update store listing version")
|
||||
return store_model.StoreSubmission(
|
||||
listing_id=current_version.StoreListing.id,
|
||||
agent_id=current_version.agentGraphId,
|
||||
agent_version=current_version.agentGraphVersion,
|
||||
name=name,
|
||||
sub_heading=sub_heading,
|
||||
slug=current_version.StoreListing.slug,
|
||||
description=description,
|
||||
instructions=instructions,
|
||||
image_urls=image_urls,
|
||||
date_submitted=updated_version.submittedAt or updated_version.createdAt,
|
||||
status=updated_version.submissionStatus,
|
||||
runs=0,
|
||||
rating=0.0,
|
||||
store_listing_version_id=updated_version.id,
|
||||
changes_summary=changes_summary,
|
||||
video_url=video_url,
|
||||
categories=categories,
|
||||
version=updated_version.version,
|
||||
)
|
||||
else:
|
||||
raise store_exceptions.InvalidOperationError(
|
||||
f"Cannot edit submission with status: {current_version.submissionStatus}"
|
||||
)
|
||||
|
||||
except (
|
||||
store_exceptions.SubmissionNotFoundError,
|
||||
@@ -1094,78 +1097,38 @@ async def create_store_version(
|
||||
f"Agent not found for this user. User ID: {user_id}, Agent ID: {agent_id}, Version: {agent_version}"
|
||||
)
|
||||
|
||||
# Check if there's already a PENDING submission for this agent (any version)
|
||||
existing_pending_submission = (
|
||||
await prisma.models.StoreListingVersion.prisma().find_first(
|
||||
where=prisma.types.StoreListingVersionWhereInput(
|
||||
storeListingId=store_listing_id,
|
||||
agentGraphId=agent_id,
|
||||
submissionStatus=prisma.enums.SubmissionStatus.PENDING,
|
||||
isDeleted=False,
|
||||
)
|
||||
# Get the latest version number
|
||||
latest_version = listing.Versions[0] if listing.Versions else None
|
||||
|
||||
next_version = (latest_version.version + 1) if latest_version else 1
|
||||
|
||||
# Create a new version for the existing listing
|
||||
new_version = await prisma.models.StoreListingVersion.prisma().create(
|
||||
data=prisma.types.StoreListingVersionCreateInput(
|
||||
version=next_version,
|
||||
agentGraphId=agent_id,
|
||||
agentGraphVersion=agent_version,
|
||||
name=name,
|
||||
videoUrl=video_url,
|
||||
agentOutputDemoUrl=agent_output_demo_url,
|
||||
imageUrls=image_urls,
|
||||
description=description,
|
||||
instructions=instructions,
|
||||
categories=categories,
|
||||
subHeading=sub_heading,
|
||||
submissionStatus=prisma.enums.SubmissionStatus.PENDING,
|
||||
submittedAt=datetime.now(),
|
||||
changesSummary=changes_summary,
|
||||
recommendedScheduleCron=recommended_schedule_cron,
|
||||
storeListingId=store_listing_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Handle existing pending submission and create new one atomically
|
||||
async with transaction() as tx:
|
||||
# Get the latest version number first
|
||||
latest_listing = await prisma.models.StoreListing.prisma(tx).find_first(
|
||||
where=prisma.types.StoreListingWhereInput(
|
||||
id=store_listing_id, owningUserId=user_id
|
||||
),
|
||||
include={"Versions": {"order_by": {"version": "desc"}, "take": 1}},
|
||||
)
|
||||
|
||||
if not latest_listing:
|
||||
raise store_exceptions.ListingNotFoundError(
|
||||
f"Store listing not found. User ID: {user_id}, Listing ID: {store_listing_id}"
|
||||
)
|
||||
|
||||
latest_version = (
|
||||
latest_listing.Versions[0] if latest_listing.Versions else None
|
||||
)
|
||||
next_version = (latest_version.version + 1) if latest_version else 1
|
||||
|
||||
# If there's an existing pending submission, delete it atomically before creating new one
|
||||
if existing_pending_submission:
|
||||
logger.info(
|
||||
f"Found existing PENDING submission for agent {agent_id} (was v{existing_pending_submission.agentGraphVersion}, now v{agent_version}), replacing existing submission instead of creating duplicate"
|
||||
)
|
||||
await prisma.models.StoreListingVersion.prisma(tx).delete(
|
||||
where={"id": existing_pending_submission.id}
|
||||
)
|
||||
logger.debug(
|
||||
f"Deleted existing pending submission {existing_pending_submission.id}"
|
||||
)
|
||||
|
||||
# Create a new version for the existing listing
|
||||
new_version = await prisma.models.StoreListingVersion.prisma(tx).create(
|
||||
data=prisma.types.StoreListingVersionCreateInput(
|
||||
version=next_version,
|
||||
agentGraphId=agent_id,
|
||||
agentGraphVersion=agent_version,
|
||||
name=name,
|
||||
videoUrl=video_url,
|
||||
agentOutputDemoUrl=agent_output_demo_url,
|
||||
imageUrls=image_urls,
|
||||
description=description,
|
||||
instructions=instructions,
|
||||
categories=categories,
|
||||
subHeading=sub_heading,
|
||||
submissionStatus=prisma.enums.SubmissionStatus.PENDING,
|
||||
submittedAt=datetime.now(),
|
||||
changesSummary=changes_summary,
|
||||
recommendedScheduleCron=recommended_schedule_cron,
|
||||
storeListingId=store_listing_id,
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Created new version for listing {store_listing_id} of agent {agent_id}"
|
||||
)
|
||||
# Return submission details
|
||||
return store_model.StoreSubmission(
|
||||
listing_id=listing.id,
|
||||
agent_id=agent_id,
|
||||
agent_version=agent_version,
|
||||
name=name,
|
||||
@@ -1743,37 +1706,17 @@ async def review_store_submission(
|
||||
# Don't fail the review process if email sending fails
|
||||
pass
|
||||
|
||||
# Notify waitlist users if this is an approval and has a linked waitlist
|
||||
if is_approved and submission.StoreListing:
|
||||
try:
|
||||
frontend_base_url = (
|
||||
settings.config.frontend_base_url
|
||||
or settings.config.platform_base_url
|
||||
)
|
||||
store_agent = (
|
||||
await prisma.models.StoreAgent.prisma().find_first_or_raise(
|
||||
where={"storeListingVersionId": submission.id}
|
||||
)
|
||||
)
|
||||
creator_username = store_agent.creator_username or "unknown"
|
||||
store_url = f"{frontend_base_url}/marketplace/agent/{creator_username}/{store_agent.slug}"
|
||||
await notify_waitlist_users_on_launch(
|
||||
store_listing_id=submission.StoreListing.id,
|
||||
agent_name=submission.name,
|
||||
store_url=store_url,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to notify waitlist users on agent approval: {e}")
|
||||
# Don't fail the approval process
|
||||
|
||||
# Convert to Pydantic model for consistency
|
||||
return store_model.StoreSubmission(
|
||||
listing_id=(submission.StoreListing.id if submission.StoreListing else ""),
|
||||
agent_id=submission.agentGraphId,
|
||||
agent_version=submission.agentGraphVersion,
|
||||
name=submission.name,
|
||||
sub_heading=submission.subHeading,
|
||||
slug=(submission.StoreListing.slug if submission.StoreListing else ""),
|
||||
slug=(
|
||||
submission.StoreListing.slug
|
||||
if hasattr(submission, "storeListing") and submission.StoreListing
|
||||
else ""
|
||||
),
|
||||
description=submission.description,
|
||||
instructions=submission.instructions,
|
||||
image_urls=submission.imageUrls or [],
|
||||
@@ -1875,7 +1818,9 @@ async def get_admin_listings_with_versions(
|
||||
where = prisma.types.StoreListingWhereInput(**where_dict)
|
||||
include = prisma.types.StoreListingInclude(
|
||||
Versions=prisma.types.FindManyStoreListingVersionArgsFromStoreListing(
|
||||
order_by={"version": "desc"}
|
||||
order_by=prisma.types._StoreListingVersion_version_OrderByInput(
|
||||
version="desc"
|
||||
)
|
||||
),
|
||||
OwningUser=True,
|
||||
)
|
||||
@@ -1900,7 +1845,6 @@ async def get_admin_listings_with_versions(
|
||||
# If we have versions, turn them into StoreSubmission models
|
||||
for version in listing.Versions or []:
|
||||
version_model = store_model.StoreSubmission(
|
||||
listing_id=listing.id,
|
||||
agent_id=version.agentGraphId,
|
||||
agent_version=version.agentGraphVersion,
|
||||
name=version.name,
|
||||
@@ -2013,507 +1957,3 @@ async def get_agent_as_admin(
|
||||
)
|
||||
|
||||
return graph
|
||||
|
||||
|
||||
def _waitlist_to_store_entry(
|
||||
waitlist: prisma.models.WaitlistEntry,
|
||||
) -> store_model.StoreWaitlistEntry:
|
||||
"""Convert a WaitlistEntry to StoreWaitlistEntry for public display."""
|
||||
return store_model.StoreWaitlistEntry(
|
||||
waitlistId=waitlist.id,
|
||||
slug=waitlist.slug,
|
||||
name=waitlist.name,
|
||||
subHeading=waitlist.subHeading,
|
||||
videoUrl=waitlist.videoUrl,
|
||||
agentOutputDemoUrl=waitlist.agentOutputDemoUrl,
|
||||
imageUrls=waitlist.imageUrls or [],
|
||||
description=waitlist.description,
|
||||
categories=waitlist.categories,
|
||||
)
|
||||
|
||||
|
||||
async def get_waitlist() -> list[store_model.StoreWaitlistEntry]:
|
||||
"""Get all active waitlists for public display."""
|
||||
try:
|
||||
waitlists = await prisma.models.WaitlistEntry.prisma().find_many(
|
||||
where=prisma.types.WaitlistEntryWhereInput(isDeleted=False),
|
||||
)
|
||||
|
||||
# Filter out closed/done waitlists and sort by votes (descending)
|
||||
excluded_statuses = {
|
||||
prisma.enums.WaitlistExternalStatus.CANCELED,
|
||||
prisma.enums.WaitlistExternalStatus.DONE,
|
||||
}
|
||||
active_waitlists = [w for w in waitlists if w.status not in excluded_statuses]
|
||||
sorted_list = sorted(active_waitlists, key=lambda x: x.votes, reverse=True)
|
||||
|
||||
return [_waitlist_to_store_entry(w) for w in sorted_list]
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching waitlists: {e}")
|
||||
raise DatabaseError("Failed to fetch waitlists") from e
|
||||
|
||||
|
||||
async def get_user_waitlist_memberships(user_id: str) -> list[str]:
|
||||
"""Get all waitlist IDs that a user has joined."""
|
||||
try:
|
||||
user = await prisma.models.User.prisma().find_unique(
|
||||
where={"id": user_id},
|
||||
include={"joinedWaitlists": True},
|
||||
)
|
||||
if not user or not user.joinedWaitlists:
|
||||
return []
|
||||
return [w.id for w in user.joinedWaitlists]
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching user waitlist memberships: {e}")
|
||||
raise DatabaseError("Failed to fetch waitlist memberships") from e
|
||||
|
||||
|
||||
async def add_user_to_waitlist(
|
||||
waitlist_id: str, user_id: str | None, email: str | None
|
||||
) -> store_model.StoreWaitlistEntry:
|
||||
"""
|
||||
Add a user to a waitlist.
|
||||
|
||||
For logged-in users: connects via joinedUsers relation
|
||||
For anonymous users: adds email to unaffiliatedEmailUsers array
|
||||
"""
|
||||
logger.debug(f"Adding user {user_id or email} to waitlist {waitlist_id}")
|
||||
|
||||
if not user_id and not email:
|
||||
raise ValueError("Either user_id or email must be provided")
|
||||
|
||||
try:
|
||||
# Find the waitlist
|
||||
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
|
||||
where={"id": waitlist_id},
|
||||
include={"joinedUsers": True},
|
||||
)
|
||||
|
||||
if not waitlist:
|
||||
raise ValueError(f"Waitlist {waitlist_id} not found")
|
||||
|
||||
if waitlist.isDeleted:
|
||||
raise ValueError(f"Waitlist {waitlist_id} is no longer available")
|
||||
|
||||
if waitlist.status in [
|
||||
prisma.enums.WaitlistExternalStatus.CANCELED,
|
||||
prisma.enums.WaitlistExternalStatus.DONE,
|
||||
]:
|
||||
raise ValueError(f"Waitlist {waitlist_id} is closed")
|
||||
|
||||
if user_id:
|
||||
# Check if user already joined
|
||||
joined_user_ids = [u.id for u in (waitlist.joinedUsers or [])]
|
||||
if user_id in joined_user_ids:
|
||||
# Already joined - return waitlist info
|
||||
logger.debug(f"User {user_id} already joined waitlist {waitlist_id}")
|
||||
else:
|
||||
# Connect user to waitlist
|
||||
await prisma.models.WaitlistEntry.prisma().update(
|
||||
where={"id": waitlist_id},
|
||||
data={"joinedUsers": {"connect": [{"id": user_id}]}},
|
||||
)
|
||||
logger.info(f"User {user_id} joined waitlist {waitlist_id}")
|
||||
|
||||
# If user was previously in email list, remove them
|
||||
# Use transaction to prevent race conditions
|
||||
if email:
|
||||
async with transaction() as tx:
|
||||
current_waitlist = await tx.waitlistentry.find_unique(
|
||||
where={"id": waitlist_id}
|
||||
)
|
||||
if current_waitlist and email in (
|
||||
current_waitlist.unaffiliatedEmailUsers or []
|
||||
):
|
||||
updated_emails: list[str] = [
|
||||
e
|
||||
for e in (current_waitlist.unaffiliatedEmailUsers or [])
|
||||
if e != email
|
||||
]
|
||||
await tx.waitlistentry.update(
|
||||
where={"id": waitlist_id},
|
||||
data={"unaffiliatedEmailUsers": updated_emails},
|
||||
)
|
||||
elif email:
|
||||
# Add email to unaffiliated list if not already present
|
||||
# Use transaction to prevent race conditions with concurrent signups
|
||||
async with transaction() as tx:
|
||||
# Re-fetch within transaction to get latest state
|
||||
current_waitlist = await tx.waitlistentry.find_unique(
|
||||
where={"id": waitlist_id}
|
||||
)
|
||||
if current_waitlist:
|
||||
current_emails: list[str] = list(
|
||||
current_waitlist.unaffiliatedEmailUsers or []
|
||||
)
|
||||
if email not in current_emails:
|
||||
current_emails.append(email)
|
||||
await tx.waitlistentry.update(
|
||||
where={"id": waitlist_id},
|
||||
data={"unaffiliatedEmailUsers": current_emails},
|
||||
)
|
||||
logger.info(f"Email {email} added to waitlist {waitlist_id}")
|
||||
else:
|
||||
logger.debug(f"Email {email} already on waitlist {waitlist_id}")
|
||||
|
||||
# Re-fetch to return updated data
|
||||
updated_waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
|
||||
where={"id": waitlist_id}
|
||||
)
|
||||
return _waitlist_to_store_entry(updated_waitlist or waitlist)
|
||||
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding user to waitlist: {e}")
|
||||
raise DatabaseError("Failed to add user to waitlist") from e
|
||||
|
||||
|
||||
# ============== Admin Waitlist Functions ==============
|
||||
|
||||
|
||||
def _waitlist_to_admin_response(
|
||||
waitlist: prisma.models.WaitlistEntry,
|
||||
) -> store_model.WaitlistAdminResponse:
|
||||
"""Convert a WaitlistEntry to WaitlistAdminResponse."""
|
||||
joined_count = len(waitlist.joinedUsers) if waitlist.joinedUsers else 0
|
||||
email_count = (
|
||||
len(waitlist.unaffiliatedEmailUsers) if waitlist.unaffiliatedEmailUsers else 0
|
||||
)
|
||||
|
||||
return store_model.WaitlistAdminResponse(
|
||||
id=waitlist.id,
|
||||
createdAt=waitlist.createdAt.isoformat() if waitlist.createdAt else "",
|
||||
updatedAt=waitlist.updatedAt.isoformat() if waitlist.updatedAt else "",
|
||||
slug=waitlist.slug,
|
||||
name=waitlist.name,
|
||||
subHeading=waitlist.subHeading,
|
||||
description=waitlist.description,
|
||||
categories=waitlist.categories,
|
||||
imageUrls=waitlist.imageUrls or [],
|
||||
videoUrl=waitlist.videoUrl,
|
||||
agentOutputDemoUrl=waitlist.agentOutputDemoUrl,
|
||||
status=waitlist.status or prisma.enums.WaitlistExternalStatus.NOT_STARTED,
|
||||
votes=waitlist.votes,
|
||||
signupCount=joined_count + email_count,
|
||||
storeListingId=waitlist.storeListingId,
|
||||
owningUserId=waitlist.owningUserId,
|
||||
)
|
||||
|
||||
|
||||
async def create_waitlist_admin(
|
||||
admin_user_id: str,
|
||||
data: store_model.WaitlistCreateRequest,
|
||||
) -> store_model.WaitlistAdminResponse:
|
||||
"""Create a new waitlist (admin only)."""
|
||||
logger.info(f"Admin {admin_user_id} creating waitlist: {data.name}")
|
||||
|
||||
try:
|
||||
waitlist = await prisma.models.WaitlistEntry.prisma().create(
|
||||
data=prisma.types.WaitlistEntryCreateInput(
|
||||
name=data.name,
|
||||
slug=data.slug,
|
||||
subHeading=data.subHeading,
|
||||
description=data.description,
|
||||
categories=data.categories,
|
||||
imageUrls=data.imageUrls,
|
||||
videoUrl=data.videoUrl,
|
||||
agentOutputDemoUrl=data.agentOutputDemoUrl,
|
||||
owningUserId=admin_user_id,
|
||||
status=prisma.enums.WaitlistExternalStatus.NOT_STARTED,
|
||||
),
|
||||
include={"joinedUsers": True},
|
||||
)
|
||||
|
||||
return _waitlist_to_admin_response(waitlist)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating waitlist: {e}")
|
||||
raise DatabaseError("Failed to create waitlist") from e
|
||||
|
||||
|
||||
async def get_waitlists_admin() -> store_model.WaitlistAdminListResponse:
|
||||
"""Get all waitlists with admin details."""
|
||||
try:
|
||||
waitlists = await prisma.models.WaitlistEntry.prisma().find_many(
|
||||
where=prisma.types.WaitlistEntryWhereInput(isDeleted=False),
|
||||
include={"joinedUsers": True},
|
||||
order={"createdAt": "desc"},
|
||||
)
|
||||
|
||||
return store_model.WaitlistAdminListResponse(
|
||||
waitlists=[_waitlist_to_admin_response(w) for w in waitlists],
|
||||
totalCount=len(waitlists),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching waitlists for admin: {e}")
|
||||
raise DatabaseError("Failed to fetch waitlists") from e
|
||||
|
||||
|
||||
async def get_waitlist_admin(
|
||||
waitlist_id: str,
|
||||
) -> store_model.WaitlistAdminResponse:
|
||||
"""Get a single waitlist with admin details."""
|
||||
try:
|
||||
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
|
||||
where={"id": waitlist_id},
|
||||
include={"joinedUsers": True},
|
||||
)
|
||||
|
||||
if not waitlist:
|
||||
raise ValueError(f"Waitlist {waitlist_id} not found")
|
||||
|
||||
if waitlist.isDeleted:
|
||||
raise ValueError(f"Waitlist {waitlist_id} has been deleted")
|
||||
|
||||
return _waitlist_to_admin_response(waitlist)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching waitlist {waitlist_id}: {e}")
|
||||
raise DatabaseError("Failed to fetch waitlist") from e
|
||||
|
||||
|
||||
async def update_waitlist_admin(
|
||||
waitlist_id: str,
|
||||
data: store_model.WaitlistUpdateRequest,
|
||||
) -> store_model.WaitlistAdminResponse:
|
||||
"""Update a waitlist (admin only)."""
|
||||
logger.info(f"Updating waitlist {waitlist_id}")
|
||||
|
||||
try:
|
||||
# Build update data from explicitly provided fields
|
||||
# Use model_fields_set to allow clearing fields by setting them to None
|
||||
field_mappings = {
|
||||
"name": data.name,
|
||||
"slug": data.slug,
|
||||
"subHeading": data.subHeading,
|
||||
"description": data.description,
|
||||
"categories": data.categories,
|
||||
"imageUrls": data.imageUrls,
|
||||
"videoUrl": data.videoUrl,
|
||||
"agentOutputDemoUrl": data.agentOutputDemoUrl,
|
||||
"storeListingId": data.storeListingId,
|
||||
}
|
||||
update_data: dict[str, typing.Any] = {
|
||||
k: v for k, v in field_mappings.items() if k in data.model_fields_set
|
||||
}
|
||||
|
||||
# Handle status separately due to enum conversion
|
||||
if "status" in data.model_fields_set and data.status is not None:
|
||||
update_data["status"] = prisma.enums.WaitlistExternalStatus(data.status)
|
||||
|
||||
if not update_data:
|
||||
# No updates, just return current data
|
||||
return await get_waitlist_admin(waitlist_id)
|
||||
|
||||
waitlist = await prisma.models.WaitlistEntry.prisma().update(
|
||||
where={"id": waitlist_id},
|
||||
data=prisma.types.WaitlistEntryUpdateInput(**update_data),
|
||||
include={"joinedUsers": True},
|
||||
)
|
||||
|
||||
if not waitlist:
|
||||
raise ValueError(f"Waitlist {waitlist_id} not found")
|
||||
|
||||
return _waitlist_to_admin_response(waitlist)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating waitlist {waitlist_id}: {e}")
|
||||
raise DatabaseError("Failed to update waitlist") from e
|
||||
|
||||
|
||||
async def delete_waitlist_admin(waitlist_id: str) -> bool:
|
||||
"""Soft delete a waitlist (admin only)."""
|
||||
logger.info(f"Soft deleting waitlist {waitlist_id}")
|
||||
|
||||
try:
|
||||
waitlist = await prisma.models.WaitlistEntry.prisma().update(
|
||||
where={"id": waitlist_id},
|
||||
data={"isDeleted": True},
|
||||
)
|
||||
|
||||
return waitlist is not None
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting waitlist {waitlist_id}: {e}")
|
||||
raise DatabaseError("Failed to delete waitlist") from e
|
||||
|
||||
|
||||
async def get_waitlist_signups_admin(
|
||||
waitlist_id: str,
|
||||
) -> store_model.WaitlistSignupListResponse:
|
||||
"""Get all signups for a waitlist (admin only)."""
|
||||
try:
|
||||
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
|
||||
where={"id": waitlist_id},
|
||||
include={"joinedUsers": True},
|
||||
)
|
||||
|
||||
if not waitlist:
|
||||
raise ValueError(f"Waitlist {waitlist_id} not found")
|
||||
|
||||
signups: list[store_model.WaitlistSignup] = []
|
||||
|
||||
# Add user signups
|
||||
for user in waitlist.joinedUsers or []:
|
||||
signups.append(
|
||||
store_model.WaitlistSignup(
|
||||
type="user",
|
||||
userId=user.id,
|
||||
email=user.email,
|
||||
username=user.name,
|
||||
)
|
||||
)
|
||||
|
||||
# Add email signups
|
||||
for email in waitlist.unaffiliatedEmailUsers or []:
|
||||
signups.append(
|
||||
store_model.WaitlistSignup(
|
||||
type="email",
|
||||
email=email,
|
||||
)
|
||||
)
|
||||
|
||||
return store_model.WaitlistSignupListResponse(
|
||||
waitlistId=waitlist_id,
|
||||
signups=signups,
|
||||
totalCount=len(signups),
|
||||
)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching signups for waitlist {waitlist_id}: {e}")
|
||||
raise DatabaseError("Failed to fetch waitlist signups") from e
|
||||
|
||||
|
||||
async def link_waitlist_to_listing_admin(
|
||||
waitlist_id: str,
|
||||
store_listing_id: str,
|
||||
) -> store_model.WaitlistAdminResponse:
|
||||
"""Link a waitlist to a store listing (admin only)."""
|
||||
logger.info(f"Linking waitlist {waitlist_id} to listing {store_listing_id}")
|
||||
|
||||
try:
|
||||
# Verify the store listing exists
|
||||
listing = await prisma.models.StoreListing.prisma().find_unique(
|
||||
where={"id": store_listing_id}
|
||||
)
|
||||
|
||||
if not listing:
|
||||
raise ValueError(f"Store listing {store_listing_id} not found")
|
||||
|
||||
waitlist = await prisma.models.WaitlistEntry.prisma().update(
|
||||
where={"id": waitlist_id},
|
||||
data={"StoreListing": {"connect": {"id": store_listing_id}}},
|
||||
include={"joinedUsers": True},
|
||||
)
|
||||
|
||||
if not waitlist:
|
||||
raise ValueError(f"Waitlist {waitlist_id} not found")
|
||||
|
||||
return _waitlist_to_admin_response(waitlist)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error linking waitlist to listing: {e}")
|
||||
raise DatabaseError("Failed to link waitlist to listing") from e
|
||||
|
||||
|
||||
async def notify_waitlist_users_on_launch(
|
||||
store_listing_id: str,
|
||||
agent_name: str,
|
||||
store_url: str,
|
||||
) -> int:
|
||||
"""
|
||||
Notify all users on waitlists linked to a store listing when the agent is launched.
|
||||
|
||||
Args:
|
||||
store_listing_id: The ID of the store listing that was approved
|
||||
agent_name: The name of the approved agent
|
||||
store_url: The URL to the agent's store page
|
||||
|
||||
Returns:
|
||||
The number of notifications sent
|
||||
"""
|
||||
logger.info(f"Notifying waitlist users for store listing {store_listing_id}")
|
||||
|
||||
try:
|
||||
# Find all waitlists linked to this store listing
|
||||
waitlists = await prisma.models.WaitlistEntry.prisma().find_many(
|
||||
where={
|
||||
"storeListingId": store_listing_id,
|
||||
"isDeleted": False,
|
||||
},
|
||||
include={"joinedUsers": True},
|
||||
)
|
||||
|
||||
if not waitlists:
|
||||
logger.info(f"No waitlists found for store listing {store_listing_id}")
|
||||
return 0
|
||||
|
||||
notification_count = 0
|
||||
launched_at = datetime.now(tz=timezone.utc)
|
||||
|
||||
for waitlist in waitlists:
|
||||
# Track notification results for this waitlist
|
||||
users_to_notify = waitlist.joinedUsers or []
|
||||
failed_user_ids: list[str] = []
|
||||
|
||||
# Notify registered users
|
||||
for user in users_to_notify:
|
||||
try:
|
||||
notification_data = WaitlistLaunchData(
|
||||
agent_name=agent_name,
|
||||
waitlist_name=waitlist.name,
|
||||
store_url=store_url,
|
||||
launched_at=launched_at,
|
||||
)
|
||||
|
||||
notification_event = NotificationEventModel[WaitlistLaunchData](
|
||||
user_id=user.id,
|
||||
type=prisma.enums.NotificationType.WAITLIST_LAUNCH,
|
||||
data=notification_data,
|
||||
)
|
||||
|
||||
await queue_notification_async(notification_event)
|
||||
notification_count += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to send waitlist launch notification to user {user.id}: {e}"
|
||||
)
|
||||
failed_user_ids.append(user.id)
|
||||
|
||||
# Note: For unaffiliated email users, you would need to send emails directly
|
||||
# since they don't have user IDs for the notification system.
|
||||
# This could be done via a separate email service.
|
||||
# For now, we log these for potential manual follow-up or future implementation.
|
||||
if waitlist.unaffiliatedEmailUsers:
|
||||
logger.info(
|
||||
f"Waitlist {waitlist.id} has {len(waitlist.unaffiliatedEmailUsers)} "
|
||||
f"unaffiliated email users that need email notifications"
|
||||
)
|
||||
|
||||
# Only mark waitlist as DONE if all registered user notifications succeeded
|
||||
if not failed_user_ids:
|
||||
await prisma.models.WaitlistEntry.prisma().update(
|
||||
where={"id": waitlist.id},
|
||||
data={"status": prisma.enums.WaitlistExternalStatus.DONE},
|
||||
)
|
||||
logger.info(f"Updated waitlist {waitlist.id} status to DONE")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Waitlist {waitlist.id} not marked as DONE due to "
|
||||
f"{len(failed_user_ids)} failed notifications"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Sent {notification_count} waitlist launch notifications for store listing {store_listing_id}"
|
||||
)
|
||||
return notification_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error notifying waitlist users for store listing {store_listing_id}: {e}"
|
||||
)
|
||||
# Don't raise - we don't want to fail the approval process
|
||||
return 0
|
||||
|
||||
@@ -110,7 +110,6 @@ class Profile(pydantic.BaseModel):
|
||||
|
||||
|
||||
class StoreSubmission(pydantic.BaseModel):
|
||||
listing_id: str
|
||||
agent_id: str
|
||||
agent_version: int
|
||||
name: str
|
||||
@@ -165,12 +164,8 @@ class StoreListingsWithVersionsResponse(pydantic.BaseModel):
|
||||
|
||||
|
||||
class StoreSubmissionRequest(pydantic.BaseModel):
|
||||
agent_id: str = pydantic.Field(
|
||||
..., min_length=1, description="Agent ID cannot be empty"
|
||||
)
|
||||
agent_version: int = pydantic.Field(
|
||||
..., gt=0, description="Agent version must be greater than 0"
|
||||
)
|
||||
agent_id: str
|
||||
agent_version: int
|
||||
slug: str
|
||||
name: str
|
||||
sub_heading: str
|
||||
@@ -221,99 +216,3 @@ class ReviewSubmissionRequest(pydantic.BaseModel):
|
||||
is_approved: bool
|
||||
comments: str # External comments visible to creator
|
||||
internal_comments: str | None = None # Private admin notes
|
||||
|
||||
|
||||
class StoreWaitlistEntry(pydantic.BaseModel):
|
||||
"""Public waitlist entry - no PII fields exposed."""
|
||||
|
||||
waitlistId: str
|
||||
slug: str
|
||||
|
||||
# Content fields
|
||||
name: str
|
||||
subHeading: str
|
||||
videoUrl: str | None = None
|
||||
agentOutputDemoUrl: str | None = None
|
||||
imageUrls: list[str]
|
||||
description: str
|
||||
categories: list[str]
|
||||
|
||||
|
||||
class StoreWaitlistsAllResponse(pydantic.BaseModel):
|
||||
listings: list[StoreWaitlistEntry]
|
||||
|
||||
|
||||
# Admin Waitlist Models
|
||||
|
||||
|
||||
class WaitlistCreateRequest(pydantic.BaseModel):
|
||||
"""Request model for creating a new waitlist."""
|
||||
|
||||
name: str
|
||||
slug: str
|
||||
subHeading: str
|
||||
description: str
|
||||
categories: list[str] = []
|
||||
imageUrls: list[str] = []
|
||||
videoUrl: str | None = None
|
||||
agentOutputDemoUrl: str | None = None
|
||||
|
||||
|
||||
class WaitlistUpdateRequest(pydantic.BaseModel):
|
||||
"""Request model for updating a waitlist."""
|
||||
|
||||
name: str | None = None
|
||||
slug: str | None = None
|
||||
subHeading: str | None = None
|
||||
description: str | None = None
|
||||
categories: list[str] | None = None
|
||||
imageUrls: list[str] | None = None
|
||||
videoUrl: str | None = None
|
||||
agentOutputDemoUrl: str | None = None
|
||||
status: str | None = None # WaitlistExternalStatus enum value
|
||||
storeListingId: str | None = None # Link to a store listing
|
||||
|
||||
|
||||
class WaitlistAdminResponse(pydantic.BaseModel):
|
||||
"""Admin response model with full waitlist details including internal data."""
|
||||
|
||||
id: str
|
||||
createdAt: str
|
||||
updatedAt: str
|
||||
slug: str
|
||||
name: str
|
||||
subHeading: str
|
||||
description: str
|
||||
categories: list[str]
|
||||
imageUrls: list[str]
|
||||
videoUrl: str | None = None
|
||||
agentOutputDemoUrl: str | None = None
|
||||
status: prisma.enums.WaitlistExternalStatus
|
||||
votes: int
|
||||
signupCount: int # Total count of joinedUsers + unaffiliatedEmailUsers
|
||||
storeListingId: str | None = None
|
||||
owningUserId: str
|
||||
|
||||
|
||||
class WaitlistSignup(pydantic.BaseModel):
|
||||
"""Individual signup entry for a waitlist."""
|
||||
|
||||
type: str # "user" or "email"
|
||||
userId: str | None = None
|
||||
email: str | None = None
|
||||
username: str | None = None # For user signups
|
||||
|
||||
|
||||
class WaitlistSignupListResponse(pydantic.BaseModel):
|
||||
"""Response model for listing waitlist signups."""
|
||||
|
||||
waitlistId: str
|
||||
signups: list[WaitlistSignup]
|
||||
totalCount: int
|
||||
|
||||
|
||||
class WaitlistAdminListResponse(pydantic.BaseModel):
|
||||
"""Response model for listing all waitlists (admin view)."""
|
||||
|
||||
waitlists: list[WaitlistAdminResponse]
|
||||
totalCount: int
|
||||
|
||||
@@ -138,7 +138,6 @@ def test_creator_details():
|
||||
|
||||
def test_store_submission():
|
||||
submission = store_model.StoreSubmission(
|
||||
listing_id="listing123",
|
||||
agent_id="agent123",
|
||||
agent_version=1,
|
||||
sub_heading="Test subheading",
|
||||
@@ -160,7 +159,6 @@ def test_store_submissions_response():
|
||||
response = store_model.StoreSubmissionsResponse(
|
||||
submissions=[
|
||||
store_model.StoreSubmission(
|
||||
listing_id="listing123",
|
||||
agent_id="agent123",
|
||||
agent_version=1,
|
||||
sub_heading="Test subheading",
|
||||
|
||||
@@ -7,7 +7,6 @@ from typing import Literal
|
||||
import autogpt_libs.auth
|
||||
import fastapi
|
||||
import fastapi.responses
|
||||
from autogpt_libs.auth.dependencies import get_optional_user_id
|
||||
|
||||
import backend.data.graph
|
||||
import backend.util.json
|
||||
@@ -79,63 +78,6 @@ async def update_or_create_profile(
|
||||
return updated_profile
|
||||
|
||||
|
||||
##############################################
|
||||
############## Waitlist Endpoints ############
|
||||
##############################################
|
||||
@router.get(
|
||||
"/waitlist",
|
||||
summary="Get the agent waitlist",
|
||||
tags=["store", "public"],
|
||||
response_model=store_model.StoreWaitlistsAllResponse,
|
||||
)
|
||||
async def get_waitlist():
|
||||
"""
|
||||
Get all active waitlists for public display.
|
||||
"""
|
||||
waitlists = await store_db.get_waitlist()
|
||||
return store_model.StoreWaitlistsAllResponse(listings=waitlists)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/waitlist/my-memberships",
|
||||
summary="Get waitlist IDs the current user has joined",
|
||||
tags=["store", "private"],
|
||||
)
|
||||
async def get_my_waitlist_memberships(
|
||||
user_id: str = fastapi.Security(autogpt_libs.auth.get_user_id),
|
||||
) -> list[str]:
|
||||
"""Returns list of waitlist IDs the authenticated user has joined."""
|
||||
return await store_db.get_user_waitlist_memberships(user_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
path="/waitlist/{waitlist_id}/join",
|
||||
summary="Add self to the agent waitlist",
|
||||
tags=["store", "public"],
|
||||
response_model=store_model.StoreWaitlistEntry,
|
||||
)
|
||||
async def add_self_to_waitlist(
|
||||
user_id: str | None = fastapi.Security(get_optional_user_id),
|
||||
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist to join"),
|
||||
email: str | None = fastapi.Body(
|
||||
default=None, embed=True, description="Email address for unauthenticated users"
|
||||
),
|
||||
):
|
||||
"""
|
||||
Add the current user to the agent waitlist.
|
||||
"""
|
||||
if not user_id and not email:
|
||||
raise fastapi.HTTPException(
|
||||
status_code=400,
|
||||
detail="Either user authentication or email address is required",
|
||||
)
|
||||
|
||||
waitlist_entry = await store_db.add_user_to_waitlist(
|
||||
waitlist_id=waitlist_id, user_id=user_id, email=email
|
||||
)
|
||||
return waitlist_entry
|
||||
|
||||
|
||||
##############################################
|
||||
############### Agent Endpoints ##############
|
||||
##############################################
|
||||
|
||||
@@ -521,7 +521,6 @@ def test_get_submissions_success(
|
||||
mocked_value = store_model.StoreSubmissionsResponse(
|
||||
submissions=[
|
||||
store_model.StoreSubmission(
|
||||
listing_id="test-listing-id",
|
||||
name="Test Agent",
|
||||
description="Test agent description",
|
||||
image_urls=["test.jpg"],
|
||||
|
||||
@@ -19,7 +19,6 @@ from prisma.errors import PrismaError
|
||||
import backend.api.features.admin.credit_admin_routes
|
||||
import backend.api.features.admin.execution_analytics_routes
|
||||
import backend.api.features.admin.store_admin_routes
|
||||
import backend.api.features.admin.waitlist_admin_routes
|
||||
import backend.api.features.builder
|
||||
import backend.api.features.builder.routes
|
||||
import backend.api.features.chat.routes as chat_routes
|
||||
@@ -284,11 +283,6 @@ app.include_router(
|
||||
tags=["v2", "admin"],
|
||||
prefix="/api/store",
|
||||
)
|
||||
app.include_router(
|
||||
backend.api.features.admin.waitlist_admin_routes.router,
|
||||
tags=["v2", "admin"],
|
||||
prefix="/api/store",
|
||||
)
|
||||
app.include_router(
|
||||
backend.api.features.admin.credit_admin_routes.router,
|
||||
tags=["v2", "admin"],
|
||||
|
||||
@@ -6,9 +6,6 @@ import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import cast
|
||||
|
||||
from prisma.types import Serializable
|
||||
|
||||
from backend.sdk import (
|
||||
BaseWebhooksManager,
|
||||
@@ -87,9 +84,7 @@ class AirtableWebhookManager(BaseWebhooksManager):
|
||||
# update webhook config
|
||||
await update_webhook(
|
||||
webhook.id,
|
||||
config=cast(
|
||||
dict[str, Serializable], {"base_id": base_id, "cursor": response.cursor}
|
||||
),
|
||||
config={"base_id": base_id, "cursor": response.cursor},
|
||||
)
|
||||
|
||||
event_type = "notification"
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
"""
|
||||
Shared helpers for Human-In-The-Loop (HITL) review functionality.
|
||||
Used by both the dedicated HumanInTheLoopBlock and blocks that require human review.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from prisma.enums import ReviewStatus
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.data.execution import ExecutionContext, ExecutionStatus
|
||||
from backend.data.human_review import ReviewResult
|
||||
from backend.executor.manager import async_update_node_execution_status
|
||||
from backend.util.clients import get_database_manager_async_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReviewDecision(BaseModel):
|
||||
"""Result of a review decision."""
|
||||
|
||||
should_proceed: bool
|
||||
message: str
|
||||
review_result: ReviewResult
|
||||
|
||||
|
||||
class HITLReviewHelper:
|
||||
"""Helper class for Human-In-The-Loop review operations."""
|
||||
|
||||
@staticmethod
|
||||
async def get_or_create_human_review(**kwargs) -> Optional[ReviewResult]:
|
||||
"""Create or retrieve a human review from the database."""
|
||||
return await get_database_manager_async_client().get_or_create_human_review(
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def update_node_execution_status(**kwargs) -> None:
|
||||
"""Update the execution status of a node."""
|
||||
await async_update_node_execution_status(
|
||||
db_client=get_database_manager_async_client(), **kwargs
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def update_review_processed_status(
|
||||
node_exec_id: str, processed: bool
|
||||
) -> None:
|
||||
"""Update the processed status of a review."""
|
||||
return await get_database_manager_async_client().update_review_processed_status(
|
||||
node_exec_id, processed
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _handle_review_request(
|
||||
input_data: Any,
|
||||
user_id: str,
|
||||
node_exec_id: str,
|
||||
graph_exec_id: str,
|
||||
graph_id: str,
|
||||
graph_version: int,
|
||||
execution_context: ExecutionContext,
|
||||
block_name: str = "Block",
|
||||
editable: bool = False,
|
||||
) -> Optional[ReviewResult]:
|
||||
"""
|
||||
Handle a review request for a block that requires human review.
|
||||
|
||||
Args:
|
||||
input_data: The input data to be reviewed
|
||||
user_id: ID of the user requesting the review
|
||||
node_exec_id: ID of the node execution
|
||||
graph_exec_id: ID of the graph execution
|
||||
graph_id: ID of the graph
|
||||
graph_version: Version of the graph
|
||||
execution_context: Current execution context
|
||||
block_name: Name of the block requesting review
|
||||
editable: Whether the reviewer can edit the data
|
||||
|
||||
Returns:
|
||||
ReviewResult if review is complete, None if waiting for human input
|
||||
|
||||
Raises:
|
||||
Exception: If review creation or status update fails
|
||||
"""
|
||||
# Skip review if safe mode is disabled - return auto-approved result
|
||||
if not execution_context.safe_mode:
|
||||
logger.info(
|
||||
f"Block {block_name} skipping review for node {node_exec_id} - safe mode disabled"
|
||||
)
|
||||
return ReviewResult(
|
||||
data=input_data,
|
||||
status=ReviewStatus.APPROVED,
|
||||
message="Auto-approved (safe mode disabled)",
|
||||
processed=True,
|
||||
node_exec_id=node_exec_id,
|
||||
)
|
||||
|
||||
result = await HITLReviewHelper.get_or_create_human_review(
|
||||
user_id=user_id,
|
||||
node_exec_id=node_exec_id,
|
||||
graph_exec_id=graph_exec_id,
|
||||
graph_id=graph_id,
|
||||
graph_version=graph_version,
|
||||
input_data=input_data,
|
||||
message=f"Review required for {block_name} execution",
|
||||
editable=editable,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
logger.info(
|
||||
f"Block {block_name} pausing execution for node {node_exec_id} - awaiting human review"
|
||||
)
|
||||
await HITLReviewHelper.update_node_execution_status(
|
||||
exec_id=node_exec_id,
|
||||
status=ExecutionStatus.REVIEW,
|
||||
)
|
||||
return None # Signal that execution should pause
|
||||
|
||||
# Mark review as processed if not already done
|
||||
if not result.processed:
|
||||
await HITLReviewHelper.update_review_processed_status(
|
||||
node_exec_id=node_exec_id, processed=True
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def handle_review_decision(
|
||||
input_data: Any,
|
||||
user_id: str,
|
||||
node_exec_id: str,
|
||||
graph_exec_id: str,
|
||||
graph_id: str,
|
||||
graph_version: int,
|
||||
execution_context: ExecutionContext,
|
||||
block_name: str = "Block",
|
||||
editable: bool = False,
|
||||
) -> Optional[ReviewDecision]:
|
||||
"""
|
||||
Handle a review request and return the decision in a single call.
|
||||
|
||||
Args:
|
||||
input_data: The input data to be reviewed
|
||||
user_id: ID of the user requesting the review
|
||||
node_exec_id: ID of the node execution
|
||||
graph_exec_id: ID of the graph execution
|
||||
graph_id: ID of the graph
|
||||
graph_version: Version of the graph
|
||||
execution_context: Current execution context
|
||||
block_name: Name of the block requesting review
|
||||
editable: Whether the reviewer can edit the data
|
||||
|
||||
Returns:
|
||||
ReviewDecision if review is complete (approved/rejected),
|
||||
None if execution should pause (awaiting review)
|
||||
"""
|
||||
review_result = await HITLReviewHelper._handle_review_request(
|
||||
input_data=input_data,
|
||||
user_id=user_id,
|
||||
node_exec_id=node_exec_id,
|
||||
graph_exec_id=graph_exec_id,
|
||||
graph_id=graph_id,
|
||||
graph_version=graph_version,
|
||||
execution_context=execution_context,
|
||||
block_name=block_name,
|
||||
editable=editable,
|
||||
)
|
||||
|
||||
if review_result is None:
|
||||
# Still awaiting review - return None to pause execution
|
||||
return None
|
||||
|
||||
# Review is complete, determine outcome
|
||||
should_proceed = review_result.status == ReviewStatus.APPROVED
|
||||
message = review_result.message or (
|
||||
"Execution approved by reviewer"
|
||||
if should_proceed
|
||||
else "Execution rejected by reviewer"
|
||||
)
|
||||
|
||||
return ReviewDecision(
|
||||
should_proceed=should_proceed, message=message, review_result=review_result
|
||||
)
|
||||
@@ -3,7 +3,6 @@ from typing import Any
|
||||
|
||||
from prisma.enums import ReviewStatus
|
||||
|
||||
from backend.blocks.helpers.review import HITLReviewHelper
|
||||
from backend.data.block import (
|
||||
Block,
|
||||
BlockCategory,
|
||||
@@ -12,9 +11,11 @@ from backend.data.block import (
|
||||
BlockSchemaOutput,
|
||||
BlockType,
|
||||
)
|
||||
from backend.data.execution import ExecutionContext
|
||||
from backend.data.execution import ExecutionContext, ExecutionStatus
|
||||
from backend.data.human_review import ReviewResult
|
||||
from backend.data.model import SchemaField
|
||||
from backend.executor.manager import async_update_node_execution_status
|
||||
from backend.util.clients import get_database_manager_async_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -71,26 +72,32 @@ class HumanInTheLoopBlock(Block):
|
||||
("approved_data", {"name": "John Doe", "age": 30}),
|
||||
],
|
||||
test_mock={
|
||||
"handle_review_decision": lambda **kwargs: type(
|
||||
"ReviewDecision",
|
||||
(),
|
||||
{
|
||||
"should_proceed": True,
|
||||
"message": "Test approval message",
|
||||
"review_result": ReviewResult(
|
||||
data={"name": "John Doe", "age": 30},
|
||||
status=ReviewStatus.APPROVED,
|
||||
message="",
|
||||
processed=False,
|
||||
node_exec_id="test-node-exec-id",
|
||||
),
|
||||
},
|
||||
)(),
|
||||
"get_or_create_human_review": lambda *_args, **_kwargs: ReviewResult(
|
||||
data={"name": "John Doe", "age": 30},
|
||||
status=ReviewStatus.APPROVED,
|
||||
message="",
|
||||
processed=False,
|
||||
node_exec_id="test-node-exec-id",
|
||||
),
|
||||
"update_node_execution_status": lambda *_args, **_kwargs: None,
|
||||
"update_review_processed_status": lambda *_args, **_kwargs: None,
|
||||
},
|
||||
)
|
||||
|
||||
async def handle_review_decision(self, **kwargs):
|
||||
return await HITLReviewHelper.handle_review_decision(**kwargs)
|
||||
async def get_or_create_human_review(self, **kwargs):
|
||||
return await get_database_manager_async_client().get_or_create_human_review(
|
||||
**kwargs
|
||||
)
|
||||
|
||||
async def update_node_execution_status(self, **kwargs):
|
||||
return await async_update_node_execution_status(
|
||||
db_client=get_database_manager_async_client(), **kwargs
|
||||
)
|
||||
|
||||
async def update_review_processed_status(self, node_exec_id: str, processed: bool):
|
||||
return await get_database_manager_async_client().update_review_processed_status(
|
||||
node_exec_id, processed
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
@@ -102,7 +109,7 @@ class HumanInTheLoopBlock(Block):
|
||||
graph_id: str,
|
||||
graph_version: int,
|
||||
execution_context: ExecutionContext,
|
||||
**_kwargs,
|
||||
**kwargs,
|
||||
) -> BlockOutput:
|
||||
if not execution_context.safe_mode:
|
||||
logger.info(
|
||||
@@ -112,28 +119,48 @@ class HumanInTheLoopBlock(Block):
|
||||
yield "review_message", "Auto-approved (safe mode disabled)"
|
||||
return
|
||||
|
||||
decision = await self.handle_review_decision(
|
||||
input_data=input_data.data,
|
||||
user_id=user_id,
|
||||
node_exec_id=node_exec_id,
|
||||
graph_exec_id=graph_exec_id,
|
||||
graph_id=graph_id,
|
||||
graph_version=graph_version,
|
||||
execution_context=execution_context,
|
||||
block_name=self.name,
|
||||
editable=input_data.editable,
|
||||
)
|
||||
try:
|
||||
result = await self.get_or_create_human_review(
|
||||
user_id=user_id,
|
||||
node_exec_id=node_exec_id,
|
||||
graph_exec_id=graph_exec_id,
|
||||
graph_id=graph_id,
|
||||
graph_version=graph_version,
|
||||
input_data=input_data.data,
|
||||
message=input_data.name,
|
||||
editable=input_data.editable,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in HITL block for node {node_exec_id}: {str(e)}")
|
||||
raise
|
||||
|
||||
if decision is None:
|
||||
return
|
||||
if result is None:
|
||||
logger.info(
|
||||
f"HITL block pausing execution for node {node_exec_id} - awaiting human review"
|
||||
)
|
||||
try:
|
||||
await self.update_node_execution_status(
|
||||
exec_id=node_exec_id,
|
||||
status=ExecutionStatus.REVIEW,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to update node status for HITL block {node_exec_id}: {str(e)}"
|
||||
)
|
||||
raise
|
||||
|
||||
status = decision.review_result.status
|
||||
if status == ReviewStatus.APPROVED:
|
||||
yield "approved_data", decision.review_result.data
|
||||
elif status == ReviewStatus.REJECTED:
|
||||
yield "rejected_data", decision.review_result.data
|
||||
else:
|
||||
raise RuntimeError(f"Unexpected review status: {status}")
|
||||
if not result.processed:
|
||||
await self.update_review_processed_status(
|
||||
node_exec_id=node_exec_id, processed=True
|
||||
)
|
||||
|
||||
if decision.message:
|
||||
yield "review_message", decision.message
|
||||
if result.status == ReviewStatus.APPROVED:
|
||||
yield "approved_data", result.data
|
||||
if result.message:
|
||||
yield "review_message", result.message
|
||||
|
||||
elif result.status == ReviewStatus.REJECTED:
|
||||
yield "rejected_data", result.data
|
||||
if result.message:
|
||||
yield "review_message", result.message
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -391,12 +391,8 @@ class SmartDecisionMakerBlock(Block):
|
||||
"""
|
||||
block = sink_node.block
|
||||
|
||||
# Use custom name from node metadata if set, otherwise fall back to block.name
|
||||
custom_name = sink_node.metadata.get("customized_name")
|
||||
tool_name = custom_name if custom_name else block.name
|
||||
|
||||
tool_function: dict[str, Any] = {
|
||||
"name": SmartDecisionMakerBlock.cleanup(tool_name),
|
||||
"name": SmartDecisionMakerBlock.cleanup(block.name),
|
||||
"description": block.description,
|
||||
}
|
||||
sink_block_input_schema = block.input_schema
|
||||
@@ -493,24 +489,14 @@ class SmartDecisionMakerBlock(Block):
|
||||
f"Sink graph metadata not found: {graph_id} {graph_version}"
|
||||
)
|
||||
|
||||
# Use custom name from node metadata if set, otherwise fall back to graph name
|
||||
custom_name = sink_node.metadata.get("customized_name")
|
||||
tool_name = custom_name if custom_name else sink_graph_meta.name
|
||||
|
||||
tool_function: dict[str, Any] = {
|
||||
"name": SmartDecisionMakerBlock.cleanup(tool_name),
|
||||
"name": SmartDecisionMakerBlock.cleanup(sink_graph_meta.name),
|
||||
"description": sink_graph_meta.description,
|
||||
}
|
||||
|
||||
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, {}
|
||||
@@ -520,7 +506,7 @@ class SmartDecisionMakerBlock(Block):
|
||||
if "description" in sink_block_properties
|
||||
else f"The {link.sink_name} of the tool"
|
||||
)
|
||||
properties[clean_field_name] = {
|
||||
properties[link.sink_name] = {
|
||||
"type": "string",
|
||||
"description": description,
|
||||
"default": json.dumps(sink_block_properties.get("default", None)),
|
||||
@@ -533,7 +519,7 @@ class SmartDecisionMakerBlock(Block):
|
||||
"strict": True,
|
||||
}
|
||||
|
||||
tool_function["_field_mapping"] = field_mapping
|
||||
# Store node info for later use in output processing
|
||||
tool_function["_sink_node_id"] = sink_node.id
|
||||
|
||||
return {"type": "function", "function": tool_function}
|
||||
@@ -989,28 +975,10 @@ class SmartDecisionMakerBlock(Block):
|
||||
graph_version: int,
|
||||
execution_context: ExecutionContext,
|
||||
execution_processor: "ExecutionProcessor",
|
||||
nodes_to_skip: set[str] | None = None,
|
||||
**kwargs,
|
||||
) -> BlockOutput:
|
||||
|
||||
tool_functions = await self._create_tool_node_signatures(node_id)
|
||||
original_tool_count = len(tool_functions)
|
||||
|
||||
# Filter out tools for nodes that should be skipped (e.g., missing optional credentials)
|
||||
if nodes_to_skip:
|
||||
tool_functions = [
|
||||
tf
|
||||
for tf in tool_functions
|
||||
if tf.get("function", {}).get("_sink_node_id") not in nodes_to_skip
|
||||
]
|
||||
|
||||
# Only raise error if we had tools but they were all filtered out
|
||||
if original_tool_count > 0 and not tool_functions:
|
||||
raise ValueError(
|
||||
"No available tools to execute - all downstream nodes are unavailable "
|
||||
"(possibly due to missing optional credentials)"
|
||||
)
|
||||
|
||||
yield "tool_functions", json.dumps(tool_functions)
|
||||
|
||||
conversation_history = input_data.conversation_history or []
|
||||
@@ -1161,9 +1129,8 @@ class SmartDecisionMakerBlock(Block):
|
||||
original_field_name = field_mapping.get(clean_arg_name, clean_arg_name)
|
||||
arg_value = tool_args.get(clean_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}"
|
||||
sanitized_arg_name = self.cleanup(original_field_name)
|
||||
emit_key = f"tools_^_{sink_node_id}_~_{sanitized_arg_name}"
|
||||
|
||||
logger.debug(
|
||||
"[SmartDecisionMakerBlock|geid:%s|neid:%s] emit %s",
|
||||
|
||||
@@ -1057,153 +1057,3 @@ async def test_smart_decision_maker_traditional_mode_default():
|
||||
) # Should yield individual tool parameters
|
||||
assert "tools_^_test-sink-node-id_~_max_keyword_difficulty" in outputs
|
||||
assert "conversations" in outputs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_smart_decision_maker_uses_customized_name_for_blocks():
|
||||
"""Test that SmartDecisionMakerBlock uses customized_name from node metadata for tool names."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from backend.blocks.basic import StoreValueBlock
|
||||
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
|
||||
from backend.data.graph import Link, Node
|
||||
|
||||
# Create a mock node with customized_name in metadata
|
||||
mock_node = MagicMock(spec=Node)
|
||||
mock_node.id = "test-node-id"
|
||||
mock_node.block_id = StoreValueBlock().id
|
||||
mock_node.metadata = {"customized_name": "My Custom Tool Name"}
|
||||
mock_node.block = StoreValueBlock()
|
||||
|
||||
# Create a mock link
|
||||
mock_link = MagicMock(spec=Link)
|
||||
mock_link.sink_name = "input"
|
||||
|
||||
# Call the function directly
|
||||
result = await SmartDecisionMakerBlock._create_block_function_signature(
|
||||
mock_node, [mock_link]
|
||||
)
|
||||
|
||||
# Verify the tool name uses the customized name (cleaned up)
|
||||
assert result["type"] == "function"
|
||||
assert result["function"]["name"] == "my_custom_tool_name" # Cleaned version
|
||||
assert result["function"]["_sink_node_id"] == "test-node-id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_smart_decision_maker_falls_back_to_block_name():
|
||||
"""Test that SmartDecisionMakerBlock falls back to block.name when no customized_name."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from backend.blocks.basic import StoreValueBlock
|
||||
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
|
||||
from backend.data.graph import Link, Node
|
||||
|
||||
# Create a mock node without customized_name
|
||||
mock_node = MagicMock(spec=Node)
|
||||
mock_node.id = "test-node-id"
|
||||
mock_node.block_id = StoreValueBlock().id
|
||||
mock_node.metadata = {} # No customized_name
|
||||
mock_node.block = StoreValueBlock()
|
||||
|
||||
# Create a mock link
|
||||
mock_link = MagicMock(spec=Link)
|
||||
mock_link.sink_name = "input"
|
||||
|
||||
# Call the function directly
|
||||
result = await SmartDecisionMakerBlock._create_block_function_signature(
|
||||
mock_node, [mock_link]
|
||||
)
|
||||
|
||||
# Verify the tool name uses the block's default name
|
||||
assert result["type"] == "function"
|
||||
assert result["function"]["name"] == "storevalueblock" # Default block name cleaned
|
||||
assert result["function"]["_sink_node_id"] == "test-node-id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_smart_decision_maker_uses_customized_name_for_agents():
|
||||
"""Test that SmartDecisionMakerBlock uses customized_name from metadata for agent nodes."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
|
||||
from backend.data.graph import Link, Node
|
||||
|
||||
# Create a mock node with customized_name in metadata
|
||||
mock_node = MagicMock(spec=Node)
|
||||
mock_node.id = "test-agent-node-id"
|
||||
mock_node.metadata = {"customized_name": "My Custom Agent"}
|
||||
mock_node.input_default = {
|
||||
"graph_id": "test-graph-id",
|
||||
"graph_version": 1,
|
||||
"input_schema": {"properties": {"test_input": {"description": "Test input"}}},
|
||||
}
|
||||
|
||||
# Create a mock link
|
||||
mock_link = MagicMock(spec=Link)
|
||||
mock_link.sink_name = "test_input"
|
||||
|
||||
# Mock the database client
|
||||
mock_graph_meta = MagicMock()
|
||||
mock_graph_meta.name = "Original Agent Name"
|
||||
mock_graph_meta.description = "Agent description"
|
||||
|
||||
mock_db_client = AsyncMock()
|
||||
mock_db_client.get_graph_metadata.return_value = mock_graph_meta
|
||||
|
||||
with patch(
|
||||
"backend.blocks.smart_decision_maker.get_database_manager_async_client",
|
||||
return_value=mock_db_client,
|
||||
):
|
||||
result = await SmartDecisionMakerBlock._create_agent_function_signature(
|
||||
mock_node, [mock_link]
|
||||
)
|
||||
|
||||
# Verify the tool name uses the customized name (cleaned up)
|
||||
assert result["type"] == "function"
|
||||
assert result["function"]["name"] == "my_custom_agent" # Cleaned version
|
||||
assert result["function"]["_sink_node_id"] == "test-agent-node-id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_smart_decision_maker_agent_falls_back_to_graph_name():
|
||||
"""Test that agent node falls back to graph name when no customized_name."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
|
||||
from backend.data.graph import Link, Node
|
||||
|
||||
# Create a mock node without customized_name
|
||||
mock_node = MagicMock(spec=Node)
|
||||
mock_node.id = "test-agent-node-id"
|
||||
mock_node.metadata = {} # No customized_name
|
||||
mock_node.input_default = {
|
||||
"graph_id": "test-graph-id",
|
||||
"graph_version": 1,
|
||||
"input_schema": {"properties": {"test_input": {"description": "Test input"}}},
|
||||
}
|
||||
|
||||
# Create a mock link
|
||||
mock_link = MagicMock(spec=Link)
|
||||
mock_link.sink_name = "test_input"
|
||||
|
||||
# Mock the database client
|
||||
mock_graph_meta = MagicMock()
|
||||
mock_graph_meta.name = "Original Agent Name"
|
||||
mock_graph_meta.description = "Agent description"
|
||||
|
||||
mock_db_client = AsyncMock()
|
||||
mock_db_client.get_graph_metadata.return_value = mock_graph_meta
|
||||
|
||||
with patch(
|
||||
"backend.blocks.smart_decision_maker.get_database_manager_async_client",
|
||||
return_value=mock_db_client,
|
||||
):
|
||||
result = await SmartDecisionMakerBlock._create_agent_function_signature(
|
||||
mock_node, [mock_link]
|
||||
)
|
||||
|
||||
# Verify the tool name uses the graph's default name
|
||||
assert result["type"] == "function"
|
||||
assert result["function"]["name"] == "original_agent_name" # Graph name cleaned
|
||||
assert result["function"]["_sink_node_id"] == "test-agent-node-id"
|
||||
|
||||
@@ -15,7 +15,6 @@ async def test_smart_decision_maker_handles_dynamic_dict_fields():
|
||||
mock_node.block = CreateDictionaryBlock()
|
||||
mock_node.block_id = CreateDictionaryBlock().id
|
||||
mock_node.input_default = {}
|
||||
mock_node.metadata = {}
|
||||
|
||||
# Create mock links with dynamic dictionary fields
|
||||
mock_links = [
|
||||
@@ -78,7 +77,6 @@ async def test_smart_decision_maker_handles_dynamic_list_fields():
|
||||
mock_node.block = AddToListBlock()
|
||||
mock_node.block_id = AddToListBlock().id
|
||||
mock_node.input_default = {}
|
||||
mock_node.metadata = {}
|
||||
|
||||
# Create mock links with dynamic list fields
|
||||
mock_links = [
|
||||
|
||||
@@ -44,7 +44,6 @@ async def test_create_block_function_signature_with_dict_fields():
|
||||
mock_node.block = CreateDictionaryBlock()
|
||||
mock_node.block_id = CreateDictionaryBlock().id
|
||||
mock_node.input_default = {}
|
||||
mock_node.metadata = {}
|
||||
|
||||
# Create mock links with dynamic dictionary fields (source sanitized, sink original)
|
||||
mock_links = [
|
||||
@@ -107,7 +106,6 @@ async def test_create_block_function_signature_with_list_fields():
|
||||
mock_node.block = AddToListBlock()
|
||||
mock_node.block_id = AddToListBlock().id
|
||||
mock_node.input_default = {}
|
||||
mock_node.metadata = {}
|
||||
|
||||
# Create mock links with dynamic list fields
|
||||
mock_links = [
|
||||
@@ -161,7 +159,6 @@ async def test_create_block_function_signature_with_object_fields():
|
||||
mock_node.block = MatchTextPatternBlock()
|
||||
mock_node.block_id = MatchTextPatternBlock().id
|
||||
mock_node.input_default = {}
|
||||
mock_node.metadata = {}
|
||||
|
||||
# Create mock links with dynamic object fields
|
||||
mock_links = [
|
||||
@@ -211,13 +208,11 @@ async def test_create_tool_node_signatures():
|
||||
mock_dict_node.block = CreateDictionaryBlock()
|
||||
mock_dict_node.block_id = CreateDictionaryBlock().id
|
||||
mock_dict_node.input_default = {}
|
||||
mock_dict_node.metadata = {}
|
||||
|
||||
mock_list_node = Mock()
|
||||
mock_list_node.block = AddToListBlock()
|
||||
mock_list_node.block_id = AddToListBlock().id
|
||||
mock_list_node.input_default = {}
|
||||
mock_list_node.metadata = {}
|
||||
|
||||
# Mock links with dynamic fields
|
||||
dict_link1 = Mock(
|
||||
@@ -428,7 +423,6 @@ async def test_mixed_regular_and_dynamic_fields():
|
||||
mock_node.block.name = "TestBlock"
|
||||
mock_node.block.description = "A test block"
|
||||
mock_node.block.input_schema = Mock()
|
||||
mock_node.metadata = {}
|
||||
|
||||
# Mock the get_field_schema to return a proper schema for regular fields
|
||||
def get_field_schema(field_name):
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from .blog import WordPressCreatePostBlock, WordPressGetAllPostsBlock
|
||||
from .blog import WordPressCreatePostBlock
|
||||
|
||||
__all__ = ["WordPressCreatePostBlock", "WordPressGetAllPostsBlock"]
|
||||
__all__ = ["WordPressCreatePostBlock"]
|
||||
|
||||
@@ -161,7 +161,7 @@ async def oauth_exchange_code_for_tokens(
|
||||
grant_type="authorization_code",
|
||||
).model_dump(exclude_none=True)
|
||||
|
||||
response = await Requests(raise_for_status=False).post(
|
||||
response = await Requests().post(
|
||||
f"{WORDPRESS_BASE_URL}oauth2/token",
|
||||
headers=headers,
|
||||
data=data,
|
||||
@@ -205,7 +205,7 @@ async def oauth_refresh_tokens(
|
||||
grant_type="refresh_token",
|
||||
).model_dump(exclude_none=True)
|
||||
|
||||
response = await Requests(raise_for_status=False).post(
|
||||
response = await Requests().post(
|
||||
f"{WORDPRESS_BASE_URL}oauth2/token",
|
||||
headers=headers,
|
||||
data=data,
|
||||
@@ -252,7 +252,7 @@ async def validate_token(
|
||||
"token": token,
|
||||
}
|
||||
|
||||
response = await Requests(raise_for_status=False).get(
|
||||
response = await Requests().get(
|
||||
f"{WORDPRESS_BASE_URL}oauth2/token-info",
|
||||
params=params,
|
||||
)
|
||||
@@ -296,7 +296,7 @@ async def make_api_request(
|
||||
|
||||
url = f"{WORDPRESS_BASE_URL.rstrip('/')}{endpoint}"
|
||||
|
||||
request_method = getattr(Requests(raise_for_status=False), method.lower())
|
||||
request_method = getattr(Requests(), method.lower())
|
||||
response = await request_method(
|
||||
url,
|
||||
headers=headers,
|
||||
@@ -476,7 +476,6 @@ async def create_post(
|
||||
data["tags"] = ",".join(str(t) for t in data["tags"])
|
||||
|
||||
# Make the API request
|
||||
site = normalize_site(site)
|
||||
endpoint = f"/rest/v1.1/sites/{site}/posts/new"
|
||||
|
||||
headers = {
|
||||
@@ -484,7 +483,7 @@ async def create_post(
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
|
||||
response = await Requests(raise_for_status=False).post(
|
||||
response = await Requests().post(
|
||||
f"{WORDPRESS_BASE_URL.rstrip('/')}{endpoint}",
|
||||
headers=headers,
|
||||
data=data,
|
||||
@@ -500,132 +499,3 @@ async def create_post(
|
||||
)
|
||||
error_message = error_data.get("message", response.text)
|
||||
raise ValueError(f"Failed to create post: {response.status} - {error_message}")
|
||||
|
||||
|
||||
class Post(BaseModel):
|
||||
"""Response model for individual posts in a posts list response.
|
||||
|
||||
This is a simplified version compared to PostResponse, as the list endpoint
|
||||
returns less detailed information than the create/get single post endpoints.
|
||||
"""
|
||||
|
||||
ID: int
|
||||
site_ID: int
|
||||
author: PostAuthor
|
||||
date: datetime
|
||||
modified: datetime
|
||||
title: str
|
||||
URL: str
|
||||
short_URL: str
|
||||
content: str | None = None
|
||||
excerpt: str | None = None
|
||||
slug: str
|
||||
guid: str
|
||||
status: str
|
||||
sticky: bool
|
||||
password: str | None = ""
|
||||
parent: Union[Dict[str, Any], bool, None] = None
|
||||
type: str
|
||||
discussion: Dict[str, Union[str, bool, int]] | None = None
|
||||
likes_enabled: bool | None = None
|
||||
sharing_enabled: bool | None = None
|
||||
like_count: int | None = None
|
||||
i_like: bool | None = None
|
||||
is_reblogged: bool | None = None
|
||||
is_following: bool | None = None
|
||||
global_ID: str | None = None
|
||||
featured_image: str | None = None
|
||||
post_thumbnail: Dict[str, Any] | None = None
|
||||
format: str | None = None
|
||||
geo: Union[Dict[str, Any], bool, None] = None
|
||||
menu_order: int | None = None
|
||||
page_template: str | None = None
|
||||
publicize_URLs: List[str] | None = None
|
||||
terms: Dict[str, Dict[str, Any]] | None = None
|
||||
tags: Dict[str, Dict[str, Any]] | None = None
|
||||
categories: Dict[str, Dict[str, Any]] | None = None
|
||||
attachments: Dict[str, Dict[str, Any]] | None = None
|
||||
attachment_count: int | None = None
|
||||
metadata: List[Dict[str, Any]] | None = None
|
||||
meta: Dict[str, Any] | None = None
|
||||
capabilities: Dict[str, bool] | None = None
|
||||
revisions: List[int] | None = None
|
||||
other_URLs: Dict[str, Any] | None = None
|
||||
|
||||
|
||||
class PostsResponse(BaseModel):
|
||||
"""Response model for WordPress posts list."""
|
||||
|
||||
found: int
|
||||
posts: List[Post]
|
||||
meta: Dict[str, Any]
|
||||
|
||||
|
||||
def normalize_site(site: str) -> str:
|
||||
"""
|
||||
Normalize a site identifier by stripping protocol and trailing slashes.
|
||||
|
||||
Args:
|
||||
site: Site URL, domain, or ID (e.g., "https://myblog.wordpress.com/", "myblog.wordpress.com", "123456789")
|
||||
|
||||
Returns:
|
||||
Normalized site identifier (domain or ID only)
|
||||
"""
|
||||
site = site.strip()
|
||||
if site.startswith("https://"):
|
||||
site = site[8:]
|
||||
elif site.startswith("http://"):
|
||||
site = site[7:]
|
||||
return site.rstrip("/")
|
||||
|
||||
|
||||
async def get_posts(
|
||||
credentials: Credentials,
|
||||
site: str,
|
||||
status: PostStatus | None = None,
|
||||
number: int = 100,
|
||||
offset: int = 0,
|
||||
) -> PostsResponse:
|
||||
"""
|
||||
Get posts from a WordPress site.
|
||||
|
||||
Args:
|
||||
credentials: OAuth credentials
|
||||
site: Site ID or domain (e.g., "myblog.wordpress.com" or "123456789")
|
||||
status: Filter by post status using PostStatus enum, or None for all
|
||||
number: Number of posts to retrieve (max 100)
|
||||
offset: Number of posts to skip (for pagination)
|
||||
|
||||
Returns:
|
||||
PostsResponse with the list of posts
|
||||
"""
|
||||
site = normalize_site(site)
|
||||
endpoint = f"/rest/v1.1/sites/{site}/posts"
|
||||
|
||||
headers = {
|
||||
"Authorization": credentials.auth_header(),
|
||||
}
|
||||
|
||||
params: Dict[str, Any] = {
|
||||
"number": max(1, min(number, 100)), # 1–100 posts per request
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
if status:
|
||||
params["status"] = status.value
|
||||
response = await Requests(raise_for_status=False).get(
|
||||
f"{WORDPRESS_BASE_URL.rstrip('/')}{endpoint}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
if response.ok:
|
||||
return PostsResponse.model_validate(response.json())
|
||||
|
||||
error_data = (
|
||||
response.json()
|
||||
if response.headers.get("content-type", "").startswith("application/json")
|
||||
else {}
|
||||
)
|
||||
error_message = error_data.get("message", response.text)
|
||||
raise ValueError(f"Failed to get posts: {response.status} - {error_message}")
|
||||
|
||||
@@ -9,15 +9,7 @@ from backend.sdk import (
|
||||
SchemaField,
|
||||
)
|
||||
|
||||
from ._api import (
|
||||
CreatePostRequest,
|
||||
Post,
|
||||
PostResponse,
|
||||
PostsResponse,
|
||||
PostStatus,
|
||||
create_post,
|
||||
get_posts,
|
||||
)
|
||||
from ._api import CreatePostRequest, PostResponse, PostStatus, create_post
|
||||
from ._config import wordpress
|
||||
|
||||
|
||||
@@ -57,15 +49,8 @@ class WordPressCreatePostBlock(Block):
|
||||
media_urls: list[str] = SchemaField(
|
||||
description="URLs of images to sideload and attach to the post", default=[]
|
||||
)
|
||||
publish_as_draft: bool = SchemaField(
|
||||
description="If True, publishes the post as a draft. If False, publishes it publicly.",
|
||||
default=False,
|
||||
)
|
||||
|
||||
class Output(BlockSchemaOutput):
|
||||
site: str = SchemaField(
|
||||
description="The site ID or domain (pass-through for chaining with other blocks)"
|
||||
)
|
||||
post_id: int = SchemaField(description="The ID of the created post")
|
||||
post_url: str = SchemaField(description="The full URL of the created post")
|
||||
short_url: str = SchemaField(description="The shortened wp.me URL")
|
||||
@@ -93,9 +78,7 @@ class WordPressCreatePostBlock(Block):
|
||||
tags=input_data.tags,
|
||||
featured_image=input_data.featured_image,
|
||||
media_urls=input_data.media_urls,
|
||||
status=(
|
||||
PostStatus.DRAFT if input_data.publish_as_draft else PostStatus.PUBLISH
|
||||
),
|
||||
status=PostStatus.PUBLISH,
|
||||
)
|
||||
|
||||
post_response: PostResponse = await create_post(
|
||||
@@ -104,69 +87,7 @@ class WordPressCreatePostBlock(Block):
|
||||
post_data=post_request,
|
||||
)
|
||||
|
||||
yield "site", input_data.site
|
||||
yield "post_id", post_response.ID
|
||||
yield "post_url", post_response.URL
|
||||
yield "short_url", post_response.short_URL
|
||||
yield "post_data", post_response.model_dump()
|
||||
|
||||
|
||||
class WordPressGetAllPostsBlock(Block):
|
||||
"""
|
||||
Fetches all posts from a WordPress.com site or Jetpack-enabled site.
|
||||
Supports filtering by status and pagination.
|
||||
"""
|
||||
|
||||
class Input(BlockSchemaInput):
|
||||
credentials: CredentialsMetaInput = wordpress.credentials_field()
|
||||
site: str = SchemaField(
|
||||
description="Site ID or domain (e.g., 'myblog.wordpress.com' or '123456789')"
|
||||
)
|
||||
status: PostStatus | None = SchemaField(
|
||||
description="Filter by post status, or None for all",
|
||||
default=None,
|
||||
)
|
||||
number: int = SchemaField(
|
||||
description="Number of posts to retrieve (max 100 per request)", default=20
|
||||
)
|
||||
offset: int = SchemaField(
|
||||
description="Number of posts to skip (for pagination)", default=0
|
||||
)
|
||||
|
||||
class Output(BlockSchemaOutput):
|
||||
site: str = SchemaField(
|
||||
description="The site ID or domain (pass-through for chaining with other blocks)"
|
||||
)
|
||||
found: int = SchemaField(description="Total number of posts found")
|
||||
posts: list[Post] = SchemaField(
|
||||
description="List of post objects with their details"
|
||||
)
|
||||
post: Post = SchemaField(
|
||||
description="Individual post object (yielded for each post)"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
id="97728fa7-7f6f-4789-ba0c-f2c114119536",
|
||||
description="Fetch all posts from WordPress.com or Jetpack sites",
|
||||
categories={BlockCategory.SOCIAL},
|
||||
input_schema=self.Input,
|
||||
output_schema=self.Output,
|
||||
)
|
||||
|
||||
async def run(
|
||||
self, input_data: Input, *, credentials: Credentials, **kwargs
|
||||
) -> BlockOutput:
|
||||
posts_response: PostsResponse = await get_posts(
|
||||
credentials=credentials,
|
||||
site=input_data.site,
|
||||
status=input_data.status,
|
||||
number=input_data.number,
|
||||
offset=input_data.offset,
|
||||
)
|
||||
|
||||
yield "site", input_data.site
|
||||
yield "found", posts_response.found
|
||||
yield "posts", posts_response.posts
|
||||
for post in posts_response.posts:
|
||||
yield "post", post
|
||||
|
||||
@@ -50,8 +50,6 @@ from .model import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.data.execution import ExecutionContext
|
||||
|
||||
from .graph import Link
|
||||
|
||||
app_config = Config()
|
||||
@@ -474,7 +472,6 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
|
||||
self.block_type = block_type
|
||||
self.webhook_config = webhook_config
|
||||
self.execution_stats: NodeExecutionStats = NodeExecutionStats()
|
||||
self.requires_human_review: bool = False
|
||||
|
||||
if self.webhook_config:
|
||||
if isinstance(self.webhook_config, BlockWebhookConfig):
|
||||
@@ -617,77 +614,7 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
|
||||
block_id=self.id,
|
||||
) from ex
|
||||
|
||||
async def is_block_exec_need_review(
|
||||
self,
|
||||
input_data: BlockInput,
|
||||
*,
|
||||
user_id: str,
|
||||
node_exec_id: str,
|
||||
graph_exec_id: str,
|
||||
graph_id: str,
|
||||
graph_version: int,
|
||||
execution_context: "ExecutionContext",
|
||||
**kwargs,
|
||||
) -> tuple[bool, BlockInput]:
|
||||
"""
|
||||
Check if this block execution needs human review and handle the review process.
|
||||
|
||||
Returns:
|
||||
Tuple of (should_pause, input_data_to_use)
|
||||
- should_pause: True if execution should be paused for review
|
||||
- input_data_to_use: The input data to use (may be modified by reviewer)
|
||||
"""
|
||||
# Skip review if not required or safe mode is disabled
|
||||
if not self.requires_human_review or not execution_context.safe_mode:
|
||||
return False, input_data
|
||||
|
||||
from backend.blocks.helpers.review import HITLReviewHelper
|
||||
|
||||
# Handle the review request and get decision
|
||||
decision = await HITLReviewHelper.handle_review_decision(
|
||||
input_data=input_data,
|
||||
user_id=user_id,
|
||||
node_exec_id=node_exec_id,
|
||||
graph_exec_id=graph_exec_id,
|
||||
graph_id=graph_id,
|
||||
graph_version=graph_version,
|
||||
execution_context=execution_context,
|
||||
block_name=self.name,
|
||||
editable=True,
|
||||
)
|
||||
|
||||
if decision is None:
|
||||
# We're awaiting review - pause execution
|
||||
return True, input_data
|
||||
|
||||
if not decision.should_proceed:
|
||||
# Review was rejected, raise an error to stop execution
|
||||
raise BlockExecutionError(
|
||||
message=f"Block execution rejected by reviewer: {decision.message}",
|
||||
block_name=self.name,
|
||||
block_id=self.id,
|
||||
)
|
||||
|
||||
# Review was approved - use the potentially modified data
|
||||
# ReviewResult.data must be a dict for block inputs
|
||||
reviewed_data = decision.review_result.data
|
||||
if not isinstance(reviewed_data, dict):
|
||||
raise BlockExecutionError(
|
||||
message=f"Review data must be a dict for block input, got {type(reviewed_data).__name__}",
|
||||
block_name=self.name,
|
||||
block_id=self.id,
|
||||
)
|
||||
return False, reviewed_data
|
||||
|
||||
async def _execute(self, input_data: BlockInput, **kwargs) -> BlockOutput:
|
||||
# Check for review requirement and get potentially modified input data
|
||||
should_pause, input_data = await self.is_block_exec_need_review(
|
||||
input_data, **kwargs
|
||||
)
|
||||
if should_pause:
|
||||
return
|
||||
|
||||
# Validate the input data (original or reviewer-modified) once
|
||||
if error := self.input_schema.validate_data(input_data):
|
||||
raise BlockInputError(
|
||||
message=f"Unable to execute block with invalid input data: {error}",
|
||||
@@ -695,7 +622,6 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
|
||||
block_id=self.id,
|
||||
)
|
||||
|
||||
# Use the validated input data
|
||||
async for output_name, output_data in self.run(
|
||||
self.input_schema(**{k: v for k, v in input_data.items() if v is not None}),
|
||||
**kwargs,
|
||||
|
||||
@@ -383,7 +383,6 @@ class GraphExecutionWithNodes(GraphExecution):
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
compiled_nodes_input_masks: Optional[NodesInputMasks] = None,
|
||||
nodes_to_skip: Optional[set[str]] = None,
|
||||
):
|
||||
return GraphExecutionEntry(
|
||||
user_id=self.user_id,
|
||||
@@ -391,7 +390,6 @@ class GraphExecutionWithNodes(GraphExecution):
|
||||
graph_version=self.graph_version or 0,
|
||||
graph_exec_id=self.id,
|
||||
nodes_input_masks=compiled_nodes_input_masks,
|
||||
nodes_to_skip=nodes_to_skip or set(),
|
||||
execution_context=execution_context,
|
||||
)
|
||||
|
||||
@@ -1147,8 +1145,6 @@ class GraphExecutionEntry(BaseModel):
|
||||
graph_id: str
|
||||
graph_version: int
|
||||
nodes_input_masks: Optional[NodesInputMasks] = None
|
||||
nodes_to_skip: set[str] = Field(default_factory=set)
|
||||
"""Node IDs that should be skipped due to optional credentials not being configured."""
|
||||
execution_context: ExecutionContext = Field(default_factory=ExecutionContext)
|
||||
|
||||
|
||||
|
||||
@@ -94,15 +94,6 @@ class Node(BaseDbModel):
|
||||
input_links: list[Link] = []
|
||||
output_links: list[Link] = []
|
||||
|
||||
@property
|
||||
def credentials_optional(self) -> bool:
|
||||
"""
|
||||
Whether credentials are optional for this node.
|
||||
When True and credentials are not configured, the node will be skipped
|
||||
during execution rather than causing a validation error.
|
||||
"""
|
||||
return self.metadata.get("credentials_optional", False)
|
||||
|
||||
@property
|
||||
def block(self) -> AnyBlockSchema | "_UnknownBlockBase":
|
||||
"""Get the block for this node. Returns UnknownBlock if block is deleted/missing."""
|
||||
@@ -244,10 +235,7 @@ class BaseGraph(BaseDbModel):
|
||||
return any(
|
||||
node.block_id
|
||||
for node in self.nodes
|
||||
if (
|
||||
node.block.block_type == BlockType.HUMAN_IN_THE_LOOP
|
||||
or node.block.requires_human_review
|
||||
)
|
||||
if node.block.block_type == BlockType.HUMAN_IN_THE_LOOP
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -338,35 +326,7 @@ class Graph(BaseGraph):
|
||||
@computed_field
|
||||
@property
|
||||
def credentials_input_schema(self) -> dict[str, Any]:
|
||||
schema = self._credentials_input_schema.jsonschema()
|
||||
|
||||
# Determine which credential fields are required based on credentials_optional metadata
|
||||
graph_credentials_inputs = self.aggregate_credentials_inputs()
|
||||
required_fields = []
|
||||
|
||||
# Build a map of node_id -> node for quick lookup
|
||||
all_nodes = {node.id: node for node in self.nodes}
|
||||
for sub_graph in self.sub_graphs:
|
||||
for node in sub_graph.nodes:
|
||||
all_nodes[node.id] = node
|
||||
|
||||
for field_key, (
|
||||
_field_info,
|
||||
node_field_pairs,
|
||||
) in graph_credentials_inputs.items():
|
||||
# A field is required if ANY node using it has credentials_optional=False
|
||||
is_required = False
|
||||
for node_id, _field_name in node_field_pairs:
|
||||
node = all_nodes.get(node_id)
|
||||
if node and not node.credentials_optional:
|
||||
is_required = True
|
||||
break
|
||||
|
||||
if is_required:
|
||||
required_fields.append(field_key)
|
||||
|
||||
schema["required"] = required_fields
|
||||
return schema
|
||||
return self._credentials_input_schema.jsonschema()
|
||||
|
||||
@property
|
||||
def _credentials_input_schema(self) -> type[BlockSchema]:
|
||||
|
||||
@@ -396,58 +396,3 @@ async def test_access_store_listing_graph(server: SpinTestServer):
|
||||
created_graph.id, created_graph.version, "3e53486c-cf57-477e-ba2a-cb02dc828e1b"
|
||||
)
|
||||
assert got_graph is not None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tests for Optional Credentials Feature
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_node_credentials_optional_default():
|
||||
"""Test that credentials_optional defaults to False when not set in metadata."""
|
||||
node = Node(
|
||||
id="test_node",
|
||||
block_id=StoreValueBlock().id,
|
||||
input_default={},
|
||||
metadata={},
|
||||
)
|
||||
assert node.credentials_optional is False
|
||||
|
||||
|
||||
def test_node_credentials_optional_true():
|
||||
"""Test that credentials_optional returns True when explicitly set."""
|
||||
node = Node(
|
||||
id="test_node",
|
||||
block_id=StoreValueBlock().id,
|
||||
input_default={},
|
||||
metadata={"credentials_optional": True},
|
||||
)
|
||||
assert node.credentials_optional is True
|
||||
|
||||
|
||||
def test_node_credentials_optional_false():
|
||||
"""Test that credentials_optional returns False when explicitly set to False."""
|
||||
node = Node(
|
||||
id="test_node",
|
||||
block_id=StoreValueBlock().id,
|
||||
input_default={},
|
||||
metadata={"credentials_optional": False},
|
||||
)
|
||||
assert node.credentials_optional is False
|
||||
|
||||
|
||||
def test_node_credentials_optional_with_other_metadata():
|
||||
"""Test that credentials_optional works correctly with other metadata present."""
|
||||
node = Node(
|
||||
id="test_node",
|
||||
block_id=StoreValueBlock().id,
|
||||
input_default={},
|
||||
metadata={
|
||||
"position": {"x": 100, "y": 200},
|
||||
"customized_name": "My Custom Node",
|
||||
"credentials_optional": True,
|
||||
},
|
||||
)
|
||||
assert node.credentials_optional is True
|
||||
assert node.metadata["position"] == {"x": 100, "y": 200}
|
||||
assert node.metadata["customized_name"] == "My Custom Node"
|
||||
|
||||
@@ -211,22 +211,6 @@ class AgentRejectionData(BaseNotificationData):
|
||||
return value
|
||||
|
||||
|
||||
class WaitlistLaunchData(BaseNotificationData):
|
||||
"""Notification data for when an agent from a waitlist is launched."""
|
||||
|
||||
agent_name: str
|
||||
waitlist_name: str
|
||||
store_url: str
|
||||
launched_at: datetime
|
||||
|
||||
@field_validator("launched_at")
|
||||
@classmethod
|
||||
def validate_timezone(cls, value: datetime):
|
||||
if value.tzinfo is None:
|
||||
raise ValueError("datetime must have timezone information")
|
||||
return value
|
||||
|
||||
|
||||
NotificationData = Annotated[
|
||||
Union[
|
||||
AgentRunData,
|
||||
@@ -239,7 +223,6 @@ NotificationData = Annotated[
|
||||
DailySummaryData,
|
||||
RefundRequestData,
|
||||
BaseSummaryData,
|
||||
WaitlistLaunchData,
|
||||
],
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
@@ -290,7 +273,6 @@ def get_notif_data_type(
|
||||
NotificationType.REFUND_PROCESSED: RefundRequestData,
|
||||
NotificationType.AGENT_APPROVED: AgentApprovalData,
|
||||
NotificationType.AGENT_REJECTED: AgentRejectionData,
|
||||
NotificationType.WAITLIST_LAUNCH: WaitlistLaunchData,
|
||||
}[notification_type]
|
||||
|
||||
|
||||
@@ -336,7 +318,6 @@ class NotificationTypeOverride:
|
||||
NotificationType.REFUND_PROCESSED: QueueType.ADMIN,
|
||||
NotificationType.AGENT_APPROVED: QueueType.IMMEDIATE,
|
||||
NotificationType.AGENT_REJECTED: QueueType.IMMEDIATE,
|
||||
NotificationType.WAITLIST_LAUNCH: QueueType.IMMEDIATE,
|
||||
}
|
||||
return BATCHING_RULES.get(self.notification_type, QueueType.IMMEDIATE)
|
||||
|
||||
@@ -356,7 +337,6 @@ class NotificationTypeOverride:
|
||||
NotificationType.REFUND_PROCESSED: "refund_processed.html",
|
||||
NotificationType.AGENT_APPROVED: "agent_approved.html",
|
||||
NotificationType.AGENT_REJECTED: "agent_rejected.html",
|
||||
NotificationType.WAITLIST_LAUNCH: "waitlist_launch.html",
|
||||
}[self.notification_type]
|
||||
|
||||
@property
|
||||
@@ -374,7 +354,6 @@ class NotificationTypeOverride:
|
||||
NotificationType.REFUND_PROCESSED: "Refund for ${{data.amount / 100}} to {{data.user_name}} has been processed",
|
||||
NotificationType.AGENT_APPROVED: "🎉 Your agent '{{data.agent_name}}' has been approved!",
|
||||
NotificationType.AGENT_REJECTED: "Your agent '{{data.agent_name}}' needs some updates",
|
||||
NotificationType.WAITLIST_LAUNCH: "🚀 {{data.agent_name}} is now available!",
|
||||
}[self.notification_type]
|
||||
|
||||
|
||||
|
||||
@@ -178,7 +178,6 @@ async def execute_node(
|
||||
execution_processor: "ExecutionProcessor",
|
||||
execution_stats: NodeExecutionStats | None = None,
|
||||
nodes_input_masks: Optional[NodesInputMasks] = None,
|
||||
nodes_to_skip: Optional[set[str]] = None,
|
||||
) -> BlockOutput:
|
||||
"""
|
||||
Execute a node in the graph. This will trigger a block execution on a node,
|
||||
@@ -246,7 +245,6 @@ async def execute_node(
|
||||
"user_id": user_id,
|
||||
"execution_context": execution_context,
|
||||
"execution_processor": execution_processor,
|
||||
"nodes_to_skip": nodes_to_skip or set(),
|
||||
}
|
||||
|
||||
# Last-minute fetch credentials + acquire a system-wide read-write lock to prevent
|
||||
@@ -544,7 +542,6 @@ class ExecutionProcessor:
|
||||
node_exec_progress: NodeExecutionProgress,
|
||||
nodes_input_masks: Optional[NodesInputMasks],
|
||||
graph_stats_pair: tuple[GraphExecutionStats, threading.Lock],
|
||||
nodes_to_skip: Optional[set[str]] = None,
|
||||
) -> NodeExecutionStats:
|
||||
log_metadata = LogMetadata(
|
||||
logger=_logger,
|
||||
@@ -567,7 +564,6 @@ class ExecutionProcessor:
|
||||
db_client=db_client,
|
||||
log_metadata=log_metadata,
|
||||
nodes_input_masks=nodes_input_masks,
|
||||
nodes_to_skip=nodes_to_skip,
|
||||
)
|
||||
if isinstance(status, BaseException):
|
||||
raise status
|
||||
@@ -613,7 +609,6 @@ class ExecutionProcessor:
|
||||
db_client: "DatabaseManagerAsyncClient",
|
||||
log_metadata: LogMetadata,
|
||||
nodes_input_masks: Optional[NodesInputMasks] = None,
|
||||
nodes_to_skip: Optional[set[str]] = None,
|
||||
) -> ExecutionStatus:
|
||||
status = ExecutionStatus.RUNNING
|
||||
|
||||
@@ -650,7 +645,6 @@ class ExecutionProcessor:
|
||||
execution_processor=self,
|
||||
execution_stats=stats,
|
||||
nodes_input_masks=nodes_input_masks,
|
||||
nodes_to_skip=nodes_to_skip,
|
||||
):
|
||||
await persist_output(output_name, output_data)
|
||||
|
||||
@@ -962,21 +956,6 @@ class ExecutionProcessor:
|
||||
|
||||
queued_node_exec = execution_queue.get()
|
||||
|
||||
# Check if this node should be skipped due to optional credentials
|
||||
if queued_node_exec.node_id in graph_exec.nodes_to_skip:
|
||||
log_metadata.info(
|
||||
f"Skipping node execution {queued_node_exec.node_exec_id} "
|
||||
f"for node {queued_node_exec.node_id} - optional credentials not configured"
|
||||
)
|
||||
# Mark the node as completed without executing
|
||||
# No outputs will be produced, so downstream nodes won't trigger
|
||||
update_node_execution_status(
|
||||
db_client=db_client,
|
||||
exec_id=queued_node_exec.node_exec_id,
|
||||
status=ExecutionStatus.COMPLETED,
|
||||
)
|
||||
continue
|
||||
|
||||
log_metadata.debug(
|
||||
f"Dispatching node execution {queued_node_exec.node_exec_id} "
|
||||
f"for node {queued_node_exec.node_id}",
|
||||
@@ -1037,7 +1016,6 @@ class ExecutionProcessor:
|
||||
execution_stats,
|
||||
execution_stats_lock,
|
||||
),
|
||||
nodes_to_skip=graph_exec.nodes_to_skip,
|
||||
),
|
||||
self.node_execution_loop,
|
||||
)
|
||||
|
||||
@@ -239,19 +239,14 @@ async def _validate_node_input_credentials(
|
||||
graph: GraphModel,
|
||||
user_id: str,
|
||||
nodes_input_masks: Optional[NodesInputMasks] = None,
|
||||
) -> tuple[dict[str, dict[str, str]], set[str]]:
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""
|
||||
Checks all credentials for all nodes of the graph and returns structured errors
|
||||
and a set of nodes that should be skipped due to optional missing credentials.
|
||||
Checks all credentials for all nodes of the graph and returns structured errors.
|
||||
|
||||
Returns:
|
||||
tuple[
|
||||
dict[node_id, dict[field_name, error_message]]: Credential validation errors per node,
|
||||
set[node_id]: Nodes that should be skipped (optional credentials not configured)
|
||||
]
|
||||
dict[node_id, dict[field_name, error_message]]: Credential validation errors per node
|
||||
"""
|
||||
credential_errors: dict[str, dict[str, str]] = defaultdict(dict)
|
||||
nodes_to_skip: set[str] = set()
|
||||
|
||||
for node in graph.nodes:
|
||||
block = node.block
|
||||
@@ -261,46 +256,27 @@ async def _validate_node_input_credentials(
|
||||
if not credentials_fields:
|
||||
continue
|
||||
|
||||
# Track if any credential field is missing for this node
|
||||
has_missing_credentials = False
|
||||
|
||||
for field_name, credentials_meta_type in credentials_fields.items():
|
||||
try:
|
||||
# Check nodes_input_masks first, then input_default
|
||||
field_value = None
|
||||
if (
|
||||
nodes_input_masks
|
||||
and (node_input_mask := nodes_input_masks.get(node.id))
|
||||
and field_name in node_input_mask
|
||||
):
|
||||
field_value = node_input_mask[field_name]
|
||||
credentials_meta = credentials_meta_type.model_validate(
|
||||
node_input_mask[field_name]
|
||||
)
|
||||
elif field_name in node.input_default:
|
||||
# For optional credentials, don't use input_default - treat as missing
|
||||
# This prevents stale credential IDs from failing validation
|
||||
if node.credentials_optional:
|
||||
field_value = None
|
||||
else:
|
||||
field_value = node.input_default[field_name]
|
||||
|
||||
# Check if credentials are missing (None, empty, or not present)
|
||||
if field_value is None or (
|
||||
isinstance(field_value, dict) and not field_value.get("id")
|
||||
):
|
||||
has_missing_credentials = True
|
||||
# If node has credentials_optional flag, mark for skipping instead of error
|
||||
if node.credentials_optional:
|
||||
continue # Don't add error, will be marked for skip after loop
|
||||
else:
|
||||
credential_errors[node.id][
|
||||
field_name
|
||||
] = "These credentials are required"
|
||||
continue
|
||||
|
||||
credentials_meta = credentials_meta_type.model_validate(field_value)
|
||||
|
||||
credentials_meta = credentials_meta_type.model_validate(
|
||||
node.input_default[field_name]
|
||||
)
|
||||
else:
|
||||
# Missing credentials
|
||||
credential_errors[node.id][
|
||||
field_name
|
||||
] = "These credentials are required"
|
||||
continue
|
||||
except ValidationError as e:
|
||||
# Validation error means credentials were provided but invalid
|
||||
# This should always be an error, even if optional
|
||||
credential_errors[node.id][field_name] = f"Invalid credentials: {e}"
|
||||
continue
|
||||
|
||||
@@ -311,7 +287,6 @@ async def _validate_node_input_credentials(
|
||||
)
|
||||
except Exception as e:
|
||||
# Handle any errors fetching credentials
|
||||
# If credentials were explicitly configured but unavailable, it's an error
|
||||
credential_errors[node.id][
|
||||
field_name
|
||||
] = f"Credentials not available: {e}"
|
||||
@@ -338,19 +313,7 @@ async def _validate_node_input_credentials(
|
||||
] = "Invalid credentials: type/provider mismatch"
|
||||
continue
|
||||
|
||||
# If node has optional credentials and any are missing, mark for skipping
|
||||
# But only if there are no other errors for this node
|
||||
if (
|
||||
has_missing_credentials
|
||||
and node.credentials_optional
|
||||
and node.id not in credential_errors
|
||||
):
|
||||
nodes_to_skip.add(node.id)
|
||||
logger.info(
|
||||
f"Node #{node.id} will be skipped: optional credentials not configured"
|
||||
)
|
||||
|
||||
return credential_errors, nodes_to_skip
|
||||
return credential_errors
|
||||
|
||||
|
||||
def make_node_credentials_input_map(
|
||||
@@ -392,25 +355,21 @@ async def validate_graph_with_credentials(
|
||||
graph: GraphModel,
|
||||
user_id: str,
|
||||
nodes_input_masks: Optional[NodesInputMasks] = None,
|
||||
) -> tuple[Mapping[str, Mapping[str, str]], set[str]]:
|
||||
) -> Mapping[str, Mapping[str, str]]:
|
||||
"""
|
||||
Validate graph including credentials and return structured errors per node,
|
||||
along with a set of nodes that should be skipped due to optional missing credentials.
|
||||
Validate graph including credentials and return structured errors per node.
|
||||
|
||||
Returns:
|
||||
tuple[
|
||||
dict[node_id, dict[field_name, error_message]]: Validation errors per node,
|
||||
set[node_id]: Nodes that should be skipped (optional credentials not configured)
|
||||
]
|
||||
dict[node_id, dict[field_name, error_message]]: Validation errors per node
|
||||
"""
|
||||
# Get input validation errors
|
||||
node_input_errors = GraphModel.validate_graph_get_errors(
|
||||
graph, for_run=True, nodes_input_masks=nodes_input_masks
|
||||
)
|
||||
|
||||
# Get credential input/availability/validation errors and nodes to skip
|
||||
node_credential_input_errors, nodes_to_skip = (
|
||||
await _validate_node_input_credentials(graph, user_id, nodes_input_masks)
|
||||
# Get credential input/availability/validation errors
|
||||
node_credential_input_errors = await _validate_node_input_credentials(
|
||||
graph, user_id, nodes_input_masks
|
||||
)
|
||||
|
||||
# Merge credential errors with structural errors
|
||||
@@ -419,7 +378,7 @@ async def validate_graph_with_credentials(
|
||||
node_input_errors[node_id] = {}
|
||||
node_input_errors[node_id].update(field_errors)
|
||||
|
||||
return node_input_errors, nodes_to_skip
|
||||
return node_input_errors
|
||||
|
||||
|
||||
async def _construct_starting_node_execution_input(
|
||||
@@ -427,7 +386,7 @@ async def _construct_starting_node_execution_input(
|
||||
user_id: str,
|
||||
graph_inputs: BlockInput,
|
||||
nodes_input_masks: Optional[NodesInputMasks] = None,
|
||||
) -> tuple[list[tuple[str, BlockInput]], set[str]]:
|
||||
) -> list[tuple[str, BlockInput]]:
|
||||
"""
|
||||
Validates and prepares the input data for executing a graph.
|
||||
This function checks the graph for starting nodes, validates the input data
|
||||
@@ -441,14 +400,11 @@ async def _construct_starting_node_execution_input(
|
||||
node_credentials_map: `dict[node_id, dict[input_name, CredentialsMetaInput]]`
|
||||
|
||||
Returns:
|
||||
tuple[
|
||||
list[tuple[str, BlockInput]]: A list of tuples, each containing the node ID
|
||||
and the corresponding input data for that node.
|
||||
set[str]: Node IDs that should be skipped (optional credentials not configured)
|
||||
]
|
||||
list[tuple[str, BlockInput]]: A list of tuples, each containing the node ID and
|
||||
the corresponding input data for that node.
|
||||
"""
|
||||
# Use new validation function that includes credentials
|
||||
validation_errors, nodes_to_skip = await validate_graph_with_credentials(
|
||||
validation_errors = await validate_graph_with_credentials(
|
||||
graph, user_id, nodes_input_masks
|
||||
)
|
||||
n_error_nodes = len(validation_errors)
|
||||
@@ -489,7 +445,7 @@ async def _construct_starting_node_execution_input(
|
||||
"No starting nodes found for the graph, make sure an AgentInput or blocks with no inbound links are present as starting nodes."
|
||||
)
|
||||
|
||||
return nodes_input, nodes_to_skip
|
||||
return nodes_input
|
||||
|
||||
|
||||
async def validate_and_construct_node_execution_input(
|
||||
@@ -500,7 +456,7 @@ async def validate_and_construct_node_execution_input(
|
||||
graph_credentials_inputs: Optional[Mapping[str, CredentialsMetaInput]] = None,
|
||||
nodes_input_masks: Optional[NodesInputMasks] = None,
|
||||
is_sub_graph: bool = False,
|
||||
) -> tuple[GraphModel, list[tuple[str, BlockInput]], NodesInputMasks, set[str]]:
|
||||
) -> tuple[GraphModel, list[tuple[str, BlockInput]], NodesInputMasks]:
|
||||
"""
|
||||
Public wrapper that handles graph fetching, credential mapping, and validation+construction.
|
||||
This centralizes the logic used by both scheduler validation and actual execution.
|
||||
@@ -517,7 +473,6 @@ async def validate_and_construct_node_execution_input(
|
||||
GraphModel: Full graph object for the given `graph_id`.
|
||||
list[tuple[node_id, BlockInput]]: Starting node IDs with corresponding inputs.
|
||||
dict[str, BlockInput]: Node input masks including all passed-in credentials.
|
||||
set[str]: Node IDs that should be skipped (optional credentials not configured).
|
||||
|
||||
Raises:
|
||||
NotFoundError: If the graph is not found.
|
||||
@@ -559,16 +514,14 @@ async def validate_and_construct_node_execution_input(
|
||||
nodes_input_masks or {},
|
||||
)
|
||||
|
||||
starting_nodes_input, nodes_to_skip = (
|
||||
await _construct_starting_node_execution_input(
|
||||
graph=graph,
|
||||
user_id=user_id,
|
||||
graph_inputs=graph_inputs,
|
||||
nodes_input_masks=nodes_input_masks,
|
||||
)
|
||||
starting_nodes_input = await _construct_starting_node_execution_input(
|
||||
graph=graph,
|
||||
user_id=user_id,
|
||||
graph_inputs=graph_inputs,
|
||||
nodes_input_masks=nodes_input_masks,
|
||||
)
|
||||
|
||||
return graph, starting_nodes_input, nodes_input_masks, nodes_to_skip
|
||||
return graph, starting_nodes_input, nodes_input_masks
|
||||
|
||||
|
||||
def _merge_nodes_input_masks(
|
||||
@@ -826,9 +779,6 @@ async def add_graph_execution(
|
||||
|
||||
# Use existing execution's compiled input masks
|
||||
compiled_nodes_input_masks = graph_exec.nodes_input_masks or {}
|
||||
# For resumed executions, nodes_to_skip was already determined at creation time
|
||||
# TODO: Consider storing nodes_to_skip in DB if we need to preserve it across resumes
|
||||
nodes_to_skip: set[str] = set()
|
||||
|
||||
logger.info(f"Resuming graph execution #{graph_exec.id} for graph #{graph_id}")
|
||||
else:
|
||||
@@ -837,7 +787,7 @@ async def add_graph_execution(
|
||||
)
|
||||
|
||||
# Create new execution
|
||||
graph, starting_nodes_input, compiled_nodes_input_masks, nodes_to_skip = (
|
||||
graph, starting_nodes_input, compiled_nodes_input_masks = (
|
||||
await validate_and_construct_node_execution_input(
|
||||
graph_id=graph_id,
|
||||
user_id=user_id,
|
||||
@@ -886,7 +836,6 @@ async def add_graph_execution(
|
||||
try:
|
||||
graph_exec_entry = graph_exec.to_graph_execution_entry(
|
||||
compiled_nodes_input_masks=compiled_nodes_input_masks,
|
||||
nodes_to_skip=nodes_to_skip,
|
||||
execution_context=execution_context,
|
||||
)
|
||||
logger.info(f"Publishing execution {graph_exec.id} to execution queue")
|
||||
|
||||
@@ -367,13 +367,10 @@ async def test_add_graph_execution_is_repeatable(mocker: MockerFixture):
|
||||
)
|
||||
|
||||
# Setup mock returns
|
||||
# The function returns (graph, starting_nodes_input, compiled_nodes_input_masks, nodes_to_skip)
|
||||
nodes_to_skip: set[str] = set()
|
||||
mock_validate.return_value = (
|
||||
mock_graph,
|
||||
starting_nodes_input,
|
||||
compiled_nodes_input_masks,
|
||||
nodes_to_skip,
|
||||
)
|
||||
mock_prisma.is_connected.return_value = True
|
||||
mock_edb.create_graph_execution = mocker.AsyncMock(return_value=mock_graph_exec)
|
||||
@@ -459,212 +456,3 @@ async def test_add_graph_execution_is_repeatable(mocker: MockerFixture):
|
||||
# Both executions should succeed (though they create different objects)
|
||||
assert result1 == mock_graph_exec
|
||||
assert result2 == mock_graph_exec_2
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tests for Optional Credentials Feature
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_node_input_credentials_returns_nodes_to_skip(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
"""
|
||||
Test that _validate_node_input_credentials returns nodes_to_skip set
|
||||
for nodes with credentials_optional=True and missing credentials.
|
||||
"""
|
||||
from backend.executor.utils import _validate_node_input_credentials
|
||||
|
||||
# Create a mock node with credentials_optional=True
|
||||
mock_node = mocker.MagicMock()
|
||||
mock_node.id = "node-with-optional-creds"
|
||||
mock_node.credentials_optional = True
|
||||
mock_node.input_default = {} # No credentials configured
|
||||
|
||||
# Create a mock block with credentials field
|
||||
mock_block = mocker.MagicMock()
|
||||
mock_credentials_field_type = mocker.MagicMock()
|
||||
mock_block.input_schema.get_credentials_fields.return_value = {
|
||||
"credentials": mock_credentials_field_type
|
||||
}
|
||||
mock_node.block = mock_block
|
||||
|
||||
# Create mock graph
|
||||
mock_graph = mocker.MagicMock()
|
||||
mock_graph.nodes = [mock_node]
|
||||
|
||||
# Call the function
|
||||
errors, nodes_to_skip = await _validate_node_input_credentials(
|
||||
graph=mock_graph,
|
||||
user_id="test-user-id",
|
||||
nodes_input_masks=None,
|
||||
)
|
||||
|
||||
# Node should be in nodes_to_skip, not in errors
|
||||
assert mock_node.id in nodes_to_skip
|
||||
assert mock_node.id not in errors
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_node_input_credentials_required_missing_creds_error(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
"""
|
||||
Test that _validate_node_input_credentials returns errors
|
||||
for nodes with credentials_optional=False and missing credentials.
|
||||
"""
|
||||
from backend.executor.utils import _validate_node_input_credentials
|
||||
|
||||
# Create a mock node with credentials_optional=False (required)
|
||||
mock_node = mocker.MagicMock()
|
||||
mock_node.id = "node-with-required-creds"
|
||||
mock_node.credentials_optional = False
|
||||
mock_node.input_default = {} # No credentials configured
|
||||
|
||||
# Create a mock block with credentials field
|
||||
mock_block = mocker.MagicMock()
|
||||
mock_credentials_field_type = mocker.MagicMock()
|
||||
mock_block.input_schema.get_credentials_fields.return_value = {
|
||||
"credentials": mock_credentials_field_type
|
||||
}
|
||||
mock_node.block = mock_block
|
||||
|
||||
# Create mock graph
|
||||
mock_graph = mocker.MagicMock()
|
||||
mock_graph.nodes = [mock_node]
|
||||
|
||||
# Call the function
|
||||
errors, nodes_to_skip = await _validate_node_input_credentials(
|
||||
graph=mock_graph,
|
||||
user_id="test-user-id",
|
||||
nodes_input_masks=None,
|
||||
)
|
||||
|
||||
# Node should be in errors, not in nodes_to_skip
|
||||
assert mock_node.id in errors
|
||||
assert "credentials" in errors[mock_node.id]
|
||||
assert "required" in errors[mock_node.id]["credentials"].lower()
|
||||
assert mock_node.id not in nodes_to_skip
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_graph_with_credentials_returns_nodes_to_skip(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
"""
|
||||
Test that validate_graph_with_credentials returns nodes_to_skip set
|
||||
from _validate_node_input_credentials.
|
||||
"""
|
||||
from backend.executor.utils import validate_graph_with_credentials
|
||||
|
||||
# Mock _validate_node_input_credentials to return specific values
|
||||
mock_validate = mocker.patch(
|
||||
"backend.executor.utils._validate_node_input_credentials"
|
||||
)
|
||||
expected_errors = {"node1": {"field": "error"}}
|
||||
expected_nodes_to_skip = {"node2", "node3"}
|
||||
mock_validate.return_value = (expected_errors, expected_nodes_to_skip)
|
||||
|
||||
# Mock GraphModel with validate_graph_get_errors method
|
||||
mock_graph = mocker.MagicMock()
|
||||
mock_graph.validate_graph_get_errors.return_value = {}
|
||||
|
||||
# Call the function
|
||||
errors, nodes_to_skip = await validate_graph_with_credentials(
|
||||
graph=mock_graph,
|
||||
user_id="test-user-id",
|
||||
nodes_input_masks=None,
|
||||
)
|
||||
|
||||
# Verify nodes_to_skip is passed through
|
||||
assert nodes_to_skip == expected_nodes_to_skip
|
||||
assert "node1" in errors
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_graph_execution_with_nodes_to_skip(mocker: MockerFixture):
|
||||
"""
|
||||
Test that add_graph_execution properly passes nodes_to_skip
|
||||
to the graph execution entry.
|
||||
"""
|
||||
from backend.data.execution import GraphExecutionWithNodes
|
||||
from backend.executor.utils import add_graph_execution
|
||||
|
||||
# Mock data
|
||||
graph_id = "test-graph-id"
|
||||
user_id = "test-user-id"
|
||||
inputs = {"test_input": "test_value"}
|
||||
graph_version = 1
|
||||
|
||||
# Mock the graph object
|
||||
mock_graph = mocker.MagicMock()
|
||||
mock_graph.version = graph_version
|
||||
|
||||
# Starting nodes and masks
|
||||
starting_nodes_input = [("node1", {"input1": "value1"})]
|
||||
compiled_nodes_input_masks = {}
|
||||
nodes_to_skip = {"skipped-node-1", "skipped-node-2"}
|
||||
|
||||
# Mock the graph execution object
|
||||
mock_graph_exec = mocker.MagicMock(spec=GraphExecutionWithNodes)
|
||||
mock_graph_exec.id = "execution-id-123"
|
||||
mock_graph_exec.node_executions = []
|
||||
|
||||
# Track what's passed to to_graph_execution_entry
|
||||
captured_kwargs = {}
|
||||
|
||||
def capture_to_entry(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return mocker.MagicMock()
|
||||
|
||||
mock_graph_exec.to_graph_execution_entry.side_effect = capture_to_entry
|
||||
|
||||
# Setup mocks
|
||||
mock_validate = mocker.patch(
|
||||
"backend.executor.utils.validate_and_construct_node_execution_input"
|
||||
)
|
||||
mock_edb = mocker.patch("backend.executor.utils.execution_db")
|
||||
mock_prisma = mocker.patch("backend.executor.utils.prisma")
|
||||
mock_udb = mocker.patch("backend.executor.utils.user_db")
|
||||
mock_gdb = mocker.patch("backend.executor.utils.graph_db")
|
||||
mock_get_queue = mocker.patch("backend.executor.utils.get_async_execution_queue")
|
||||
mock_get_event_bus = mocker.patch(
|
||||
"backend.executor.utils.get_async_execution_event_bus"
|
||||
)
|
||||
|
||||
# Setup returns - include nodes_to_skip in the tuple
|
||||
mock_validate.return_value = (
|
||||
mock_graph,
|
||||
starting_nodes_input,
|
||||
compiled_nodes_input_masks,
|
||||
nodes_to_skip, # This should be passed through
|
||||
)
|
||||
mock_prisma.is_connected.return_value = True
|
||||
mock_edb.create_graph_execution = mocker.AsyncMock(return_value=mock_graph_exec)
|
||||
mock_edb.update_graph_execution_stats = mocker.AsyncMock(
|
||||
return_value=mock_graph_exec
|
||||
)
|
||||
mock_edb.update_node_execution_status_batch = mocker.AsyncMock()
|
||||
|
||||
mock_user = mocker.MagicMock()
|
||||
mock_user.timezone = "UTC"
|
||||
mock_settings = mocker.MagicMock()
|
||||
mock_settings.human_in_the_loop_safe_mode = True
|
||||
|
||||
mock_udb.get_user_by_id = mocker.AsyncMock(return_value=mock_user)
|
||||
mock_gdb.get_graph_settings = mocker.AsyncMock(return_value=mock_settings)
|
||||
mock_get_queue.return_value = mocker.AsyncMock()
|
||||
mock_get_event_bus.return_value = mocker.MagicMock(publish=mocker.AsyncMock())
|
||||
|
||||
# Call the function
|
||||
await add_graph_execution(
|
||||
graph_id=graph_id,
|
||||
user_id=user_id,
|
||||
inputs=inputs,
|
||||
graph_version=graph_version,
|
||||
)
|
||||
|
||||
# Verify nodes_to_skip was passed to to_graph_execution_entry
|
||||
assert "nodes_to_skip" in captured_kwargs
|
||||
assert captured_kwargs["nodes_to_skip"] == nodes_to_skip
|
||||
|
||||
@@ -8,7 +8,6 @@ from .discord import DiscordOAuthHandler
|
||||
from .github import GitHubOAuthHandler
|
||||
from .google import GoogleOAuthHandler
|
||||
from .notion import NotionOAuthHandler
|
||||
from .reddit import RedditOAuthHandler
|
||||
from .twitter import TwitterOAuthHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -21,7 +20,6 @@ _ORIGINAL_HANDLERS = [
|
||||
GitHubOAuthHandler,
|
||||
GoogleOAuthHandler,
|
||||
NotionOAuthHandler,
|
||||
RedditOAuthHandler,
|
||||
TwitterOAuthHandler,
|
||||
TodoistOAuthHandler,
|
||||
]
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import time
|
||||
import urllib.parse
|
||||
from typing import ClassVar, Optional
|
||||
|
||||
from pydantic import SecretStr
|
||||
|
||||
from backend.data.model import OAuth2Credentials
|
||||
from backend.integrations.oauth.base import BaseOAuthHandler
|
||||
from backend.integrations.providers import ProviderName
|
||||
from backend.util.request import Requests
|
||||
from backend.util.settings import Settings
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
class RedditOAuthHandler(BaseOAuthHandler):
|
||||
"""
|
||||
Reddit OAuth 2.0 handler.
|
||||
|
||||
Based on the documentation at:
|
||||
- https://github.com/reddit-archive/reddit/wiki/OAuth2
|
||||
|
||||
Notes:
|
||||
- Reddit requires `duration=permanent` to get refresh tokens
|
||||
- Access tokens expire after 1 hour (3600 seconds)
|
||||
- Reddit requires HTTP Basic Auth for token requests
|
||||
- Reddit requires a unique User-Agent header
|
||||
"""
|
||||
|
||||
PROVIDER_NAME = ProviderName.REDDIT
|
||||
DEFAULT_SCOPES: ClassVar[list[str]] = [
|
||||
"identity", # Get username, verify auth
|
||||
"read", # Access posts and comments
|
||||
"submit", # Submit new posts and comments
|
||||
"edit", # Edit own posts and comments
|
||||
"history", # Access user's post history
|
||||
"privatemessages", # Access inbox and send private messages
|
||||
"flair", # Access and set flair on posts/subreddits
|
||||
]
|
||||
|
||||
AUTHORIZE_URL = "https://www.reddit.com/api/v1/authorize"
|
||||
TOKEN_URL = "https://www.reddit.com/api/v1/access_token"
|
||||
USERNAME_URL = "https://oauth.reddit.com/api/v1/me"
|
||||
REVOKE_URL = "https://www.reddit.com/api/v1/revoke_token"
|
||||
|
||||
def __init__(self, client_id: str, client_secret: str, redirect_uri: str):
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
|
||||
def get_login_url(
|
||||
self, scopes: list[str], state: str, code_challenge: Optional[str]
|
||||
) -> str:
|
||||
"""Generate Reddit OAuth 2.0 authorization URL"""
|
||||
scopes = self.handle_default_scopes(scopes)
|
||||
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"scope": " ".join(scopes),
|
||||
"state": state,
|
||||
"duration": "permanent", # Required for refresh tokens
|
||||
}
|
||||
|
||||
return f"{self.AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
async def exchange_code_for_tokens(
|
||||
self, code: str, scopes: list[str], code_verifier: Optional[str]
|
||||
) -> OAuth2Credentials:
|
||||
"""Exchange authorization code for access tokens"""
|
||||
scopes = self.handle_default_scopes(scopes)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": settings.config.reddit_user_agent,
|
||||
}
|
||||
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
}
|
||||
|
||||
# Reddit requires HTTP Basic Auth for token requests
|
||||
auth = (self.client_id, self.client_secret)
|
||||
|
||||
response = await Requests().post(
|
||||
self.TOKEN_URL, headers=headers, data=data, auth=auth
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
error_text = response.text()
|
||||
raise ValueError(
|
||||
f"Reddit token exchange failed: {response.status} - {error_text}"
|
||||
)
|
||||
|
||||
tokens = response.json()
|
||||
|
||||
if "error" in tokens:
|
||||
raise ValueError(f"Reddit OAuth error: {tokens.get('error')}")
|
||||
|
||||
username = await self._get_username(tokens["access_token"])
|
||||
|
||||
return OAuth2Credentials(
|
||||
provider=self.PROVIDER_NAME,
|
||||
title=None,
|
||||
username=username,
|
||||
access_token=tokens["access_token"],
|
||||
refresh_token=tokens.get("refresh_token"),
|
||||
access_token_expires_at=int(time.time()) + tokens.get("expires_in", 3600),
|
||||
refresh_token_expires_at=None, # Reddit refresh tokens don't expire
|
||||
scopes=scopes,
|
||||
)
|
||||
|
||||
async def _get_username(self, access_token: str) -> str:
|
||||
"""Get the username from the access token"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"User-Agent": settings.config.reddit_user_agent,
|
||||
}
|
||||
|
||||
response = await Requests().get(self.USERNAME_URL, headers=headers)
|
||||
|
||||
if not response.ok:
|
||||
raise ValueError(f"Failed to get Reddit username: {response.status}")
|
||||
|
||||
data = response.json()
|
||||
return data.get("name", "unknown")
|
||||
|
||||
async def _refresh_tokens(
|
||||
self, credentials: OAuth2Credentials
|
||||
) -> OAuth2Credentials:
|
||||
"""Refresh access tokens using refresh token"""
|
||||
if not credentials.refresh_token:
|
||||
raise ValueError("No refresh token available")
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": settings.config.reddit_user_agent,
|
||||
}
|
||||
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": credentials.refresh_token.get_secret_value(),
|
||||
}
|
||||
|
||||
auth = (self.client_id, self.client_secret)
|
||||
|
||||
response = await Requests().post(
|
||||
self.TOKEN_URL, headers=headers, data=data, auth=auth
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
error_text = response.text()
|
||||
raise ValueError(
|
||||
f"Reddit token refresh failed: {response.status} - {error_text}"
|
||||
)
|
||||
|
||||
tokens = response.json()
|
||||
|
||||
if "error" in tokens:
|
||||
raise ValueError(f"Reddit OAuth error: {tokens.get('error')}")
|
||||
|
||||
username = await self._get_username(tokens["access_token"])
|
||||
|
||||
# Reddit may or may not return a new refresh token
|
||||
new_refresh_token = tokens.get("refresh_token")
|
||||
if new_refresh_token:
|
||||
refresh_token: SecretStr | None = SecretStr(new_refresh_token)
|
||||
elif credentials.refresh_token:
|
||||
# Keep the existing refresh token
|
||||
refresh_token = credentials.refresh_token
|
||||
else:
|
||||
refresh_token = None
|
||||
|
||||
return OAuth2Credentials(
|
||||
id=credentials.id,
|
||||
provider=self.PROVIDER_NAME,
|
||||
title=credentials.title,
|
||||
username=username,
|
||||
access_token=tokens["access_token"],
|
||||
refresh_token=refresh_token,
|
||||
access_token_expires_at=int(time.time()) + tokens.get("expires_in", 3600),
|
||||
refresh_token_expires_at=None,
|
||||
scopes=credentials.scopes,
|
||||
)
|
||||
|
||||
async def revoke_tokens(self, credentials: OAuth2Credentials) -> bool:
|
||||
"""Revoke the access token"""
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": settings.config.reddit_user_agent,
|
||||
}
|
||||
|
||||
data = {
|
||||
"token": credentials.access_token.get_secret_value(),
|
||||
"token_type_hint": "access_token",
|
||||
}
|
||||
|
||||
auth = (self.client_id, self.client_secret)
|
||||
|
||||
response = await Requests().post(
|
||||
self.REVOKE_URL, headers=headers, data=data, auth=auth
|
||||
)
|
||||
|
||||
# Reddit returns 204 No Content on successful revocation
|
||||
return response.ok
|
||||
@@ -264,7 +264,7 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
|
||||
)
|
||||
|
||||
reddit_user_agent: str = Field(
|
||||
default="web:AutoGPT:v0.6.0 (by /u/autogpt)",
|
||||
default="AutoGPT:1.0 (by /u/autogpt)",
|
||||
description="The user agent for the Reddit API",
|
||||
)
|
||||
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate a lightweight stub for prisma/types.py that collapses all exported
|
||||
symbols to Any. This prevents Pyright from spending time/budget on Prisma's
|
||||
query DSL types while keeping runtime behavior unchanged.
|
||||
|
||||
Usage:
|
||||
poetry run gen-prisma-stub
|
||||
|
||||
This script automatically finds the prisma package location and generates
|
||||
the types.pyi stub file in the same directory as types.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Set
|
||||
|
||||
|
||||
def _iter_assigned_names(target: ast.expr) -> Iterable[str]:
|
||||
"""Extract names from assignment targets (handles tuple unpacking)."""
|
||||
if isinstance(target, ast.Name):
|
||||
yield target.id
|
||||
elif isinstance(target, (ast.Tuple, ast.List)):
|
||||
for elt in target.elts:
|
||||
yield from _iter_assigned_names(elt)
|
||||
|
||||
|
||||
def _is_private(name: str) -> bool:
|
||||
"""Check if a name is private (starts with _ but not __)."""
|
||||
return name.startswith("_") and not name.startswith("__")
|
||||
|
||||
|
||||
def _is_safe_type_alias(node: ast.Assign) -> bool:
|
||||
"""Check if an assignment is a safe type alias that shouldn't be stubbed.
|
||||
|
||||
Safe types are:
|
||||
- Literal types (don't cause type budget issues)
|
||||
- Simple type references (SortMode, SortOrder, etc.)
|
||||
- TypeVar definitions
|
||||
"""
|
||||
if not node.value:
|
||||
return False
|
||||
|
||||
# Check if it's a Subscript (like Literal[...], Union[...], TypeVar[...])
|
||||
if isinstance(node.value, ast.Subscript):
|
||||
# Get the base type name
|
||||
if isinstance(node.value.value, ast.Name):
|
||||
base_name = node.value.value.id
|
||||
# Literal types are safe
|
||||
if base_name == "Literal":
|
||||
return True
|
||||
# TypeVar is safe
|
||||
if base_name == "TypeVar":
|
||||
return True
|
||||
elif isinstance(node.value.value, ast.Attribute):
|
||||
# Handle typing_extensions.Literal etc.
|
||||
if node.value.value.attr == "Literal":
|
||||
return True
|
||||
|
||||
# Check if it's a simple Name reference (like SortMode = _types.SortMode)
|
||||
if isinstance(node.value, ast.Attribute):
|
||||
return True
|
||||
|
||||
# Check if it's a Call (like TypeVar(...))
|
||||
if isinstance(node.value, ast.Call):
|
||||
if isinstance(node.value.func, ast.Name):
|
||||
if node.value.func.id == "TypeVar":
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def collect_top_level_symbols(
|
||||
tree: ast.Module, source_lines: list[str]
|
||||
) -> tuple[Set[str], Set[str], list[str], Set[str]]:
|
||||
"""Collect all top-level symbols from an AST module.
|
||||
|
||||
Returns:
|
||||
Tuple of (class_names, function_names, safe_variable_sources, unsafe_variable_names)
|
||||
safe_variable_sources contains the actual source code lines for safe variables
|
||||
"""
|
||||
classes: Set[str] = set()
|
||||
functions: Set[str] = set()
|
||||
safe_variable_sources: list[str] = []
|
||||
unsafe_variables: Set[str] = set()
|
||||
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.ClassDef):
|
||||
if not _is_private(node.name):
|
||||
classes.add(node.name)
|
||||
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
if not _is_private(node.name):
|
||||
functions.add(node.name)
|
||||
elif isinstance(node, ast.Assign):
|
||||
is_safe = _is_safe_type_alias(node)
|
||||
names = []
|
||||
for t in node.targets:
|
||||
for n in _iter_assigned_names(t):
|
||||
if not _is_private(n):
|
||||
names.append(n)
|
||||
if names:
|
||||
if is_safe:
|
||||
# Extract the source code for this assignment
|
||||
start_line = node.lineno - 1 # 0-indexed
|
||||
end_line = node.end_lineno if node.end_lineno else node.lineno
|
||||
source = "\n".join(source_lines[start_line:end_line])
|
||||
safe_variable_sources.append(source)
|
||||
else:
|
||||
unsafe_variables.update(names)
|
||||
elif isinstance(node, ast.AnnAssign) and node.target:
|
||||
# Annotated assignments are always stubbed
|
||||
for n in _iter_assigned_names(node.target):
|
||||
if not _is_private(n):
|
||||
unsafe_variables.add(n)
|
||||
|
||||
return classes, functions, safe_variable_sources, unsafe_variables
|
||||
|
||||
|
||||
def find_prisma_types_path() -> Path:
|
||||
"""Find the prisma types.py file in the installed package."""
|
||||
spec = importlib.util.find_spec("prisma")
|
||||
if spec is None or spec.origin is None:
|
||||
raise RuntimeError("Could not find prisma package. Is it installed?")
|
||||
|
||||
prisma_dir = Path(spec.origin).parent
|
||||
types_path = prisma_dir / "types.py"
|
||||
|
||||
if not types_path.exists():
|
||||
raise RuntimeError(f"prisma/types.py not found at {types_path}")
|
||||
|
||||
return types_path
|
||||
|
||||
|
||||
def generate_stub(src_path: Path, stub_path: Path) -> int:
|
||||
"""Generate the .pyi stub file from the source types.py."""
|
||||
code = src_path.read_text(encoding="utf-8", errors="ignore")
|
||||
source_lines = code.splitlines()
|
||||
tree = ast.parse(code, filename=str(src_path))
|
||||
classes, functions, safe_variable_sources, unsafe_variables = (
|
||||
collect_top_level_symbols(tree, source_lines)
|
||||
)
|
||||
|
||||
header = """\
|
||||
# -*- coding: utf-8 -*-
|
||||
# Auto-generated stub file - DO NOT EDIT
|
||||
# Generated by gen_prisma_types_stub.py
|
||||
#
|
||||
# This stub intentionally collapses complex Prisma query DSL types to Any.
|
||||
# Prisma's generated types can explode Pyright's type inference budgets
|
||||
# on large schemas. We collapse them to Any so the rest of the codebase
|
||||
# can remain strongly typed while keeping runtime behavior unchanged.
|
||||
#
|
||||
# Safe types (Literal, TypeVar, simple references) are preserved from the
|
||||
# original types.py to maintain proper type checking where possible.
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from typing_extensions import Literal
|
||||
|
||||
# Re-export commonly used typing constructs that may be imported from this module
|
||||
from typing import TYPE_CHECKING, TypeVar, Generic, Union, Optional, List, Dict
|
||||
|
||||
# Base type alias for stubbed Prisma types - allows any dict structure
|
||||
_PrismaDict = dict[str, Any]
|
||||
|
||||
"""
|
||||
|
||||
lines = [header]
|
||||
|
||||
# Include safe variable definitions (Literal types, TypeVars, etc.)
|
||||
lines.append("# Safe type definitions preserved from original types.py")
|
||||
for source in safe_variable_sources:
|
||||
lines.append(source)
|
||||
lines.append("")
|
||||
|
||||
# Stub all classes and unsafe variables uniformly as dict[str, Any] aliases
|
||||
# This allows:
|
||||
# 1. Use in type annotations: x: SomeType
|
||||
# 2. Constructor calls: SomeType(...)
|
||||
# 3. Dict literal assignments: x: SomeType = {...}
|
||||
lines.append(
|
||||
"# Stubbed types (collapsed to dict[str, Any] to prevent type budget exhaustion)"
|
||||
)
|
||||
all_stubbed = sorted(classes | unsafe_variables)
|
||||
for name in all_stubbed:
|
||||
lines.append(f"{name} = _PrismaDict")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Stub functions
|
||||
for name in sorted(functions):
|
||||
lines.append(f"def {name}(*args: Any, **kwargs: Any) -> Any: ...")
|
||||
|
||||
lines.append("")
|
||||
|
||||
stub_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return (
|
||||
len(classes)
|
||||
+ len(functions)
|
||||
+ len(safe_variable_sources)
|
||||
+ len(unsafe_variables)
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point."""
|
||||
try:
|
||||
types_path = find_prisma_types_path()
|
||||
stub_path = types_path.with_suffix(".pyi")
|
||||
|
||||
print(f"Found prisma types.py at: {types_path}")
|
||||
print(f"Generating stub at: {stub_path}")
|
||||
|
||||
num_symbols = generate_stub(types_path, stub_path)
|
||||
print(f"Generated {stub_path.name} with {num_symbols} Any-typed symbols")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -25,9 +25,6 @@ def run(*command: str) -> None:
|
||||
|
||||
|
||||
def lint():
|
||||
# Generate Prisma types stub before running pyright to prevent type budget exhaustion
|
||||
run("gen-prisma-stub")
|
||||
|
||||
lint_step_args: list[list[str]] = [
|
||||
["ruff", "check", *TARGET_DIRS, "--exit-zero"],
|
||||
["ruff", "format", "--diff", "--check", LIBS_DIR],
|
||||
@@ -52,6 +49,4 @@ def format():
|
||||
run("ruff", "format", LIBS_DIR)
|
||||
run("isort", "--profile", "black", BACKEND_DIR)
|
||||
run("black", BACKEND_DIR)
|
||||
# Generate Prisma types stub before running pyright to prevent type budget exhaustion
|
||||
run("gen-prisma-stub")
|
||||
run("pyright", *TARGET_DIRS)
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WaitlistExternalStatus" AS ENUM ('DONE', 'NOT_STARTED', 'CANCELED', 'WORK_IN_PROGRESS');
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'WAITLIST_LAUNCH';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WaitlistEntry" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"storeListingId" TEXT,
|
||||
"owningUserId" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"search" tsvector DEFAULT ''::tsvector,
|
||||
"name" TEXT NOT NULL,
|
||||
"subHeading" TEXT NOT NULL,
|
||||
"videoUrl" TEXT,
|
||||
"agentOutputDemoUrl" TEXT,
|
||||
"imageUrls" TEXT[],
|
||||
"description" TEXT NOT NULL,
|
||||
"categories" TEXT[],
|
||||
"status" "WaitlistExternalStatus" NOT NULL DEFAULT 'NOT_STARTED',
|
||||
"votes" INTEGER NOT NULL DEFAULT 0,
|
||||
"unaffiliatedEmailUsers" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_joinedWaitlists" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_joinedWaitlists_AB_unique" ON "_joinedWaitlists"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_joinedWaitlists_B_index" ON "_joinedWaitlists"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_storeListingId_fkey" FOREIGN KEY ("storeListingId") REFERENCES "StoreListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_owningUserId_fkey" FOREIGN KEY ("owningUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_joinedWaitlists" ADD CONSTRAINT "_joinedWaitlists_A_fkey" FOREIGN KEY ("A") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_joinedWaitlists" ADD CONSTRAINT "_joinedWaitlists_B_fkey" FOREIGN KEY ("B") REFERENCES "WaitlistEntry"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Testing" (
|
||||
"id" TEXT NOT NULL,
|
||||
"maxEmailsPerDay" INTEGER NOT NULL DEFAULT 3,
|
||||
|
||||
CONSTRAINT "Testing_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -117,7 +117,6 @@ lint = "linter:lint"
|
||||
test = "run_tests:test"
|
||||
load-store-agents = "test.load_store_agents:run"
|
||||
export-api-schema = "backend.cli.generate_openapi_json:main"
|
||||
gen-prisma-stub = "gen_prisma_types_stub:main"
|
||||
oauth-tool = "backend.cli.oauth_tool:cli"
|
||||
|
||||
[tool.isort]
|
||||
@@ -135,9 +134,6 @@ ignore_patterns = []
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "session"
|
||||
# Disable syrupy plugin to avoid conflict with pytest-snapshot
|
||||
# Both provide --snapshot-update argument causing ArgumentError
|
||||
addopts = "-p no:syrupy"
|
||||
filterwarnings = [
|
||||
"ignore:'audioop' is deprecated:DeprecationWarning:discord.player",
|
||||
"ignore:invalid escape sequence:DeprecationWarning:tweepy.api",
|
||||
|
||||
@@ -12,6 +12,11 @@ generator client {
|
||||
partial_type_generator = "backend/data/partial_types.py"
|
||||
}
|
||||
|
||||
model Testing {
|
||||
id String @id // This should match the Supabase user ID
|
||||
maxEmailsPerDay Int @default(3)
|
||||
}
|
||||
|
||||
// User model to mirror Auth provider users
|
||||
model User {
|
||||
id String @id // This should match the Supabase user ID
|
||||
@@ -67,10 +72,6 @@ model User {
|
||||
OAuthAuthorizationCodes OAuthAuthorizationCode[]
|
||||
OAuthAccessTokens OAuthAccessToken[]
|
||||
OAuthRefreshTokens OAuthRefreshToken[]
|
||||
|
||||
// Waitlist relations
|
||||
waitlistEntries WaitlistEntry[]
|
||||
joinedWaitlists WaitlistEntry[] @relation("joinedWaitlists")
|
||||
}
|
||||
|
||||
enum OnboardingStep {
|
||||
@@ -131,8 +132,8 @@ model BuilderSearchHistory {
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
searchQuery String
|
||||
filter String[] @default([])
|
||||
byCreator String[] @default([])
|
||||
filter String[] @default([])
|
||||
byCreator String[] @default([])
|
||||
|
||||
userId String
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@ -232,7 +233,6 @@ enum NotificationType {
|
||||
REFUND_PROCESSED
|
||||
AGENT_APPROVED
|
||||
AGENT_REJECTED
|
||||
WAITLIST_LAUNCH
|
||||
}
|
||||
|
||||
model NotificationEvent {
|
||||
@@ -726,26 +726,26 @@ view StoreAgent {
|
||||
storeListingVersionId String
|
||||
updated_at DateTime
|
||||
|
||||
slug String
|
||||
agent_name String
|
||||
agent_video String?
|
||||
agent_output_demo String?
|
||||
agent_image String[]
|
||||
slug String
|
||||
agent_name String
|
||||
agent_video String?
|
||||
agent_output_demo String?
|
||||
agent_image String[]
|
||||
|
||||
featured Boolean @default(false)
|
||||
creator_username String?
|
||||
creator_avatar String?
|
||||
sub_heading String
|
||||
description String
|
||||
categories String[]
|
||||
search Unsupported("tsvector")? @default(dbgenerated("''::tsvector"))
|
||||
runs Int
|
||||
rating Float
|
||||
versions String[]
|
||||
agentGraphVersions String[]
|
||||
agentGraphId String
|
||||
is_available Boolean @default(true)
|
||||
useForOnboarding Boolean @default(false)
|
||||
featured Boolean @default(false)
|
||||
creator_username String?
|
||||
creator_avatar String?
|
||||
sub_heading String
|
||||
description String
|
||||
categories String[]
|
||||
search Unsupported("tsvector")? @default(dbgenerated("''::tsvector"))
|
||||
runs Int
|
||||
rating Float
|
||||
versions String[]
|
||||
agentGraphVersions String[]
|
||||
agentGraphId String
|
||||
is_available Boolean @default(true)
|
||||
useForOnboarding Boolean @default(false)
|
||||
|
||||
// Materialized views used (refreshed every 15 minutes via pg_cron):
|
||||
// - mv_agent_run_counts - Pre-aggregated agent execution counts by agentGraphId
|
||||
@@ -839,8 +839,7 @@ model StoreListing {
|
||||
OwningUser User @relation(fields: [owningUserId], references: [id])
|
||||
|
||||
// Relations
|
||||
Versions StoreListingVersion[] @relation("ListingVersions")
|
||||
waitlistEntries WaitlistEntry[]
|
||||
Versions StoreListingVersion[] @relation("ListingVersions")
|
||||
|
||||
// Unique index on agentId to ensure only one listing per agent, regardless of number of versions the agent has.
|
||||
@@unique([agentGraphId])
|
||||
@@ -862,14 +861,14 @@ model StoreListingVersion {
|
||||
AgentGraph AgentGraph @relation(fields: [agentGraphId, agentGraphVersion], references: [id, version])
|
||||
|
||||
// Content fields
|
||||
name String
|
||||
subHeading String
|
||||
videoUrl String?
|
||||
agentOutputDemoUrl String?
|
||||
imageUrls String[]
|
||||
description String
|
||||
instructions String?
|
||||
categories String[]
|
||||
name String
|
||||
subHeading String
|
||||
videoUrl String?
|
||||
agentOutputDemoUrl String?
|
||||
imageUrls String[]
|
||||
description String
|
||||
instructions String?
|
||||
categories String[]
|
||||
|
||||
isFeatured Boolean @default(false)
|
||||
|
||||
@@ -930,47 +929,6 @@ model StoreListingReview {
|
||||
@@index([reviewByUserId])
|
||||
}
|
||||
|
||||
enum WaitlistExternalStatus {
|
||||
DONE
|
||||
NOT_STARTED
|
||||
CANCELED
|
||||
WORK_IN_PROGRESS
|
||||
}
|
||||
|
||||
model WaitlistEntry {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
storeListingId String?
|
||||
StoreListing StoreListing? @relation(fields: [storeListingId], references: [id], onDelete: SetNull)
|
||||
|
||||
owningUserId String
|
||||
OwningUser User @relation(fields: [owningUserId], references: [id])
|
||||
|
||||
slug String
|
||||
search Unsupported("tsvector")? @default(dbgenerated("''::tsvector"))
|
||||
|
||||
// Content fields
|
||||
name String
|
||||
subHeading String
|
||||
videoUrl String?
|
||||
agentOutputDemoUrl String?
|
||||
imageUrls String[]
|
||||
description String
|
||||
categories String[]
|
||||
|
||||
//Waitlist specific fields
|
||||
status WaitlistExternalStatus @default(NOT_STARTED)
|
||||
votes Int @default(0) // Hide from frontend api
|
||||
joinedUsers User[] @relation("joinedWaitlists")
|
||||
// NOTE: DO NOT DOUBLE SEND TO THESE USERS, IF THEY HAVE SIGNED UP SINCE THEY MAY HAVE ALREADY RECEIVED AN EMAIL
|
||||
// DOUBLE CHECK WHEN SENDING THAT THEY ARE NOT IN THE JOINED USERS LIST ALSO
|
||||
unaffiliatedEmailUsers String[] @default([])
|
||||
|
||||
isDeleted Boolean @default(false)
|
||||
}
|
||||
|
||||
enum SubmissionStatus {
|
||||
DRAFT // Being prepared, not yet submitted
|
||||
PENDING // Submitted, awaiting review
|
||||
@@ -1045,16 +1003,16 @@ model OAuthApplication {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Application metadata
|
||||
name String
|
||||
description String?
|
||||
logoUrl String? // URL to app logo stored in GCS
|
||||
clientId String @unique
|
||||
clientSecret String // Hashed with Scrypt (same as API keys)
|
||||
clientSecretSalt String // Salt for Scrypt hashing
|
||||
name String
|
||||
description String?
|
||||
logoUrl String? // URL to app logo stored in GCS
|
||||
clientId String @unique
|
||||
clientSecret String // Hashed with Scrypt (same as API keys)
|
||||
clientSecretSalt String // Salt for Scrypt hashing
|
||||
|
||||
// OAuth configuration
|
||||
redirectUris String[] // Allowed callback URLs
|
||||
grantTypes String[] @default(["authorization_code", "refresh_token"])
|
||||
grantTypes String[] @default(["authorization_code", "refresh_token"])
|
||||
scopes APIKeyPermission[] // Which permissions the app can request
|
||||
|
||||
// Application management
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"created_at": "2025-09-04T13:37:00",
|
||||
"credentials_input_schema": {
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"title": "TestGraphCredentialsInputSchema",
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
{
|
||||
"credentials_input_schema": {
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"title": "TestGraphCredentialsInputSchema",
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"id": "test-agent-1",
|
||||
"graph_id": "test-agent-1",
|
||||
"graph_version": 1,
|
||||
"owner_user_id": "3e53486c-cf57-477e-ba2a-cb02dc828e1a",
|
||||
"image_url": null,
|
||||
"creator_name": "Test Creator",
|
||||
"creator_image_url": "",
|
||||
@@ -42,7 +41,6 @@
|
||||
"id": "test-agent-2",
|
||||
"graph_id": "test-agent-2",
|
||||
"graph_version": 1,
|
||||
"owner_user_id": "3e53486c-cf57-477e-ba2a-cb02dc828e1a",
|
||||
"image_url": null,
|
||||
"creator_name": "Test Creator",
|
||||
"creator_image_url": "",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"submissions": [
|
||||
{
|
||||
"listing_id": "test-listing-id",
|
||||
"agent_id": "test-agent-id",
|
||||
"agent_version": 1,
|
||||
"name": "Test Agent",
|
||||
|
||||
@@ -37,7 +37,7 @@ services:
|
||||
context: ../
|
||||
dockerfile: autogpt_platform/backend/Dockerfile
|
||||
target: migrate
|
||||
command: ["sh", "-c", "poetry run prisma generate && poetry run gen-prisma-stub && poetry run prisma migrate deploy"]
|
||||
command: ["sh", "-c", "poetry run prisma generate && poetry run prisma migrate deploy"]
|
||||
develop:
|
||||
watch:
|
||||
- path: ./
|
||||
|
||||
@@ -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 |
@@ -1,5 +1,5 @@
|
||||
import { Sidebar } from "@/components/__legacy__/Sidebar";
|
||||
import { Users, DollarSign, UserSearch, FileText, Clock } from "lucide-react";
|
||||
import { Users, DollarSign, UserSearch, FileText } from "lucide-react";
|
||||
|
||||
import { IconSliders } from "@/components/__legacy__/ui/icons";
|
||||
|
||||
@@ -11,11 +11,6 @@ const sidebarLinkGroups = [
|
||||
href: "/admin/marketplace",
|
||||
icon: <Users className="h-6 w-6" />,
|
||||
},
|
||||
{
|
||||
text: "Waitlist Management",
|
||||
href: "/admin/waitlist",
|
||||
icon: <Clock className="h-6 w-6" />,
|
||||
},
|
||||
{
|
||||
text: "User Spending",
|
||||
href: "/admin/spending",
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import {
|
||||
usePostV2CreateWaitlist,
|
||||
getGetV2ListAllWaitlistsQueryKey,
|
||||
} from "@/app/api/__generated__/endpoints/admin/admin";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
|
||||
export function CreateWaitlistButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createWaitlistMutation = usePostV2CreateWaitlist({
|
||||
mutation: {
|
||||
onSuccess: (response) => {
|
||||
if (response.status === 200) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Waitlist created successfully",
|
||||
});
|
||||
setOpen(false);
|
||||
setFormData({
|
||||
name: "",
|
||||
slug: "",
|
||||
subHeading: "",
|
||||
description: "",
|
||||
categories: "",
|
||||
imageUrls: "",
|
||||
videoUrl: "",
|
||||
agentOutputDemoUrl: "",
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListAllWaitlistsQueryKey(),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to create waitlist",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error creating waitlist:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to create waitlist",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
slug: "",
|
||||
subHeading: "",
|
||||
description: "",
|
||||
categories: "",
|
||||
imageUrls: "",
|
||||
videoUrl: "",
|
||||
agentOutputDemoUrl: "",
|
||||
});
|
||||
|
||||
function handleInputChange(id: string, value: string) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[id]: value,
|
||||
}));
|
||||
}
|
||||
|
||||
function generateSlug(name: string) {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
createWaitlistMutation.mutate({
|
||||
data: {
|
||||
name: formData.name,
|
||||
slug: formData.slug || generateSlug(formData.name),
|
||||
subHeading: formData.subHeading,
|
||||
description: formData.description,
|
||||
categories: formData.categories
|
||||
? formData.categories.split(",").map((c) => c.trim())
|
||||
: [],
|
||||
imageUrls: formData.imageUrls
|
||||
? formData.imageUrls.split(",").map((u) => u.trim())
|
||||
: [],
|
||||
videoUrl: formData.videoUrl || null,
|
||||
agentOutputDemoUrl: formData.agentOutputDemoUrl || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Waitlist
|
||||
</Button>
|
||||
|
||||
<Dialog
|
||||
title="Create New Waitlist"
|
||||
controlled={{
|
||||
isOpen: open,
|
||||
set: async (isOpen) => setOpen(isOpen),
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
styling={{ maxWidth: "600px" }}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<p className="mb-4 text-sm text-zinc-500">
|
||||
Create a new waitlist for an upcoming agent. Users can sign up to be
|
||||
notified when it launches.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
|
||||
<Input
|
||||
id="name"
|
||||
label="Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange("name", e.target.value)}
|
||||
placeholder="SEO Analysis Agent"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="slug"
|
||||
label="Slug"
|
||||
value={formData.slug}
|
||||
onChange={(e) => handleInputChange("slug", e.target.value)}
|
||||
placeholder="seo-analysis-agent (auto-generated if empty)"
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="subHeading"
|
||||
label="Subheading"
|
||||
value={formData.subHeading}
|
||||
onChange={(e) => handleInputChange("subHeading", e.target.value)}
|
||||
placeholder="Analyze your website's SEO in minutes"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="description"
|
||||
label="Description"
|
||||
type="textarea"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange("description", e.target.value)}
|
||||
placeholder="Detailed description of what this agent does..."
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="categories"
|
||||
label="Categories (comma-separated)"
|
||||
value={formData.categories}
|
||||
onChange={(e) => handleInputChange("categories", e.target.value)}
|
||||
placeholder="SEO, Marketing, Analysis"
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="imageUrls"
|
||||
label="Image URLs (comma-separated)"
|
||||
value={formData.imageUrls}
|
||||
onChange={(e) => handleInputChange("imageUrls", e.target.value)}
|
||||
placeholder="https://example.com/image1.jpg, https://example.com/image2.jpg"
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="videoUrl"
|
||||
label="Video URL (optional)"
|
||||
value={formData.videoUrl}
|
||||
onChange={(e) => handleInputChange("videoUrl", e.target.value)}
|
||||
placeholder="https://youtube.com/watch?v=..."
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="agentOutputDemoUrl"
|
||||
label="Output Demo URL (optional)"
|
||||
value={formData.agentOutputDemoUrl}
|
||||
onChange={(e) =>
|
||||
handleInputChange("agentOutputDemoUrl", e.target.value)
|
||||
}
|
||||
placeholder="https://example.com/demo-output.mp4"
|
||||
/>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={createWaitlistMutation.isPending}>
|
||||
Create Waitlist
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Select } from "@/components/atoms/Select/Select";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { usePutV2UpdateWaitlist } from "@/app/api/__generated__/endpoints/admin/admin";
|
||||
import type { WaitlistAdminResponse } from "@/app/api/__generated__/models/waitlistAdminResponse";
|
||||
import type { WaitlistUpdateRequest } from "@/app/api/__generated__/models/waitlistUpdateRequest";
|
||||
import { WaitlistExternalStatus } from "@/app/api/__generated__/models/waitlistExternalStatus";
|
||||
|
||||
type EditWaitlistDialogProps = {
|
||||
waitlist: WaitlistAdminResponse;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: WaitlistExternalStatus.NOT_STARTED, label: "Not Started" },
|
||||
{ value: WaitlistExternalStatus.WORK_IN_PROGRESS, label: "Work In Progress" },
|
||||
{ value: WaitlistExternalStatus.DONE, label: "Done" },
|
||||
{ value: WaitlistExternalStatus.CANCELED, label: "Canceled" },
|
||||
];
|
||||
|
||||
export function EditWaitlistDialog({
|
||||
waitlist,
|
||||
onClose,
|
||||
onSave,
|
||||
}: EditWaitlistDialogProps) {
|
||||
const { toast } = useToast();
|
||||
const updateWaitlistMutation = usePutV2UpdateWaitlist();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: waitlist.name,
|
||||
slug: waitlist.slug,
|
||||
subHeading: waitlist.subHeading,
|
||||
description: waitlist.description,
|
||||
categories: waitlist.categories.join(", "),
|
||||
imageUrls: waitlist.imageUrls.join(", "),
|
||||
videoUrl: waitlist.videoUrl || "",
|
||||
agentOutputDemoUrl: waitlist.agentOutputDemoUrl || "",
|
||||
status: waitlist.status,
|
||||
storeListingId: waitlist.storeListingId || "",
|
||||
});
|
||||
|
||||
function handleInputChange(id: string, value: string) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[id]: value,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleStatusChange(value: string) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
status: value as WaitlistExternalStatus,
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
const updateData: WaitlistUpdateRequest = {
|
||||
name: formData.name,
|
||||
slug: formData.slug,
|
||||
subHeading: formData.subHeading,
|
||||
description: formData.description,
|
||||
categories: formData.categories
|
||||
? formData.categories.split(",").map((c) => c.trim())
|
||||
: [],
|
||||
imageUrls: formData.imageUrls
|
||||
? formData.imageUrls.split(",").map((u) => u.trim())
|
||||
: [],
|
||||
videoUrl: formData.videoUrl || null,
|
||||
agentOutputDemoUrl: formData.agentOutputDemoUrl || null,
|
||||
status: formData.status,
|
||||
storeListingId: formData.storeListingId || null,
|
||||
};
|
||||
|
||||
updateWaitlistMutation.mutate(
|
||||
{ waitlistId: waitlist.id, data: updateData },
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
if (response.status === 200) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Waitlist updated successfully",
|
||||
});
|
||||
onSave();
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to update waitlist",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to update waitlist",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Edit Waitlist"
|
||||
controlled={{
|
||||
isOpen: true,
|
||||
set: async (open) => {
|
||||
if (!open) onClose();
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
styling={{ maxWidth: "600px" }}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<p className="mb-4 text-sm text-zinc-500">
|
||||
Update the waitlist details. Changes will be reflected immediately.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
|
||||
<Input
|
||||
id="name"
|
||||
label="Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange("name", e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="slug"
|
||||
label="Slug"
|
||||
value={formData.slug}
|
||||
onChange={(e) => handleInputChange("slug", e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="subHeading"
|
||||
label="Subheading"
|
||||
value={formData.subHeading}
|
||||
onChange={(e) => handleInputChange("subHeading", e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="description"
|
||||
label="Description"
|
||||
type="textarea"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange("description", e.target.value)}
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
id="status"
|
||||
label="Status"
|
||||
value={formData.status}
|
||||
onValueChange={handleStatusChange}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="categories"
|
||||
label="Categories (comma-separated)"
|
||||
value={formData.categories}
|
||||
onChange={(e) => handleInputChange("categories", e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="imageUrls"
|
||||
label="Image URLs (comma-separated)"
|
||||
value={formData.imageUrls}
|
||||
onChange={(e) => handleInputChange("imageUrls", e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="videoUrl"
|
||||
label="Video URL"
|
||||
value={formData.videoUrl}
|
||||
onChange={(e) => handleInputChange("videoUrl", e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="agentOutputDemoUrl"
|
||||
label="Output Demo URL"
|
||||
value={formData.agentOutputDemoUrl}
|
||||
onChange={(e) =>
|
||||
handleInputChange("agentOutputDemoUrl", e.target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="storeListingId"
|
||||
label="Store Listing ID (for linking)"
|
||||
value={formData.storeListingId}
|
||||
onChange={(e) =>
|
||||
handleInputChange("storeListingId", e.target.value)
|
||||
}
|
||||
placeholder="Leave empty if not linked"
|
||||
/>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={updateWaitlistMutation.isPending}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { User, Envelope, DownloadSimple } from "@phosphor-icons/react";
|
||||
import { useGetV2GetWaitlistSignups } from "@/app/api/__generated__/endpoints/admin/admin";
|
||||
|
||||
type WaitlistSignupsDialogProps = {
|
||||
waitlistId: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function WaitlistSignupsDialog({
|
||||
waitlistId,
|
||||
onClose,
|
||||
}: WaitlistSignupsDialogProps) {
|
||||
const {
|
||||
data: signupsResponse,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useGetV2GetWaitlistSignups(waitlistId);
|
||||
|
||||
const signups = signupsResponse?.status === 200 ? signupsResponse.data : null;
|
||||
|
||||
function exportToCSV() {
|
||||
if (!signups) return;
|
||||
|
||||
const headers = ["Type", "Email", "User ID", "Username"];
|
||||
const rows = signups.signups.map((signup) => [
|
||||
signup.type,
|
||||
signup.email || "",
|
||||
signup.userId || "",
|
||||
signup.username || "",
|
||||
]);
|
||||
|
||||
const escapeCell = (cell: string) => `"${cell.replace(/"/g, '""')}"`;
|
||||
|
||||
const csvContent = [
|
||||
headers.join(","),
|
||||
...rows.map((row) => row.map(escapeCell).join(",")),
|
||||
].join("\n");
|
||||
|
||||
const blob = new Blob([csvContent], { type: "text/csv" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `waitlist-${waitlistId}-signups.csv`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
if (isLoading) {
|
||||
return <div className="py-10 text-center">Loading signups...</div>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="py-10 text-center text-red-500">
|
||||
Failed to load signups. Please try again.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!signups || signups.signups.length === 0) {
|
||||
return (
|
||||
<div className="py-10 text-center text-gray-500">
|
||||
No signups yet for this waitlist.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="secondary" size="small" onClick={exportToCSV}>
|
||||
<DownloadSimple className="mr-2 h-4 w-4" size={16} />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[400px] overflow-y-auto rounded-md border">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">
|
||||
Email / Username
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">
|
||||
User ID
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{signups.signups.map((signup, index) => (
|
||||
<tr key={index}>
|
||||
<td className="px-4 py-3">
|
||||
{signup.type === "user" ? (
|
||||
<span className="flex items-center gap-1 text-blue-600">
|
||||
<User className="h-4 w-4" size={16} /> User
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-gray-600">
|
||||
<Envelope className="h-4 w-4" size={16} /> Email
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{signup.type === "user"
|
||||
? signup.username || signup.email
|
||||
: signup.email}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-sm">
|
||||
{signup.userId || "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Waitlist Signups"
|
||||
controlled={{
|
||||
isOpen: true,
|
||||
set: async (open) => {
|
||||
if (!open) onClose();
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
styling={{ maxWidth: "700px" }}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<p className="mb-4 text-sm text-zinc-500">
|
||||
{signups
|
||||
? `${signups.totalCount} total signups`
|
||||
: "Loading signups..."}
|
||||
</p>
|
||||
|
||||
{renderContent()}
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/__legacy__/ui/table";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import {
|
||||
useGetV2ListAllWaitlists,
|
||||
useDeleteV2DeleteWaitlist,
|
||||
getGetV2ListAllWaitlistsQueryKey,
|
||||
} from "@/app/api/__generated__/endpoints/admin/admin";
|
||||
import type { WaitlistAdminResponse } from "@/app/api/__generated__/models/waitlistAdminResponse";
|
||||
import { EditWaitlistDialog } from "./EditWaitlistDialog";
|
||||
import { WaitlistSignupsDialog } from "./WaitlistSignupsDialog";
|
||||
import { Trash, PencilSimple, Users, Link } from "@phosphor-icons/react";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
|
||||
export function WaitlistTable() {
|
||||
const [editingWaitlist, setEditingWaitlist] =
|
||||
useState<WaitlistAdminResponse | null>(null);
|
||||
const [viewingSignups, setViewingSignups] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: response, isLoading, error } = useGetV2ListAllWaitlists();
|
||||
|
||||
const deleteWaitlistMutation = useDeleteV2DeleteWaitlist({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Waitlist deleted successfully",
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListAllWaitlistsQueryKey(),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error deleting waitlist:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to delete waitlist",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function handleDelete(waitlistId: string) {
|
||||
if (!confirm("Are you sure you want to delete this waitlist?")) return;
|
||||
deleteWaitlistMutation.mutate({ waitlistId });
|
||||
}
|
||||
|
||||
function handleWaitlistSaved() {
|
||||
setEditingWaitlist(null);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListAllWaitlistsQueryKey(),
|
||||
});
|
||||
}
|
||||
|
||||
function formatStatus(status: string) {
|
||||
const statusColors: Record<string, string> = {
|
||||
NOT_STARTED: "bg-gray-100 text-gray-800",
|
||||
WORK_IN_PROGRESS: "bg-blue-100 text-blue-800",
|
||||
DONE: "bg-green-100 text-green-800",
|
||||
CANCELED: "bg-red-100 text-red-800",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-1 text-xs font-medium ${statusColors[status] || "bg-gray-100 text-gray-700"}`}
|
||||
>
|
||||
{status.replace(/_/g, " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return "-";
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(dateStr));
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="py-10 text-center">Loading waitlists...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-10 text-center text-red-500">
|
||||
Error loading waitlists. Please try again.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const waitlists = response?.status === 200 ? response.data.waitlists : [];
|
||||
|
||||
if (waitlists.length === 0) {
|
||||
return (
|
||||
<div className="py-10 text-center text-gray-500">
|
||||
No waitlists found. Create one to get started!
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md border bg-white">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="font-medium">Name</TableHead>
|
||||
<TableHead className="font-medium">Status</TableHead>
|
||||
<TableHead className="font-medium">Signups</TableHead>
|
||||
<TableHead className="font-medium">Votes</TableHead>
|
||||
<TableHead className="font-medium">Created</TableHead>
|
||||
<TableHead className="font-medium">Linked Agent</TableHead>
|
||||
<TableHead className="font-medium">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{waitlists.map((waitlist) => (
|
||||
<TableRow key={waitlist.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{waitlist.name}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{waitlist.subHeading}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{formatStatus(waitlist.status)}</TableCell>
|
||||
<TableCell>{waitlist.signupCount}</TableCell>
|
||||
<TableCell>{waitlist.votes}</TableCell>
|
||||
<TableCell>{formatDate(waitlist.createdAt)}</TableCell>
|
||||
<TableCell>
|
||||
{waitlist.storeListingId ? (
|
||||
<span className="text-green-600">
|
||||
<Link size={16} className="inline" /> Linked
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">Not linked</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => setViewingSignups(waitlist.id)}
|
||||
title="View signups"
|
||||
>
|
||||
<Users size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => setEditingWaitlist(waitlist)}
|
||||
title="Edit"
|
||||
>
|
||||
<PencilSimple size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => handleDelete(waitlist.id)}
|
||||
title="Delete"
|
||||
disabled={deleteWaitlistMutation.isPending}
|
||||
>
|
||||
<Trash size={16} className="text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{editingWaitlist && (
|
||||
<EditWaitlistDialog
|
||||
waitlist={editingWaitlist}
|
||||
onClose={() => setEditingWaitlist(null)}
|
||||
onSave={handleWaitlistSaved}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewingSignups && (
|
||||
<WaitlistSignupsDialog
|
||||
waitlistId={viewingSignups}
|
||||
onClose={() => setViewingSignups(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { withRoleAccess } from "@/lib/withRoleAccess";
|
||||
import { Suspense } from "react";
|
||||
import { WaitlistTable } from "./components/WaitlistTable";
|
||||
import { CreateWaitlistButton } from "./components/CreateWaitlistButton";
|
||||
|
||||
function WaitlistDashboard() {
|
||||
return (
|
||||
<div className="mx-auto p-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Waitlist Management</h1>
|
||||
<p className="text-gray-500">
|
||||
Manage upcoming agent waitlists and track signups
|
||||
</p>
|
||||
</div>
|
||||
<CreateWaitlistButton />
|
||||
</div>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="py-10 text-center">Loading waitlists...</div>
|
||||
}
|
||||
>
|
||||
<WaitlistTable />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function WaitlistDashboardPage() {
|
||||
const withAdminAccess = await withRoleAccess(["admin"]);
|
||||
const ProtectedWaitlistDashboard = await withAdminAccess(WaitlistDashboard);
|
||||
return <ProtectedWaitlistDashboard />;
|
||||
}
|
||||
@@ -66,7 +66,6 @@ export const RunInputDialog = ({
|
||||
formContext={{
|
||||
showHandles: false,
|
||||
size: "large",
|
||||
showOptionalToggle: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,7 @@ export const useRunInputDialog = ({
|
||||
if (isCredentialFieldSchema(fieldSchema)) {
|
||||
dynamicUiSchema[fieldName] = {
|
||||
...dynamicUiSchema[fieldName],
|
||||
"ui:field": "custom/credential_field",
|
||||
"ui:field": "credentials",
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -76,18 +76,12 @@ export const useRunInputDialog = ({
|
||||
}, [credentialsSchema]);
|
||||
|
||||
const handleManualRun = async () => {
|
||||
// Filter out incomplete credentials (those without a valid id)
|
||||
// RJSF auto-populates const values (provider, type) but not id field
|
||||
const validCredentials = Object.fromEntries(
|
||||
Object.entries(credentialValues).filter(([_, cred]) => cred && cred.id),
|
||||
);
|
||||
|
||||
await executeGraph({
|
||||
graphId: flowID ?? "",
|
||||
graphVersion: flowVersion || null,
|
||||
data: {
|
||||
inputs: inputValues,
|
||||
credentials_inputs: validCredentials,
|
||||
credentials_inputs: credentialValues,
|
||||
source: "builder",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -68,10 +69,7 @@ export const NodeHeader = ({ data, nodeId }: Props) => {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Text
|
||||
variant="large-semibold"
|
||||
className="line-clamp-1 hover:cursor-text"
|
||||
>
|
||||
<Text variant="large-semibold" className="line-clamp-1">
|
||||
{beautifyString(title).replace("Block", "").trim()}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -89,18 +89,6 @@ export function extractOptions(
|
||||
|
||||
// get display type and color for schema types [need for type display next to field name]
|
||||
export const getTypeDisplayInfo = (schema: any) => {
|
||||
if (
|
||||
schema?.type === "array" &&
|
||||
"format" in schema &&
|
||||
schema.format === "table"
|
||||
) {
|
||||
return {
|
||||
displayType: "table",
|
||||
colorClass: "!text-indigo-500",
|
||||
hexColor: "#6366f1",
|
||||
};
|
||||
}
|
||||
|
||||
if (schema?.type === "string" && schema?.format) {
|
||||
const formatMap: Record<
|
||||
string,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const uiSchema = {
|
||||
credentials: {
|
||||
"ui:field": "custom/credential_field",
|
||||
"ui:field": "credentials",
|
||||
provider: { "ui:widget": "hidden" },
|
||||
type: { "ui:widget": "hidden" },
|
||||
id: { "ui:autofocus": true },
|
||||
|
||||
@@ -68,9 +68,6 @@ type NodeStore = {
|
||||
clearAllNodeErrors: () => void; // Add this
|
||||
|
||||
syncHardcodedValuesWithHandleIds: (nodeId: string) => void;
|
||||
|
||||
// Credentials optional helpers
|
||||
setCredentialsOptional: (nodeId: string, optional: boolean) => void;
|
||||
};
|
||||
|
||||
export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
@@ -229,9 +226,6 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
...(node.data.metadata?.customized_name !== undefined && {
|
||||
customized_name: node.data.metadata.customized_name,
|
||||
}),
|
||||
...(node.data.metadata?.credentials_optional !== undefined && {
|
||||
credentials_optional: node.data.metadata.credentials_optional,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -348,30 +342,4 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
setCredentialsOptional: (nodeId: string, optional: boolean) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((n) =>
|
||||
n.id === nodeId
|
||||
? {
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
metadata: {
|
||||
...n.data.metadata,
|
||||
credentials_optional: optional,
|
||||
},
|
||||
},
|
||||
}
|
||||
: n,
|
||||
),
|
||||
}));
|
||||
|
||||
const newState = {
|
||||
nodes: get().nodes,
|
||||
edges: useEdgeStore.getState().edges,
|
||||
};
|
||||
|
||||
useHistoryStore.getState().pushState(newState);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -34,9 +34,7 @@ type Props = {
|
||||
onSelectCredentials: (newValue?: CredentialsMetaInput) => void;
|
||||
onLoaded?: (loaded: boolean) => void;
|
||||
readOnly?: boolean;
|
||||
isOptional?: boolean;
|
||||
showTitle?: boolean;
|
||||
variant?: "default" | "node";
|
||||
};
|
||||
|
||||
export function CredentialsInput({
|
||||
@@ -47,9 +45,7 @@ export function CredentialsInput({
|
||||
siblingInputs,
|
||||
onLoaded,
|
||||
readOnly = false,
|
||||
isOptional = false,
|
||||
showTitle = true,
|
||||
variant = "default",
|
||||
}: Props) {
|
||||
const hookData = useCredentialsInput({
|
||||
schema,
|
||||
@@ -58,7 +54,6 @@ export function CredentialsInput({
|
||||
siblingInputs,
|
||||
onLoaded,
|
||||
readOnly,
|
||||
isOptional,
|
||||
});
|
||||
|
||||
if (!isLoaded(hookData)) {
|
||||
@@ -99,14 +94,7 @@ export function CredentialsInput({
|
||||
<div className={cn("mb-6", className)}>
|
||||
{showTitle && (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Text variant="large-medium">
|
||||
{displayName} credentials
|
||||
{isOptional && (
|
||||
<span className="ml-1 text-sm font-normal text-gray-500">
|
||||
(optional)
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
<Text variant="large-medium">{displayName} credentials</Text>
|
||||
{schema.description && (
|
||||
<InformationTooltip description={schema.description} />
|
||||
)}
|
||||
@@ -115,17 +103,14 @@ export function CredentialsInput({
|
||||
|
||||
{hasCredentialsToShow ? (
|
||||
<>
|
||||
{(credentialsToShow.length > 1 || isOptional) && !readOnly ? (
|
||||
{credentialsToShow.length > 1 && !readOnly ? (
|
||||
<CredentialsSelect
|
||||
credentials={credentialsToShow}
|
||||
provider={provider}
|
||||
displayName={displayName}
|
||||
selectedCredentials={selectedCredential}
|
||||
onSelectCredential={handleCredentialSelect}
|
||||
onClearCredential={() => onSelectCredential(undefined)}
|
||||
readOnly={readOnly}
|
||||
allowNone={isOptional}
|
||||
variant={variant}
|
||||
/>
|
||||
) : (
|
||||
<div className="mb-4 space-y-2">
|
||||
|
||||
@@ -30,8 +30,6 @@ type CredentialRowProps = {
|
||||
readOnly?: boolean;
|
||||
showCaret?: boolean;
|
||||
asSelectTrigger?: boolean;
|
||||
/** When "node", applies compact styling for node context */
|
||||
variant?: "default" | "node";
|
||||
};
|
||||
|
||||
export function CredentialRow({
|
||||
@@ -43,22 +41,14 @@ export function CredentialRow({
|
||||
readOnly = false,
|
||||
showCaret = false,
|
||||
asSelectTrigger = false,
|
||||
variant = "default",
|
||||
}: CredentialRowProps) {
|
||||
const ProviderIcon = providerIcons[provider] || fallbackIcon;
|
||||
const isNodeVariant = variant === "node";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-medium border border-zinc-200 bg-white p-3 transition-colors",
|
||||
asSelectTrigger && isNodeVariant
|
||||
? "min-w-0 flex-1 overflow-hidden border-0 bg-transparent"
|
||||
: asSelectTrigger
|
||||
? "border-0 bg-transparent"
|
||||
: readOnly
|
||||
? "w-fit"
|
||||
: "",
|
||||
asSelectTrigger ? "border-0 bg-transparent" : readOnly ? "w-fit" : "",
|
||||
)}
|
||||
onClick={readOnly || showCaret || asSelectTrigger ? undefined : onSelect}
|
||||
style={
|
||||
@@ -71,31 +61,19 @@ export function CredentialRow({
|
||||
<ProviderIcon className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
<IconKey className="h-5 w-5 shrink-0 text-zinc-800" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-nowrap items-center gap-4",
|
||||
isNodeVariant && "overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-nowrap items-center gap-4">
|
||||
<Text
|
||||
variant="body"
|
||||
className={cn(
|
||||
"tracking-tight",
|
||||
isNodeVariant
|
||||
? "truncate"
|
||||
: "line-clamp-1 flex-[0_0_50%] text-ellipsis",
|
||||
)}
|
||||
className="line-clamp-1 flex-[0_0_50%] text-ellipsis tracking-tight"
|
||||
>
|
||||
{getCredentialDisplayName(credential, displayName)}
|
||||
</Text>
|
||||
{!(asSelectTrigger && isNodeVariant) && (
|
||||
<Text
|
||||
variant="large"
|
||||
className="relative top-1 hidden overflow-hidden whitespace-nowrap font-mono tracking-tight md:block"
|
||||
>
|
||||
{"*".repeat(MASKED_KEY_LENGTH)}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
variant="large"
|
||||
className="lex-[0_0_40%] relative top-1 hidden overflow-hidden whitespace-nowrap font-mono tracking-tight md:block"
|
||||
>
|
||||
{"*".repeat(MASKED_KEY_LENGTH)}
|
||||
</Text>
|
||||
</div>
|
||||
{showCaret && !asSelectTrigger && (
|
||||
<CaretDown className="h-4 w-4 shrink-0 text-gray-400" />
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "@/components/__legacy__/ui/select";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect } from "react";
|
||||
import { getCredentialDisplayName } from "../../helpers";
|
||||
import { CredentialRow } from "../CredentialRow/CredentialRow";
|
||||
@@ -24,11 +23,7 @@ interface Props {
|
||||
displayName: string;
|
||||
selectedCredentials?: CredentialsMetaInput;
|
||||
onSelectCredential: (credentialId: string) => void;
|
||||
onClearCredential?: () => void;
|
||||
readOnly?: boolean;
|
||||
allowNone?: boolean;
|
||||
/** When "node", applies compact styling for node context */
|
||||
variant?: "default" | "node";
|
||||
}
|
||||
|
||||
export function CredentialsSelect({
|
||||
@@ -37,38 +32,22 @@ export function CredentialsSelect({
|
||||
displayName,
|
||||
selectedCredentials,
|
||||
onSelectCredential,
|
||||
onClearCredential,
|
||||
readOnly = false,
|
||||
allowNone = true,
|
||||
variant = "default",
|
||||
}: Props) {
|
||||
// Auto-select first credential if none is selected (only if allowNone is false)
|
||||
// Auto-select first credential if none is selected
|
||||
useEffect(() => {
|
||||
if (!allowNone && !selectedCredentials && credentials.length > 0) {
|
||||
if (!selectedCredentials && credentials.length > 0) {
|
||||
onSelectCredential(credentials[0].id);
|
||||
}
|
||||
}, [allowNone, selectedCredentials, credentials, onSelectCredential]);
|
||||
|
||||
const handleValueChange = (value: string) => {
|
||||
if (value === "__none__") {
|
||||
onClearCredential?.();
|
||||
} else {
|
||||
onSelectCredential(value);
|
||||
}
|
||||
};
|
||||
}, [selectedCredentials, credentials, onSelectCredential]);
|
||||
|
||||
return (
|
||||
<div className="mb-4 w-full">
|
||||
<Select
|
||||
value={selectedCredentials?.id || (allowNone ? "__none__" : "")}
|
||||
onValueChange={handleValueChange}
|
||||
value={selectedCredentials?.id || ""}
|
||||
onValueChange={(value) => onSelectCredential(value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"h-auto min-h-12 w-full rounded-medium border-zinc-200 p-0 pr-4 shadow-none",
|
||||
variant === "node" && "overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<SelectTrigger className="h-auto min-h-12 w-full rounded-medium border-zinc-200 p-0 pr-4 shadow-none">
|
||||
{selectedCredentials ? (
|
||||
<SelectValue key={selectedCredentials.id} asChild>
|
||||
<CredentialRow
|
||||
@@ -84,7 +63,6 @@ export function CredentialsSelect({
|
||||
onDelete={() => {}}
|
||||
readOnly={readOnly}
|
||||
asSelectTrigger={true}
|
||||
variant={variant}
|
||||
/>
|
||||
</SelectValue>
|
||||
) : (
|
||||
@@ -92,15 +70,6 @@ export function CredentialsSelect({
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allowNone && (
|
||||
<SelectItem key="__none__" value="__none__">
|
||||
<div className="flex items-center gap-2">
|
||||
<Text variant="body" className="tracking-tight text-gray-500">
|
||||
None (skip this credential)
|
||||
</Text>
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
{credentials.map((credential) => (
|
||||
<SelectItem key={credential.id} value={credential.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -22,7 +22,6 @@ type Params = {
|
||||
siblingInputs?: Record<string, any>;
|
||||
onLoaded?: (loaded: boolean) => void;
|
||||
readOnly?: boolean;
|
||||
isOptional?: boolean;
|
||||
};
|
||||
|
||||
export function useCredentialsInput({
|
||||
@@ -32,7 +31,6 @@ export function useCredentialsInput({
|
||||
siblingInputs,
|
||||
onLoaded,
|
||||
readOnly = false,
|
||||
isOptional = false,
|
||||
}: Params) {
|
||||
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
|
||||
useState(false);
|
||||
@@ -101,20 +99,13 @@ export function useCredentialsInput({
|
||||
: null;
|
||||
}, [credentials]);
|
||||
|
||||
// Auto-select the one available credential (only if not optional)
|
||||
// Auto-select the one available credential
|
||||
useEffect(() => {
|
||||
if (readOnly) return;
|
||||
if (isOptional) return; // Don't auto-select when credential is optional
|
||||
if (singleCredential && !selectedCredential) {
|
||||
onSelectCredential(singleCredential);
|
||||
}
|
||||
}, [
|
||||
singleCredential,
|
||||
selectedCredential,
|
||||
onSelectCredential,
|
||||
readOnly,
|
||||
isOptional,
|
||||
]);
|
||||
}, [singleCredential, selectedCredential, onSelectCredential, readOnly]);
|
||||
|
||||
if (
|
||||
!credentials ||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { WebhookTriggerBanner } from "../WebhookTriggerBanner/WebhookTriggerBann
|
||||
|
||||
export function ModalRunSection() {
|
||||
const {
|
||||
agent,
|
||||
defaultRunType,
|
||||
presetName,
|
||||
setPresetName,
|
||||
@@ -25,11 +24,6 @@ export function ModalRunSection() {
|
||||
const inputFields = Object.entries(agentInputFields || {});
|
||||
const credentialFields = Object.entries(agentCredentialsInputFields || {});
|
||||
|
||||
// Get the list of required credentials from the schema
|
||||
const requiredCredentials = new Set(
|
||||
(agent.credentials_input_schema?.required as string[]) || [],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{defaultRunType === "automatic-trigger" ||
|
||||
@@ -105,12 +99,14 @@ export function ModalRunSection() {
|
||||
schema={
|
||||
{ ...inputSubSchema, discriminator: undefined } as any
|
||||
}
|
||||
selectedCredentials={inputCredentials?.[key]}
|
||||
selectedCredentials={
|
||||
(inputCredentials && inputCredentials[key]) ??
|
||||
inputSubSchema.default
|
||||
}
|
||||
onSelectCredentials={(value) =>
|
||||
setInputCredentialsValue(key, value)
|
||||
}
|
||||
siblingInputs={inputValues}
|
||||
isOptional={!requiredCredentials.has(key)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -163,21 +163,15 @@ export function useAgentRunModal(
|
||||
}, [agentInputSchema.required, inputValues]);
|
||||
|
||||
const [allCredentialsAreSet, missingCredentials] = useMemo(() => {
|
||||
// Only check required credentials from schema, not all properties
|
||||
// Credentials marked as optional in node metadata won't be in the required array
|
||||
const requiredCredentials = new Set(
|
||||
(agent.credentials_input_schema?.required as string[]) || [],
|
||||
const availableCredentials = new Set(Object.keys(inputCredentials));
|
||||
const allCredentials = new Set(
|
||||
Object.keys(agentCredentialsInputFields || {}) ?? [],
|
||||
);
|
||||
const missing = [...allCredentials].filter(
|
||||
(key) => !availableCredentials.has(key),
|
||||
);
|
||||
|
||||
// Check if required credentials have valid id (not just key existence)
|
||||
// A credential is valid only if it has an id field set
|
||||
const missing = [...requiredCredentials].filter((key) => {
|
||||
const cred = inputCredentials[key];
|
||||
return !cred || !cred.id;
|
||||
});
|
||||
|
||||
return [missing.length === 0, missing];
|
||||
}, [agent.credentials_input_schema, inputCredentials]);
|
||||
}, [agentCredentialsInputFields, inputCredentials]);
|
||||
|
||||
const credentialsRequired = useMemo(
|
||||
() => Object.keys(agentCredentialsInputFields || {}).length > 0,
|
||||
@@ -245,18 +239,12 @@ export function useAgentRunModal(
|
||||
});
|
||||
} else {
|
||||
// Manual execution
|
||||
// Filter out incomplete credentials (optional ones not selected)
|
||||
// Only send credentials that have a valid id field
|
||||
const validCredentials = Object.fromEntries(
|
||||
Object.entries(inputCredentials).filter(([_, cred]) => cred && cred.id),
|
||||
);
|
||||
|
||||
executeGraphMutation.mutate({
|
||||
graphId: agent.graph_id,
|
||||
graphVersion: agent.graph_version,
|
||||
data: {
|
||||
inputs: inputValues,
|
||||
credentials_inputs: validCredentials,
|
||||
credentials_inputs: inputCredentials,
|
||||
source: "library",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -40,17 +40,15 @@ export function useMarketplaceUpdate({ agent }: UseMarketplaceUpdateProps) {
|
||||
},
|
||||
);
|
||||
|
||||
// Get user's submissions - only fetch if user is the creator
|
||||
const { data: submissionsData, isLoading: isSubmissionsLoading } =
|
||||
useGetV2ListMySubmissions(
|
||||
{ page: 1, page_size: 50 },
|
||||
{
|
||||
query: {
|
||||
// Only fetch if user is the creator
|
||||
enabled: !!(user?.id && agent?.owner_user_id === user.id),
|
||||
},
|
||||
// Get user's submissions to check for pending submissions
|
||||
const { data: submissionsData } = useGetV2ListMySubmissions(
|
||||
{ page: 1, page_size: 50 }, // Get enough to cover recent submissions
|
||||
{
|
||||
query: {
|
||||
enabled: !!user?.id, // Only fetch if user is authenticated
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const updateToLatestMutation = usePatchV2UpdateLibraryAgent({
|
||||
mutation: {
|
||||
@@ -80,45 +78,16 @@ export function useMarketplaceUpdate({ agent }: UseMarketplaceUpdateProps) {
|
||||
// Check if marketplace has a newer version than user's current version
|
||||
const marketplaceUpdateInfo = React.useMemo(() => {
|
||||
const storeAgent = okData(storeAgentData) as any;
|
||||
|
||||
if (!agent || isSubmissionsLoading) {
|
||||
if (!agent || !storeAgent) {
|
||||
return {
|
||||
hasUpdate: false,
|
||||
latestVersion: undefined,
|
||||
isUserCreator: false,
|
||||
hasPublishUpdate: false,
|
||||
};
|
||||
}
|
||||
|
||||
const isUserCreator = agent?.owner_user_id === user?.id;
|
||||
|
||||
const submissionsResponse = okData(submissionsData) as any;
|
||||
const agentSubmissions =
|
||||
submissionsResponse?.submissions?.filter(
|
||||
(submission: StoreSubmission) => submission.agent_id === agent.graph_id,
|
||||
) || [];
|
||||
|
||||
const highestSubmittedVersion =
|
||||
agentSubmissions.length > 0
|
||||
? Math.max(
|
||||
...agentSubmissions.map(
|
||||
(submission: StoreSubmission) => submission.agent_version,
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
|
||||
const hasUnpublishedChanges =
|
||||
isUserCreator && agent.graph_version > highestSubmittedVersion;
|
||||
|
||||
if (!storeAgent) {
|
||||
return {
|
||||
hasUpdate: false,
|
||||
latestVersion: undefined,
|
||||
isUserCreator,
|
||||
hasPublishUpdate: agentSubmissions.length > 0 && hasUnpublishedChanges,
|
||||
};
|
||||
}
|
||||
|
||||
// Get the latest version from the marketplace
|
||||
// agentGraphVersions array contains graph version numbers as strings, get the highest one
|
||||
const latestMarketplaceVersion =
|
||||
storeAgent.agentGraphVersions?.length > 0
|
||||
? Math.max(
|
||||
@@ -128,11 +97,32 @@ export function useMarketplaceUpdate({ agent }: UseMarketplaceUpdateProps) {
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Determine if the user is the creator of this agent
|
||||
// Compare current user ID with the marketplace listing creator ID
|
||||
const isUserCreator =
|
||||
user?.id && agent.marketplace_listing?.creator.id === user.id;
|
||||
|
||||
// Check if there's a pending submission for this specific agent version
|
||||
const submissionsResponse = okData(submissionsData) as any;
|
||||
const hasPendingSubmissionForCurrentVersion =
|
||||
isUserCreator &&
|
||||
submissionsResponse?.submissions?.some(
|
||||
(submission: StoreSubmission) =>
|
||||
submission.agent_id === agent.graph_id &&
|
||||
submission.agent_version === agent.graph_version &&
|
||||
submission.status === "PENDING",
|
||||
);
|
||||
|
||||
// If user is creator and their version is newer than marketplace, show publish update banner
|
||||
// BUT only if there's no pending submission for this version
|
||||
const hasPublishUpdate =
|
||||
isUserCreator &&
|
||||
agent.graph_version >
|
||||
Math.max(latestMarketplaceVersion || 0, highestSubmittedVersion);
|
||||
!hasPendingSubmissionForCurrentVersion &&
|
||||
latestMarketplaceVersion !== undefined &&
|
||||
agent.graph_version > latestMarketplaceVersion;
|
||||
|
||||
// If marketplace version is newer than user's version, show update banner
|
||||
// This applies to both creators and non-creators
|
||||
const hasMarketplaceUpdate =
|
||||
latestMarketplaceVersion !== undefined &&
|
||||
latestMarketplaceVersion > agent.graph_version;
|
||||
@@ -143,7 +133,7 @@ export function useMarketplaceUpdate({ agent }: UseMarketplaceUpdateProps) {
|
||||
isUserCreator,
|
||||
hasPublishUpdate,
|
||||
};
|
||||
}, [agent, storeAgentData, user, submissionsData, isSubmissionsLoading]);
|
||||
}, [agent, storeAgentData, user, submissionsData]);
|
||||
|
||||
const handlePublishUpdate = () => {
|
||||
setModalOpen(true);
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
import { HeartIcon } from "@phosphor-icons/react";
|
||||
import React from "react";
|
||||
import { useFavoriteAgents } from "../../hooks/useFavoriteAgents";
|
||||
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
|
||||
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 { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
export function FavoritesSection({ searchTerm }: Props) {
|
||||
export default function FavoritesSection() {
|
||||
const isAgentFavoritingEnabled = useGetFlag(Flag.AGENT_FAVORITING);
|
||||
const {
|
||||
allAgents: favoriteAgents,
|
||||
agentLoading: isLoading,
|
||||
@@ -19,50 +18,60 @@ export function FavoritesSection({ searchTerm }: Props) {
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useFavoriteAgents({ searchTerm });
|
||||
} = useFavoriteAgents();
|
||||
|
||||
if (isLoading || favoriteAgents.length === 0) {
|
||||
// Only show this section if the feature flag is enabled
|
||||
if (!isAgentFavoritingEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't show the section if there are no favorites
|
||||
if (!isLoading && favoriteAgents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="!mb-8">
|
||||
<div className="mb-3 flex items-center gap-2 p-2">
|
||||
<HeartIcon className="h-5 w-5" weight="fill" />
|
||||
<div className="flex items-baseline gap-2">
|
||||
<Text variant="h4">Favorites</Text>
|
||||
{!isLoading && (
|
||||
<Text
|
||||
variant="body"
|
||||
data-testid="agents-count"
|
||||
className="relative bottom-px text-zinc-500"
|
||||
>
|
||||
{agentCount}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-[10px] p-2 pb-[10px]">
|
||||
<Heart className="h-5 w-5 fill-red-500 text-red-500" />
|
||||
<span className="font-poppin text-[18px] font-semibold leading-[28px] text-neutral-800">
|
||||
Favorites
|
||||
</span>
|
||||
{!isLoading && (
|
||||
<span className="font-sans text-[14px] font-normal leading-6">
|
||||
{agentCount} {agentCount === 1 ? "agent" : "agents"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<InfiniteScroll
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
loader={
|
||||
<div className="flex h-8 w-full items-center justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-b-2 border-t-2 border-neutral-800" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{favoriteAgents.map((agent: LibraryAgent) => (
|
||||
<LibraryAgentCard key={agent.id} agent={agent} />
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-48 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
) : (
|
||||
<InfiniteScroll
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
loader={
|
||||
<div className="flex h-8 w-full items-center justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-b-2 border-t-2 border-neutral-800" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{favoriteAgents.map((agent: LibraryAgent) => (
|
||||
<LibraryAgentCard key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{favoriteAgents.length > 0 && <div className="!mt-10 border-t" />}
|
||||
{favoriteAgents.length > 0 && <div className="mt-6 border-t pt-6" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,126 +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,
|
||||
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"
|
||||
>
|
||||
<NextLink href={`/library/agents/${id}`} className="flex-shrink-0">
|
||||
<div className="relative 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>
|
||||
</NextLink>
|
||||
<FavoriteButton
|
||||
isFavorite={isFavorite}
|
||||
onClick={handleToggleFavorite}
|
||||
className="absolute right-10 top-0"
|
||||
/>
|
||||
<AgentCardMenu agent={agent} />
|
||||
</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,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HeartIcon } from "@phosphor-icons/react";
|
||||
import type { MouseEvent } from "react";
|
||||
|
||||
interface FavoriteButtonProps {
|
||||
isFavorite: boolean;
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FavoriteButton({
|
||||
isFavorite,
|
||||
onClick,
|
||||
className,
|
||||
}: FavoriteButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-full p-2 transition-all duration-200",
|
||||
"hover:scale-110",
|
||||
!isFavorite && "opacity-0 group-hover:opacity-100",
|
||||
className,
|
||||
)}
|
||||
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,84 +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 { 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 [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();
|
||||
|
||||
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,
|
||||
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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user