mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-13 09:08:02 -05:00
Compare commits
25 Commits
typing-che
...
fix/run-mo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
383e22da19 | ||
|
|
8957ecb099 | ||
|
|
d2305d047d | ||
|
|
db8b43bb3d | ||
|
|
923d8baedc | ||
|
|
a55b2e02dc | ||
|
|
6b6648b290 | ||
|
|
c0a9c0410b | ||
|
|
17a77b02c7 | ||
|
|
701fce83ca | ||
|
|
78d89d0faf | ||
|
|
f482eb668b | ||
|
|
4a52b7eca0 | ||
|
|
97847f59f7 | ||
|
|
22ca8955c5 | ||
|
|
43cbe2e011 | ||
|
|
a318832414 | ||
|
|
843c487500 | ||
|
|
47a3a5ef41 | ||
|
|
ec00aa951a | ||
|
|
36fb1ea004 | ||
|
|
a81ac150da | ||
|
|
49ee087496 | ||
|
|
fc25e008b3 | ||
|
|
b0855e8cf2 |
37
.branchlet.json
Normal file
37
.branchlet.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"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,6 +16,7 @@
|
||||
!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
|
||||
run: poetry run prisma generate && poetry run gen-prisma-stub
|
||||
|
||||
# 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
|
||||
run: poetry run prisma generate && poetry run gen-prisma-stub
|
||||
|
||||
# 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
|
||||
run: poetry run prisma generate && poetry run gen-prisma-stub
|
||||
|
||||
# Frontend Node.js/pnpm setup (mirrors platform-frontend-ci.yml)
|
||||
- name: Set up Node.js
|
||||
@@ -108,6 +108,16 @@ 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
|
||||
run: poetry run prisma generate && poetry run gen-prisma-stub
|
||||
|
||||
- id: supabase
|
||||
name: Start Supabase
|
||||
|
||||
@@ -12,6 +12,7 @@ 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:
|
||||
@@ -33,6 +34,7 @@ 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,7 +48,8 @@ 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
|
||||
RUN poetry run prisma generate
|
||||
COPY autogpt_platform/backend/gen_prisma_types_stub.py ./
|
||||
RUN poetry run prisma generate && poetry run gen-prisma-stub
|
||||
|
||||
FROM debian:13-slim AS server_dependencies
|
||||
|
||||
|
||||
@@ -108,6 +108,9 @@ class CredentialsMetaResponse(BaseModel):
|
||||
host: str | None = Field(
|
||||
default=None, description="Host pattern for host-scoped credentials"
|
||||
)
|
||||
is_system: bool = Field(
|
||||
default=False, description="Whether this is a system-managed credential"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{provider}/callback", summary="Exchange OAuth code for tokens")
|
||||
@@ -175,6 +178,8 @@ async def callback(
|
||||
f"Successfully processed OAuth callback for user {user_id} "
|
||||
f"and provider {provider.value}"
|
||||
)
|
||||
from backend.integrations.credentials_store import is_system_credential
|
||||
|
||||
return CredentialsMetaResponse(
|
||||
id=credentials.id,
|
||||
provider=credentials.provider,
|
||||
@@ -185,6 +190,7 @@ async def callback(
|
||||
host=(
|
||||
credentials.host if isinstance(credentials, HostScopedCredentials) else None
|
||||
),
|
||||
is_system=is_system_credential(credentials.id),
|
||||
)
|
||||
|
||||
|
||||
@@ -192,6 +198,8 @@ async def callback(
|
||||
async def list_credentials(
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
) -> list[CredentialsMetaResponse]:
|
||||
from backend.integrations.credentials_store import is_system_credential
|
||||
|
||||
credentials = await creds_manager.store.get_all_creds(user_id)
|
||||
return [
|
||||
CredentialsMetaResponse(
|
||||
@@ -202,6 +210,7 @@ async def list_credentials(
|
||||
scopes=cred.scopes if isinstance(cred, OAuth2Credentials) else None,
|
||||
username=cred.username if isinstance(cred, OAuth2Credentials) else None,
|
||||
host=cred.host if isinstance(cred, HostScopedCredentials) else None,
|
||||
is_system=is_system_credential(cred.id),
|
||||
)
|
||||
for cred in credentials
|
||||
]
|
||||
@@ -214,6 +223,8 @@ async def list_credentials_by_provider(
|
||||
],
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
) -> list[CredentialsMetaResponse]:
|
||||
from backend.integrations.credentials_store import is_system_credential
|
||||
|
||||
credentials = await creds_manager.store.get_creds_by_provider(user_id, provider)
|
||||
return [
|
||||
CredentialsMetaResponse(
|
||||
@@ -224,6 +235,7 @@ async def list_credentials_by_provider(
|
||||
scopes=cred.scopes if isinstance(cred, OAuth2Credentials) else None,
|
||||
username=cred.username if isinstance(cred, OAuth2Credentials) else None,
|
||||
host=cred.host if isinstance(cred, HostScopedCredentials) else None,
|
||||
is_system=is_system_credential(cred.id),
|
||||
)
|
||||
for cred in credentials
|
||||
]
|
||||
|
||||
@@ -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 if useGraphIsActiveVersion is True.
|
||||
Updates the agent version in the library for any agent owned by the user.
|
||||
|
||||
Args:
|
||||
user_id: Owner of the LibraryAgent.
|
||||
@@ -498,20 +498,31 @@ 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}"
|
||||
)
|
||||
try:
|
||||
library_agent = await prisma.models.LibraryAgent.prisma().find_first_or_raise(
|
||||
async with transaction() as tx:
|
||||
library_agent = await prisma.models.LibraryAgent.prisma(tx).find_first_or_raise(
|
||||
where={
|
||||
"userId": user_id,
|
||||
"agentGraphId": agent_graph_id,
|
||||
"useGraphIsActiveVersion": True,
|
||||
},
|
||||
)
|
||||
lib = await prisma.models.LibraryAgent.prisma().update(
|
||||
|
||||
# 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(
|
||||
where={"id": library_agent.id},
|
||||
data={
|
||||
"AgentGraph": {
|
||||
@@ -525,13 +536,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")
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
|
||||
async def update_library_agent(
|
||||
@@ -825,6 +836,7 @@ async def add_store_agent_to_library(
|
||||
}
|
||||
},
|
||||
"isCreatedByUser": False,
|
||||
"useGraphIsActiveVersion": False,
|
||||
"settings": SafeJson(
|
||||
_initialize_graph_settings(graph_model).model_dump()
|
||||
),
|
||||
|
||||
@@ -48,6 +48,7 @@ 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
|
||||
|
||||
@@ -163,6 +164,7 @@ 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,6 +42,7 @@ 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,
|
||||
@@ -64,6 +65,7 @@ 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,
|
||||
@@ -138,6 +140,7 @@ 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,
|
||||
@@ -205,6 +208,7 @@ 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,
|
||||
|
||||
@@ -614,6 +614,7 @@ 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,
|
||||
@@ -667,35 +668,48 @@ async def delete_store_submission(
|
||||
submission_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a store listing submission as the submitting user.
|
||||
Delete a store submission version as the submitting user.
|
||||
|
||||
Args:
|
||||
user_id: ID of the authenticated user
|
||||
submission_id: ID of the submission to be deleted
|
||||
submission_id: StoreListingVersion ID to delete
|
||||
|
||||
Returns:
|
||||
bool: True if the submission was successfully deleted, False otherwise
|
||||
bool: True if successfully deleted
|
||||
"""
|
||||
logger.debug(f"Deleting store submission {submission_id} for user {user_id}")
|
||||
|
||||
try:
|
||||
# Verify the submission belongs to this user
|
||||
submission = await prisma.models.StoreListing.prisma().find_first(
|
||||
where={"agentGraphId": submission_id, "owningUserId": user_id}
|
||||
# Find the submission version with ownership check
|
||||
version = await prisma.models.StoreListingVersion.prisma().find_first(
|
||||
where={"id": submission_id}, include={"StoreListing": True}
|
||||
)
|
||||
|
||||
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}"
|
||||
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"
|
||||
)
|
||||
|
||||
# Delete the submission
|
||||
await prisma.models.StoreListing.prisma().delete(where={"id": submission.id})
|
||||
|
||||
logger.debug(
|
||||
f"Successfully deleted submission {submission_id} for user {user_id}"
|
||||
# Delete the version
|
||||
await prisma.models.StoreListingVersion.prisma().delete(
|
||||
where={"id": version.id}
|
||||
)
|
||||
|
||||
# Clean up empty listing if this was the last version
|
||||
remaining = await prisma.models.StoreListingVersion.prisma().count(
|
||||
where={"storeListingId": version.storeListingId}
|
||||
)
|
||||
if remaining == 0:
|
||||
await prisma.models.StoreListing.prisma().delete(
|
||||
where={"id": version.storeListingId}
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -759,9 +773,15 @@ async def create_store_submission(
|
||||
logger.warning(
|
||||
f"Agent not found for user {user_id}: {agent_id} v{agent_version}"
|
||||
)
|
||||
raise store_exceptions.AgentNotFoundError(
|
||||
f"Agent not found for this user. User ID: {user_id}, Agent ID: {agent_id}, Version: {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}"
|
||||
)
|
||||
|
||||
# Check if listing already exists for this agent
|
||||
existing_listing = await prisma.models.StoreListing.prisma().find_first(
|
||||
@@ -833,6 +853,7 @@ 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,
|
||||
@@ -944,81 +965,56 @@ 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.
|
||||
|
||||
# Check if we can edit this submission
|
||||
if current_version.submissionStatus == prisma.enums.SubmissionStatus.REJECTED:
|
||||
# Only allow editing of PENDING submissions
|
||||
if current_version.submissionStatus != prisma.enums.SubmissionStatus.PENDING:
|
||||
raise store_exceptions.InvalidOperationError(
|
||||
"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,
|
||||
f"Cannot edit a {current_version.submissionStatus.value.lower()} submission. Only pending submissions can be edited."
|
||||
)
|
||||
|
||||
# For PENDING submissions, we can update the existing version
|
||||
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,
|
||||
# Update the existing version
|
||||
updated_version = await prisma.models.StoreListingVersion.prisma().update(
|
||||
where={"id": store_listing_version_id},
|
||||
data=prisma.types.StoreListingVersionUpdateInput(
|
||||
name=name,
|
||||
sub_heading=sub_heading,
|
||||
slug=current_version.StoreListing.slug,
|
||||
videoUrl=video_url,
|
||||
agentOutputDemoUrl=agent_output_demo_url,
|
||||
imageUrls=image_urls,
|
||||
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,
|
||||
)
|
||||
subHeading=sub_heading,
|
||||
changesSummary=changes_summary,
|
||||
recommendedScheduleCron=recommended_schedule_cron,
|
||||
instructions=instructions,
|
||||
),
|
||||
)
|
||||
|
||||
else:
|
||||
raise store_exceptions.InvalidOperationError(
|
||||
f"Cannot edit submission with status: {current_version.submissionStatus}"
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
except (
|
||||
store_exceptions.SubmissionNotFoundError,
|
||||
@@ -1097,38 +1093,78 @@ async def create_store_version(
|
||||
f"Agent not found for this user. User ID: {user_id}, Agent ID: {agent_id}, Version: {agent_version}"
|
||||
)
|
||||
|
||||
# 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,
|
||||
# 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,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# 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,
|
||||
@@ -1708,15 +1744,12 @@ async def review_store_submission(
|
||||
|
||||
# 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 hasattr(submission, "storeListing") and submission.StoreListing
|
||||
else ""
|
||||
),
|
||||
slug=(submission.StoreListing.slug if submission.StoreListing else ""),
|
||||
description=submission.description,
|
||||
instructions=submission.instructions,
|
||||
image_urls=submission.imageUrls or [],
|
||||
@@ -1818,9 +1851,7 @@ async def get_admin_listings_with_versions(
|
||||
where = prisma.types.StoreListingWhereInput(**where_dict)
|
||||
include = prisma.types.StoreListingInclude(
|
||||
Versions=prisma.types.FindManyStoreListingVersionArgsFromStoreListing(
|
||||
order_by=prisma.types._StoreListingVersion_version_OrderByInput(
|
||||
version="desc"
|
||||
)
|
||||
order_by={"version": "desc"}
|
||||
),
|
||||
OwningUser=True,
|
||||
)
|
||||
@@ -1845,6 +1876,7 @@ 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,
|
||||
|
||||
@@ -110,6 +110,7 @@ class Profile(pydantic.BaseModel):
|
||||
|
||||
|
||||
class StoreSubmission(pydantic.BaseModel):
|
||||
listing_id: str
|
||||
agent_id: str
|
||||
agent_version: int
|
||||
name: str
|
||||
@@ -164,8 +165,12 @@ class StoreListingsWithVersionsResponse(pydantic.BaseModel):
|
||||
|
||||
|
||||
class StoreSubmissionRequest(pydantic.BaseModel):
|
||||
agent_id: str
|
||||
agent_version: int
|
||||
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"
|
||||
)
|
||||
slug: str
|
||||
name: str
|
||||
sub_heading: str
|
||||
|
||||
@@ -138,6 +138,7 @@ 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",
|
||||
@@ -159,6 +160,7 @@ 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",
|
||||
|
||||
@@ -521,6 +521,7 @@ 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"],
|
||||
|
||||
@@ -6,6 +6,9 @@ import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import cast
|
||||
|
||||
from prisma.types import Serializable
|
||||
|
||||
from backend.sdk import (
|
||||
BaseWebhooksManager,
|
||||
@@ -84,7 +87,9 @@ class AirtableWebhookManager(BaseWebhooksManager):
|
||||
# update webhook config
|
||||
await update_webhook(
|
||||
webhook.id,
|
||||
config={"base_id": base_id, "cursor": response.cursor},
|
||||
config=cast(
|
||||
dict[str, Serializable], {"base_id": base_id, "cursor": response.cursor}
|
||||
),
|
||||
)
|
||||
|
||||
event_type = "notification"
|
||||
|
||||
184
autogpt_platform/backend/backend/blocks/helpers/review.py
Normal file
184
autogpt_platform/backend/backend/blocks/helpers/review.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
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,6 +3,7 @@ from typing import Any
|
||||
|
||||
from prisma.enums import ReviewStatus
|
||||
|
||||
from backend.blocks.helpers.review import HITLReviewHelper
|
||||
from backend.data.block import (
|
||||
Block,
|
||||
BlockCategory,
|
||||
@@ -11,11 +12,9 @@ from backend.data.block import (
|
||||
BlockSchemaOutput,
|
||||
BlockType,
|
||||
)
|
||||
from backend.data.execution import ExecutionContext, ExecutionStatus
|
||||
from backend.data.execution import ExecutionContext
|
||||
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__)
|
||||
|
||||
@@ -72,32 +71,26 @@ class HumanInTheLoopBlock(Block):
|
||||
("approved_data", {"name": "John Doe", "age": 30}),
|
||||
],
|
||||
test_mock={
|
||||
"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,
|
||||
"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",
|
||||
),
|
||||
},
|
||||
)(),
|
||||
},
|
||||
)
|
||||
|
||||
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 handle_review_decision(self, **kwargs):
|
||||
return await HITLReviewHelper.handle_review_decision(**kwargs)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
@@ -109,7 +102,7 @@ class HumanInTheLoopBlock(Block):
|
||||
graph_id: str,
|
||||
graph_version: int,
|
||||
execution_context: ExecutionContext,
|
||||
**kwargs,
|
||||
**_kwargs,
|
||||
) -> BlockOutput:
|
||||
if not execution_context.safe_mode:
|
||||
logger.info(
|
||||
@@ -119,48 +112,28 @@ class HumanInTheLoopBlock(Block):
|
||||
yield "review_message", "Auto-approved (safe mode disabled)"
|
||||
return
|
||||
|
||||
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
|
||||
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,
|
||||
)
|
||||
|
||||
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
|
||||
if decision is None:
|
||||
return
|
||||
|
||||
if not result.processed:
|
||||
await self.update_review_processed_status(
|
||||
node_exec_id=node_exec_id, processed=True
|
||||
)
|
||||
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 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
|
||||
if decision.message:
|
||||
yield "review_message", decision.message
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -391,8 +391,12 @@ 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(block.name),
|
||||
"name": SmartDecisionMakerBlock.cleanup(tool_name),
|
||||
"description": block.description,
|
||||
}
|
||||
sink_block_input_schema = block.input_schema
|
||||
@@ -489,14 +493,24 @@ 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(sink_graph_meta.name),
|
||||
"name": SmartDecisionMakerBlock.cleanup(tool_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, {}
|
||||
@@ -506,7 +520,7 @@ class SmartDecisionMakerBlock(Block):
|
||||
if "description" in sink_block_properties
|
||||
else f"The {link.sink_name} of the tool"
|
||||
)
|
||||
properties[link.sink_name] = {
|
||||
properties[clean_field_name] = {
|
||||
"type": "string",
|
||||
"description": description,
|
||||
"default": json.dumps(sink_block_properties.get("default", None)),
|
||||
@@ -519,7 +533,7 @@ class SmartDecisionMakerBlock(Block):
|
||||
"strict": True,
|
||||
}
|
||||
|
||||
# Store node info for later use in output processing
|
||||
tool_function["_field_mapping"] = field_mapping
|
||||
tool_function["_sink_node_id"] = sink_node.id
|
||||
|
||||
return {"type": "function", "function": tool_function}
|
||||
@@ -975,10 +989,28 @@ 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 []
|
||||
@@ -1129,8 +1161,9 @@ class SmartDecisionMakerBlock(Block):
|
||||
original_field_name = field_mapping.get(clean_arg_name, clean_arg_name)
|
||||
arg_value = tool_args.get(clean_arg_name)
|
||||
|
||||
sanitized_arg_name = self.cleanup(original_field_name)
|
||||
emit_key = f"tools_^_{sink_node_id}_~_{sanitized_arg_name}"
|
||||
# Use original_field_name directly (not sanitized) to match link sink_name
|
||||
# The field_mapping already translates from LLM's cleaned names to original names
|
||||
emit_key = f"tools_^_{sink_node_id}_~_{original_field_name}"
|
||||
|
||||
logger.debug(
|
||||
"[SmartDecisionMakerBlock|geid:%s|neid:%s] emit %s",
|
||||
|
||||
@@ -1057,3 +1057,153 @@ 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,6 +15,7 @@ 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 = [
|
||||
@@ -77,6 +78,7 @@ 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,6 +44,7 @@ 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 = [
|
||||
@@ -106,6 +107,7 @@ 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 = [
|
||||
@@ -159,6 +161,7 @@ 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 = [
|
||||
@@ -208,11 +211,13 @@ 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(
|
||||
@@ -423,6 +428,7 @@ 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
|
||||
from .blog import WordPressCreatePostBlock, WordPressGetAllPostsBlock
|
||||
|
||||
__all__ = ["WordPressCreatePostBlock"]
|
||||
__all__ = ["WordPressCreatePostBlock", "WordPressGetAllPostsBlock"]
|
||||
|
||||
@@ -161,7 +161,7 @@ async def oauth_exchange_code_for_tokens(
|
||||
grant_type="authorization_code",
|
||||
).model_dump(exclude_none=True)
|
||||
|
||||
response = await Requests().post(
|
||||
response = await Requests(raise_for_status=False).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().post(
|
||||
response = await Requests(raise_for_status=False).post(
|
||||
f"{WORDPRESS_BASE_URL}oauth2/token",
|
||||
headers=headers,
|
||||
data=data,
|
||||
@@ -252,7 +252,7 @@ async def validate_token(
|
||||
"token": token,
|
||||
}
|
||||
|
||||
response = await Requests().get(
|
||||
response = await Requests(raise_for_status=False).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(), method.lower())
|
||||
request_method = getattr(Requests(raise_for_status=False), method.lower())
|
||||
response = await request_method(
|
||||
url,
|
||||
headers=headers,
|
||||
@@ -476,6 +476,7 @@ 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 = {
|
||||
@@ -483,7 +484,7 @@ async def create_post(
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
|
||||
response = await Requests().post(
|
||||
response = await Requests(raise_for_status=False).post(
|
||||
f"{WORDPRESS_BASE_URL.rstrip('/')}{endpoint}",
|
||||
headers=headers,
|
||||
data=data,
|
||||
@@ -499,3 +500,132 @@ 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,7 +9,15 @@ from backend.sdk import (
|
||||
SchemaField,
|
||||
)
|
||||
|
||||
from ._api import CreatePostRequest, PostResponse, PostStatus, create_post
|
||||
from ._api import (
|
||||
CreatePostRequest,
|
||||
Post,
|
||||
PostResponse,
|
||||
PostsResponse,
|
||||
PostStatus,
|
||||
create_post,
|
||||
get_posts,
|
||||
)
|
||||
from ._config import wordpress
|
||||
|
||||
|
||||
@@ -49,8 +57,15 @@ 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")
|
||||
@@ -78,7 +93,9 @@ class WordPressCreatePostBlock(Block):
|
||||
tags=input_data.tags,
|
||||
featured_image=input_data.featured_image,
|
||||
media_urls=input_data.media_urls,
|
||||
status=PostStatus.PUBLISH,
|
||||
status=(
|
||||
PostStatus.DRAFT if input_data.publish_as_draft else PostStatus.PUBLISH
|
||||
),
|
||||
)
|
||||
|
||||
post_response: PostResponse = await create_post(
|
||||
@@ -87,7 +104,69 @@ 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,6 +50,8 @@ from .model import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.data.execution import ExecutionContext
|
||||
|
||||
from .graph import Link
|
||||
|
||||
app_config = Config()
|
||||
@@ -472,6 +474,7 @@ 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):
|
||||
@@ -614,7 +617,77 @@ 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}",
|
||||
@@ -622,6 +695,7 @@ 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,6 +383,7 @@ 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,
|
||||
@@ -390,6 +391,7 @@ 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,
|
||||
)
|
||||
|
||||
@@ -1145,6 +1147,8 @@ 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,6 +94,15 @@ 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."""
|
||||
@@ -235,7 +244,10 @@ class BaseGraph(BaseDbModel):
|
||||
return any(
|
||||
node.block_id
|
||||
for node in self.nodes
|
||||
if node.block.block_type == BlockType.HUMAN_IN_THE_LOOP
|
||||
if (
|
||||
node.block.block_type == BlockType.HUMAN_IN_THE_LOOP
|
||||
or node.block.requires_human_review
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -326,7 +338,35 @@ class Graph(BaseGraph):
|
||||
@computed_field
|
||||
@property
|
||||
def credentials_input_schema(self) -> dict[str, Any]:
|
||||
return self._credentials_input_schema.jsonschema()
|
||||
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
|
||||
|
||||
@property
|
||||
def _credentials_input_schema(self) -> type[BlockSchema]:
|
||||
|
||||
@@ -396,3 +396,58 @@ 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"
|
||||
|
||||
@@ -178,6 +178,7 @@ 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,
|
||||
@@ -245,6 +246,7 @@ 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
|
||||
@@ -542,6 +544,7 @@ 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,
|
||||
@@ -564,6 +567,7 @@ 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
|
||||
@@ -609,6 +613,7 @@ 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
|
||||
|
||||
@@ -645,6 +650,7 @@ 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)
|
||||
|
||||
@@ -956,6 +962,21 @@ 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}",
|
||||
@@ -1016,6 +1037,7 @@ class ExecutionProcessor:
|
||||
execution_stats,
|
||||
execution_stats_lock,
|
||||
),
|
||||
nodes_to_skip=graph_exec.nodes_to_skip,
|
||||
),
|
||||
self.node_execution_loop,
|
||||
)
|
||||
|
||||
@@ -239,14 +239,19 @@ async def _validate_node_input_credentials(
|
||||
graph: GraphModel,
|
||||
user_id: str,
|
||||
nodes_input_masks: Optional[NodesInputMasks] = None,
|
||||
) -> dict[str, dict[str, str]]:
|
||||
) -> tuple[dict[str, dict[str, str]], set[str]]:
|
||||
"""
|
||||
Checks all credentials for all nodes of the graph and returns structured errors.
|
||||
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.
|
||||
|
||||
Returns:
|
||||
dict[node_id, dict[field_name, error_message]]: Credential validation errors per node
|
||||
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)
|
||||
]
|
||||
"""
|
||||
credential_errors: dict[str, dict[str, str]] = defaultdict(dict)
|
||||
nodes_to_skip: set[str] = set()
|
||||
|
||||
for node in graph.nodes:
|
||||
block = node.block
|
||||
@@ -256,27 +261,46 @@ 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
|
||||
):
|
||||
credentials_meta = credentials_meta_type.model_validate(
|
||||
node_input_mask[field_name]
|
||||
)
|
||||
field_value = node_input_mask[field_name]
|
||||
elif field_name in node.input_default:
|
||||
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
|
||||
# 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)
|
||||
|
||||
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
|
||||
|
||||
@@ -287,6 +311,7 @@ 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}"
|
||||
@@ -313,7 +338,19 @@ async def _validate_node_input_credentials(
|
||||
] = "Invalid credentials: type/provider mismatch"
|
||||
continue
|
||||
|
||||
return credential_errors
|
||||
# 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
|
||||
|
||||
|
||||
def make_node_credentials_input_map(
|
||||
@@ -355,21 +392,25 @@ async def validate_graph_with_credentials(
|
||||
graph: GraphModel,
|
||||
user_id: str,
|
||||
nodes_input_masks: Optional[NodesInputMasks] = None,
|
||||
) -> Mapping[str, Mapping[str, str]]:
|
||||
) -> tuple[Mapping[str, Mapping[str, str]], set[str]]:
|
||||
"""
|
||||
Validate graph including credentials and return structured errors per node.
|
||||
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.
|
||||
|
||||
Returns:
|
||||
dict[node_id, dict[field_name, error_message]]: Validation errors per node
|
||||
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)
|
||||
]
|
||||
"""
|
||||
# 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
|
||||
node_credential_input_errors = await _validate_node_input_credentials(
|
||||
graph, user_id, 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)
|
||||
)
|
||||
|
||||
# Merge credential errors with structural errors
|
||||
@@ -378,7 +419,7 @@ async def validate_graph_with_credentials(
|
||||
node_input_errors[node_id] = {}
|
||||
node_input_errors[node_id].update(field_errors)
|
||||
|
||||
return node_input_errors
|
||||
return node_input_errors, nodes_to_skip
|
||||
|
||||
|
||||
async def _construct_starting_node_execution_input(
|
||||
@@ -386,7 +427,7 @@ async def _construct_starting_node_execution_input(
|
||||
user_id: str,
|
||||
graph_inputs: BlockInput,
|
||||
nodes_input_masks: Optional[NodesInputMasks] = None,
|
||||
) -> list[tuple[str, BlockInput]]:
|
||||
) -> tuple[list[tuple[str, BlockInput]], set[str]]:
|
||||
"""
|
||||
Validates and prepares the input data for executing a graph.
|
||||
This function checks the graph for starting nodes, validates the input data
|
||||
@@ -400,11 +441,14 @@ async def _construct_starting_node_execution_input(
|
||||
node_credentials_map: `dict[node_id, dict[input_name, CredentialsMetaInput]]`
|
||||
|
||||
Returns:
|
||||
list[tuple[str, BlockInput]]: A list of tuples, each containing the node ID and
|
||||
the corresponding input data for that node.
|
||||
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)
|
||||
]
|
||||
"""
|
||||
# Use new validation function that includes credentials
|
||||
validation_errors = await validate_graph_with_credentials(
|
||||
validation_errors, nodes_to_skip = await validate_graph_with_credentials(
|
||||
graph, user_id, nodes_input_masks
|
||||
)
|
||||
n_error_nodes = len(validation_errors)
|
||||
@@ -445,7 +489,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
|
||||
return nodes_input, nodes_to_skip
|
||||
|
||||
|
||||
async def validate_and_construct_node_execution_input(
|
||||
@@ -456,7 +500,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]:
|
||||
) -> tuple[GraphModel, list[tuple[str, BlockInput]], NodesInputMasks, set[str]]:
|
||||
"""
|
||||
Public wrapper that handles graph fetching, credential mapping, and validation+construction.
|
||||
This centralizes the logic used by both scheduler validation and actual execution.
|
||||
@@ -473,6 +517,7 @@ 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.
|
||||
@@ -514,14 +559,16 @@ async def validate_and_construct_node_execution_input(
|
||||
nodes_input_masks or {},
|
||||
)
|
||||
|
||||
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,
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
return graph, starting_nodes_input, nodes_input_masks
|
||||
return graph, starting_nodes_input, nodes_input_masks, nodes_to_skip
|
||||
|
||||
|
||||
def _merge_nodes_input_masks(
|
||||
@@ -779,6 +826,9 @@ 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:
|
||||
@@ -787,7 +837,7 @@ async def add_graph_execution(
|
||||
)
|
||||
|
||||
# Create new execution
|
||||
graph, starting_nodes_input, compiled_nodes_input_masks = (
|
||||
graph, starting_nodes_input, compiled_nodes_input_masks, nodes_to_skip = (
|
||||
await validate_and_construct_node_execution_input(
|
||||
graph_id=graph_id,
|
||||
user_id=user_id,
|
||||
@@ -836,6 +886,7 @@ 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,10 +367,13 @@ 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)
|
||||
@@ -456,3 +459,212 @@ 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
|
||||
|
||||
@@ -245,6 +245,13 @@ DEFAULT_CREDENTIALS = [
|
||||
webshare_proxy_credentials,
|
||||
]
|
||||
|
||||
SYSTEM_CREDENTIAL_IDS = {cred.id for cred in DEFAULT_CREDENTIALS}
|
||||
|
||||
|
||||
def is_system_credential(credential_id: str) -> bool:
|
||||
"""Check if a credential ID belongs to a system-managed credential."""
|
||||
return credential_id in SYSTEM_CREDENTIAL_IDS
|
||||
|
||||
|
||||
class IntegrationCredentialsStore:
|
||||
def __init__(self):
|
||||
|
||||
@@ -8,6 +8,7 @@ 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:
|
||||
@@ -20,6 +21,7 @@ _ORIGINAL_HANDLERS = [
|
||||
GitHubOAuthHandler,
|
||||
GoogleOAuthHandler,
|
||||
NotionOAuthHandler,
|
||||
RedditOAuthHandler,
|
||||
TwitterOAuthHandler,
|
||||
TodoistOAuthHandler,
|
||||
]
|
||||
|
||||
208
autogpt_platform/backend/backend/integrations/oauth/reddit.py
Normal file
208
autogpt_platform/backend/backend/integrations/oauth/reddit.py
Normal file
@@ -0,0 +1,208 @@
|
||||
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="AutoGPT:1.0 (by /u/autogpt)",
|
||||
default="web:AutoGPT:v0.6.0 (by /u/autogpt)",
|
||||
description="The user agent for the Reddit API",
|
||||
)
|
||||
|
||||
|
||||
227
autogpt_platform/backend/gen_prisma_types_stub.py
Normal file
227
autogpt_platform/backend/gen_prisma_types_stub.py
Normal file
@@ -0,0 +1,227 @@
|
||||
#!/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,6 +25,9 @@ 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],
|
||||
@@ -49,4 +52,6 @@ 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,7 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Testing" (
|
||||
"id" TEXT NOT NULL,
|
||||
"maxEmailsPerDay" INTEGER NOT NULL DEFAULT 3,
|
||||
|
||||
CONSTRAINT "Testing_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -117,6 +117,7 @@ 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]
|
||||
@@ -134,6 +135,9 @@ 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,11 +12,6 @@ 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
|
||||
@@ -132,8 +127,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)
|
||||
@@ -726,26 +721,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
|
||||
@@ -861,14 +856,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)
|
||||
|
||||
@@ -1003,16 +998,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,6 +2,7 @@
|
||||
"created_at": "2025-09-04T13:37:00",
|
||||
"credentials_input_schema": {
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"title": "TestGraphCredentialsInputSchema",
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{
|
||||
"credentials_input_schema": {
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"title": "TestGraphCredentialsInputSchema",
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"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": "",
|
||||
@@ -41,6 +42,7 @@
|
||||
"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,6 +1,7 @@
|
||||
{
|
||||
"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 prisma migrate deploy"]
|
||||
command: ["sh", "-c", "poetry run prisma generate && poetry run gen-prisma-stub && poetry run prisma migrate deploy"]
|
||||
develop:
|
||||
watch:
|
||||
- path: ./
|
||||
|
||||
@@ -3,6 +3,13 @@ import { withSentryConfig } from "@sentry/nextjs";
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
productionBrowserSourceMaps: true,
|
||||
// Externalize OpenTelemetry packages to fix Turbopack HMR issues
|
||||
serverExternalPackages: [
|
||||
"@opentelemetry/instrumentation",
|
||||
"@opentelemetry/sdk-node",
|
||||
"import-in-the-middle",
|
||||
"require-in-the-middle",
|
||||
],
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "256mb",
|
||||
|
||||
@@ -92,7 +92,6 @@
|
||||
"react-currency-input-field": "4.0.3",
|
||||
"react-day-picker": "9.11.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-drag-drop-files": "2.4.0",
|
||||
"react-hook-form": "7.66.0",
|
||||
"react-icons": "5.5.0",
|
||||
"react-markdown": "9.0.3",
|
||||
@@ -118,6 +117,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "4.1.2",
|
||||
"@opentelemetry/instrumentation": "0.209.0",
|
||||
"@playwright/test": "1.56.1",
|
||||
"@storybook/addon-a11y": "9.1.5",
|
||||
"@storybook/addon-docs": "9.1.5",
|
||||
@@ -141,6 +141,7 @@
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-next": "15.5.7",
|
||||
"eslint-plugin-storybook": "9.1.5",
|
||||
"import-in-the-middle": "2.0.2",
|
||||
"msw": "2.11.6",
|
||||
"msw-storybook-addon": "2.0.6",
|
||||
"orval": "7.13.0",
|
||||
@@ -148,7 +149,7 @@
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-tailwindcss": "0.7.1",
|
||||
"require-in-the-middle": "7.5.2",
|
||||
"require-in-the-middle": "8.0.1",
|
||||
"storybook": "9.1.5",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.9.3"
|
||||
|
||||
171
autogpt_platform/frontend/pnpm-lock.yaml
generated
171
autogpt_platform/frontend/pnpm-lock.yaml
generated
@@ -200,9 +200,6 @@ importers:
|
||||
react-dom:
|
||||
specifier: 18.3.1
|
||||
version: 18.3.1(react@18.3.1)
|
||||
react-drag-drop-files:
|
||||
specifier: 2.4.0
|
||||
version: 2.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-hook-form:
|
||||
specifier: 7.66.0
|
||||
version: 7.66.0(react@18.3.1)
|
||||
@@ -273,6 +270,9 @@ importers:
|
||||
'@chromatic-com/storybook':
|
||||
specifier: 4.1.2
|
||||
version: 4.1.2(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))
|
||||
'@opentelemetry/instrumentation':
|
||||
specifier: 0.209.0
|
||||
version: 0.209.0(@opentelemetry/api@1.9.0)
|
||||
'@playwright/test':
|
||||
specifier: 1.56.1
|
||||
version: 1.56.1
|
||||
@@ -342,6 +342,9 @@ importers:
|
||||
eslint-plugin-storybook:
|
||||
specifier: 9.1.5
|
||||
version: 9.1.5(eslint@8.57.1)(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))(typescript@5.9.3)
|
||||
import-in-the-middle:
|
||||
specifier: 2.0.2
|
||||
version: 2.0.2
|
||||
msw:
|
||||
specifier: 2.11.6
|
||||
version: 2.11.6(@types/node@24.10.0)(typescript@5.9.3)
|
||||
@@ -364,8 +367,8 @@ importers:
|
||||
specifier: 0.7.1
|
||||
version: 0.7.1(prettier@3.6.2)
|
||||
require-in-the-middle:
|
||||
specifier: 7.5.2
|
||||
version: 7.5.2
|
||||
specifier: 8.0.1
|
||||
version: 8.0.1
|
||||
storybook:
|
||||
specifier: 9.1.5
|
||||
version: 9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2)
|
||||
@@ -1004,9 +1007,6 @@ packages:
|
||||
'@emotion/memoize@0.8.1':
|
||||
resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==}
|
||||
|
||||
'@emotion/unitless@0.8.1':
|
||||
resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==}
|
||||
|
||||
'@epic-web/invariant@1.0.0':
|
||||
resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
|
||||
|
||||
@@ -1553,6 +1553,10 @@ packages:
|
||||
resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@opentelemetry/api-logs@0.209.0':
|
||||
resolution: {integrity: sha512-xomnUNi7TiAGtOgs0tb54LyrjRZLu9shJGGwkcN7NgtiPYOpNnKLkRJtzZvTjD/w6knSZH9sFZcUSUovYOPg6A==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@opentelemetry/api@1.9.0':
|
||||
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -1707,6 +1711,12 @@ packages:
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/instrumentation@0.209.0':
|
||||
resolution: {integrity: sha512-Cwe863ojTCnFlxVuuhG7s6ODkAOzKsAEthKAcI4MDRYz1OmGWYnmSl4X2pbyS+hBxVTdvfZePfoEA01IjqcEyw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/redis-common@0.38.2':
|
||||
resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
@@ -3122,9 +3132,6 @@ packages:
|
||||
'@types/statuses@2.0.6':
|
||||
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
||||
|
||||
'@types/stylis@4.2.7':
|
||||
resolution: {integrity: sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==}
|
||||
|
||||
'@types/tedious@4.0.14':
|
||||
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
|
||||
|
||||
@@ -3781,9 +3788,6 @@ packages:
|
||||
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
camelize@1.0.1:
|
||||
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
|
||||
|
||||
caniuse-lite@1.0.30001762:
|
||||
resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==}
|
||||
|
||||
@@ -3997,10 +4001,6 @@ packages:
|
||||
resolution: {integrity: sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
css-color-keywords@1.0.0:
|
||||
resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
css-loader@6.11.0:
|
||||
resolution: {integrity: sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
@@ -4016,9 +4016,6 @@ packages:
|
||||
css-select@4.3.0:
|
||||
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
|
||||
|
||||
css-to-react-native@3.2.0:
|
||||
resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
|
||||
|
||||
css-what@6.2.2:
|
||||
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -4976,8 +4973,8 @@ packages:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
import-in-the-middle@2.0.1:
|
||||
resolution: {integrity: sha512-bruMpJ7xz+9jwGzrwEhWgvRrlKRYCRDBrfU+ur3FcasYXLJDxTruJ//8g2Noj+QFyRBeqbpj8Bhn4Fbw6HjvhA==}
|
||||
import-in-the-middle@2.0.2:
|
||||
resolution: {integrity: sha512-qet/hkGt3EbNGVtbDfPu0BM+tCqBS8wT1SYrstPaDKoWtshsC6licOemz7DVtpBEyvDNzo8UTBf9/GwWuSDZ9w==}
|
||||
|
||||
imurmurhash@0.1.4:
|
||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||
@@ -6131,10 +6128,6 @@ packages:
|
||||
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.4.49:
|
||||
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.6:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -6306,12 +6299,6 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.3.1
|
||||
|
||||
react-drag-drop-files@2.4.0:
|
||||
resolution: {integrity: sha512-MGPV3HVVnwXEXq3gQfLtSU3jz5j5jrabvGedokpiSEMoONrDHgYl/NpIOlfsqGQ4zBv1bzzv7qbKURZNOX32PA==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
|
||||
react-hook-form@7.66.0:
|
||||
resolution: {integrity: sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -6531,10 +6518,6 @@ packages:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-in-the-middle@7.5.2:
|
||||
resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
|
||||
require-in-the-middle@8.0.1:
|
||||
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
|
||||
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
|
||||
@@ -6678,9 +6661,6 @@ packages:
|
||||
engines: {node: '>= 0.10'}
|
||||
hasBin: true
|
||||
|
||||
shallowequal@1.1.0:
|
||||
resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
|
||||
|
||||
sharp@0.34.5:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
@@ -6894,13 +6874,6 @@ packages:
|
||||
style-to-object@1.0.14:
|
||||
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
|
||||
|
||||
styled-components@6.2.0:
|
||||
resolution: {integrity: sha512-ryFCkETE++8jlrBmC+BoGPUN96ld1/Yp0s7t5bcXDobrs4XoXroY1tN+JbFi09hV6a5h3MzbcVi8/BGDP0eCgQ==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
react: '>= 16.8.0'
|
||||
react-dom: '>= 16.8.0'
|
||||
|
||||
styled-jsx@5.1.6:
|
||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -6927,9 +6900,6 @@ packages:
|
||||
babel-plugin-macros:
|
||||
optional: true
|
||||
|
||||
stylis@4.3.6:
|
||||
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
|
||||
|
||||
sucrase@3.35.1:
|
||||
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
@@ -7096,9 +7066,6 @@ packages:
|
||||
tslib@1.14.1:
|
||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||
|
||||
tslib@2.6.2:
|
||||
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -8335,10 +8302,10 @@ snapshots:
|
||||
'@emotion/is-prop-valid@1.2.2':
|
||||
dependencies:
|
||||
'@emotion/memoize': 0.8.1
|
||||
optional: true
|
||||
|
||||
'@emotion/memoize@0.8.1': {}
|
||||
|
||||
'@emotion/unitless@0.8.1': {}
|
||||
'@emotion/memoize@0.8.1':
|
||||
optional: true
|
||||
|
||||
'@epic-web/invariant@1.0.0': {}
|
||||
|
||||
@@ -8765,6 +8732,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
|
||||
'@opentelemetry/api-logs@0.209.0':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
|
||||
'@opentelemetry/api@1.9.0': {}
|
||||
|
||||
'@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0)':
|
||||
@@ -8965,7 +8936,16 @@ snapshots:
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/api-logs': 0.208.0
|
||||
import-in-the-middle: 2.0.1
|
||||
import-in-the-middle: 2.0.2
|
||||
require-in-the-middle: 8.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@opentelemetry/instrumentation@0.209.0(@opentelemetry/api@1.9.0)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/api-logs': 0.209.0
|
||||
import-in-the-middle: 2.0.2
|
||||
require-in-the-middle: 8.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -9145,7 +9125,7 @@ snapshots:
|
||||
'@prisma/instrumentation@6.19.0(@opentelemetry/api@1.9.0)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -9989,7 +9969,7 @@ snapshots:
|
||||
'@opentelemetry/semantic-conventions': 1.38.0
|
||||
'@sentry/core': 10.27.0
|
||||
'@sentry/opentelemetry': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
|
||||
import-in-the-middle: 2.0.1
|
||||
import-in-the-middle: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -10028,7 +10008,7 @@ snapshots:
|
||||
'@sentry/core': 10.27.0
|
||||
'@sentry/node-core': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
|
||||
'@sentry/opentelemetry': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
|
||||
import-in-the-middle: 2.0.1
|
||||
import-in-the-middle: 2.0.2
|
||||
minimatch: 9.0.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -10734,8 +10714,6 @@ snapshots:
|
||||
|
||||
'@types/statuses@2.0.6': {}
|
||||
|
||||
'@types/stylis@4.2.7': {}
|
||||
|
||||
'@types/tedious@4.0.14':
|
||||
dependencies:
|
||||
'@types/node': 24.10.0
|
||||
@@ -11432,8 +11410,6 @@ snapshots:
|
||||
|
||||
camelcase-css@2.0.1: {}
|
||||
|
||||
camelize@1.0.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001762: {}
|
||||
|
||||
case-sensitive-paths-webpack-plugin@2.4.0: {}
|
||||
@@ -11645,8 +11621,6 @@ snapshots:
|
||||
randombytes: 2.1.0
|
||||
randomfill: 1.0.4
|
||||
|
||||
css-color-keywords@1.0.0: {}
|
||||
|
||||
css-loader@6.11.0(webpack@5.104.1(esbuild@0.25.12)):
|
||||
dependencies:
|
||||
icss-utils: 5.1.0(postcss@8.5.6)
|
||||
@@ -11668,12 +11642,6 @@ snapshots:
|
||||
domutils: 2.8.0
|
||||
nth-check: 2.1.1
|
||||
|
||||
css-to-react-native@3.2.0:
|
||||
dependencies:
|
||||
camelize: 1.0.1
|
||||
css-color-keywords: 1.0.0
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
css-what@6.2.2: {}
|
||||
|
||||
css.escape@1.5.1: {}
|
||||
@@ -12127,8 +12095,8 @@ snapshots:
|
||||
'@typescript-eslint/parser': 8.52.0(eslint@8.57.1)(typescript@5.9.3)
|
||||
eslint: 8.57.1
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
|
||||
eslint-plugin-react: 7.37.5(eslint@8.57.1)
|
||||
eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1)
|
||||
@@ -12147,7 +12115,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3
|
||||
@@ -12158,22 +12126,22 @@ snapshots:
|
||||
tinyglobby: 0.2.15
|
||||
unrs-resolver: 1.11.1
|
||||
optionalDependencies:
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.52.0(eslint@8.57.1)(typescript@5.9.3)
|
||||
eslint: 8.57.1
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
@@ -12184,7 +12152,7 @@ snapshots:
|
||||
doctrine: 2.1.0
|
||||
eslint: 8.57.1
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.52.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
@@ -12849,7 +12817,7 @@ snapshots:
|
||||
parent-module: 1.0.1
|
||||
resolve-from: 4.0.0
|
||||
|
||||
import-in-the-middle@2.0.1:
|
||||
import-in-the-middle@2.0.2:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
acorn-import-attributes: 1.9.5(acorn@8.15.0)
|
||||
@@ -14259,12 +14227,6 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.4.49:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.6:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
@@ -14386,13 +14348,6 @@ snapshots:
|
||||
react: 18.3.1
|
||||
scheduler: 0.23.2
|
||||
|
||||
react-drag-drop-files@2.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
prop-types: 15.8.1
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
styled-components: 6.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
react-hook-form@7.66.0(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
@@ -14701,14 +14656,6 @@ snapshots:
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
require-in-the-middle@7.5.2:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
module-details-from-path: 1.0.4
|
||||
resolve: 1.22.11
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
require-in-the-middle@8.0.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@@ -14886,8 +14833,6 @@ snapshots:
|
||||
safe-buffer: 5.2.1
|
||||
to-buffer: 1.2.2
|
||||
|
||||
shallowequal@1.1.0: {}
|
||||
|
||||
sharp@0.34.5:
|
||||
dependencies:
|
||||
'@img/colour': 1.0.0
|
||||
@@ -15178,20 +15123,6 @@ snapshots:
|
||||
dependencies:
|
||||
inline-style-parser: 0.2.7
|
||||
|
||||
styled-components@6.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@emotion/is-prop-valid': 1.2.2
|
||||
'@emotion/unitless': 0.8.1
|
||||
'@types/stylis': 4.2.7
|
||||
css-to-react-native: 3.2.0
|
||||
csstype: 3.2.3
|
||||
postcss: 8.4.49
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
shallowequal: 1.1.0
|
||||
stylis: 4.3.6
|
||||
tslib: 2.6.2
|
||||
|
||||
styled-jsx@5.1.6(@babel/core@7.28.5)(react@18.3.1):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
@@ -15206,8 +15137,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.28.5
|
||||
|
||||
stylis@4.3.6: {}
|
||||
|
||||
sucrase@3.35.1:
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
@@ -15390,8 +15319,6 @@ snapshots:
|
||||
|
||||
tslib@1.14.1: {}
|
||||
|
||||
tslib@2.6.2: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tty-browserify@0.0.1: {}
|
||||
|
||||
BIN
autogpt_platform/frontend/public/integrations/webshare_proxy.png
Normal file
BIN
autogpt_platform/frontend/public/integrations/webshare_proxy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
BIN
autogpt_platform/frontend/public/integrations/wordpress.png
Normal file
BIN
autogpt_platform/frontend/public/integrations/wordpress.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -66,6 +66,7 @@ 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": "credentials",
|
||||
"ui:field": "custom/credential_field",
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -76,12 +76,18 @@ 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: credentialValues,
|
||||
credentials_inputs: validCredentials,
|
||||
source: "builder",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -97,6 +97,9 @@ export const Flow = () => {
|
||||
onConnect={onConnect}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onNodeContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
maxZoom={2}
|
||||
minZoom={0.1}
|
||||
onDragOver={onDragOver}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import React from "react";
|
||||
import { Node as XYNode, NodeProps } from "@xyflow/react";
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
import { BlockUIType } from "../../../types";
|
||||
import { StickyNoteBlock } from "./components/StickyNoteBlock";
|
||||
import { BlockInfoCategoriesItem } from "@/app/api/__generated__/models/blockInfoCategoriesItem";
|
||||
import { BlockCost } from "@/app/api/__generated__/models/blockCost";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import { BlockCost } from "@/app/api/__generated__/models/blockCost";
|
||||
import { BlockInfoCategoriesItem } from "@/app/api/__generated__/models/blockInfoCategoriesItem";
|
||||
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
|
||||
import { NodeContainer } from "./components/NodeContainer";
|
||||
import { NodeHeader } from "./components/NodeHeader";
|
||||
import { FormCreator } from "../FormCreator";
|
||||
import { preprocessInputSchema } from "@/components/renderers/InputRenderer/utils/input-schema-pre-processor";
|
||||
import { OutputHandler } from "../OutputHandler";
|
||||
import { NodeAdvancedToggle } from "./components/NodeAdvancedToggle";
|
||||
import { NodeDataRenderer } from "./components/NodeOutput/NodeOutput";
|
||||
import { NodeExecutionBadge } from "./components/NodeExecutionBadge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WebhookDisclaimer } from "./components/WebhookDisclaimer";
|
||||
import { AyrshareConnectButton } from "./components/AyrshareConnectButton";
|
||||
import { NodeModelMetadata } from "@/app/api/__generated__/models/nodeModelMetadata";
|
||||
import { preprocessInputSchema } from "@/components/renderers/InputRenderer/utils/input-schema-pre-processor";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
import { NodeProps, Node as XYNode } from "@xyflow/react";
|
||||
import React from "react";
|
||||
import { BlockUIType } from "../../../types";
|
||||
import { FormCreator } from "../FormCreator";
|
||||
import { OutputHandler } from "../OutputHandler";
|
||||
import { AyrshareConnectButton } from "./components/AyrshareConnectButton";
|
||||
import { NodeAdvancedToggle } from "./components/NodeAdvancedToggle";
|
||||
import { NodeContainer } from "./components/NodeContainer";
|
||||
import { NodeExecutionBadge } from "./components/NodeExecutionBadge";
|
||||
import { NodeHeader } from "./components/NodeHeader";
|
||||
import { NodeDataRenderer } from "./components/NodeOutput/NodeOutput";
|
||||
import { NodeRightClickMenu } from "./components/NodeRightClickMenu";
|
||||
import { StickyNoteBlock } from "./components/StickyNoteBlock";
|
||||
import { WebhookDisclaimer } from "./components/WebhookDisclaimer";
|
||||
|
||||
export type CustomNodeData = {
|
||||
hardcodedValues: {
|
||||
@@ -88,7 +89,7 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
||||
|
||||
// Currently all blockTypes design are similar - that's why i am using the same component for all of them
|
||||
// If in future - if we need some drastic change in some blockTypes design - we can create separate components for them
|
||||
return (
|
||||
const node = (
|
||||
<NodeContainer selected={selected} nodeId={nodeId} hasErrors={hasErrors}>
|
||||
<div className="rounded-xlarge bg-white">
|
||||
<NodeHeader data={data} nodeId={nodeId} />
|
||||
@@ -117,6 +118,15 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
||||
<NodeExecutionBadge nodeId={nodeId} />
|
||||
</NodeContainer>
|
||||
);
|
||||
|
||||
return (
|
||||
<NodeRightClickMenu
|
||||
nodeId={nodeId}
|
||||
subGraphID={data.hardcodedValues?.graph_id}
|
||||
>
|
||||
{node}
|
||||
</NodeRightClickMenu>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { useCopyPasteStore } from "@/app/(platform)/build/stores/copyPasteStore";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/molecules/DropdownMenu/DropdownMenu";
|
||||
import { DotsThreeOutlineVerticalIcon } from "@phosphor-icons/react";
|
||||
import { Copy, Trash2, ExternalLink } from "lucide-react";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { useCopyPasteStore } from "@/app/(platform)/build/stores/copyPasteStore";
|
||||
import {
|
||||
SecondaryDropdownMenuContent,
|
||||
SecondaryDropdownMenuItem,
|
||||
SecondaryDropdownMenuSeparator,
|
||||
} from "@/components/molecules/SecondaryMenu/SecondaryMenu";
|
||||
import {
|
||||
ArrowSquareOutIcon,
|
||||
CopyIcon,
|
||||
DotsThreeOutlineVerticalIcon,
|
||||
TrashIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useReactFlow } from "@xyflow/react";
|
||||
|
||||
export const NodeContextMenu = ({
|
||||
nodeId,
|
||||
subGraphID,
|
||||
}: {
|
||||
type Props = {
|
||||
nodeId: string;
|
||||
subGraphID?: string;
|
||||
}) => {
|
||||
};
|
||||
|
||||
export const NodeContextMenu = ({ nodeId, subGraphID }: Props) => {
|
||||
const { deleteElements } = useReactFlow();
|
||||
|
||||
const handleCopy = () => {
|
||||
function handleCopy() {
|
||||
useNodeStore.setState((state) => ({
|
||||
nodes: state.nodes.map((node) => ({
|
||||
...node,
|
||||
@@ -30,47 +35,47 @@ export const NodeContextMenu = ({
|
||||
|
||||
useCopyPasteStore.getState().copySelectedNodes();
|
||||
useCopyPasteStore.getState().pasteNodes();
|
||||
};
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
function handleDelete() {
|
||||
deleteElements({ nodes: [{ id: nodeId }] });
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="py-2">
|
||||
<DotsThreeOutlineVerticalIcon size={16} weight="fill" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="rounded-xlarge"
|
||||
>
|
||||
<DropdownMenuItem onClick={handleCopy} className="hover:rounded-xlarge">
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Node
|
||||
</DropdownMenuItem>
|
||||
<SecondaryDropdownMenuContent side="right" align="start">
|
||||
<SecondaryDropdownMenuItem onClick={handleCopy}>
|
||||
<CopyIcon size={20} className="mr-2 dark:text-gray-100" />
|
||||
<span className="dark:text-gray-100">Copy</span>
|
||||
</SecondaryDropdownMenuItem>
|
||||
<SecondaryDropdownMenuSeparator />
|
||||
|
||||
{subGraphID && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => window.open(`/build?flowID=${subGraphID}`)}
|
||||
className="hover:rounded-xlarge"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Agent
|
||||
</DropdownMenuItem>
|
||||
<>
|
||||
<SecondaryDropdownMenuItem
|
||||
onClick={() => window.open(`/build?flowID=${subGraphID}`)}
|
||||
>
|
||||
<ArrowSquareOutIcon
|
||||
size={20}
|
||||
className="mr-2 dark:text-gray-100"
|
||||
/>
|
||||
<span className="dark:text-gray-100">Open agent</span>
|
||||
</SecondaryDropdownMenuItem>
|
||||
<SecondaryDropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={handleDelete}
|
||||
className="text-red-600 hover:rounded-xlarge"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
<SecondaryDropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<TrashIcon
|
||||
size={20}
|
||||
className="mr-2 text-red-500 dark:text-red-400"
|
||||
/>
|
||||
<span className="dark:text-red-400">Delete</span>
|
||||
</SecondaryDropdownMenuItem>
|
||||
</SecondaryDropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { beautifyString, cn } from "@/lib/utils";
|
||||
import { NodeCost } from "./NodeCost";
|
||||
import { NodeBadges } from "./NodeBadges";
|
||||
import { NodeContextMenu } from "./NodeContextMenu";
|
||||
import { CustomNodeData } from "../CustomNode";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { useState } from "react";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { beautifyString, cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { CustomNodeData } from "../CustomNode";
|
||||
import { NodeBadges } from "./NodeBadges";
|
||||
import { NodeContextMenu } from "./NodeContextMenu";
|
||||
import { NodeCost } from "./NodeCost";
|
||||
|
||||
export const NodeHeader = ({
|
||||
data,
|
||||
nodeId,
|
||||
}: {
|
||||
type Props = {
|
||||
data: CustomNodeData;
|
||||
nodeId: string;
|
||||
}) => {
|
||||
};
|
||||
|
||||
export const NodeHeader = ({ data, nodeId }: Props) => {
|
||||
const updateNodeData = useNodeStore((state) => state.updateNodeData);
|
||||
const title = (data.metadata?.customized_name as string) || data.title;
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
@@ -69,7 +68,10 @@ export const NodeHeader = ({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Text variant="large-semibold" className="line-clamp-1">
|
||||
<Text
|
||||
variant="large-semibold"
|
||||
className="line-clamp-1 hover:cursor-text"
|
||||
>
|
||||
{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 > 0 && (
|
||||
{outputItems.length > 1 && (
|
||||
<OutputActions
|
||||
items={outputItems.map((item) => ({
|
||||
value: item.value,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCopyPasteStore } from "@/app/(platform)/build/stores/copyPasteStore";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import {
|
||||
SecondaryMenuContent,
|
||||
SecondaryMenuItem,
|
||||
SecondaryMenuSeparator,
|
||||
} from "@/components/molecules/SecondaryMenu/SecondaryMenu";
|
||||
import { ArrowSquareOutIcon, CopyIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import * as ContextMenu from "@radix-ui/react-context-menu";
|
||||
import { useReactFlow } from "@xyflow/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { CustomNode } from "../CustomNode";
|
||||
|
||||
type Props = {
|
||||
nodeId: string;
|
||||
subGraphID?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const DOUBLE_CLICK_TIMEOUT = 300;
|
||||
|
||||
export function NodeRightClickMenu({ nodeId, subGraphID, children }: Props) {
|
||||
const { deleteElements } = useReactFlow<CustomNode>();
|
||||
const lastRightClickTime = useRef<number>(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function copyNode() {
|
||||
useNodeStore.setState((state) => ({
|
||||
nodes: state.nodes.map((node) => ({
|
||||
...node,
|
||||
selected: node.id === nodeId,
|
||||
})),
|
||||
}));
|
||||
|
||||
useCopyPasteStore.getState().copySelectedNodes();
|
||||
useCopyPasteStore.getState().pasteNodes();
|
||||
}
|
||||
|
||||
function deleteNode() {
|
||||
deleteElements({ nodes: [{ id: nodeId }] });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
const now = Date.now();
|
||||
const timeSinceLastClick = now - lastRightClickTime.current;
|
||||
|
||||
if (timeSinceLastClick < DOUBLE_CLICK_TIMEOUT) {
|
||||
e.stopImmediatePropagation();
|
||||
lastRightClickTime.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
lastRightClickTime.current = now;
|
||||
}
|
||||
|
||||
container.addEventListener("contextmenu", handleContextMenu, true);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("contextmenu", handleContextMenu, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger asChild>
|
||||
<div ref={containerRef}>{children}</div>
|
||||
</ContextMenu.Trigger>
|
||||
<SecondaryMenuContent>
|
||||
<SecondaryMenuItem onSelect={copyNode}>
|
||||
<CopyIcon size={20} className="mr-2 dark:text-gray-100" />
|
||||
<span className="dark:text-gray-100">Copy</span>
|
||||
</SecondaryMenuItem>
|
||||
<SecondaryMenuSeparator />
|
||||
|
||||
{subGraphID && (
|
||||
<>
|
||||
<SecondaryMenuItem
|
||||
onClick={() => window.open(`/build?flowID=${subGraphID}`)}
|
||||
>
|
||||
<ArrowSquareOutIcon
|
||||
size={20}
|
||||
className="mr-2 dark:text-gray-100"
|
||||
/>
|
||||
<span className="dark:text-gray-100">Open agent</span>
|
||||
</SecondaryMenuItem>
|
||||
<SecondaryMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<SecondaryMenuItem variant="destructive" onSelect={deleteNode}>
|
||||
<TrashIcon
|
||||
size={20}
|
||||
className="mr-2 text-red-500 dark:text-red-400"
|
||||
/>
|
||||
<span className="dark:text-red-400">Delete</span>
|
||||
</SecondaryMenuItem>
|
||||
</SecondaryMenuContent>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
}
|
||||
@@ -89,6 +89,18 @@ 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": "credentials",
|
||||
"ui:field": "custom/credential_field",
|
||||
provider: { "ui:widget": "hidden" },
|
||||
type: { "ui:widget": "hidden" },
|
||||
id: { "ui:autofocus": true },
|
||||
|
||||
@@ -68,6 +68,9 @@ 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) => ({
|
||||
@@ -226,6 +229,9 @@ 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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -342,4 +348,30 @@ 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);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,32 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/molecules/Alert/Alert";
|
||||
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PlusIcon } from "@phosphor-icons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RunAgentModal } from "./components/modals/RunAgentModal/RunAgentModal";
|
||||
import { useMarketplaceUpdate } from "./hooks/useMarketplaceUpdate";
|
||||
import { AgentVersionChangelog } from "./components/AgentVersionChangelog";
|
||||
import { MarketplaceBanners } from "@/components/contextual/MarketplaceBanners/MarketplaceBanners";
|
||||
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
|
||||
import { AgentSettingsButton } from "./components/other/AgentSettingsButton";
|
||||
import { AgentSettingsModal } from "./components/modals/AgentSettingsModal/AgentSettingsModal";
|
||||
import { RunAgentModal } from "./components/modals/RunAgentModal/RunAgentModal";
|
||||
import { AgentRunsLoading } from "./components/other/AgentRunsLoading";
|
||||
import { EmptySchedules } from "./components/other/EmptySchedules";
|
||||
import { EmptyTasks } from "./components/other/EmptyTasks";
|
||||
import { EmptyTemplates } from "./components/other/EmptyTemplates";
|
||||
import { EmptyTriggers } from "./components/other/EmptyTriggers";
|
||||
import { MarketplaceBanners } from "./components/other/MarketplaceBanners";
|
||||
import { SectionWrap } from "./components/other/SectionWrap";
|
||||
import { LoadingSelectedContent } from "./components/selected-views/LoadingSelectedContent";
|
||||
import { SelectedRunView } from "./components/selected-views/SelectedRunView/SelectedRunView";
|
||||
import { SelectedScheduleView } from "./components/selected-views/SelectedScheduleView/SelectedScheduleView";
|
||||
import { SelectedSettingsView } from "./components/selected-views/SelectedSettingsView/SelectedSettingsView";
|
||||
import { SelectedTemplateView } from "./components/selected-views/SelectedTemplateView/SelectedTemplateView";
|
||||
import { SelectedTriggerView } from "./components/selected-views/SelectedTriggerView/SelectedTriggerView";
|
||||
import { SelectedViewLayout } from "./components/selected-views/SelectedViewLayout";
|
||||
import { SidebarRunsList } from "./components/sidebar/SidebarRunsList/SidebarRunsList";
|
||||
import { AGENT_LIBRARY_SECTION_PADDING_X } from "./helpers";
|
||||
import { useAgentMissingCredentials } from "./hooks/useAgentMissingCredentials";
|
||||
import { useMarketplaceUpdate } from "./hooks/useMarketplaceUpdate";
|
||||
import { useNewAgentLibraryView } from "./useNewAgentLibraryView";
|
||||
|
||||
export function NewAgentLibraryView() {
|
||||
@@ -45,7 +51,6 @@ export function NewAgentLibraryView() {
|
||||
handleSelectRun,
|
||||
handleCountsChange,
|
||||
handleClearSelectedRun,
|
||||
handleSelectSettings,
|
||||
onRunInitiated,
|
||||
onTriggerSetup,
|
||||
onScheduleCreated,
|
||||
@@ -63,6 +68,10 @@ export function NewAgentLibraryView() {
|
||||
} = useMarketplaceUpdate({ agent });
|
||||
|
||||
const [changelogOpen, setChangelogOpen] = useState(false);
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
|
||||
const { hasMissingCredentials, isLoading: isLoadingCredentials } =
|
||||
useAgentMissingCredentials(agent);
|
||||
|
||||
useEffect(() => {
|
||||
if (agent) {
|
||||
@@ -137,13 +146,33 @@ export function NewAgentLibraryView() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="mx-6 pt-4">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ name: "My Library", link: "/library" },
|
||||
{ name: agent.name, link: `/library/agents/${agentId}` },
|
||||
]}
|
||||
/>
|
||||
<div className="mx-6 flex flex-col gap-4 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ name: "My Library", link: "/library" },
|
||||
{ name: agent.name, link: `/library/agents/${agentId}` },
|
||||
]}
|
||||
/>
|
||||
<AgentSettingsModal agent={agent} />
|
||||
</div>
|
||||
{hasMissingCredentials && !isLoadingCredentials && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Missing credentials</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Text variant="small" className="text-zinc-800">
|
||||
This agent requires credentials that are not configured.{" "}
|
||||
<button
|
||||
onClick={() => setSettingsModalOpen(true)}
|
||||
className="font-medium underline hover:no-underline"
|
||||
>
|
||||
Configure credentials
|
||||
</button>{" "}
|
||||
to run tasks.
|
||||
</Text>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<EmptyTasks
|
||||
@@ -154,6 +183,13 @@ export function NewAgentLibraryView() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{agent && (
|
||||
<AgentSettingsModal
|
||||
agent={agent}
|
||||
controlledOpen={settingsModalOpen}
|
||||
onOpenChange={setSettingsModalOpen}
|
||||
/>
|
||||
)}
|
||||
{renderPublishAgentModal()}
|
||||
{renderVersionChangelog()}
|
||||
</>
|
||||
@@ -164,37 +200,49 @@ export function NewAgentLibraryView() {
|
||||
<>
|
||||
<div className="mx-4 grid h-full grid-cols-1 gap-0 pt-3 md:ml-4 md:mr-0 md:gap-4 lg:grid-cols-[25%_70%]">
|
||||
<SectionWrap className="mb-3 block">
|
||||
{hasMissingCredentials && !isLoadingCredentials && (
|
||||
<div className={cn("mb-4", AGENT_LIBRARY_SECTION_PADDING_X)}>
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Missing credentials</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Text variant="small" className="text-zinc-800">
|
||||
This agent requires credentials that are not configured.{" "}
|
||||
<button
|
||||
onClick={() => setSettingsModalOpen(true)}
|
||||
className="font-medium underline hover:no-underline"
|
||||
>
|
||||
Configure credentials
|
||||
</button>{" "}
|
||||
to run tasks.
|
||||
</Text>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"border-b border-zinc-100 pb-5",
|
||||
AGENT_LIBRARY_SECTION_PADDING_X,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RunAgentModal
|
||||
triggerSlot={
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className="flex-1"
|
||||
disabled={isTemplateLoading && activeTab === "templates"}
|
||||
>
|
||||
<PlusIcon size={20} /> New task
|
||||
</Button>
|
||||
}
|
||||
agent={agent}
|
||||
onRunCreated={onRunInitiated}
|
||||
onScheduleCreated={onScheduleCreated}
|
||||
onTriggerSetup={onTriggerSetup}
|
||||
initialInputValues={activeTemplate?.inputs}
|
||||
initialInputCredentials={activeTemplate?.credentials}
|
||||
/>
|
||||
<AgentSettingsButton
|
||||
agent={agent}
|
||||
onSelectSettings={handleSelectSettings}
|
||||
selected={activeItem === "settings"}
|
||||
/>
|
||||
</div>
|
||||
<RunAgentModal
|
||||
triggerSlot={
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className="w-full"
|
||||
disabled={isTemplateLoading && activeTab === "templates"}
|
||||
>
|
||||
<PlusIcon size={20} /> New task
|
||||
</Button>
|
||||
}
|
||||
agent={agent}
|
||||
onRunCreated={onRunInitiated}
|
||||
onScheduleCreated={onScheduleCreated}
|
||||
onTriggerSetup={onTriggerSetup}
|
||||
initialInputValues={activeTemplate?.inputs}
|
||||
initialInputCredentials={activeTemplate?.credentials}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SidebarRunsList
|
||||
@@ -208,12 +256,7 @@ export function NewAgentLibraryView() {
|
||||
</SectionWrap>
|
||||
|
||||
{activeItem ? (
|
||||
activeItem === "settings" ? (
|
||||
<SelectedSettingsView
|
||||
agent={agent}
|
||||
onClearSelectedRun={handleClearSelectedRun}
|
||||
/>
|
||||
) : activeTab === "scheduled" ? (
|
||||
activeTab === "scheduled" ? (
|
||||
<SelectedScheduleView
|
||||
agent={agent}
|
||||
scheduleId={activeItem}
|
||||
@@ -246,8 +289,6 @@ export function NewAgentLibraryView() {
|
||||
onSelectRun={handleSelectRun}
|
||||
onClearSelectedRun={handleClearSelectedRun}
|
||||
banner={renderMarketplaceUpdateBanner()}
|
||||
onSelectSettings={handleSelectSettings}
|
||||
selectedSettings={activeItem === "settings"}
|
||||
/>
|
||||
)
|
||||
) : sidebarLoading ? (
|
||||
@@ -287,6 +328,13 @@ export function NewAgentLibraryView() {
|
||||
</SelectedViewLayout>
|
||||
)}
|
||||
</div>
|
||||
{agent && (
|
||||
<AgentSettingsModal
|
||||
agent={agent}
|
||||
controlledOpen={settingsModalOpen}
|
||||
onOpenChange={setSettingsModalOpen}
|
||||
/>
|
||||
)}
|
||||
{renderPublishAgentModal()}
|
||||
{renderVersionChangelog()}
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
|
||||
import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs";
|
||||
import { isSystemCredential } from "../CredentialsInputs/helpers";
|
||||
import { RunAgentInputs } from "../RunAgentInputs/RunAgentInputs";
|
||||
import { getAgentCredentialsFields, getAgentInputFields } from "./helpers";
|
||||
|
||||
@@ -71,6 +72,7 @@ export function AgentInputsReadOnly({
|
||||
{credentialFieldEntries.map(([key, inputSubSchema]) => {
|
||||
const credential = credentialInputs![key];
|
||||
if (!credential) return null;
|
||||
if (isSystemCredential(credential)) return null;
|
||||
|
||||
return (
|
||||
<CredentialsInput
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Switch } from "@/components/atoms/Switch/Switch";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
|
||||
import { GearIcon } from "@phosphor-icons/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useAgentSystemCredentials } from "../../../hooks/useAgentSystemCredentials";
|
||||
import { SystemCredentialRow } from "../../selected-views/SelectedSettingsView/components/SystemCredentialRow";
|
||||
|
||||
interface Props {
|
||||
agent: LibraryAgent;
|
||||
controlledOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function AgentSettingsModal({
|
||||
agent,
|
||||
controlledOpen,
|
||||
onOpenChange,
|
||||
}: Props) {
|
||||
const [internalIsOpen, setInternalIsOpen] = useState(false);
|
||||
const isOpen = controlledOpen !== undefined ? controlledOpen : internalIsOpen;
|
||||
|
||||
function setIsOpen(open: boolean) {
|
||||
if (onOpenChange) {
|
||||
onOpenChange(open);
|
||||
} else {
|
||||
setInternalIsOpen(open);
|
||||
}
|
||||
}
|
||||
|
||||
const { currentSafeMode, isPending, hasHITLBlocks, handleToggle } =
|
||||
useAgentSafeMode(agent);
|
||||
|
||||
const { hasSystemCredentials, systemCredentials } =
|
||||
useAgentSystemCredentials(agent);
|
||||
|
||||
// Only show credential fields that have system credentials
|
||||
const credentialFieldsWithSystemCreds = useMemo(() => {
|
||||
return systemCredentials.map((item) => ({
|
||||
fieldKey: item.key,
|
||||
schema: item.schema,
|
||||
systemCredential: item.credential,
|
||||
}));
|
||||
}, [systemCredentials]);
|
||||
|
||||
const hasAnySettings = hasHITLBlocks || hasSystemCredentials;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
controlled={{ isOpen, set: setIsOpen }}
|
||||
styling={{ maxWidth: "600px", maxHeight: "90vh" }}
|
||||
title="Agent Settings"
|
||||
>
|
||||
{controlledOpen === undefined && (
|
||||
<Dialog.Trigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
className="m-0 min-w-0 rounded-full p-0 px-1"
|
||||
aria-label="Agent Settings"
|
||||
>
|
||||
<GearIcon size={18} className="text-zinc-600" />
|
||||
<Text variant="small">Agent Settings</Text>
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
)}
|
||||
<Dialog.Content>
|
||||
<div className="space-y-6">
|
||||
{hasHITLBlocks && (
|
||||
<div className="flex w-full flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
|
||||
<div className="flex w-full items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<Text variant="large-semibold">Require human approval</Text>
|
||||
<Text variant="large" className="mt-1 text-zinc-900">
|
||||
The agent will pause and wait for your review before
|
||||
continuing
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={currentSafeMode || false}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isPending}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSystemCredentials && (
|
||||
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
|
||||
<div>
|
||||
<Text variant="large-semibold">System Credentials</Text>
|
||||
<Text variant="body" className="mt-1 text-muted-foreground">
|
||||
These credentials are managed by AutoGPT and used by the agent
|
||||
to access various services. You can switch to your own
|
||||
credentials if preferred.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="w-full space-y-4">
|
||||
{credentialFieldsWithSystemCreds.map(
|
||||
({ fieldKey, schema, systemCredential }) => (
|
||||
<SystemCredentialRow
|
||||
key={fieldKey}
|
||||
credentialKey={fieldKey}
|
||||
agentId={agent.id.toString()}
|
||||
schema={schema}
|
||||
systemCredential={systemCredential}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasAnySettings && (
|
||||
<div className="py-6">
|
||||
<Text variant="body" className="text-muted-foreground">
|
||||
This agent doesn't have any configurable settings.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -10,11 +10,10 @@ import { toDisplayName } from "@/providers/agent-credentials/helper";
|
||||
import { APIKeyCredentialsModal } from "./components/APIKeyCredentialsModal/APIKeyCredentialsModal";
|
||||
import { CredentialRow } from "./components/CredentialRow/CredentialRow";
|
||||
import { CredentialsSelect } from "./components/CredentialsSelect/CredentialsSelect";
|
||||
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal/DeleteConfirmationModal";
|
||||
import { HostScopedCredentialsModal } from "./components/HotScopedCredentialsModal/HotScopedCredentialsModal";
|
||||
import { OAuthFlowWaitingModal } from "./components/OAuthWaitingModal/OAuthWaitingModal";
|
||||
import { PasswordCredentialsModal } from "./components/PasswordCredentialsModal/PasswordCredentialsModal";
|
||||
import { getCredentialDisplayName } from "./helpers";
|
||||
import { isSystemCredential } from "./helpers";
|
||||
import {
|
||||
CredentialsInputState,
|
||||
useCredentialsInput,
|
||||
@@ -34,7 +33,10 @@ type Props = {
|
||||
onSelectCredentials: (newValue?: CredentialsMetaInput) => void;
|
||||
onLoaded?: (loaded: boolean) => void;
|
||||
readOnly?: boolean;
|
||||
isOptional?: boolean;
|
||||
showTitle?: boolean;
|
||||
variant?: "default" | "node";
|
||||
allowSystemCredentials?: boolean; // Allow system credentials (for settings only)
|
||||
};
|
||||
|
||||
export function CredentialsInput({
|
||||
@@ -45,7 +47,10 @@ export function CredentialsInput({
|
||||
siblingInputs,
|
||||
onLoaded,
|
||||
readOnly = false,
|
||||
isOptional = false,
|
||||
showTitle = true,
|
||||
variant = "default",
|
||||
allowSystemCredentials = false,
|
||||
}: Props) {
|
||||
const hookData = useCredentialsInput({
|
||||
schema,
|
||||
@@ -54,6 +59,8 @@ export function CredentialsInput({
|
||||
siblingInputs,
|
||||
onLoaded,
|
||||
readOnly,
|
||||
isOptional,
|
||||
allowSystemCredentials,
|
||||
});
|
||||
|
||||
if (!isLoaded(hookData)) {
|
||||
@@ -74,27 +81,35 @@ export function CredentialsInput({
|
||||
isHostScopedCredentialsModalOpen,
|
||||
isOAuth2FlowInProgress,
|
||||
oAuthPopupController,
|
||||
credentialToDelete,
|
||||
deleteCredentialsMutation,
|
||||
actionButtonText,
|
||||
setAPICredentialsModalOpen,
|
||||
setUserPasswordCredentialsModalOpen,
|
||||
setHostScopedCredentialsModalOpen,
|
||||
setCredentialToDelete,
|
||||
handleActionButtonClick,
|
||||
handleCredentialSelect,
|
||||
handleDeleteCredential,
|
||||
handleDeleteConfirm,
|
||||
} = hookData;
|
||||
|
||||
const displayName = toDisplayName(provider);
|
||||
const hasCredentialsToShow = credentialsToShow.length > 0;
|
||||
const selectedCredentialIsSystem =
|
||||
selectedCredential && isSystemCredential(selectedCredential);
|
||||
|
||||
if (readOnly && selectedCredentialIsSystem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("mb-6", className)}>
|
||||
{showTitle && (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Text variant="large-medium">{displayName} credentials</Text>
|
||||
<Text variant="large-medium">
|
||||
{displayName} credentials
|
||||
{isOptional && (
|
||||
<span className="ml-1 text-sm font-normal text-gray-500">
|
||||
(optional)
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
{schema.description && (
|
||||
<InformationTooltip description={schema.description} />
|
||||
)}
|
||||
@@ -103,14 +118,17 @@ export function CredentialsInput({
|
||||
|
||||
{hasCredentialsToShow ? (
|
||||
<>
|
||||
{credentialsToShow.length > 1 && !readOnly ? (
|
||||
{(credentialsToShow.length > 1 || isOptional) && !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">
|
||||
@@ -122,15 +140,6 @@ export function CredentialsInput({
|
||||
provider={provider}
|
||||
displayName={displayName}
|
||||
onSelect={() => handleCredentialSelect(credential.id)}
|
||||
onDelete={() =>
|
||||
handleDeleteCredential({
|
||||
id: credential.id,
|
||||
title: getCredentialDisplayName(
|
||||
credential,
|
||||
displayName,
|
||||
),
|
||||
})
|
||||
}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
);
|
||||
@@ -214,13 +223,6 @@ export function CredentialsInput({
|
||||
Error: {oAuthError}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<DeleteConfirmationModal
|
||||
credentialToDelete={credentialToDelete}
|
||||
isDeleting={deleteCredentialsMutation.isPending}
|
||||
onClose={() => setCredentialToDelete(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import {
|
||||
Form,
|
||||
FormDescription,
|
||||
FormField,
|
||||
} from "@/components/__legacy__/ui/form";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import {
|
||||
BlockIOCredentialsSubSchema,
|
||||
CredentialsMetaInput,
|
||||
@@ -60,7 +60,10 @@ export function APIKeyCredentialsModal({
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-2 px-2"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
@@ -70,8 +73,7 @@ export function APIKeyCredentialsModal({
|
||||
id="apiKey"
|
||||
label="API Key"
|
||||
type="password"
|
||||
placeholder="Enter API key..."
|
||||
size="small"
|
||||
placeholder="Enter API Key..."
|
||||
hint={
|
||||
schema.credentials_scopes ? (
|
||||
<FormDescription>
|
||||
@@ -98,8 +100,7 @@ export function APIKeyCredentialsModal({
|
||||
id="title"
|
||||
label="Name"
|
||||
type="text"
|
||||
placeholder="Enter a name for this API key..."
|
||||
size="small"
|
||||
placeholder="Enter a name for this API Key..."
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
@@ -113,13 +114,12 @@ export function APIKeyCredentialsModal({
|
||||
label="Expiration Date"
|
||||
type="datetime-local"
|
||||
placeholder="Select expiration date..."
|
||||
size="small"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" size="small" className="min-w-68">
|
||||
Save & use this API key
|
||||
<Button type="submit" className="min-w-68">
|
||||
Add API Key
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -26,10 +26,12 @@ type CredentialRowProps = {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
onDelete?: () => void;
|
||||
readOnly?: boolean;
|
||||
showCaret?: boolean;
|
||||
asSelectTrigger?: boolean;
|
||||
/** When "node", applies compact styling for node context */
|
||||
variant?: "default" | "node";
|
||||
};
|
||||
|
||||
export function CredentialRow({
|
||||
@@ -41,14 +43,22 @@ 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 ? "border-0 bg-transparent" : readOnly ? "w-fit" : "",
|
||||
asSelectTrigger && isNodeVariant
|
||||
? "min-w-0 flex-1 overflow-hidden border-0 bg-transparent"
|
||||
: asSelectTrigger
|
||||
? "border-0 bg-transparent"
|
||||
: readOnly
|
||||
? "w-fit"
|
||||
: "",
|
||||
)}
|
||||
onClick={readOnly || showCaret || asSelectTrigger ? undefined : onSelect}
|
||||
style={
|
||||
@@ -61,24 +71,36 @@ 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="flex min-w-0 flex-1 flex-nowrap items-center gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-nowrap items-center gap-4",
|
||||
isNodeVariant && "overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
className="line-clamp-1 flex-[0_0_50%] text-ellipsis tracking-tight"
|
||||
className={cn(
|
||||
"tracking-tight",
|
||||
isNodeVariant
|
||||
? "truncate"
|
||||
: "line-clamp-1 flex-[0_0_50%] text-ellipsis",
|
||||
)}
|
||||
>
|
||||
{getCredentialDisplayName(credential, displayName)}
|
||||
</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>
|
||||
{!(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>
|
||||
)}
|
||||
</div>
|
||||
{showCaret && !asSelectTrigger && (
|
||||
<CaretDown className="h-4 w-4 shrink-0 text-gray-400" />
|
||||
)}
|
||||
{!readOnly && !showCaret && !asSelectTrigger && (
|
||||
{!readOnly && !showCaret && !asSelectTrigger && onDelete && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -7,6 +7,7 @@ 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";
|
||||
@@ -23,7 +24,11 @@ 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({
|
||||
@@ -32,22 +37,38 @@ export function CredentialsSelect({
|
||||
displayName,
|
||||
selectedCredentials,
|
||||
onSelectCredential,
|
||||
onClearCredential,
|
||||
readOnly = false,
|
||||
allowNone = true,
|
||||
variant = "default",
|
||||
}: Props) {
|
||||
// Auto-select first credential if none is selected
|
||||
// Auto-select first credential if none is selected (only if allowNone is false)
|
||||
useEffect(() => {
|
||||
if (!selectedCredentials && credentials.length > 0) {
|
||||
if (!allowNone && !selectedCredentials && credentials.length > 0) {
|
||||
onSelectCredential(credentials[0].id);
|
||||
}
|
||||
}, [selectedCredentials, credentials, onSelectCredential]);
|
||||
}, [allowNone, selectedCredentials, credentials, onSelectCredential]);
|
||||
|
||||
const handleValueChange = (value: string) => {
|
||||
if (value === "__none__") {
|
||||
onClearCredential?.();
|
||||
} else {
|
||||
onSelectCredential(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4 w-full">
|
||||
<Select
|
||||
value={selectedCredentials?.id || ""}
|
||||
onValueChange={(value) => onSelectCredential(value)}
|
||||
value={selectedCredentials?.id || (allowNone ? "__none__" : "")}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<SelectTrigger className="h-auto min-h-12 w-full rounded-medium border-zinc-200 p-0 pr-4 shadow-none">
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"h-auto min-h-12 w-full rounded-medium p-0 pr-4 shadow-none",
|
||||
variant === "node" && "overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{selectedCredentials ? (
|
||||
<SelectValue key={selectedCredentials.id} asChild>
|
||||
<CredentialRow
|
||||
@@ -63,13 +84,56 @@ export function CredentialsSelect({
|
||||
onDelete={() => {}}
|
||||
readOnly={readOnly}
|
||||
asSelectTrigger={true}
|
||||
variant={variant}
|
||||
/>
|
||||
</SelectValue>
|
||||
) : allowNone ? (
|
||||
<SelectValue key="__none__" asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-medium border border-zinc-200 bg-white p-3 transition-colors",
|
||||
variant === "node"
|
||||
? "min-w-0 flex-1 overflow-hidden border-0 bg-transparent"
|
||||
: "border-0 bg-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-zinc-200">
|
||||
<Text
|
||||
variant="body"
|
||||
className="text-xs font-medium text-zinc-500"
|
||||
>
|
||||
—
|
||||
</Text>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-nowrap items-center gap-4",
|
||||
variant === "node" && "overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
className={cn("tracking-tight text-zinc-500")}
|
||||
>
|
||||
None (skip this credential)
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</SelectValue>
|
||||
) : (
|
||||
<SelectValue key="placeholder" placeholder="Select credential" />
|
||||
)}
|
||||
</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">
|
||||
|
||||
@@ -100,3 +100,29 @@ export function getCredentialDisplayName(
|
||||
|
||||
export const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
export const MASKED_KEY_LENGTH = 30;
|
||||
|
||||
export function isSystemCredential(credential: {
|
||||
title?: string | null;
|
||||
is_system?: boolean;
|
||||
}): boolean {
|
||||
if (credential.is_system === true) return true;
|
||||
if (!credential.title) return false;
|
||||
const titleLower = credential.title.toLowerCase();
|
||||
return (
|
||||
titleLower.includes("system") ||
|
||||
titleLower.startsWith("use credits for") ||
|
||||
titleLower.includes("use credits")
|
||||
);
|
||||
}
|
||||
|
||||
export function filterSystemCredentials<
|
||||
T extends { title?: string; is_system?: boolean },
|
||||
>(credentials: T[]): T[] {
|
||||
return credentials.filter((cred) => !isSystemCredential(cred));
|
||||
}
|
||||
|
||||
export function getSystemCredentials<
|
||||
T extends { title?: string; is_system?: boolean },
|
||||
>(credentials: T[]): T[] {
|
||||
return credentials.filter((cred) => isSystemCredential(cred));
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
CredentialsMetaInput,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
filterSystemCredentials,
|
||||
getActionButtonText,
|
||||
getSystemCredentials,
|
||||
OAUTH_TIMEOUT_MS,
|
||||
OAuthPopupResultMessage,
|
||||
} from "./helpers";
|
||||
@@ -22,6 +24,8 @@ type Params = {
|
||||
siblingInputs?: Record<string, any>;
|
||||
onLoaded?: (loaded: boolean) => void;
|
||||
readOnly?: boolean;
|
||||
isOptional?: boolean;
|
||||
allowSystemCredentials?: boolean; // Allow system credentials (for settings only)
|
||||
};
|
||||
|
||||
export function useCredentialsInput({
|
||||
@@ -31,6 +35,8 @@ export function useCredentialsInput({
|
||||
siblingInputs,
|
||||
onLoaded,
|
||||
readOnly = false,
|
||||
isOptional = false,
|
||||
allowSystemCredentials = false,
|
||||
}: Params) {
|
||||
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
|
||||
useState(false);
|
||||
@@ -52,6 +58,7 @@ export function useCredentialsInput({
|
||||
const api = useBackendAPI();
|
||||
const queryClient = useQueryClient();
|
||||
const credentials = useCredentials(schema, siblingInputs);
|
||||
const hasAttemptedAutoSelect = useRef(false);
|
||||
|
||||
const deleteCredentialsMutation = useDeleteV1DeleteCredentials({
|
||||
mutation: {
|
||||
@@ -80,13 +87,22 @@ export function useCredentialsInput({
|
||||
useEffect(() => {
|
||||
if (readOnly) return;
|
||||
if (!credentials || !("savedCredentials" in credentials)) return;
|
||||
const availableCreds = allowSystemCredentials
|
||||
? credentials.savedCredentials
|
||||
: filterSystemCredentials(credentials.savedCredentials);
|
||||
if (
|
||||
selectedCredential &&
|
||||
!credentials.savedCredentials.some((c) => c.id === selectedCredential.id)
|
||||
!availableCreds.some((c) => c.id === selectedCredential.id)
|
||||
) {
|
||||
onSelectCredential(undefined);
|
||||
}
|
||||
}, [credentials, selectedCredential, onSelectCredential, readOnly]);
|
||||
}, [
|
||||
credentials,
|
||||
selectedCredential,
|
||||
onSelectCredential,
|
||||
readOnly,
|
||||
allowSystemCredentials,
|
||||
]);
|
||||
|
||||
// The available credential, if there is only one
|
||||
const singleCredential = useMemo(() => {
|
||||
@@ -94,18 +110,112 @@ export function useCredentialsInput({
|
||||
return null;
|
||||
}
|
||||
|
||||
return credentials.savedCredentials.length === 1
|
||||
? credentials.savedCredentials[0]
|
||||
: null;
|
||||
}, [credentials]);
|
||||
const credsToUse = allowSystemCredentials
|
||||
? credentials.savedCredentials
|
||||
: filterSystemCredentials(credentials.savedCredentials);
|
||||
return credsToUse.length === 1 ? credsToUse[0] : null;
|
||||
}, [credentials, allowSystemCredentials]);
|
||||
|
||||
// Auto-select the one available credential
|
||||
// Prioritize system credentials if available
|
||||
// For system credentials, always auto-select even if optional (they should be used by default)
|
||||
useEffect(() => {
|
||||
if (readOnly) return;
|
||||
if (singleCredential && !selectedCredential) {
|
||||
if (!credentials || !("savedCredentials" in credentials)) return;
|
||||
|
||||
// Early return if already selected to prevent infinite loops
|
||||
const currentSelectedId = selectedCredential?.id;
|
||||
if (currentSelectedId) {
|
||||
hasAttemptedAutoSelect.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// If selectedCredential is explicitly undefined and isOptional is true,
|
||||
// don't auto-select - this could mean "None" was explicitly selected
|
||||
// The parent component should handle setting the initial value
|
||||
if (selectedCredential === undefined && isOptional) {
|
||||
// Mark as attempted to prevent auto-selection when "None" is a valid choice
|
||||
hasAttemptedAutoSelect.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only attempt auto-selection once per credential load
|
||||
if (hasAttemptedAutoSelect.current) return;
|
||||
|
||||
const supportedTypes = schema.credentials_types || [];
|
||||
const requiredScopes = schema.credentials_scopes;
|
||||
const savedCreds = credentials.savedCredentials;
|
||||
const systemCreds = getSystemCredentials(savedCreds);
|
||||
|
||||
// Filter system credentials by type and scopes (same logic as useCredentials)
|
||||
const matchingSystemCreds = systemCreds.filter((cred) => {
|
||||
// Check type match
|
||||
if (!supportedTypes.includes(cred.type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For OAuth2 credentials, check scopes
|
||||
if (
|
||||
cred.type === "oauth2" &&
|
||||
requiredScopes &&
|
||||
requiredScopes.length > 0
|
||||
) {
|
||||
const grantedScopes = new Set(cred.scopes || []);
|
||||
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
|
||||
grantedScopes,
|
||||
);
|
||||
if (!hasAllRequiredScopes) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// First, try to auto-select system credential if available
|
||||
if (matchingSystemCreds.length === 1) {
|
||||
const systemCred = matchingSystemCreds[0];
|
||||
const credProvider = credentials.provider;
|
||||
hasAttemptedAutoSelect.current = true;
|
||||
onSelectCredential({
|
||||
id: systemCred.id,
|
||||
type: systemCred.type,
|
||||
provider: credProvider,
|
||||
title: (systemCred as any).title,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, auto-select single credential if there's only one (and not optional)
|
||||
if (!isOptional && singleCredential) {
|
||||
hasAttemptedAutoSelect.current = true;
|
||||
onSelectCredential(singleCredential);
|
||||
}
|
||||
}, [singleCredential, selectedCredential, onSelectCredential, readOnly]);
|
||||
}, [
|
||||
singleCredential?.id, // Only depend on the ID, not the whole object
|
||||
selectedCredential?.id, // Only depend on the ID, not the whole object
|
||||
readOnly,
|
||||
isOptional,
|
||||
credentials,
|
||||
schema.credentials_types,
|
||||
schema.credentials_scopes,
|
||||
// Note: onSelectCredential removed from deps to prevent infinite loops
|
||||
// It should be stable, but if it's not, the ref will prevent multiple calls
|
||||
]);
|
||||
|
||||
// Reset the ref when credentials change significantly
|
||||
useEffect(() => {
|
||||
if (credentials && "savedCredentials" in credentials) {
|
||||
hasAttemptedAutoSelect.current = false;
|
||||
}
|
||||
}, [
|
||||
credentials && "savedCredentials" in credentials
|
||||
? credentials.savedCredentials.length
|
||||
: 0,
|
||||
credentials && "savedCredentials" in credentials
|
||||
? credentials.provider
|
||||
: null,
|
||||
]);
|
||||
|
||||
if (
|
||||
!credentials ||
|
||||
@@ -128,6 +238,11 @@ export function useCredentialsInput({
|
||||
oAuthCallback,
|
||||
} = credentials;
|
||||
|
||||
// Filter system credentials unless explicitly allowed (for settings)
|
||||
const filteredCredentials = allowSystemCredentials
|
||||
? savedCredentials
|
||||
: filterSystemCredentials(savedCredentials);
|
||||
|
||||
async function handleOAuthLogin() {
|
||||
setOAuthError(null);
|
||||
const { login_url, state_token } = await api.oAuthLogin(
|
||||
@@ -282,7 +397,7 @@ export function useCredentialsInput({
|
||||
supportsOAuth2,
|
||||
supportsUserPassword,
|
||||
supportsHostScoped,
|
||||
credentialsToShow: savedCredentials,
|
||||
credentialsToShow: filteredCredentials,
|
||||
selectedCredential,
|
||||
oAuthError,
|
||||
isAPICredentialsModalOpen,
|
||||
@@ -297,7 +412,7 @@ export function useCredentialsInput({
|
||||
supportsApiKey,
|
||||
supportsUserPassword,
|
||||
supportsHostScoped,
|
||||
savedCredentials.length > 0,
|
||||
filteredCredentials.length > 0,
|
||||
),
|
||||
setAPICredentialsModalOpen,
|
||||
setUserPasswordCredentialsModalOpen,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ScheduleAgentModal } from "../ScheduleAgentModal/ScheduleAgentModal";
|
||||
import { ModalHeader } from "./components/ModalHeader/ModalHeader";
|
||||
import { ModalRunSection } from "./components/ModalRunSection/ModalRunSection";
|
||||
@@ -82,6 +82,8 @@ export function RunAgentModal({
|
||||
});
|
||||
|
||||
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
|
||||
const [hasOverflow, setHasOverflow] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasAnySetupFields =
|
||||
Object.keys(agentInputFields || {}).length > 0 ||
|
||||
@@ -89,6 +91,43 @@ export function RunAgentModal({
|
||||
|
||||
const isTriggerRunType = defaultRunType.includes("trigger");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
function checkOverflow() {
|
||||
if (!contentRef.current) return;
|
||||
const scrollableParent = contentRef.current
|
||||
.closest("[data-dialog-content]")
|
||||
?.querySelector('[class*="overflow-y-auto"]');
|
||||
if (scrollableParent) {
|
||||
setHasOverflow(
|
||||
scrollableParent.scrollHeight > scrollableParent.clientHeight,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(checkOverflow, 100);
|
||||
const resizeObserver = new ResizeObserver(checkOverflow);
|
||||
if (contentRef.current) {
|
||||
const scrollableParent = contentRef.current
|
||||
.closest("[data-dialog-content]")
|
||||
?.querySelector('[class*="overflow-y-auto"]');
|
||||
if (scrollableParent) {
|
||||
resizeObserver.observe(scrollableParent);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [
|
||||
isOpen,
|
||||
hasAnySetupFields,
|
||||
agentInputFields,
|
||||
agentCredentialsInputFields,
|
||||
]);
|
||||
|
||||
function handleInputChange(key: string, value: string) {
|
||||
setInputValues((prev) => ({
|
||||
...prev,
|
||||
@@ -134,91 +173,97 @@ export function RunAgentModal({
|
||||
>
|
||||
<Dialog.Trigger>{triggerSlot}</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
{/* Header */}
|
||||
<ModalHeader agent={agent} />
|
||||
<div ref={contentRef} className="flex min-h-full flex-col">
|
||||
<div className="flex-1">
|
||||
{/* Header */}
|
||||
<ModalHeader agent={agent} />
|
||||
|
||||
{/* Content */}
|
||||
{hasAnySetupFields ? (
|
||||
<div className="mt-10">
|
||||
<RunAgentModalContextProvider
|
||||
value={{
|
||||
agent,
|
||||
defaultRunType,
|
||||
presetName,
|
||||
setPresetName,
|
||||
presetDescription,
|
||||
setPresetDescription,
|
||||
inputValues,
|
||||
setInputValue: handleInputChange,
|
||||
agentInputFields,
|
||||
inputCredentials,
|
||||
setInputCredentialsValue: handleCredentialsChange,
|
||||
agentCredentialsInputFields,
|
||||
}}
|
||||
>
|
||||
<ModalRunSection />
|
||||
</RunAgentModalContextProvider>
|
||||
{/* Content */}
|
||||
{hasAnySetupFields ? (
|
||||
<div className="mt-10 pb-32">
|
||||
<RunAgentModalContextProvider
|
||||
value={{
|
||||
agent,
|
||||
defaultRunType,
|
||||
presetName,
|
||||
setPresetName,
|
||||
presetDescription,
|
||||
setPresetDescription,
|
||||
inputValues,
|
||||
setInputValue: handleInputChange,
|
||||
agentInputFields,
|
||||
inputCredentials,
|
||||
setInputCredentialsValue: handleCredentialsChange,
|
||||
agentCredentialsInputFields,
|
||||
}}
|
||||
>
|
||||
<ModalRunSection />
|
||||
</RunAgentModalContextProvider>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Dialog.Footer className="mt-6 bg-white pt-4">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{isTriggerRunType ? null : !allRequiredInputsAreSet ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleOpenScheduleModal}
|
||||
disabled={
|
||||
isExecuting ||
|
||||
isSettingUpTrigger ||
|
||||
!allRequiredInputsAreSet
|
||||
}
|
||||
>
|
||||
Schedule Task
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Please set up all required inputs and credentials before
|
||||
scheduling
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleOpenScheduleModal}
|
||||
disabled={
|
||||
isExecuting ||
|
||||
isSettingUpTrigger ||
|
||||
!allRequiredInputsAreSet
|
||||
}
|
||||
>
|
||||
Schedule Task
|
||||
</Button>
|
||||
)}
|
||||
<RunActions
|
||||
defaultRunType={defaultRunType}
|
||||
onRun={handleRun}
|
||||
isExecuting={isExecuting}
|
||||
isSettingUpTrigger={isSettingUpTrigger}
|
||||
isRunReady={allRequiredInputsAreSet}
|
||||
<Dialog.Footer
|
||||
className={`sticky bottom-0 z-10 bg-white pt-4 ${
|
||||
hasOverflow
|
||||
? "border-t border-neutral-100 shadow-[0_-2px_8px_rgba(0,0,0,0.04)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{isTriggerRunType ? null : !allRequiredInputsAreSet ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleOpenScheduleModal}
|
||||
disabled={
|
||||
isExecuting ||
|
||||
isSettingUpTrigger ||
|
||||
!allRequiredInputsAreSet
|
||||
}
|
||||
>
|
||||
Schedule Task
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Please set up all required inputs and credentials
|
||||
before scheduling
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleOpenScheduleModal}
|
||||
disabled={isExecuting || isSettingUpTrigger}
|
||||
>
|
||||
Schedule Task
|
||||
</Button>
|
||||
)}
|
||||
<RunActions
|
||||
defaultRunType={defaultRunType}
|
||||
onRun={handleRun}
|
||||
isExecuting={isExecuting}
|
||||
isSettingUpTrigger={isSettingUpTrigger}
|
||||
isRunReady={allRequiredInputsAreSet}
|
||||
/>
|
||||
</div>
|
||||
<ScheduleAgentModal
|
||||
isOpen={isScheduleModalOpen}
|
||||
onClose={handleCloseScheduleModal}
|
||||
agent={agent}
|
||||
inputValues={inputValues}
|
||||
inputCredentials={inputCredentials}
|
||||
onScheduleCreated={handleScheduleCreated}
|
||||
/>
|
||||
</div>
|
||||
<ScheduleAgentModal
|
||||
isOpen={isScheduleModalOpen}
|
||||
onClose={handleCloseScheduleModal}
|
||||
agent={agent}
|
||||
inputValues={inputValues}
|
||||
inputCredentials={inputCredentials}
|
||||
onScheduleCreated={handleScheduleCreated}
|
||||
/>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Footer>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
|
||||
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
|
||||
import { useContext, useMemo } from "react";
|
||||
import {
|
||||
NONE_CREDENTIAL_MARKER,
|
||||
useAgentCredentialPreferencesStore,
|
||||
} from "../../../../../stores/agentCredentialPreferencesStore";
|
||||
import {
|
||||
filterSystemCredentials,
|
||||
isSystemCredential,
|
||||
} from "../../../CredentialsInputs/helpers";
|
||||
import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs";
|
||||
import { useRunAgentModalContext } from "../../context";
|
||||
import { ModalSection } from "../ModalSection/ModalSection";
|
||||
@@ -8,6 +18,7 @@ import { WebhookTriggerBanner } from "../WebhookTriggerBanner/WebhookTriggerBann
|
||||
|
||||
export function ModalRunSection() {
|
||||
const {
|
||||
agent,
|
||||
defaultRunType,
|
||||
presetName,
|
||||
setPresetName,
|
||||
@@ -21,8 +32,49 @@ export function ModalRunSection() {
|
||||
agentCredentialsInputFields,
|
||||
} = useRunAgentModalContext();
|
||||
|
||||
const allProviders = useContext(CredentialsProvidersContext);
|
||||
const store = useAgentCredentialPreferencesStore();
|
||||
|
||||
const inputFields = Object.entries(agentInputFields || {});
|
||||
const credentialFields = Object.entries(agentCredentialsInputFields || {});
|
||||
|
||||
// Only show credential fields that have user credentials (NOT system credentials)
|
||||
// System credentials should only be shown in settings, not in run modal
|
||||
const credentialFields = useMemo(() => {
|
||||
if (!allProviders || !agentCredentialsInputFields) return [];
|
||||
|
||||
return Object.entries(agentCredentialsInputFields).filter(
|
||||
([_key, schema]) => {
|
||||
const providerNames = schema.credentials_provider || [];
|
||||
const supportedTypes = schema.credentials_types || [];
|
||||
|
||||
// Check if any provider has user credentials (NOT system credentials)
|
||||
for (const providerName of providerNames) {
|
||||
const providerData = allProviders[providerName];
|
||||
if (!providerData) continue;
|
||||
|
||||
const userCreds = filterSystemCredentials(
|
||||
providerData.savedCredentials,
|
||||
);
|
||||
const matchingUserCreds = userCreds.filter((cred: { type: string }) =>
|
||||
supportedTypes.includes(cred.type),
|
||||
);
|
||||
|
||||
// If there are user credentials available, show this field
|
||||
if (matchingUserCreds.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Hide the field if only system credentials exist (or no credentials at all)
|
||||
return false;
|
||||
},
|
||||
);
|
||||
}, [agentCredentialsInputFields, allProviders]);
|
||||
|
||||
// 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">
|
||||
@@ -92,24 +144,113 @@ export function ModalRunSection() {
|
||||
subtitle="These are the credentials the agent will use to perform this task"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{Object.entries(agentCredentialsInputFields || {}).map(
|
||||
([key, inputSubSchema]) => (
|
||||
<CredentialsInput
|
||||
key={key}
|
||||
schema={
|
||||
{ ...inputSubSchema, discriminator: undefined } as any
|
||||
{credentialFields
|
||||
.map(([key, inputSubSchema]) => {
|
||||
const selectedCred = inputCredentials?.[key];
|
||||
|
||||
// Check if the selected credential is a system credential
|
||||
// First check the credential object itself, then look it up in providers
|
||||
let isSystemCredSelected = false;
|
||||
if (selectedCred) {
|
||||
// Check if credential object has is_system or title indicates system credential
|
||||
isSystemCredSelected = isSystemCredential(
|
||||
selectedCred as { title?: string; is_system?: boolean },
|
||||
);
|
||||
|
||||
// If not detected by title/is_system, check by looking up in providers
|
||||
if (
|
||||
!isSystemCredSelected &&
|
||||
selectedCred.id &&
|
||||
allProviders
|
||||
) {
|
||||
const providerNames =
|
||||
inputSubSchema.credentials_provider || [];
|
||||
for (const providerName of providerNames) {
|
||||
const providerData = allProviders[providerName];
|
||||
if (!providerData) continue;
|
||||
const systemCreds = providerData.savedCredentials.filter(
|
||||
(cred: any) => cred.is_system === true,
|
||||
);
|
||||
if (
|
||||
systemCreds.some(
|
||||
(cred: any) => cred.id === selectedCred.id,
|
||||
)
|
||||
) {
|
||||
isSystemCredSelected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedCredentials={
|
||||
(inputCredentials && inputCredentials[key]) ??
|
||||
inputSubSchema.default
|
||||
}
|
||||
|
||||
// If a system credential is selected, check if there are user credentials available
|
||||
// If not, hide this field entirely (it will still be used for execution)
|
||||
if (isSystemCredSelected) {
|
||||
const providerNames =
|
||||
inputSubSchema.credentials_provider || [];
|
||||
const supportedTypes = inputSubSchema.credentials_types || [];
|
||||
const hasUserCreds = providerNames.some(
|
||||
(providerName: string) => {
|
||||
const providerData = allProviders?.[providerName];
|
||||
if (!providerData) return false;
|
||||
const userCreds = filterSystemCredentials(
|
||||
providerData.savedCredentials,
|
||||
);
|
||||
return userCreds.some((cred: { type: string }) =>
|
||||
supportedTypes.includes(cred.type),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// If no user credentials available, hide the field completely
|
||||
if (!hasUserCreds) {
|
||||
return null;
|
||||
}
|
||||
onSelectCredentials={(value) =>
|
||||
setInputCredentialsValue(key, value)
|
||||
}
|
||||
siblingInputs={inputValues}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
}
|
||||
|
||||
// If a system credential is selected but user creds exist, don't show it in the UI
|
||||
// (it will still be used for execution, but user can select a user credential instead)
|
||||
const credToDisplay = isSystemCredSelected
|
||||
? undefined
|
||||
: selectedCred;
|
||||
|
||||
return (
|
||||
<CredentialsInput
|
||||
key={key}
|
||||
schema={
|
||||
{ ...inputSubSchema, discriminator: undefined } as any
|
||||
}
|
||||
selectedCredentials={credToDisplay}
|
||||
onSelectCredentials={(value) => {
|
||||
// When user selects a credential, update the state and save to preferences
|
||||
setInputCredentialsValue(key, value);
|
||||
// Save to preferences store
|
||||
if (value === undefined) {
|
||||
store.setCredentialPreference(
|
||||
agent.id.toString(),
|
||||
key,
|
||||
NONE_CREDENTIAL_MARKER,
|
||||
);
|
||||
} else if (value === null) {
|
||||
store.setCredentialPreference(
|
||||
agent.id.toString(),
|
||||
key,
|
||||
null,
|
||||
);
|
||||
} else {
|
||||
store.setCredentialPreference(
|
||||
agent.id.toString(),
|
||||
key,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}}
|
||||
siblingInputs={inputValues}
|
||||
isOptional={!requiredCredentials.has(key)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.filter(Boolean)}
|
||||
</div>
|
||||
</ModalSection>
|
||||
) : null}
|
||||
|
||||
@@ -11,9 +11,25 @@ import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { isEmpty } from "@/lib/utils";
|
||||
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
|
||||
import { analytics } from "@/services/analytics";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
NONE_CREDENTIAL_MARKER,
|
||||
useAgentCredentialPreferencesStore,
|
||||
} from "../../../stores/agentCredentialPreferencesStore";
|
||||
import {
|
||||
filterSystemCredentials,
|
||||
getSystemCredentials,
|
||||
} from "../CredentialsInputs/helpers";
|
||||
import { showExecutionErrorToast } from "./errorHelpers";
|
||||
|
||||
export type RunVariant =
|
||||
@@ -42,8 +58,10 @@ export function useAgentRunModal(
|
||||
const [inputCredentials, setInputCredentials] = useState<Record<string, any>>(
|
||||
callbacks?.initialInputCredentials || {},
|
||||
);
|
||||
|
||||
const [presetName, setPresetName] = useState<string>("");
|
||||
const [presetDescription, setPresetDescription] = useState<string>("");
|
||||
const hasInitializedSystemCreds = useRef(false);
|
||||
|
||||
// Determine the default run type based on agent capabilities
|
||||
const defaultRunType: RunVariant = agent.trigger_setup_info
|
||||
@@ -58,6 +76,198 @@ export function useAgentRunModal(
|
||||
setInputCredentials(callbacks?.initialInputCredentials || {});
|
||||
}, [callbacks?.initialInputValues, callbacks?.initialInputCredentials]);
|
||||
|
||||
const allProviders = useContext(CredentialsProvidersContext);
|
||||
const store = useAgentCredentialPreferencesStore();
|
||||
|
||||
// Initialize credentials from saved preferences or default system credentials
|
||||
// This ensures credentials are used even when the field is not displayed
|
||||
useEffect(() => {
|
||||
if (!allProviders || !agent.credentials_input_schema?.properties) return;
|
||||
if (callbacks?.initialInputCredentials) {
|
||||
hasInitializedSystemCreds.current = true;
|
||||
return; // Don't override if initial credentials provided
|
||||
}
|
||||
if (hasInitializedSystemCreds.current) return; // Already initialized
|
||||
|
||||
const properties = agent.credentials_input_schema.properties as Record<
|
||||
string,
|
||||
any
|
||||
>;
|
||||
|
||||
// Use functional update to get current state and avoid stale closures
|
||||
setInputCredentials((currentCreds) => {
|
||||
const credsToAdd: Record<string, any> = {};
|
||||
|
||||
for (const [key, schema] of Object.entries(properties)) {
|
||||
// Skip if already set
|
||||
if (currentCreds[key]) continue;
|
||||
|
||||
// First, check if user has a saved preference
|
||||
const savedPreference = store.getCredentialPreference(
|
||||
agent.id.toString(),
|
||||
key,
|
||||
);
|
||||
// Check if "None" was explicitly selected (special marker)
|
||||
if (savedPreference === NONE_CREDENTIAL_MARKER) {
|
||||
// User explicitly selected "None" - don't add any credential
|
||||
continue;
|
||||
}
|
||||
if (savedPreference) {
|
||||
credsToAdd[key] = savedPreference;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise, find default system credentials for this field
|
||||
const providerNames = schema.credentials_provider || [];
|
||||
const supportedTypes = schema.credentials_types || [];
|
||||
const requiredScopes = schema.credentials_scopes;
|
||||
|
||||
for (const providerName of providerNames) {
|
||||
const providerData = allProviders[providerName];
|
||||
if (!providerData) continue;
|
||||
|
||||
const systemCreds = getSystemCredentials(
|
||||
providerData.savedCredentials,
|
||||
);
|
||||
const matchingSystemCreds = systemCreds.filter((cred) => {
|
||||
if (!supportedTypes.includes(cred.type)) return false;
|
||||
|
||||
// For OAuth2 credentials, check scopes
|
||||
if (
|
||||
cred.type === "oauth2" &&
|
||||
requiredScopes &&
|
||||
requiredScopes.length > 0
|
||||
) {
|
||||
const grantedScopes = new Set(cred.scopes || []);
|
||||
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
|
||||
grantedScopes,
|
||||
);
|
||||
if (!hasAllRequiredScopes) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// If there's exactly one system credential, use it as default
|
||||
if (matchingSystemCreds.length === 1) {
|
||||
const systemCred = matchingSystemCreds[0];
|
||||
credsToAdd[key] = {
|
||||
id: systemCred.id,
|
||||
type: systemCred.type,
|
||||
provider: providerName,
|
||||
title: systemCred.title,
|
||||
};
|
||||
break; // Use first matching provider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only update if we found credentials to add
|
||||
if (Object.keys(credsToAdd).length > 0) {
|
||||
hasInitializedSystemCreds.current = true;
|
||||
return {
|
||||
...currentCreds,
|
||||
...credsToAdd,
|
||||
};
|
||||
}
|
||||
|
||||
return currentCreds; // No changes
|
||||
});
|
||||
}, [
|
||||
allProviders,
|
||||
agent.credentials_input_schema,
|
||||
agent.id,
|
||||
store,
|
||||
callbacks?.initialInputCredentials,
|
||||
]);
|
||||
|
||||
// Sync credentials with preferences store when modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen || !allProviders || !agent.credentials_input_schema?.properties)
|
||||
return;
|
||||
if (callbacks?.initialInputCredentials) return; // Don't override if initial credentials provided
|
||||
|
||||
const properties = agent.credentials_input_schema.properties as Record<
|
||||
string,
|
||||
any
|
||||
>;
|
||||
|
||||
setInputCredentials((currentCreds) => {
|
||||
const updatedCreds: Record<string, any> = { ...currentCreds };
|
||||
|
||||
for (const [key, schema] of Object.entries(properties)) {
|
||||
const savedPreference = store.getCredentialPreference(
|
||||
agent.id.toString(),
|
||||
key,
|
||||
);
|
||||
|
||||
if (savedPreference === NONE_CREDENTIAL_MARKER) {
|
||||
// User explicitly selected "None" - remove from credentials
|
||||
delete updatedCreds[key];
|
||||
} else if (savedPreference) {
|
||||
// User has a saved preference - use it
|
||||
updatedCreds[key] = savedPreference;
|
||||
} else if (!updatedCreds[key]) {
|
||||
// No preference and no current credential - try to find default system credential
|
||||
const providerNames = schema.credentials_provider || [];
|
||||
const supportedTypes = schema.credentials_types || [];
|
||||
const requiredScopes = schema.credentials_scopes;
|
||||
|
||||
for (const providerName of providerNames) {
|
||||
const providerData = allProviders[providerName];
|
||||
if (!providerData) continue;
|
||||
|
||||
const systemCreds = getSystemCredentials(
|
||||
providerData.savedCredentials,
|
||||
);
|
||||
const matchingSystemCreds = systemCreds.filter((cred) => {
|
||||
if (!supportedTypes.includes(cred.type)) return false;
|
||||
|
||||
if (
|
||||
cred.type === "oauth2" &&
|
||||
requiredScopes &&
|
||||
requiredScopes.length > 0
|
||||
) {
|
||||
const grantedScopes = new Set(cred.scopes || []);
|
||||
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
|
||||
grantedScopes,
|
||||
);
|
||||
if (!hasAllRequiredScopes) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (matchingSystemCreds.length === 1) {
|
||||
const systemCred = matchingSystemCreds[0];
|
||||
updatedCreds[key] = {
|
||||
id: systemCred.id,
|
||||
type: systemCred.type,
|
||||
provider: providerName,
|
||||
title: systemCred.title,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedCreds;
|
||||
});
|
||||
}, [
|
||||
isOpen,
|
||||
agent.id,
|
||||
agent.credentials_input_schema,
|
||||
allProviders,
|
||||
store,
|
||||
callbacks?.initialInputCredentials,
|
||||
]);
|
||||
|
||||
// Reset initialization flag when modal closes/opens or agent changes
|
||||
useEffect(() => {
|
||||
hasInitializedSystemCreds.current = false;
|
||||
}, [isOpen, agent.graph_id]);
|
||||
|
||||
// API mutations
|
||||
const executeGraphMutation = usePostV1ExecuteGraphAgent({
|
||||
mutation: {
|
||||
@@ -163,15 +373,76 @@ export function useAgentRunModal(
|
||||
}, [agentInputSchema.required, inputValues]);
|
||||
|
||||
const [allCredentialsAreSet, missingCredentials] = useMemo(() => {
|
||||
const availableCredentials = new Set(Object.keys(inputCredentials));
|
||||
const allCredentials = new Set(
|
||||
Object.keys(agentCredentialsInputFields || {}) ?? [],
|
||||
// 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 missing = [...allCredentials].filter(
|
||||
(key) => !availableCredentials.has(key),
|
||||
|
||||
// Filter out credential fields that only have system credentials available
|
||||
// System credentials should not be required in the run modal
|
||||
// Also check if user has a saved preference (including NONE_MARKER)
|
||||
const requiredCredentialsToCheck = [...requiredCredentials].filter(
|
||||
(key) => {
|
||||
// Check if user has a saved preference first
|
||||
const savedPreference = store.getCredentialPreference(
|
||||
agent.id.toString(),
|
||||
key,
|
||||
);
|
||||
// If "None" was explicitly selected, don't require it
|
||||
if (savedPreference === NONE_CREDENTIAL_MARKER) {
|
||||
return false;
|
||||
}
|
||||
// If user has a saved preference, it should be checked
|
||||
if (savedPreference) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const schema = agentCredentialsInputFields[key];
|
||||
if (!schema || !allProviders) return true; // If we can't check, include it
|
||||
|
||||
const providerNames = schema.credentials_provider || [];
|
||||
const supportedTypes = schema.credentials_types || [];
|
||||
|
||||
// Check if any provider has non-system credentials available
|
||||
for (const providerName of providerNames) {
|
||||
const providerData = allProviders[providerName];
|
||||
if (!providerData) continue;
|
||||
|
||||
const userCreds = filterSystemCredentials(
|
||||
providerData.savedCredentials,
|
||||
);
|
||||
const matchingUserCreds = userCreds.filter((cred) =>
|
||||
supportedTypes.includes(cred.type),
|
||||
);
|
||||
|
||||
// If there are user credentials available, this field should be checked
|
||||
if (matchingUserCreds.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If only system credentials are available, exclude from required check
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
// 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 = requiredCredentialsToCheck.filter((key) => {
|
||||
const cred = inputCredentials[key];
|
||||
return !cred || !cred.id;
|
||||
});
|
||||
|
||||
return [missing.length === 0, missing];
|
||||
}, [agentCredentialsInputFields, inputCredentials]);
|
||||
}, [
|
||||
agent.credentials_input_schema,
|
||||
agentCredentialsInputFields,
|
||||
inputCredentials,
|
||||
allProviders,
|
||||
agent.id,
|
||||
store,
|
||||
]);
|
||||
|
||||
const credentialsRequired = useMemo(
|
||||
() => Object.keys(agentCredentialsInputFields || {}).length > 0,
|
||||
@@ -239,12 +510,18 @@ 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: inputCredentials,
|
||||
credentials_inputs: validCredentials,
|
||||
source: "library",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,37 +1,17 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { GearIcon } from "@phosphor-icons/react";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
|
||||
|
||||
interface Props {
|
||||
agent: LibraryAgent;
|
||||
onSelectSettings: () => void;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export function AgentSettingsButton({
|
||||
agent,
|
||||
onSelectSettings,
|
||||
selected,
|
||||
}: Props) {
|
||||
const { hasHITLBlocks } = useAgentSafeMode(agent);
|
||||
|
||||
if (!hasHITLBlocks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AgentSettingsButton() {
|
||||
return (
|
||||
<Button
|
||||
variant={selected ? "secondary" : "ghost"}
|
||||
variant="ghost"
|
||||
size="small"
|
||||
className="m-0 min-w-0 rounded-full p-0 px-1"
|
||||
onClick={onSelectSettings}
|
||||
aria-label="Agent Settings"
|
||||
>
|
||||
<GearIcon
|
||||
size={18}
|
||||
className={selected ? "text-zinc-900" : "text-zinc-600"}
|
||||
/>
|
||||
<GearIcon size={18} className="text-zinc-600" />
|
||||
<Text variant="small">Agent Settings</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
|
||||
export function EmptySchedules() {
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
"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 { useAgentMissingCredentials } from "../../hooks/useAgentMissingCredentials";
|
||||
import { RunAgentModal } from "../modals/RunAgentModal/RunAgentModal";
|
||||
import { RunDetailCard } from "../selected-views/RunDetailCard/RunDetailCard";
|
||||
import { EmptyTasksIllustration } from "./EmptyTasksIllustration";
|
||||
@@ -30,6 +39,42 @@ 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();
|
||||
const { hasMissingCredentials } = useAgentMissingCredentials(agent);
|
||||
|
||||
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 {
|
||||
@@ -81,6 +126,7 @@ export function EmptyTasks({
|
||||
variant="primary"
|
||||
size="large"
|
||||
className="inline-flex w-[19.75rem]"
|
||||
disabled={hasMissingCredentials}
|
||||
>
|
||||
Setup your task
|
||||
</Button>
|
||||
@@ -147,9 +193,50 @@ export function EmptyTasks({
|
||||
<Button variant="secondary" size="small" onClick={handleExport}>
|
||||
Export agent to file
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
Delete agent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
controlled={{
|
||||
isOpen: showDeleteDialog,
|
||||
set: setShowDeleteDialog,
|
||||
}}
|
||||
styling={{ maxWidth: "32rem" }}
|
||||
title="Delete agent"
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div>
|
||||
<Text variant="large">
|
||||
Are you sure you want to delete this agent? This action cannot be
|
||||
undone.
|
||||
</Text>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isDeletingAgent}
|
||||
onClick={() => setShowDeleteDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteAgent}
|
||||
loading={isDeletingAgent}
|
||||
>
|
||||
Delete Agent
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
|
||||
export function EmptyTemplates() {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
|
||||
export function EmptyTriggers() {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
|
||||
interface MarketplaceBannersProps {
|
||||
interface Props {
|
||||
hasUpdate?: boolean;
|
||||
latestVersion?: number;
|
||||
hasUnpublishedChanges?: boolean;
|
||||
@@ -21,7 +21,7 @@ export function MarketplaceBanners({
|
||||
isUpdating,
|
||||
onUpdate,
|
||||
onPublish,
|
||||
}: MarketplaceBannersProps) {
|
||||
}: Props) {
|
||||
const renderUpdateBanner = () => {
|
||||
if (hasUpdate && latestVersion) {
|
||||
return (
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers";
|
||||
import { SelectedViewLayout } from "./SelectedViewLayout";
|
||||
|
||||
interface Props {
|
||||
agent: LibraryAgent;
|
||||
onSelectSettings?: () => void;
|
||||
selectedSettings?: boolean;
|
||||
}
|
||||
|
||||
export function LoadingSelectedContent(props: Props) {
|
||||
return (
|
||||
<SelectedViewLayout
|
||||
agent={props.agent}
|
||||
onSelectSettings={props.onSelectSettings}
|
||||
selectedSettings={props.selectedSettings}
|
||||
>
|
||||
<SelectedViewLayout agent={props.agent}>
|
||||
<div
|
||||
className={cn("flex flex-col gap-4", AGENT_LIBRARY_SECTION_PADDING_X)}
|
||||
>
|
||||
|
||||
@@ -83,7 +83,9 @@ function renderCode(
|
||||
</div>
|
||||
)}
|
||||
<pre className="overflow-x-auto rounded-md bg-muted p-3">
|
||||
<code className="font-mono text-sm">{codeValue}</code>
|
||||
<code className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
{codeValue}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -33,8 +33,6 @@ interface Props {
|
||||
onSelectRun?: (id: string) => void;
|
||||
onClearSelectedRun?: () => void;
|
||||
banner?: React.ReactNode;
|
||||
onSelectSettings?: () => void;
|
||||
selectedSettings?: boolean;
|
||||
}
|
||||
|
||||
export function SelectedRunView({
|
||||
@@ -43,8 +41,6 @@ export function SelectedRunView({
|
||||
onSelectRun,
|
||||
onClearSelectedRun,
|
||||
banner,
|
||||
onSelectSettings,
|
||||
selectedSettings,
|
||||
}: Props) {
|
||||
const { run, preset, isLoading, responseError, httpError } =
|
||||
useSelectedRunView(agent.graph_id, runId);
|
||||
@@ -84,12 +80,7 @@ export function SelectedRunView({
|
||||
return (
|
||||
<div className="flex h-full w-full gap-4">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<SelectedViewLayout
|
||||
agent={agent}
|
||||
banner={banner}
|
||||
onSelectSettings={onSelectSettings}
|
||||
selectedSettings={selectedSettings}
|
||||
>
|
||||
<SelectedViewLayout agent={agent} banner={banner}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<RunDetailHeader agent={agent} run={run} />
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { LoadingSelectedContent } from "../LoadingSelectedContent";
|
||||
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
|
||||
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
|
||||
import { SelectedViewLayout } from "../SelectedViewLayout";
|
||||
import { SelectedScheduleActions } from "./components/SelectedScheduleActions";
|
||||
import { SelectedScheduleActions } from "./components/SelectedScheduleActions/SelectedScheduleActions";
|
||||
import { useSelectedScheduleView } from "./useSelectedScheduleView";
|
||||
|
||||
interface Props {
|
||||
@@ -21,8 +21,6 @@ interface Props {
|
||||
scheduleId: string;
|
||||
onClearSelectedRun?: () => void;
|
||||
banner?: React.ReactNode;
|
||||
onSelectSettings?: () => void;
|
||||
selectedSettings?: boolean;
|
||||
}
|
||||
|
||||
export function SelectedScheduleView({
|
||||
@@ -30,8 +28,6 @@ export function SelectedScheduleView({
|
||||
scheduleId,
|
||||
onClearSelectedRun,
|
||||
banner,
|
||||
onSelectSettings,
|
||||
selectedSettings,
|
||||
}: Props) {
|
||||
const { schedule, isLoading, error } = useSelectedScheduleView(
|
||||
agent.graph_id,
|
||||
@@ -76,12 +72,7 @@ export function SelectedScheduleView({
|
||||
return (
|
||||
<div className="flex h-full w-full gap-4">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<SelectedViewLayout
|
||||
agent={agent}
|
||||
banner={banner}
|
||||
onSelectSettings={onSelectSettings}
|
||||
selectedSettings={selectedSettings}
|
||||
>
|
||||
<SelectedViewLayout agent={agent} banner={banner}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex w-full flex-col gap-0">
|
||||
<RunDetailHeader
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { EyeIcon } from "@phosphor-icons/react";
|
||||
import { AgentActionsDropdown } from "../../AgentActionsDropdown";
|
||||
import { useScheduleDetailHeader } from "../../RunDetailHeader/useScheduleDetailHeader";
|
||||
import { SelectedActionsWrap } from "../../SelectedActionsWrap";
|
||||
|
||||
type Props = {
|
||||
agent: LibraryAgent;
|
||||
scheduleId: string;
|
||||
onDeleted?: () => void;
|
||||
};
|
||||
|
||||
export function SelectedScheduleActions({ agent, scheduleId }: Props) {
|
||||
const { openInBuilderHref } = useScheduleDetailHeader(
|
||||
agent.graph_id,
|
||||
scheduleId,
|
||||
agent.graph_version,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectedActionsWrap>
|
||||
{openInBuilderHref && (
|
||||
<Button
|
||||
variant="icon"
|
||||
size="icon"
|
||||
as="NextLink"
|
||||
href={openInBuilderHref}
|
||||
target="_blank"
|
||||
aria-label="View scheduled task details"
|
||||
>
|
||||
<EyeIcon weight="bold" size={18} className="text-zinc-700" />
|
||||
</Button>
|
||||
)}
|
||||
<AgentActionsDropdown agent={agent} scheduleId={scheduleId} />
|
||||
</SelectedActionsWrap>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { EyeIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import { AgentActionsDropdown } from "../../../AgentActionsDropdown";
|
||||
import { SelectedActionsWrap } from "../../../SelectedActionsWrap";
|
||||
import { useSelectedScheduleActions } from "./useSelectedScheduleActions";
|
||||
|
||||
type Props = {
|
||||
agent: LibraryAgent;
|
||||
scheduleId: string;
|
||||
onDeleted?: () => void;
|
||||
};
|
||||
|
||||
export function SelectedScheduleActions({
|
||||
agent,
|
||||
scheduleId,
|
||||
onDeleted,
|
||||
}: Props) {
|
||||
const {
|
||||
openInBuilderHref,
|
||||
showDeleteDialog,
|
||||
setShowDeleteDialog,
|
||||
handleDelete,
|
||||
isDeleting,
|
||||
} = useSelectedScheduleActions({ agent, scheduleId, onDeleted });
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectedActionsWrap>
|
||||
{openInBuilderHref && (
|
||||
<Button
|
||||
variant="icon"
|
||||
size="icon"
|
||||
as="NextLink"
|
||||
href={openInBuilderHref}
|
||||
target="_blank"
|
||||
aria-label="View scheduled task details"
|
||||
>
|
||||
<EyeIcon weight="bold" size={18} className="text-zinc-700" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="icon"
|
||||
size="icon"
|
||||
aria-label="Delete schedule"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<LoadingSpinner size="small" />
|
||||
) : (
|
||||
<TrashIcon weight="bold" size={18} />
|
||||
)}
|
||||
</Button>
|
||||
<AgentActionsDropdown agent={agent} scheduleId={scheduleId} />
|
||||
</SelectedActionsWrap>
|
||||
|
||||
<Dialog
|
||||
controlled={{
|
||||
isOpen: showDeleteDialog,
|
||||
set: setShowDeleteDialog,
|
||||
}}
|
||||
styling={{ maxWidth: "32rem" }}
|
||||
title="Delete schedule"
|
||||
>
|
||||
<Dialog.Content>
|
||||
<Text variant="large">
|
||||
Are you sure you want to delete this schedule? This action cannot be
|
||||
undone.
|
||||
</Text>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowDeleteDialog(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
loading={isDeleting}
|
||||
>
|
||||
Delete Schedule
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
getGetV1ListExecutionSchedulesForAGraphQueryOptions,
|
||||
useDeleteV1DeleteExecutionSchedule,
|
||||
} from "@/app/api/__generated__/endpoints/schedules/schedules";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
interface UseSelectedScheduleActionsProps {
|
||||
agent: LibraryAgent;
|
||||
scheduleId: string;
|
||||
onDeleted?: () => void;
|
||||
}
|
||||
|
||||
export function useSelectedScheduleActions({
|
||||
agent,
|
||||
scheduleId,
|
||||
onDeleted,
|
||||
}: UseSelectedScheduleActionsProps) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const deleteMutation = useDeleteV1DeleteExecutionSchedule({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
toast({ title: "Schedule deleted" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV1ListExecutionSchedulesForAGraphQueryOptions(
|
||||
agent.graph_id,
|
||||
).queryKey,
|
||||
});
|
||||
setShowDeleteDialog(false);
|
||||
onDeleted?.();
|
||||
},
|
||||
onError: (error: unknown) =>
|
||||
toast({
|
||||
title: "Failed to delete schedule",
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!scheduleId) return;
|
||||
deleteMutation.mutate({ scheduleId });
|
||||
}
|
||||
|
||||
const openInBuilderHref = `/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}`;
|
||||
|
||||
return {
|
||||
openInBuilderHref,
|
||||
showDeleteDialog,
|
||||
setShowDeleteDialog,
|
||||
handleDelete,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Switch } from "@/components/atoms/Switch/Switch";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { ArrowLeftIcon } from "@phosphor-icons/react";
|
||||
import { Switch } from "@/components/atoms/Switch/Switch";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
|
||||
import { SelectedViewLayout } from "../SelectedViewLayout";
|
||||
import { ArrowLeftIcon } from "@phosphor-icons/react";
|
||||
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
|
||||
import { SelectedViewLayout } from "../SelectedViewLayout";
|
||||
import { SystemCredentialsSection } from "./components/SystemCredentialsSection";
|
||||
|
||||
interface Props {
|
||||
agent: LibraryAgent;
|
||||
@@ -16,8 +17,12 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
|
||||
const { currentSafeMode, isPending, hasHITLBlocks, handleToggle } =
|
||||
useAgentSafeMode(agent);
|
||||
|
||||
const hasCredentialsSchema =
|
||||
agent.credentials_input_schema &&
|
||||
Object.keys(agent.credentials_input_schema.properties || {}).length > 0;
|
||||
|
||||
return (
|
||||
<SelectedViewLayout agent={agent} onSelectSettings={() => {}}>
|
||||
<SelectedViewLayout agent={agent}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
className={`${AGENT_LIBRARY_SECTION_PADDING_X} mb-8 flex items-center gap-3`}
|
||||
@@ -33,15 +38,8 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
|
||||
<Text variant="h2">Agent Settings</Text>
|
||||
</div>
|
||||
|
||||
<div className={AGENT_LIBRARY_SECTION_PADDING_X}>
|
||||
{!hasHITLBlocks ? (
|
||||
<div className="rounded-xl border border-zinc-100 bg-white p-6">
|
||||
<Text variant="body" className="text-muted-foreground">
|
||||
This agent doesn't have any human-in-the-loop blocks, so
|
||||
there are no settings to configure.
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${AGENT_LIBRARY_SECTION_PADDING_X} space-y-6`}>
|
||||
{hasHITLBlocks && (
|
||||
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
|
||||
<div className="flex w-full items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
@@ -60,6 +58,16 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCredentialsSchema && <SystemCredentialsSection agent={agent} />}
|
||||
|
||||
{!hasHITLBlocks && !hasCredentialsSchema && (
|
||||
<div className="rounded-xl border border-zinc-100 bg-white p-6">
|
||||
<Text variant="body" className="text-muted-foreground">
|
||||
This agent doesn't have any configurable settings.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SelectedViewLayout>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { CredentialsMetaResponse } from "@/lib/autogpt-server-api/types";
|
||||
import { toDisplayName } from "@/providers/agent-credentials/helper";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CredentialsInput } from "../../../../components/modals/CredentialsInputs/CredentialsInputs";
|
||||
import {
|
||||
NONE_CREDENTIAL_MARKER,
|
||||
useAgentCredentialPreferencesStore,
|
||||
} from "../../../../stores/agentCredentialPreferencesStore";
|
||||
|
||||
interface Props {
|
||||
credentialKey: string;
|
||||
agentId: string;
|
||||
schema: any;
|
||||
systemCredential: CredentialsMetaResponse;
|
||||
}
|
||||
|
||||
export function SystemCredentialRow({
|
||||
credentialKey,
|
||||
agentId,
|
||||
schema,
|
||||
systemCredential,
|
||||
}: Props) {
|
||||
const store = useAgentCredentialPreferencesStore();
|
||||
|
||||
// Initialize with saved preference or default to system credential
|
||||
const savedPreference = store.getCredentialPreference(agentId, credentialKey);
|
||||
const defaultCredential = {
|
||||
id: systemCredential.id,
|
||||
type: systemCredential.type,
|
||||
provider: systemCredential.provider,
|
||||
title: systemCredential.title,
|
||||
};
|
||||
|
||||
// If saved preference is the NONE marker, use undefined (which CredentialsInput interprets as "None")
|
||||
// Otherwise use saved preference or default
|
||||
const [selectedCredential, setSelectedCredential] = useState<any>(
|
||||
savedPreference === NONE_CREDENTIAL_MARKER
|
||||
? undefined
|
||||
: savedPreference || defaultCredential,
|
||||
);
|
||||
|
||||
// Update when preference changes externally
|
||||
useEffect(() => {
|
||||
const preference = store.getCredentialPreference(agentId, credentialKey);
|
||||
if (preference === NONE_CREDENTIAL_MARKER) {
|
||||
setSelectedCredential(undefined);
|
||||
} else if (preference) {
|
||||
setSelectedCredential(preference);
|
||||
} else {
|
||||
setSelectedCredential(defaultCredential);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [credentialKey, agentId]);
|
||||
|
||||
const providerName = schema.credentials_provider?.[0] || "";
|
||||
const displayName = toDisplayName(providerName);
|
||||
|
||||
function handleSelectCredentials(value: any) {
|
||||
setSelectedCredential(value);
|
||||
// Save preference:
|
||||
// - undefined = explicitly selected "None" (save NONE_CREDENTIAL_MARKER)
|
||||
// - null = use default system credential (fallback behavior, save null)
|
||||
// - credential object = use this specific credential
|
||||
if (value === undefined) {
|
||||
// User explicitly selected "None" - save special marker
|
||||
store.setCredentialPreference(
|
||||
agentId,
|
||||
credentialKey,
|
||||
NONE_CREDENTIAL_MARKER,
|
||||
);
|
||||
} else if (value === null) {
|
||||
// User cleared selection - use default system credential
|
||||
store.setCredentialPreference(agentId, credentialKey, null);
|
||||
} else {
|
||||
// User selected a credential
|
||||
store.setCredentialPreference(agentId, credentialKey, value);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-100 bg-zinc-50/50 px-4 pb-2 pt-4">
|
||||
<Text variant="body-medium" className="mb-2 ml-2">
|
||||
{displayName}
|
||||
</Text>
|
||||
|
||||
<CredentialsInput
|
||||
schema={{ ...schema, discriminator: undefined }}
|
||||
selectedCredentials={selectedCredential}
|
||||
onSelectCredentials={handleSelectCredentials}
|
||||
showTitle={false}
|
||||
isOptional
|
||||
allowSystemCredentials={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { useAgentSystemCredentials } from "../../../../hooks/useAgentSystemCredentials";
|
||||
import { SystemCredentialRow } from "./SystemCredentialRow";
|
||||
|
||||
interface Props {
|
||||
agent: LibraryAgent;
|
||||
}
|
||||
|
||||
export function SystemCredentialsSection({ agent }: Props) {
|
||||
const { hasSystemCredentials, systemCredentials, isLoading } =
|
||||
useAgentSystemCredentials(agent);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
|
||||
<Text variant="large-semibold">System Credentials</Text>
|
||||
<Text variant="body" className="text-muted-foreground">
|
||||
Loading credentials...
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasSystemCredentials) return null;
|
||||
|
||||
// Group by credential field key (from schema) to show one row per field
|
||||
const credentialsByField = systemCredentials.reduce(
|
||||
(acc, item) => {
|
||||
if (!acc[item.key]) {
|
||||
acc[item.key] = item;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, (typeof systemCredentials)[0]>,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
|
||||
<div>
|
||||
<Text variant="large-semibold">System Credentials</Text>
|
||||
<Text variant="body" className="mt-1 text-muted-foreground">
|
||||
These credentials are managed by AutoGPT and used by the agent to
|
||||
access various services. You can switch to your own credentials if
|
||||
preferred.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="w-full space-y-4">
|
||||
{Object.entries(credentialsByField).map(([fieldKey, item]) => (
|
||||
<SystemCredentialRow
|
||||
key={fieldKey}
|
||||
credentialKey={fieldKey}
|
||||
agentId={agent.id.toString()}
|
||||
schema={item.schema}
|
||||
systemCredential={item.credential}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
|
||||
import { AgentSettingsButton } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/AgentSettingsButton";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
|
||||
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers";
|
||||
import { AgentSettingsModal } from "../modals/AgentSettingsModal/AgentSettingsModal";
|
||||
import { SectionWrap } from "../other/SectionWrap";
|
||||
|
||||
interface Props {
|
||||
@@ -9,8 +9,6 @@ interface Props {
|
||||
children: React.ReactNode;
|
||||
banner?: React.ReactNode;
|
||||
additionalBreadcrumb?: { name: string; link?: string };
|
||||
onSelectSettings?: () => void;
|
||||
selectedSettings?: boolean;
|
||||
}
|
||||
|
||||
export function SelectedViewLayout(props: Props) {
|
||||
@@ -19,8 +17,8 @@ export function SelectedViewLayout(props: Props) {
|
||||
<div
|
||||
className={`${AGENT_LIBRARY_SECTION_PADDING_X} flex-shrink-0 border-b border-zinc-100 pb-0 lg:pb-4`}
|
||||
>
|
||||
{props.banner && <div className="mb-4">{props.banner}</div>}
|
||||
<div className="relative flex w-fit items-center gap-2">
|
||||
{props.banner}
|
||||
<div className="relative flex w-full items-center justify-between">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ name: "My Library", link: "/library" },
|
||||
@@ -33,15 +31,9 @@ export function SelectedViewLayout(props: Props) {
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
{props.agent && props.onSelectSettings && (
|
||||
<div className="absolute -right-8">
|
||||
<AgentSettingsButton
|
||||
agent={props.agent}
|
||||
onSelectSettings={props.onSelectSettings}
|
||||
selected={props.selectedSettings}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute right-0">
|
||||
<AgentSettingsModal agent={props.agent} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-visible">
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
|
||||
import { storage } from "@/services/storage/local-storage";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
// Special marker to indicate "None" was explicitly selected
|
||||
export const NONE_CREDENTIAL_MARKER = { __none__: true } as const;
|
||||
|
||||
type AgentCredentialPreferences = Record<
|
||||
string,
|
||||
CredentialsMetaInput | null | typeof NONE_CREDENTIAL_MARKER
|
||||
>;
|
||||
|
||||
const STORAGE_KEY_PREFIX = "agent_credential_prefs_";
|
||||
|
||||
function getStorageKey(agentId: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}${agentId}`;
|
||||
}
|
||||
|
||||
function loadPreferences(agentId: string): AgentCredentialPreferences {
|
||||
const key = getStorageKey(agentId);
|
||||
const stored = storage.get(key as any);
|
||||
if (!stored) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
// Convert serialized NONE markers back to the constant
|
||||
const result: AgentCredentialPreferences = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
"__none__" in value &&
|
||||
(value as any).__none__ === true
|
||||
) {
|
||||
result[key] = NONE_CREDENTIAL_MARKER;
|
||||
} else {
|
||||
result[key] = value as CredentialsMetaInput | null;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function savePreferences(
|
||||
agentId: string,
|
||||
preferences: AgentCredentialPreferences,
|
||||
): void {
|
||||
const key = getStorageKey(agentId);
|
||||
storage.set(key as any, JSON.stringify(preferences));
|
||||
}
|
||||
|
||||
export function useAgentCredentialPreferences(agentId: string) {
|
||||
const [preferences, setPreferences] = useState<AgentCredentialPreferences>(
|
||||
() => loadPreferences(agentId),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loaded = loadPreferences(agentId);
|
||||
setPreferences(loaded);
|
||||
}, [agentId]);
|
||||
|
||||
const setCredentialPreference = useCallback(
|
||||
(
|
||||
credentialKey: string,
|
||||
credential: CredentialsMetaInput | null | typeof NONE_CREDENTIAL_MARKER,
|
||||
) => {
|
||||
setPreferences((prev) => {
|
||||
const updated = {
|
||||
...prev,
|
||||
[credentialKey]: credential,
|
||||
};
|
||||
savePreferences(agentId, updated);
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[agentId],
|
||||
);
|
||||
|
||||
const getCredentialPreference = useCallback(
|
||||
(
|
||||
credentialKey: string,
|
||||
): CredentialsMetaInput | null | typeof NONE_CREDENTIAL_MARKER => {
|
||||
return preferences[credentialKey] ?? null;
|
||||
},
|
||||
[preferences],
|
||||
);
|
||||
|
||||
const clearPreference = useCallback(
|
||||
(credentialKey: string) => {
|
||||
setPreferences((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[credentialKey];
|
||||
savePreferences(agentId, updated);
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[agentId],
|
||||
);
|
||||
|
||||
return {
|
||||
preferences,
|
||||
setCredentialPreference,
|
||||
getCredentialPreference,
|
||||
clearPreference,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
|
||||
import { toDisplayName } from "@/providers/agent-credentials/helper";
|
||||
import { useContext, useMemo } from "react";
|
||||
import { getSystemCredentials } from "../components/modals/CredentialsInputs/helpers";
|
||||
|
||||
/**
|
||||
* Hook to check if an agent is missing required SYSTEM credentials.
|
||||
* This is only used to block "New Task" buttons.
|
||||
* User credential validation is handled separately in RunAgentModal.
|
||||
*/
|
||||
export function useAgentMissingCredentials(
|
||||
agent: LibraryAgent | null | undefined,
|
||||
) {
|
||||
const allProviders = useContext(CredentialsProvidersContext);
|
||||
|
||||
const result = useMemo(() => {
|
||||
if (
|
||||
!agent ||
|
||||
!agent.id ||
|
||||
!allProviders ||
|
||||
!agent.credentials_input_schema?.properties
|
||||
) {
|
||||
return {
|
||||
hasMissingCredentials: false,
|
||||
missingCredentials: [],
|
||||
isLoading: !allProviders || !agent,
|
||||
};
|
||||
}
|
||||
|
||||
const properties = agent.credentials_input_schema.properties as Record<
|
||||
string,
|
||||
any
|
||||
>;
|
||||
const requiredCredentials = new Set(
|
||||
(agent.credentials_input_schema.required as string[]) || [],
|
||||
);
|
||||
|
||||
const missingCredentials: Array<{
|
||||
key: string;
|
||||
providerDisplayName: string;
|
||||
}> = [];
|
||||
|
||||
for (const [key, schema] of Object.entries(properties)) {
|
||||
const isRequired = requiredCredentials.has(key);
|
||||
if (!isRequired) continue; // Only check required credentials
|
||||
|
||||
const providerNames = schema.credentials_provider || [];
|
||||
const supportedTypes = schema.credentials_types || [];
|
||||
const requiredScopes = schema.credentials_scopes;
|
||||
|
||||
let hasSystemCredential = false;
|
||||
|
||||
// Check if any provider has a system credential available
|
||||
for (const providerName of providerNames) {
|
||||
const providerData = allProviders[providerName];
|
||||
if (!providerData) continue;
|
||||
|
||||
const systemCreds = getSystemCredentials(providerData.savedCredentials);
|
||||
const matchingSystemCreds = systemCreds.filter((cred) => {
|
||||
if (!supportedTypes.includes(cred.type)) return false;
|
||||
|
||||
if (
|
||||
cred.type === "oauth2" &&
|
||||
requiredScopes &&
|
||||
requiredScopes.length > 0
|
||||
) {
|
||||
const grantedScopes = new Set(cred.scopes || []);
|
||||
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
|
||||
grantedScopes,
|
||||
);
|
||||
if (!hasAllRequiredScopes) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// If there's a system credential available, it's not missing
|
||||
if (matchingSystemCreds.length > 0) {
|
||||
hasSystemCredential = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no system credential available, mark as missing
|
||||
if (!hasSystemCredential) {
|
||||
const providerName = providerNames[0] || "";
|
||||
missingCredentials.push({
|
||||
key,
|
||||
providerDisplayName: toDisplayName(providerName),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasMissingCredentials: missingCredentials.length > 0,
|
||||
missingCredentials,
|
||||
isLoading: false,
|
||||
};
|
||||
}, [allProviders, agent?.credentials_input_schema, agent?.id]);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { CredentialsMetaResponse } from "@/lib/autogpt-server-api/types";
|
||||
import {
|
||||
CredentialsProviderData,
|
||||
CredentialsProvidersContext,
|
||||
} from "@/providers/agent-credentials/credentials-provider";
|
||||
import { toDisplayName } from "@/providers/agent-credentials/helper";
|
||||
import { useContext, useMemo } from "react";
|
||||
import {
|
||||
filterSystemCredentials,
|
||||
getSystemCredentials,
|
||||
} from "../components/modals/CredentialsInputs/helpers";
|
||||
|
||||
interface SystemCredentialInfo {
|
||||
key: string;
|
||||
provider: string;
|
||||
schema: any;
|
||||
credential: CredentialsMetaResponse;
|
||||
}
|
||||
|
||||
interface MissingCredentialInfo {
|
||||
key: string;
|
||||
provider: string;
|
||||
providerDisplayName: string;
|
||||
}
|
||||
|
||||
interface UseAgentSystemCredentialsResult {
|
||||
hasSystemCredentials: boolean;
|
||||
systemCredentials: SystemCredentialInfo[];
|
||||
hasMissingSystemCredentials: boolean;
|
||||
missingSystemCredentials: MissingCredentialInfo[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useAgentSystemCredentials(
|
||||
agent: LibraryAgent,
|
||||
): UseAgentSystemCredentialsResult {
|
||||
const allProviders = useContext(CredentialsProvidersContext);
|
||||
|
||||
const result = useMemo(() => {
|
||||
const empty = {
|
||||
hasSystemCredentials: false,
|
||||
systemCredentials: [],
|
||||
hasMissingSystemCredentials: false,
|
||||
missingSystemCredentials: [],
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
if (!agent.credentials_input_schema?.properties) return empty;
|
||||
|
||||
if (!allProviders) return { ...empty, isLoading: true };
|
||||
|
||||
const properties = agent.credentials_input_schema.properties as Record<
|
||||
string,
|
||||
any
|
||||
>;
|
||||
const requiredCredentials = new Set(
|
||||
(agent.credentials_input_schema.required as string[]) || [],
|
||||
);
|
||||
const systemCredentials: SystemCredentialInfo[] = [];
|
||||
const missingSystemCredentials: MissingCredentialInfo[] = [];
|
||||
|
||||
for (const [key, schema] of Object.entries(properties)) {
|
||||
const providerNames = schema.credentials_provider || [];
|
||||
const isRequired = requiredCredentials.has(key);
|
||||
const supportedTypes = schema.credentials_types || [];
|
||||
|
||||
for (const providerName of providerNames) {
|
||||
const providerData: CredentialsProviderData | undefined =
|
||||
allProviders[providerName];
|
||||
|
||||
if (!providerData) {
|
||||
// Provider not loaded yet - don't mark as missing, wait for load
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for system credentials - now backend always returns them with is_system: true
|
||||
const systemCreds = getSystemCredentials(providerData.savedCredentials);
|
||||
const userCreds = filterSystemCredentials(
|
||||
providerData.savedCredentials,
|
||||
);
|
||||
|
||||
const matchingSystemCreds = systemCreds.filter((cred) =>
|
||||
supportedTypes.includes(cred.type),
|
||||
);
|
||||
const matchingUserCreds = userCreds.filter((cred) =>
|
||||
supportedTypes.includes(cred.type),
|
||||
);
|
||||
|
||||
// Add system credentials if they exist (even if not configured, backend returns them)
|
||||
for (const cred of matchingSystemCreds) {
|
||||
systemCredentials.push({
|
||||
key,
|
||||
provider: providerName,
|
||||
schema,
|
||||
credential: cred,
|
||||
});
|
||||
}
|
||||
|
||||
// Only mark as missing if it's required AND there are NO credentials available
|
||||
// (neither system nor user). This is a true "missing" state.
|
||||
// Note: We don't block based on this anymore since the run modal
|
||||
// has its own validation (allRequiredInputsAreSet)
|
||||
if (
|
||||
isRequired &&
|
||||
matchingSystemCreds.length === 0 &&
|
||||
matchingUserCreds.length === 0
|
||||
) {
|
||||
missingSystemCredentials.push({
|
||||
key,
|
||||
provider: providerName,
|
||||
providerDisplayName: toDisplayName(providerName),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasSystemCredentials: systemCredentials.length > 0,
|
||||
systemCredentials,
|
||||
hasMissingSystemCredentials: missingSystemCredentials.length > 0,
|
||||
missingSystemCredentials,
|
||||
isLoading: false,
|
||||
};
|
||||
}, [agent.credentials_input_schema, allProviders]);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -40,15 +40,17 @@ export function useMarketplaceUpdate({ agent }: UseMarketplaceUpdateProps) {
|
||||
},
|
||||
);
|
||||
|
||||
// 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
|
||||
// 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),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
const updateToLatestMutation = usePatchV2UpdateLibraryAgent({
|
||||
mutation: {
|
||||
@@ -78,16 +80,45 @@ 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 || !storeAgent) {
|
||||
|
||||
if (!agent || isSubmissionsLoading) {
|
||||
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(
|
||||
@@ -97,32 +128,11 @@ 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 &&
|
||||
!hasPendingSubmissionForCurrentVersion &&
|
||||
latestMarketplaceVersion !== undefined &&
|
||||
agent.graph_version > latestMarketplaceVersion;
|
||||
agent.graph_version >
|
||||
Math.max(latestMarketplaceVersion || 0, highestSubmittedVersion);
|
||||
|
||||
// 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;
|
||||
@@ -133,7 +143,7 @@ export function useMarketplaceUpdate({ agent }: UseMarketplaceUpdateProps) {
|
||||
isUserCreator,
|
||||
hasPublishUpdate,
|
||||
};
|
||||
}, [agent, storeAgentData, user, submissionsData]);
|
||||
}, [agent, storeAgentData, user, submissionsData, isSubmissionsLoading]);
|
||||
|
||||
const handlePublishUpdate = () => {
|
||||
setModalOpen(true);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
|
||||
import { storage } from "@/services/storage/local-storage";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
// Special marker to indicate "None" was explicitly selected
|
||||
export const NONE_CREDENTIAL_MARKER = { __none__: true } as const;
|
||||
|
||||
type CredentialPreference =
|
||||
| CredentialsMetaInput
|
||||
| null
|
||||
| typeof NONE_CREDENTIAL_MARKER;
|
||||
|
||||
type AgentCredentialPreferences = Record<string, CredentialPreference>;
|
||||
|
||||
interface AgentCredentialPreferencesStore {
|
||||
preferences: Record<string, AgentCredentialPreferences>; // agentId -> preferences
|
||||
setCredentialPreference: (
|
||||
agentId: string,
|
||||
credentialKey: string,
|
||||
credential: CredentialPreference,
|
||||
) => void;
|
||||
getCredentialPreference: (
|
||||
agentId: string,
|
||||
credentialKey: string,
|
||||
) => CredentialPreference;
|
||||
clearPreference: (agentId: string, credentialKey: string) => void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "agent_credential_preferences";
|
||||
|
||||
// Custom storage adapter for localStorage
|
||||
const customStorage = {
|
||||
getItem: (name: string): string | null => {
|
||||
return storage.get(name as any) || null;
|
||||
},
|
||||
setItem: (name: string, value: string): void => {
|
||||
storage.set(name as any, value);
|
||||
},
|
||||
removeItem: (name: string): void => {
|
||||
storage.clean(name as any);
|
||||
},
|
||||
};
|
||||
|
||||
export const useAgentCredentialPreferencesStore =
|
||||
create<AgentCredentialPreferencesStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
preferences: {},
|
||||
|
||||
setCredentialPreference: (agentId, credentialKey, credential) => {
|
||||
set((state) => {
|
||||
const agentPrefs = state.preferences[agentId] || {};
|
||||
const updated = {
|
||||
...state.preferences,
|
||||
[agentId]: {
|
||||
...agentPrefs,
|
||||
[credentialKey]: credential,
|
||||
},
|
||||
};
|
||||
return { preferences: updated };
|
||||
});
|
||||
},
|
||||
|
||||
getCredentialPreference: (agentId, credentialKey) => {
|
||||
const state = get();
|
||||
const pref = state.preferences[agentId]?.[credentialKey];
|
||||
// Convert serialized NONE marker back to constant
|
||||
if (
|
||||
pref &&
|
||||
typeof pref === "object" &&
|
||||
"__none__" in pref &&
|
||||
(pref as any).__none__ === true &&
|
||||
pref !== NONE_CREDENTIAL_MARKER
|
||||
) {
|
||||
return NONE_CREDENTIAL_MARKER;
|
||||
}
|
||||
return pref ?? null;
|
||||
},
|
||||
|
||||
clearPreference: (agentId, credentialKey) => {
|
||||
set((state) => {
|
||||
const agentPrefs = state.preferences[agentId] || {};
|
||||
const updated = { ...agentPrefs };
|
||||
delete updated[credentialKey];
|
||||
return {
|
||||
preferences: {
|
||||
...state.preferences,
|
||||
[agentId]: updated,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: STORAGE_KEY,
|
||||
storage: createJSONStorage(() => customStorage),
|
||||
// Transform on rehydrate to convert NONE markers
|
||||
onRehydrateStorage: () => (state, error) => {
|
||||
if (error || !state) {
|
||||
console.error("Failed to rehydrate credential preferences:", error);
|
||||
return;
|
||||
}
|
||||
// Convert serialized NONE markers back to constant
|
||||
const converted: Record<string, AgentCredentialPreferences> = {};
|
||||
for (const [agentId, prefs] of Object.entries(
|
||||
state.preferences || {},
|
||||
)) {
|
||||
const convertedPrefs: AgentCredentialPreferences = {};
|
||||
for (const [key, value] of Object.entries(prefs)) {
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
"__none__" in value &&
|
||||
(value as any).__none__ === true &&
|
||||
value !== NONE_CREDENTIAL_MARKER
|
||||
) {
|
||||
convertedPrefs[key] = NONE_CREDENTIAL_MARKER;
|
||||
} else {
|
||||
convertedPrefs[key] = value as CredentialPreference;
|
||||
}
|
||||
}
|
||||
converted[agentId] = convertedPrefs;
|
||||
}
|
||||
// Update state with converted preferences
|
||||
if (
|
||||
Object.keys(converted).length > 0 ||
|
||||
Object.keys(state.preferences || {}).length > 0
|
||||
) {
|
||||
state.preferences = converted;
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -1,16 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useFavoriteAgents } from "../../hooks/useFavoriteAgents";
|
||||
import LibraryAgentCard from "../LibraryAgentCard/LibraryAgentCard";
|
||||
import { useGetFlag, Flag } from "@/services/feature-flags/use-get-flag";
|
||||
import { Heart } from "lucide-react";
|
||||
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
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 { useFavoriteAgents } from "../../hooks/useFavoriteAgents";
|
||||
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
|
||||
|
||||
export default function FavoritesSection() {
|
||||
const isAgentFavoritingEnabled = useGetFlag(Flag.AGENT_FAVORITING);
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
export function FavoritesSection({ searchTerm }: Props) {
|
||||
const {
|
||||
allAgents: favoriteAgents,
|
||||
agentLoading: isLoading,
|
||||
@@ -18,60 +19,50 @@ export default function FavoritesSection() {
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useFavoriteAgents();
|
||||
} = useFavoriteAgents({ searchTerm });
|
||||
|
||||
// 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) {
|
||||
if (isLoading || favoriteAgents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-[10px] p-2 pb-[10px]">
|
||||
<Heart className="h-5 w-5 fill-red-500 text-red-500" />
|
||||
<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 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>
|
||||
|
||||
<div className="relative">
|
||||
{isLoading ? (
|
||||
<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">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-48 w-full rounded-lg" />
|
||||
{favoriteAgents.map((agent: LibraryAgent) => (
|
||||
<LibraryAgentCard key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
|
||||
{favoriteAgents.length > 0 && <div className="mt-6 border-t pt-6" />}
|
||||
{favoriteAgents.length > 0 && <div className="!mt-10 border-t" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user