mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-14 17:47:57 -05:00
Compare commits
26 Commits
fix/execut
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b09a94e3f | ||
|
|
61efee4139 | ||
|
|
e539280e98 | ||
|
|
db8b43bb3d | ||
|
|
923d8baedc | ||
|
|
a55b2e02dc | ||
|
|
6b6648b290 | ||
|
|
c0a9c0410b | ||
|
|
17a77b02c7 | ||
|
|
701fce83ca | ||
|
|
78d89d0faf | ||
|
|
4a52b7eca0 | ||
|
|
97847f59f7 | ||
|
|
22ca8955c5 | ||
|
|
43cbe2e011 | ||
|
|
a318832414 | ||
|
|
843c487500 | ||
|
|
47a3a5ef41 | ||
|
|
ec00aa951a | ||
|
|
36fb1ea004 | ||
|
|
a81ac150da | ||
|
|
49ee087496 | ||
|
|
fc25e008b3 | ||
|
|
b0855e8cf2 | ||
|
|
5e2146dd76 | ||
|
|
103a62c9da |
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/poetry.lock
|
||||||
!autogpt_platform/backend/README.md
|
!autogpt_platform/backend/README.md
|
||||||
!autogpt_platform/backend/.env
|
!autogpt_platform/backend/.env
|
||||||
|
!autogpt_platform/backend/gen_prisma_types_stub.py
|
||||||
|
|
||||||
# Platform - Market
|
# Platform - Market
|
||||||
!autogpt_platform/market/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
|
- name: Generate Prisma Client
|
||||||
working-directory: autogpt_platform/backend
|
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)
|
# Frontend Node.js/pnpm setup (mirrors platform-frontend-ci.yml)
|
||||||
- name: Set up Node.js
|
- 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
|
- name: Generate Prisma Client
|
||||||
working-directory: autogpt_platform/backend
|
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)
|
# Frontend Node.js/pnpm setup (mirrors platform-frontend-ci.yml)
|
||||||
- name: Set up Node.js
|
- 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
|
- name: Generate Prisma Client
|
||||||
working-directory: autogpt_platform/backend
|
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)
|
# Frontend Node.js/pnpm setup (mirrors platform-frontend-ci.yml)
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
@@ -108,6 +108,16 @@ jobs:
|
|||||||
# run: pnpm playwright install --with-deps chromium
|
# run: pnpm playwright install --with-deps chromium
|
||||||
|
|
||||||
# Docker setup for development environment
|
# 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
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
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
|
run: poetry install
|
||||||
|
|
||||||
- name: Generate Prisma Client
|
- name: Generate Prisma Client
|
||||||
run: poetry run prisma generate
|
run: poetry run prisma generate && poetry run gen-prisma-stub
|
||||||
|
|
||||||
- id: supabase
|
- id: supabase
|
||||||
name: Start Supabase
|
name: Start Supabase
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ reset-db:
|
|||||||
rm -rf db/docker/volumes/db/data
|
rm -rf db/docker/volumes/db/data
|
||||||
cd backend && poetry run prisma migrate deploy
|
cd backend && poetry run prisma migrate deploy
|
||||||
cd backend && poetry run prisma generate
|
cd backend && poetry run prisma generate
|
||||||
|
cd backend && poetry run gen-prisma-stub
|
||||||
|
|
||||||
# View logs for core services
|
# View logs for core services
|
||||||
logs-core:
|
logs-core:
|
||||||
@@ -33,6 +34,7 @@ init-env:
|
|||||||
migrate:
|
migrate:
|
||||||
cd backend && poetry run prisma migrate deploy
|
cd backend && poetry run prisma migrate deploy
|
||||||
cd backend && poetry run prisma generate
|
cd backend && poetry run prisma generate
|
||||||
|
cd backend && poetry run gen-prisma-stub
|
||||||
|
|
||||||
run-backend:
|
run-backend:
|
||||||
cd backend && poetry run app
|
cd backend && poetry run app
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ RUN poetry install --no-ansi --no-root
|
|||||||
# Generate Prisma client
|
# Generate Prisma client
|
||||||
COPY autogpt_platform/backend/schema.prisma ./
|
COPY autogpt_platform/backend/schema.prisma ./
|
||||||
COPY autogpt_platform/backend/backend/data/partial_types.py ./backend/data/partial_types.py
|
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
|
FROM debian:13-slim AS server_dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -489,7 +489,7 @@ async def update_agent_version_in_library(
|
|||||||
agent_graph_version: int,
|
agent_graph_version: int,
|
||||||
) -> library_model.LibraryAgent:
|
) -> 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:
|
Args:
|
||||||
user_id: Owner of the LibraryAgent.
|
user_id: Owner of the LibraryAgent.
|
||||||
@@ -498,20 +498,31 @@ async def update_agent_version_in_library(
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
DatabaseError: If there's an error with the update.
|
DatabaseError: If there's an error with the update.
|
||||||
|
NotFoundError: If no library agent is found for this user and agent.
|
||||||
"""
|
"""
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Updating agent version in library for user #{user_id}, "
|
f"Updating agent version in library for user #{user_id}, "
|
||||||
f"agent #{agent_graph_id} v{agent_graph_version}"
|
f"agent #{agent_graph_id} v{agent_graph_version}"
|
||||||
)
|
)
|
||||||
try:
|
async with transaction() as tx:
|
||||||
library_agent = await prisma.models.LibraryAgent.prisma().find_first_or_raise(
|
library_agent = await prisma.models.LibraryAgent.prisma(tx).find_first_or_raise(
|
||||||
where={
|
where={
|
||||||
"userId": user_id,
|
"userId": user_id,
|
||||||
"agentGraphId": agent_graph_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},
|
where={"id": library_agent.id},
|
||||||
data={
|
data={
|
||||||
"AgentGraph": {
|
"AgentGraph": {
|
||||||
@@ -525,13 +536,13 @@ async def update_agent_version_in_library(
|
|||||||
},
|
},
|
||||||
include={"AgentGraph": True},
|
include={"AgentGraph": True},
|
||||||
)
|
)
|
||||||
if lib is None:
|
|
||||||
raise NotFoundError(f"Library agent {library_agent.id} not found")
|
|
||||||
|
|
||||||
return library_model.LibraryAgent.from_db(lib)
|
if lib is None:
|
||||||
except prisma.errors.PrismaError as e:
|
raise NotFoundError(
|
||||||
logger.error(f"Database error updating agent version in library: {e}")
|
f"Failed to update library agent for {agent_graph_id} v{agent_graph_version}"
|
||||||
raise DatabaseError("Failed to update agent version in library") from e
|
)
|
||||||
|
|
||||||
|
return library_model.LibraryAgent.from_db(lib)
|
||||||
|
|
||||||
|
|
||||||
async def update_library_agent(
|
async def update_library_agent(
|
||||||
@@ -825,6 +836,7 @@ async def add_store_agent_to_library(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"isCreatedByUser": False,
|
"isCreatedByUser": False,
|
||||||
|
"useGraphIsActiveVersion": False,
|
||||||
"settings": SafeJson(
|
"settings": SafeJson(
|
||||||
_initialize_graph_settings(graph_model).model_dump()
|
_initialize_graph_settings(graph_model).model_dump()
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class LibraryAgent(pydantic.BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
graph_id: str
|
graph_id: str
|
||||||
graph_version: int
|
graph_version: int
|
||||||
|
owner_user_id: str # ID of user who owns/created this agent graph
|
||||||
|
|
||||||
image_url: str | None
|
image_url: str | None
|
||||||
|
|
||||||
@@ -163,6 +164,7 @@ class LibraryAgent(pydantic.BaseModel):
|
|||||||
id=agent.id,
|
id=agent.id,
|
||||||
graph_id=agent.agentGraphId,
|
graph_id=agent.agentGraphId,
|
||||||
graph_version=agent.agentGraphVersion,
|
graph_version=agent.agentGraphVersion,
|
||||||
|
owner_user_id=agent.userId,
|
||||||
image_url=agent.imageUrl,
|
image_url=agent.imageUrl,
|
||||||
creator_name=creator_name,
|
creator_name=creator_name,
|
||||||
creator_image_url=creator_image_url,
|
creator_image_url=creator_image_url,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ async def test_get_library_agents_success(
|
|||||||
id="test-agent-1",
|
id="test-agent-1",
|
||||||
graph_id="test-agent-1",
|
graph_id="test-agent-1",
|
||||||
graph_version=1,
|
graph_version=1,
|
||||||
|
owner_user_id=test_user_id,
|
||||||
name="Test Agent 1",
|
name="Test Agent 1",
|
||||||
description="Test Description 1",
|
description="Test Description 1",
|
||||||
image_url=None,
|
image_url=None,
|
||||||
@@ -64,6 +65,7 @@ async def test_get_library_agents_success(
|
|||||||
id="test-agent-2",
|
id="test-agent-2",
|
||||||
graph_id="test-agent-2",
|
graph_id="test-agent-2",
|
||||||
graph_version=1,
|
graph_version=1,
|
||||||
|
owner_user_id=test_user_id,
|
||||||
name="Test Agent 2",
|
name="Test Agent 2",
|
||||||
description="Test Description 2",
|
description="Test Description 2",
|
||||||
image_url=None,
|
image_url=None,
|
||||||
@@ -138,6 +140,7 @@ async def test_get_favorite_library_agents_success(
|
|||||||
id="test-agent-1",
|
id="test-agent-1",
|
||||||
graph_id="test-agent-1",
|
graph_id="test-agent-1",
|
||||||
graph_version=1,
|
graph_version=1,
|
||||||
|
owner_user_id=test_user_id,
|
||||||
name="Favorite Agent 1",
|
name="Favorite Agent 1",
|
||||||
description="Test Favorite Description 1",
|
description="Test Favorite Description 1",
|
||||||
image_url=None,
|
image_url=None,
|
||||||
@@ -205,6 +208,7 @@ def test_add_agent_to_library_success(
|
|||||||
id="test-library-agent-id",
|
id="test-library-agent-id",
|
||||||
graph_id="test-agent-1",
|
graph_id="test-agent-1",
|
||||||
graph_version=1,
|
graph_version=1,
|
||||||
|
owner_user_id=test_user_id,
|
||||||
name="Test Agent 1",
|
name="Test Agent 1",
|
||||||
description="Test Description 1",
|
description="Test Description 1",
|
||||||
image_url=None,
|
image_url=None,
|
||||||
|
|||||||
@@ -614,6 +614,7 @@ async def get_store_submissions(
|
|||||||
submission_models = []
|
submission_models = []
|
||||||
for sub in submissions:
|
for sub in submissions:
|
||||||
submission_model = store_model.StoreSubmission(
|
submission_model = store_model.StoreSubmission(
|
||||||
|
listing_id=sub.listing_id,
|
||||||
agent_id=sub.agent_id,
|
agent_id=sub.agent_id,
|
||||||
agent_version=sub.agent_version,
|
agent_version=sub.agent_version,
|
||||||
name=sub.name,
|
name=sub.name,
|
||||||
@@ -667,35 +668,48 @@ async def delete_store_submission(
|
|||||||
submission_id: str,
|
submission_id: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Delete a store listing submission as the submitting user.
|
Delete a store submission version as the submitting user.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: ID of the authenticated user
|
user_id: ID of the authenticated user
|
||||||
submission_id: ID of the submission to be deleted
|
submission_id: StoreListingVersion ID to delete
|
||||||
|
|
||||||
Returns:
|
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:
|
try:
|
||||||
# Verify the submission belongs to this user
|
# Find the submission version with ownership check
|
||||||
submission = await prisma.models.StoreListing.prisma().find_first(
|
version = await prisma.models.StoreListingVersion.prisma().find_first(
|
||||||
where={"agentGraphId": submission_id, "owningUserId": user_id}
|
where={"id": submission_id}, include={"StoreListing": True}
|
||||||
)
|
)
|
||||||
|
|
||||||
if not submission:
|
if (
|
||||||
logger.warning(f"Submission not found for user {user_id}: {submission_id}")
|
not version
|
||||||
raise store_exceptions.SubmissionNotFoundError(
|
or not version.StoreListing
|
||||||
f"Submission not found for this user. User ID: {user_id}, Submission ID: {submission_id}"
|
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
|
# Delete the version
|
||||||
await prisma.models.StoreListing.prisma().delete(where={"id": submission.id})
|
await prisma.models.StoreListingVersion.prisma().delete(
|
||||||
|
where={"id": version.id}
|
||||||
logger.debug(
|
|
||||||
f"Successfully deleted submission {submission_id} for user {user_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
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -759,9 +773,15 @@ async def create_store_submission(
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f"Agent not found for user {user_id}: {agent_id} v{agent_version}"
|
f"Agent not found for user {user_id}: {agent_id} v{agent_version}"
|
||||||
)
|
)
|
||||||
raise store_exceptions.AgentNotFoundError(
|
# Provide more user-friendly error message when agent_id is empty
|
||||||
f"Agent not found for this user. User ID: {user_id}, Agent ID: {agent_id}, Version: {agent_version}"
|
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
|
# Check if listing already exists for this agent
|
||||||
existing_listing = await prisma.models.StoreListing.prisma().find_first(
|
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}")
|
logger.debug(f"Created store listing for agent {agent_id}")
|
||||||
# Return submission details
|
# Return submission details
|
||||||
return store_model.StoreSubmission(
|
return store_model.StoreSubmission(
|
||||||
|
listing_id=listing.id,
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
agent_version=agent_version,
|
agent_version=agent_version,
|
||||||
name=name,
|
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
|
# 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.
|
# 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
|
# Only allow editing of PENDING submissions
|
||||||
if current_version.submissionStatus == prisma.enums.SubmissionStatus.REJECTED:
|
if current_version.submissionStatus != prisma.enums.SubmissionStatus.PENDING:
|
||||||
raise store_exceptions.InvalidOperationError(
|
raise store_exceptions.InvalidOperationError(
|
||||||
"Cannot edit a rejected submission"
|
f"Cannot edit a {current_version.submissionStatus.value.lower()} submission. Only pending submissions can be edited."
|
||||||
)
|
|
||||||
|
|
||||||
# For APPROVED submissions, we need to create a new version
|
|
||||||
if current_version.submissionStatus == prisma.enums.SubmissionStatus.APPROVED:
|
|
||||||
# Create a new version for the existing listing
|
|
||||||
return await create_store_version(
|
|
||||||
user_id=user_id,
|
|
||||||
agent_id=current_version.agentGraphId,
|
|
||||||
agent_version=current_version.agentGraphVersion,
|
|
||||||
store_listing_id=current_version.storeListingId,
|
|
||||||
name=name,
|
|
||||||
video_url=video_url,
|
|
||||||
agent_output_demo_url=agent_output_demo_url,
|
|
||||||
image_urls=image_urls,
|
|
||||||
description=description,
|
|
||||||
sub_heading=sub_heading,
|
|
||||||
categories=categories,
|
|
||||||
changes_summary=changes_summary,
|
|
||||||
recommended_schedule_cron=recommended_schedule_cron,
|
|
||||||
instructions=instructions,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# For PENDING submissions, we can update the existing version
|
# For PENDING submissions, we can update the existing version
|
||||||
elif current_version.submissionStatus == prisma.enums.SubmissionStatus.PENDING:
|
# Update the existing version
|
||||||
# Update the existing version
|
updated_version = await prisma.models.StoreListingVersion.prisma().update(
|
||||||
updated_version = await prisma.models.StoreListingVersion.prisma().update(
|
where={"id": store_listing_version_id},
|
||||||
where={"id": store_listing_version_id},
|
data=prisma.types.StoreListingVersionUpdateInput(
|
||||||
data=prisma.types.StoreListingVersionUpdateInput(
|
|
||||||
name=name,
|
|
||||||
videoUrl=video_url,
|
|
||||||
agentOutputDemoUrl=agent_output_demo_url,
|
|
||||||
imageUrls=image_urls,
|
|
||||||
description=description,
|
|
||||||
categories=categories,
|
|
||||||
subHeading=sub_heading,
|
|
||||||
changesSummary=changes_summary,
|
|
||||||
recommendedScheduleCron=recommended_schedule_cron,
|
|
||||||
instructions=instructions,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"Updated existing version {store_listing_version_id} for agent {current_version.agentGraphId}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not updated_version:
|
|
||||||
raise DatabaseError("Failed to update store listing version")
|
|
||||||
return store_model.StoreSubmission(
|
|
||||||
agent_id=current_version.agentGraphId,
|
|
||||||
agent_version=current_version.agentGraphVersion,
|
|
||||||
name=name,
|
name=name,
|
||||||
sub_heading=sub_heading,
|
videoUrl=video_url,
|
||||||
slug=current_version.StoreListing.slug,
|
agentOutputDemoUrl=agent_output_demo_url,
|
||||||
|
imageUrls=image_urls,
|
||||||
description=description,
|
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,
|
categories=categories,
|
||||||
version=updated_version.version,
|
subHeading=sub_heading,
|
||||||
)
|
changesSummary=changes_summary,
|
||||||
|
recommendedScheduleCron=recommended_schedule_cron,
|
||||||
|
instructions=instructions,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
logger.debug(
|
||||||
raise store_exceptions.InvalidOperationError(
|
f"Updated existing version {store_listing_version_id} for agent {current_version.agentGraphId}"
|
||||||
f"Cannot edit submission with status: {current_version.submissionStatus}"
|
)
|
||||||
)
|
|
||||||
|
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 (
|
except (
|
||||||
store_exceptions.SubmissionNotFoundError,
|
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}"
|
f"Agent not found for this user. User ID: {user_id}, Agent ID: {agent_id}, Version: {agent_version}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get the latest version number
|
# Check if there's already a PENDING submission for this agent (any version)
|
||||||
latest_version = listing.Versions[0] if listing.Versions else None
|
existing_pending_submission = (
|
||||||
|
await prisma.models.StoreListingVersion.prisma().find_first(
|
||||||
next_version = (latest_version.version + 1) if latest_version else 1
|
where=prisma.types.StoreListingVersionWhereInput(
|
||||||
|
storeListingId=store_listing_id,
|
||||||
# Create a new version for the existing listing
|
agentGraphId=agent_id,
|
||||||
new_version = await prisma.models.StoreListingVersion.prisma().create(
|
submissionStatus=prisma.enums.SubmissionStatus.PENDING,
|
||||||
data=prisma.types.StoreListingVersionCreateInput(
|
isDeleted=False,
|
||||||
version=next_version,
|
)
|
||||||
agentGraphId=agent_id,
|
|
||||||
agentGraphVersion=agent_version,
|
|
||||||
name=name,
|
|
||||||
videoUrl=video_url,
|
|
||||||
agentOutputDemoUrl=agent_output_demo_url,
|
|
||||||
imageUrls=image_urls,
|
|
||||||
description=description,
|
|
||||||
instructions=instructions,
|
|
||||||
categories=categories,
|
|
||||||
subHeading=sub_heading,
|
|
||||||
submissionStatus=prisma.enums.SubmissionStatus.PENDING,
|
|
||||||
submittedAt=datetime.now(),
|
|
||||||
changesSummary=changes_summary,
|
|
||||||
recommendedScheduleCron=recommended_schedule_cron,
|
|
||||||
storeListingId=store_listing_id,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Handle existing pending submission and create new one atomically
|
||||||
|
async with transaction() as tx:
|
||||||
|
# Get the latest version number first
|
||||||
|
latest_listing = await prisma.models.StoreListing.prisma(tx).find_first(
|
||||||
|
where=prisma.types.StoreListingWhereInput(
|
||||||
|
id=store_listing_id, owningUserId=user_id
|
||||||
|
),
|
||||||
|
include={"Versions": {"order_by": {"version": "desc"}, "take": 1}},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not latest_listing:
|
||||||
|
raise store_exceptions.ListingNotFoundError(
|
||||||
|
f"Store listing not found. User ID: {user_id}, Listing ID: {store_listing_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
latest_version = (
|
||||||
|
latest_listing.Versions[0] if latest_listing.Versions else None
|
||||||
|
)
|
||||||
|
next_version = (latest_version.version + 1) if latest_version else 1
|
||||||
|
|
||||||
|
# If there's an existing pending submission, delete it atomically before creating new one
|
||||||
|
if existing_pending_submission:
|
||||||
|
logger.info(
|
||||||
|
f"Found existing PENDING submission for agent {agent_id} (was v{existing_pending_submission.agentGraphVersion}, now v{agent_version}), replacing existing submission instead of creating duplicate"
|
||||||
|
)
|
||||||
|
await prisma.models.StoreListingVersion.prisma(tx).delete(
|
||||||
|
where={"id": existing_pending_submission.id}
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"Deleted existing pending submission {existing_pending_submission.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a new version for the existing listing
|
||||||
|
new_version = await prisma.models.StoreListingVersion.prisma(tx).create(
|
||||||
|
data=prisma.types.StoreListingVersionCreateInput(
|
||||||
|
version=next_version,
|
||||||
|
agentGraphId=agent_id,
|
||||||
|
agentGraphVersion=agent_version,
|
||||||
|
name=name,
|
||||||
|
videoUrl=video_url,
|
||||||
|
agentOutputDemoUrl=agent_output_demo_url,
|
||||||
|
imageUrls=image_urls,
|
||||||
|
description=description,
|
||||||
|
instructions=instructions,
|
||||||
|
categories=categories,
|
||||||
|
subHeading=sub_heading,
|
||||||
|
submissionStatus=prisma.enums.SubmissionStatus.PENDING,
|
||||||
|
submittedAt=datetime.now(),
|
||||||
|
changesSummary=changes_summary,
|
||||||
|
recommendedScheduleCron=recommended_schedule_cron,
|
||||||
|
storeListingId=store_listing_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Created new version for listing {store_listing_id} of agent {agent_id}"
|
f"Created new version for listing {store_listing_id} of agent {agent_id}"
|
||||||
)
|
)
|
||||||
# Return submission details
|
# Return submission details
|
||||||
return store_model.StoreSubmission(
|
return store_model.StoreSubmission(
|
||||||
|
listing_id=listing.id,
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
agent_version=agent_version,
|
agent_version=agent_version,
|
||||||
name=name,
|
name=name,
|
||||||
@@ -1708,15 +1744,12 @@ async def review_store_submission(
|
|||||||
|
|
||||||
# Convert to Pydantic model for consistency
|
# Convert to Pydantic model for consistency
|
||||||
return store_model.StoreSubmission(
|
return store_model.StoreSubmission(
|
||||||
|
listing_id=(submission.StoreListing.id if submission.StoreListing else ""),
|
||||||
agent_id=submission.agentGraphId,
|
agent_id=submission.agentGraphId,
|
||||||
agent_version=submission.agentGraphVersion,
|
agent_version=submission.agentGraphVersion,
|
||||||
name=submission.name,
|
name=submission.name,
|
||||||
sub_heading=submission.subHeading,
|
sub_heading=submission.subHeading,
|
||||||
slug=(
|
slug=(submission.StoreListing.slug if submission.StoreListing else ""),
|
||||||
submission.StoreListing.slug
|
|
||||||
if hasattr(submission, "storeListing") and submission.StoreListing
|
|
||||||
else ""
|
|
||||||
),
|
|
||||||
description=submission.description,
|
description=submission.description,
|
||||||
instructions=submission.instructions,
|
instructions=submission.instructions,
|
||||||
image_urls=submission.imageUrls or [],
|
image_urls=submission.imageUrls or [],
|
||||||
@@ -1818,9 +1851,7 @@ async def get_admin_listings_with_versions(
|
|||||||
where = prisma.types.StoreListingWhereInput(**where_dict)
|
where = prisma.types.StoreListingWhereInput(**where_dict)
|
||||||
include = prisma.types.StoreListingInclude(
|
include = prisma.types.StoreListingInclude(
|
||||||
Versions=prisma.types.FindManyStoreListingVersionArgsFromStoreListing(
|
Versions=prisma.types.FindManyStoreListingVersionArgsFromStoreListing(
|
||||||
order_by=prisma.types._StoreListingVersion_version_OrderByInput(
|
order_by={"version": "desc"}
|
||||||
version="desc"
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
OwningUser=True,
|
OwningUser=True,
|
||||||
)
|
)
|
||||||
@@ -1845,6 +1876,7 @@ async def get_admin_listings_with_versions(
|
|||||||
# If we have versions, turn them into StoreSubmission models
|
# If we have versions, turn them into StoreSubmission models
|
||||||
for version in listing.Versions or []:
|
for version in listing.Versions or []:
|
||||||
version_model = store_model.StoreSubmission(
|
version_model = store_model.StoreSubmission(
|
||||||
|
listing_id=listing.id,
|
||||||
agent_id=version.agentGraphId,
|
agent_id=version.agentGraphId,
|
||||||
agent_version=version.agentGraphVersion,
|
agent_version=version.agentGraphVersion,
|
||||||
name=version.name,
|
name=version.name,
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ class Profile(pydantic.BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class StoreSubmission(pydantic.BaseModel):
|
class StoreSubmission(pydantic.BaseModel):
|
||||||
|
listing_id: str
|
||||||
agent_id: str
|
agent_id: str
|
||||||
agent_version: int
|
agent_version: int
|
||||||
name: str
|
name: str
|
||||||
@@ -164,8 +165,12 @@ class StoreListingsWithVersionsResponse(pydantic.BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class StoreSubmissionRequest(pydantic.BaseModel):
|
class StoreSubmissionRequest(pydantic.BaseModel):
|
||||||
agent_id: str
|
agent_id: str = pydantic.Field(
|
||||||
agent_version: int
|
..., 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
|
slug: str
|
||||||
name: str
|
name: str
|
||||||
sub_heading: str
|
sub_heading: str
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ def test_creator_details():
|
|||||||
|
|
||||||
def test_store_submission():
|
def test_store_submission():
|
||||||
submission = store_model.StoreSubmission(
|
submission = store_model.StoreSubmission(
|
||||||
|
listing_id="listing123",
|
||||||
agent_id="agent123",
|
agent_id="agent123",
|
||||||
agent_version=1,
|
agent_version=1,
|
||||||
sub_heading="Test subheading",
|
sub_heading="Test subheading",
|
||||||
@@ -159,6 +160,7 @@ def test_store_submissions_response():
|
|||||||
response = store_model.StoreSubmissionsResponse(
|
response = store_model.StoreSubmissionsResponse(
|
||||||
submissions=[
|
submissions=[
|
||||||
store_model.StoreSubmission(
|
store_model.StoreSubmission(
|
||||||
|
listing_id="listing123",
|
||||||
agent_id="agent123",
|
agent_id="agent123",
|
||||||
agent_version=1,
|
agent_version=1,
|
||||||
sub_heading="Test subheading",
|
sub_heading="Test subheading",
|
||||||
|
|||||||
@@ -521,6 +521,7 @@ def test_get_submissions_success(
|
|||||||
mocked_value = store_model.StoreSubmissionsResponse(
|
mocked_value = store_model.StoreSubmissionsResponse(
|
||||||
submissions=[
|
submissions=[
|
||||||
store_model.StoreSubmission(
|
store_model.StoreSubmission(
|
||||||
|
listing_id="test-listing-id",
|
||||||
name="Test Agent",
|
name="Test Agent",
|
||||||
description="Test agent description",
|
description="Test agent description",
|
||||||
image_urls=["test.jpg"],
|
image_urls=["test.jpg"],
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from prisma.types import Serializable
|
||||||
|
|
||||||
from backend.sdk import (
|
from backend.sdk import (
|
||||||
BaseWebhooksManager,
|
BaseWebhooksManager,
|
||||||
@@ -84,7 +87,9 @@ class AirtableWebhookManager(BaseWebhooksManager):
|
|||||||
# update webhook config
|
# update webhook config
|
||||||
await update_webhook(
|
await update_webhook(
|
||||||
webhook.id,
|
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"
|
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 prisma.enums import ReviewStatus
|
||||||
|
|
||||||
|
from backend.blocks.helpers.review import HITLReviewHelper
|
||||||
from backend.data.block import (
|
from backend.data.block import (
|
||||||
Block,
|
Block,
|
||||||
BlockCategory,
|
BlockCategory,
|
||||||
@@ -11,11 +12,9 @@ from backend.data.block import (
|
|||||||
BlockSchemaOutput,
|
BlockSchemaOutput,
|
||||||
BlockType,
|
BlockType,
|
||||||
)
|
)
|
||||||
from backend.data.execution import ExecutionContext, ExecutionStatus
|
from backend.data.execution import ExecutionContext
|
||||||
from backend.data.human_review import ReviewResult
|
from backend.data.human_review import ReviewResult
|
||||||
from backend.data.model import SchemaField
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -72,32 +71,26 @@ class HumanInTheLoopBlock(Block):
|
|||||||
("approved_data", {"name": "John Doe", "age": 30}),
|
("approved_data", {"name": "John Doe", "age": 30}),
|
||||||
],
|
],
|
||||||
test_mock={
|
test_mock={
|
||||||
"get_or_create_human_review": lambda *_args, **_kwargs: ReviewResult(
|
"handle_review_decision": lambda **kwargs: type(
|
||||||
data={"name": "John Doe", "age": 30},
|
"ReviewDecision",
|
||||||
status=ReviewStatus.APPROVED,
|
(),
|
||||||
message="",
|
{
|
||||||
processed=False,
|
"should_proceed": True,
|
||||||
node_exec_id="test-node-exec-id",
|
"message": "Test approval message",
|
||||||
),
|
"review_result": ReviewResult(
|
||||||
"update_node_execution_status": lambda *_args, **_kwargs: None,
|
data={"name": "John Doe", "age": 30},
|
||||||
"update_review_processed_status": lambda *_args, **_kwargs: None,
|
status=ReviewStatus.APPROVED,
|
||||||
|
message="",
|
||||||
|
processed=False,
|
||||||
|
node_exec_id="test-node-exec-id",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_or_create_human_review(self, **kwargs):
|
async def handle_review_decision(self, **kwargs):
|
||||||
return await get_database_manager_async_client().get_or_create_human_review(
|
return await HITLReviewHelper.handle_review_decision(**kwargs)
|
||||||
**kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
async def update_node_execution_status(self, **kwargs):
|
|
||||||
return await async_update_node_execution_status(
|
|
||||||
db_client=get_database_manager_async_client(), **kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
async def update_review_processed_status(self, node_exec_id: str, processed: bool):
|
|
||||||
return await get_database_manager_async_client().update_review_processed_status(
|
|
||||||
node_exec_id, processed
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run(
|
async def run(
|
||||||
self,
|
self,
|
||||||
@@ -109,7 +102,7 @@ class HumanInTheLoopBlock(Block):
|
|||||||
graph_id: str,
|
graph_id: str,
|
||||||
graph_version: int,
|
graph_version: int,
|
||||||
execution_context: ExecutionContext,
|
execution_context: ExecutionContext,
|
||||||
**kwargs,
|
**_kwargs,
|
||||||
) -> BlockOutput:
|
) -> BlockOutput:
|
||||||
if not execution_context.safe_mode:
|
if not execution_context.safe_mode:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -119,48 +112,28 @@ class HumanInTheLoopBlock(Block):
|
|||||||
yield "review_message", "Auto-approved (safe mode disabled)"
|
yield "review_message", "Auto-approved (safe mode disabled)"
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
decision = await self.handle_review_decision(
|
||||||
result = await self.get_or_create_human_review(
|
input_data=input_data.data,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
node_exec_id=node_exec_id,
|
node_exec_id=node_exec_id,
|
||||||
graph_exec_id=graph_exec_id,
|
graph_exec_id=graph_exec_id,
|
||||||
graph_id=graph_id,
|
graph_id=graph_id,
|
||||||
graph_version=graph_version,
|
graph_version=graph_version,
|
||||||
input_data=input_data.data,
|
execution_context=execution_context,
|
||||||
message=input_data.name,
|
block_name=self.name,
|
||||||
editable=input_data.editable,
|
editable=input_data.editable,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in HITL block for node {node_exec_id}: {str(e)}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
if result is None:
|
if decision is None:
|
||||||
logger.info(
|
return
|
||||||
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 not result.processed:
|
status = decision.review_result.status
|
||||||
await self.update_review_processed_status(
|
if status == ReviewStatus.APPROVED:
|
||||||
node_exec_id=node_exec_id, processed=True
|
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:
|
if decision.message:
|
||||||
yield "approved_data", result.data
|
yield "review_message", decision.message
|
||||||
if result.message:
|
|
||||||
yield "review_message", result.message
|
|
||||||
|
|
||||||
elif result.status == ReviewStatus.REJECTED:
|
|
||||||
yield "rejected_data", result.data
|
|
||||||
if result.message:
|
|
||||||
yield "review_message", result.message
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ from backend.data.model import (
|
|||||||
SchemaField,
|
SchemaField,
|
||||||
)
|
)
|
||||||
from backend.integrations.providers import ProviderName
|
from backend.integrations.providers import ProviderName
|
||||||
|
from backend.util.request import DEFAULT_USER_AGENT
|
||||||
|
|
||||||
|
|
||||||
class GetWikipediaSummaryBlock(Block, GetRequest):
|
class GetWikipediaSummaryBlock(Block, GetRequest):
|
||||||
@@ -39,17 +40,27 @@ class GetWikipediaSummaryBlock(Block, GetRequest):
|
|||||||
output_schema=GetWikipediaSummaryBlock.Output,
|
output_schema=GetWikipediaSummaryBlock.Output,
|
||||||
test_input={"topic": "Artificial Intelligence"},
|
test_input={"topic": "Artificial Intelligence"},
|
||||||
test_output=("summary", "summary content"),
|
test_output=("summary", "summary content"),
|
||||||
test_mock={"get_request": lambda url, json: {"extract": "summary content"}},
|
test_mock={
|
||||||
|
"get_request": lambda url, headers, json: {"extract": "summary content"}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
||||||
topic = input_data.topic
|
topic = input_data.topic
|
||||||
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{topic}"
|
# URL-encode the topic to handle spaces and special characters
|
||||||
|
encoded_topic = quote(topic, safe="")
|
||||||
|
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{encoded_topic}"
|
||||||
|
|
||||||
|
# Set headers per Wikimedia robot policy (https://w.wiki/4wJS)
|
||||||
|
# - User-Agent: Required, must identify the bot
|
||||||
|
# - Accept-Encoding: gzip recommended to reduce bandwidth
|
||||||
|
headers = {
|
||||||
|
"User-Agent": DEFAULT_USER_AGENT,
|
||||||
|
"Accept-Encoding": "gzip, deflate",
|
||||||
|
}
|
||||||
|
|
||||||
# Note: User-Agent is now automatically set by the request library
|
|
||||||
# to comply with Wikimedia's robot policy (https://w.wiki/4wJS)
|
|
||||||
try:
|
try:
|
||||||
response = await self.get_request(url, json=True)
|
response = await self.get_request(url, headers=headers, json=True)
|
||||||
if "extract" not in response:
|
if "extract" not in response:
|
||||||
raise ValueError(f"Unable to parse Wikipedia response: {response}")
|
raise ValueError(f"Unable to parse Wikipedia response: {response}")
|
||||||
yield "summary", response["extract"]
|
yield "summary", response["extract"]
|
||||||
|
|||||||
@@ -391,8 +391,12 @@ class SmartDecisionMakerBlock(Block):
|
|||||||
"""
|
"""
|
||||||
block = sink_node.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] = {
|
tool_function: dict[str, Any] = {
|
||||||
"name": SmartDecisionMakerBlock.cleanup(block.name),
|
"name": SmartDecisionMakerBlock.cleanup(tool_name),
|
||||||
"description": block.description,
|
"description": block.description,
|
||||||
}
|
}
|
||||||
sink_block_input_schema = block.input_schema
|
sink_block_input_schema = block.input_schema
|
||||||
@@ -489,8 +493,12 @@ class SmartDecisionMakerBlock(Block):
|
|||||||
f"Sink graph metadata not found: {graph_id} {graph_version}"
|
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] = {
|
tool_function: dict[str, Any] = {
|
||||||
"name": SmartDecisionMakerBlock.cleanup(sink_graph_meta.name),
|
"name": SmartDecisionMakerBlock.cleanup(tool_name),
|
||||||
"description": sink_graph_meta.description,
|
"description": sink_graph_meta.description,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -981,10 +989,28 @@ class SmartDecisionMakerBlock(Block):
|
|||||||
graph_version: int,
|
graph_version: int,
|
||||||
execution_context: ExecutionContext,
|
execution_context: ExecutionContext,
|
||||||
execution_processor: "ExecutionProcessor",
|
execution_processor: "ExecutionProcessor",
|
||||||
|
nodes_to_skip: set[str] | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> BlockOutput:
|
) -> BlockOutput:
|
||||||
|
|
||||||
tool_functions = await self._create_tool_node_signatures(node_id)
|
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)
|
yield "tool_functions", json.dumps(tool_functions)
|
||||||
|
|
||||||
conversation_history = input_data.conversation_history or []
|
conversation_history = input_data.conversation_history or []
|
||||||
|
|||||||
@@ -1057,3 +1057,153 @@ async def test_smart_decision_maker_traditional_mode_default():
|
|||||||
) # Should yield individual tool parameters
|
) # Should yield individual tool parameters
|
||||||
assert "tools_^_test-sink-node-id_~_max_keyword_difficulty" in outputs
|
assert "tools_^_test-sink-node-id_~_max_keyword_difficulty" in outputs
|
||||||
assert "conversations" 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 = CreateDictionaryBlock()
|
||||||
mock_node.block_id = CreateDictionaryBlock().id
|
mock_node.block_id = CreateDictionaryBlock().id
|
||||||
mock_node.input_default = {}
|
mock_node.input_default = {}
|
||||||
|
mock_node.metadata = {}
|
||||||
|
|
||||||
# Create mock links with dynamic dictionary fields
|
# Create mock links with dynamic dictionary fields
|
||||||
mock_links = [
|
mock_links = [
|
||||||
@@ -77,6 +78,7 @@ async def test_smart_decision_maker_handles_dynamic_list_fields():
|
|||||||
mock_node.block = AddToListBlock()
|
mock_node.block = AddToListBlock()
|
||||||
mock_node.block_id = AddToListBlock().id
|
mock_node.block_id = AddToListBlock().id
|
||||||
mock_node.input_default = {}
|
mock_node.input_default = {}
|
||||||
|
mock_node.metadata = {}
|
||||||
|
|
||||||
# Create mock links with dynamic list fields
|
# Create mock links with dynamic list fields
|
||||||
mock_links = [
|
mock_links = [
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ async def test_create_block_function_signature_with_dict_fields():
|
|||||||
mock_node.block = CreateDictionaryBlock()
|
mock_node.block = CreateDictionaryBlock()
|
||||||
mock_node.block_id = CreateDictionaryBlock().id
|
mock_node.block_id = CreateDictionaryBlock().id
|
||||||
mock_node.input_default = {}
|
mock_node.input_default = {}
|
||||||
|
mock_node.metadata = {}
|
||||||
|
|
||||||
# Create mock links with dynamic dictionary fields (source sanitized, sink original)
|
# Create mock links with dynamic dictionary fields (source sanitized, sink original)
|
||||||
mock_links = [
|
mock_links = [
|
||||||
@@ -106,6 +107,7 @@ async def test_create_block_function_signature_with_list_fields():
|
|||||||
mock_node.block = AddToListBlock()
|
mock_node.block = AddToListBlock()
|
||||||
mock_node.block_id = AddToListBlock().id
|
mock_node.block_id = AddToListBlock().id
|
||||||
mock_node.input_default = {}
|
mock_node.input_default = {}
|
||||||
|
mock_node.metadata = {}
|
||||||
|
|
||||||
# Create mock links with dynamic list fields
|
# Create mock links with dynamic list fields
|
||||||
mock_links = [
|
mock_links = [
|
||||||
@@ -159,6 +161,7 @@ async def test_create_block_function_signature_with_object_fields():
|
|||||||
mock_node.block = MatchTextPatternBlock()
|
mock_node.block = MatchTextPatternBlock()
|
||||||
mock_node.block_id = MatchTextPatternBlock().id
|
mock_node.block_id = MatchTextPatternBlock().id
|
||||||
mock_node.input_default = {}
|
mock_node.input_default = {}
|
||||||
|
mock_node.metadata = {}
|
||||||
|
|
||||||
# Create mock links with dynamic object fields
|
# Create mock links with dynamic object fields
|
||||||
mock_links = [
|
mock_links = [
|
||||||
@@ -208,11 +211,13 @@ async def test_create_tool_node_signatures():
|
|||||||
mock_dict_node.block = CreateDictionaryBlock()
|
mock_dict_node.block = CreateDictionaryBlock()
|
||||||
mock_dict_node.block_id = CreateDictionaryBlock().id
|
mock_dict_node.block_id = CreateDictionaryBlock().id
|
||||||
mock_dict_node.input_default = {}
|
mock_dict_node.input_default = {}
|
||||||
|
mock_dict_node.metadata = {}
|
||||||
|
|
||||||
mock_list_node = Mock()
|
mock_list_node = Mock()
|
||||||
mock_list_node.block = AddToListBlock()
|
mock_list_node.block = AddToListBlock()
|
||||||
mock_list_node.block_id = AddToListBlock().id
|
mock_list_node.block_id = AddToListBlock().id
|
||||||
mock_list_node.input_default = {}
|
mock_list_node.input_default = {}
|
||||||
|
mock_list_node.metadata = {}
|
||||||
|
|
||||||
# Mock links with dynamic fields
|
# Mock links with dynamic fields
|
||||||
dict_link1 = Mock(
|
dict_link1 = Mock(
|
||||||
@@ -423,6 +428,7 @@ async def test_mixed_regular_and_dynamic_fields():
|
|||||||
mock_node.block.name = "TestBlock"
|
mock_node.block.name = "TestBlock"
|
||||||
mock_node.block.description = "A test block"
|
mock_node.block.description = "A test block"
|
||||||
mock_node.block.input_schema = Mock()
|
mock_node.block.input_schema = Mock()
|
||||||
|
mock_node.metadata = {}
|
||||||
|
|
||||||
# Mock the get_field_schema to return a proper schema for regular fields
|
# Mock the get_field_schema to return a proper schema for regular fields
|
||||||
def get_field_schema(field_name):
|
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",
|
grant_type="authorization_code",
|
||||||
).model_dump(exclude_none=True)
|
).model_dump(exclude_none=True)
|
||||||
|
|
||||||
response = await Requests().post(
|
response = await Requests(raise_for_status=False).post(
|
||||||
f"{WORDPRESS_BASE_URL}oauth2/token",
|
f"{WORDPRESS_BASE_URL}oauth2/token",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
data=data,
|
data=data,
|
||||||
@@ -205,7 +205,7 @@ async def oauth_refresh_tokens(
|
|||||||
grant_type="refresh_token",
|
grant_type="refresh_token",
|
||||||
).model_dump(exclude_none=True)
|
).model_dump(exclude_none=True)
|
||||||
|
|
||||||
response = await Requests().post(
|
response = await Requests(raise_for_status=False).post(
|
||||||
f"{WORDPRESS_BASE_URL}oauth2/token",
|
f"{WORDPRESS_BASE_URL}oauth2/token",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
data=data,
|
data=data,
|
||||||
@@ -252,7 +252,7 @@ async def validate_token(
|
|||||||
"token": token,
|
"token": token,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = await Requests().get(
|
response = await Requests(raise_for_status=False).get(
|
||||||
f"{WORDPRESS_BASE_URL}oauth2/token-info",
|
f"{WORDPRESS_BASE_URL}oauth2/token-info",
|
||||||
params=params,
|
params=params,
|
||||||
)
|
)
|
||||||
@@ -296,7 +296,7 @@ async def make_api_request(
|
|||||||
|
|
||||||
url = f"{WORDPRESS_BASE_URL.rstrip('/')}{endpoint}"
|
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(
|
response = await request_method(
|
||||||
url,
|
url,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -476,6 +476,7 @@ async def create_post(
|
|||||||
data["tags"] = ",".join(str(t) for t in data["tags"])
|
data["tags"] = ",".join(str(t) for t in data["tags"])
|
||||||
|
|
||||||
# Make the API request
|
# Make the API request
|
||||||
|
site = normalize_site(site)
|
||||||
endpoint = f"/rest/v1.1/sites/{site}/posts/new"
|
endpoint = f"/rest/v1.1/sites/{site}/posts/new"
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
@@ -483,7 +484,7 @@ async def create_post(
|
|||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"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}",
|
f"{WORDPRESS_BASE_URL.rstrip('/')}{endpoint}",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
data=data,
|
data=data,
|
||||||
@@ -499,3 +500,132 @@ async def create_post(
|
|||||||
)
|
)
|
||||||
error_message = error_data.get("message", response.text)
|
error_message = error_data.get("message", response.text)
|
||||||
raise ValueError(f"Failed to create post: {response.status} - {error_message}")
|
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,
|
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
|
from ._config import wordpress
|
||||||
|
|
||||||
|
|
||||||
@@ -49,8 +57,15 @@ class WordPressCreatePostBlock(Block):
|
|||||||
media_urls: list[str] = SchemaField(
|
media_urls: list[str] = SchemaField(
|
||||||
description="URLs of images to sideload and attach to the post", default=[]
|
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):
|
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_id: int = SchemaField(description="The ID of the created post")
|
||||||
post_url: str = SchemaField(description="The full URL 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")
|
short_url: str = SchemaField(description="The shortened wp.me URL")
|
||||||
@@ -78,7 +93,9 @@ class WordPressCreatePostBlock(Block):
|
|||||||
tags=input_data.tags,
|
tags=input_data.tags,
|
||||||
featured_image=input_data.featured_image,
|
featured_image=input_data.featured_image,
|
||||||
media_urls=input_data.media_urls,
|
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(
|
post_response: PostResponse = await create_post(
|
||||||
@@ -87,7 +104,69 @@ class WordPressCreatePostBlock(Block):
|
|||||||
post_data=post_request,
|
post_data=post_request,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
yield "site", input_data.site
|
||||||
yield "post_id", post_response.ID
|
yield "post_id", post_response.ID
|
||||||
yield "post_url", post_response.URL
|
yield "post_url", post_response.URL
|
||||||
yield "short_url", post_response.short_URL
|
yield "short_url", post_response.short_URL
|
||||||
yield "post_data", post_response.model_dump()
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from backend.data.execution import ExecutionContext
|
||||||
|
|
||||||
from .graph import Link
|
from .graph import Link
|
||||||
|
|
||||||
app_config = Config()
|
app_config = Config()
|
||||||
@@ -472,6 +474,7 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
|
|||||||
self.block_type = block_type
|
self.block_type = block_type
|
||||||
self.webhook_config = webhook_config
|
self.webhook_config = webhook_config
|
||||||
self.execution_stats: NodeExecutionStats = NodeExecutionStats()
|
self.execution_stats: NodeExecutionStats = NodeExecutionStats()
|
||||||
|
self.requires_human_review: bool = False
|
||||||
|
|
||||||
if self.webhook_config:
|
if self.webhook_config:
|
||||||
if isinstance(self.webhook_config, BlockWebhookConfig):
|
if isinstance(self.webhook_config, BlockWebhookConfig):
|
||||||
@@ -614,7 +617,77 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
|
|||||||
block_id=self.id,
|
block_id=self.id,
|
||||||
) from ex
|
) 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:
|
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):
|
if error := self.input_schema.validate_data(input_data):
|
||||||
raise BlockInputError(
|
raise BlockInputError(
|
||||||
message=f"Unable to execute block with invalid input data: {error}",
|
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,
|
block_id=self.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Use the validated input data
|
||||||
async for output_name, output_data in self.run(
|
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}),
|
self.input_schema(**{k: v for k, v in input_data.items() if v is not None}),
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
|||||||
@@ -383,6 +383,7 @@ class GraphExecutionWithNodes(GraphExecution):
|
|||||||
self,
|
self,
|
||||||
execution_context: ExecutionContext,
|
execution_context: ExecutionContext,
|
||||||
compiled_nodes_input_masks: Optional[NodesInputMasks] = None,
|
compiled_nodes_input_masks: Optional[NodesInputMasks] = None,
|
||||||
|
nodes_to_skip: Optional[set[str]] = None,
|
||||||
):
|
):
|
||||||
return GraphExecutionEntry(
|
return GraphExecutionEntry(
|
||||||
user_id=self.user_id,
|
user_id=self.user_id,
|
||||||
@@ -390,6 +391,7 @@ class GraphExecutionWithNodes(GraphExecution):
|
|||||||
graph_version=self.graph_version or 0,
|
graph_version=self.graph_version or 0,
|
||||||
graph_exec_id=self.id,
|
graph_exec_id=self.id,
|
||||||
nodes_input_masks=compiled_nodes_input_masks,
|
nodes_input_masks=compiled_nodes_input_masks,
|
||||||
|
nodes_to_skip=nodes_to_skip or set(),
|
||||||
execution_context=execution_context,
|
execution_context=execution_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1145,6 +1147,8 @@ class GraphExecutionEntry(BaseModel):
|
|||||||
graph_id: str
|
graph_id: str
|
||||||
graph_version: int
|
graph_version: int
|
||||||
nodes_input_masks: Optional[NodesInputMasks] = None
|
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)
|
execution_context: ExecutionContext = Field(default_factory=ExecutionContext)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,15 @@ class Node(BaseDbModel):
|
|||||||
input_links: list[Link] = []
|
input_links: list[Link] = []
|
||||||
output_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
|
@property
|
||||||
def block(self) -> AnyBlockSchema | "_UnknownBlockBase":
|
def block(self) -> AnyBlockSchema | "_UnknownBlockBase":
|
||||||
"""Get the block for this node. Returns UnknownBlock if block is deleted/missing."""
|
"""Get the block for this node. Returns UnknownBlock if block is deleted/missing."""
|
||||||
@@ -235,7 +244,10 @@ class BaseGraph(BaseDbModel):
|
|||||||
return any(
|
return any(
|
||||||
node.block_id
|
node.block_id
|
||||||
for node in self.nodes
|
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
|
@property
|
||||||
@@ -326,7 +338,35 @@ class Graph(BaseGraph):
|
|||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def credentials_input_schema(self) -> dict[str, Any]:
|
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
|
@property
|
||||||
def _credentials_input_schema(self) -> type[BlockSchema]:
|
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"
|
created_graph.id, created_graph.version, "3e53486c-cf57-477e-ba2a-cb02dc828e1b"
|
||||||
)
|
)
|
||||||
assert got_graph is not None
|
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_processor: "ExecutionProcessor",
|
||||||
execution_stats: NodeExecutionStats | None = None,
|
execution_stats: NodeExecutionStats | None = None,
|
||||||
nodes_input_masks: Optional[NodesInputMasks] = None,
|
nodes_input_masks: Optional[NodesInputMasks] = None,
|
||||||
|
nodes_to_skip: Optional[set[str]] = None,
|
||||||
) -> BlockOutput:
|
) -> BlockOutput:
|
||||||
"""
|
"""
|
||||||
Execute a node in the graph. This will trigger a block execution on a node,
|
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,
|
"user_id": user_id,
|
||||||
"execution_context": execution_context,
|
"execution_context": execution_context,
|
||||||
"execution_processor": execution_processor,
|
"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
|
# Last-minute fetch credentials + acquire a system-wide read-write lock to prevent
|
||||||
@@ -542,6 +544,7 @@ class ExecutionProcessor:
|
|||||||
node_exec_progress: NodeExecutionProgress,
|
node_exec_progress: NodeExecutionProgress,
|
||||||
nodes_input_masks: Optional[NodesInputMasks],
|
nodes_input_masks: Optional[NodesInputMasks],
|
||||||
graph_stats_pair: tuple[GraphExecutionStats, threading.Lock],
|
graph_stats_pair: tuple[GraphExecutionStats, threading.Lock],
|
||||||
|
nodes_to_skip: Optional[set[str]] = None,
|
||||||
) -> NodeExecutionStats:
|
) -> NodeExecutionStats:
|
||||||
log_metadata = LogMetadata(
|
log_metadata = LogMetadata(
|
||||||
logger=_logger,
|
logger=_logger,
|
||||||
@@ -564,6 +567,7 @@ class ExecutionProcessor:
|
|||||||
db_client=db_client,
|
db_client=db_client,
|
||||||
log_metadata=log_metadata,
|
log_metadata=log_metadata,
|
||||||
nodes_input_masks=nodes_input_masks,
|
nodes_input_masks=nodes_input_masks,
|
||||||
|
nodes_to_skip=nodes_to_skip,
|
||||||
)
|
)
|
||||||
if isinstance(status, BaseException):
|
if isinstance(status, BaseException):
|
||||||
raise status
|
raise status
|
||||||
@@ -609,6 +613,7 @@ class ExecutionProcessor:
|
|||||||
db_client: "DatabaseManagerAsyncClient",
|
db_client: "DatabaseManagerAsyncClient",
|
||||||
log_metadata: LogMetadata,
|
log_metadata: LogMetadata,
|
||||||
nodes_input_masks: Optional[NodesInputMasks] = None,
|
nodes_input_masks: Optional[NodesInputMasks] = None,
|
||||||
|
nodes_to_skip: Optional[set[str]] = None,
|
||||||
) -> ExecutionStatus:
|
) -> ExecutionStatus:
|
||||||
status = ExecutionStatus.RUNNING
|
status = ExecutionStatus.RUNNING
|
||||||
|
|
||||||
@@ -645,6 +650,7 @@ class ExecutionProcessor:
|
|||||||
execution_processor=self,
|
execution_processor=self,
|
||||||
execution_stats=stats,
|
execution_stats=stats,
|
||||||
nodes_input_masks=nodes_input_masks,
|
nodes_input_masks=nodes_input_masks,
|
||||||
|
nodes_to_skip=nodes_to_skip,
|
||||||
):
|
):
|
||||||
await persist_output(output_name, output_data)
|
await persist_output(output_name, output_data)
|
||||||
|
|
||||||
@@ -956,6 +962,21 @@ class ExecutionProcessor:
|
|||||||
|
|
||||||
queued_node_exec = execution_queue.get()
|
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(
|
log_metadata.debug(
|
||||||
f"Dispatching node execution {queued_node_exec.node_exec_id} "
|
f"Dispatching node execution {queued_node_exec.node_exec_id} "
|
||||||
f"for node {queued_node_exec.node_id}",
|
f"for node {queued_node_exec.node_id}",
|
||||||
@@ -1016,6 +1037,7 @@ class ExecutionProcessor:
|
|||||||
execution_stats,
|
execution_stats,
|
||||||
execution_stats_lock,
|
execution_stats_lock,
|
||||||
),
|
),
|
||||||
|
nodes_to_skip=graph_exec.nodes_to_skip,
|
||||||
),
|
),
|
||||||
self.node_execution_loop,
|
self.node_execution_loop,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -239,14 +239,19 @@ async def _validate_node_input_credentials(
|
|||||||
graph: GraphModel,
|
graph: GraphModel,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
nodes_input_masks: Optional[NodesInputMasks] = None,
|
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:
|
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)
|
credential_errors: dict[str, dict[str, str]] = defaultdict(dict)
|
||||||
|
nodes_to_skip: set[str] = set()
|
||||||
|
|
||||||
for node in graph.nodes:
|
for node in graph.nodes:
|
||||||
block = node.block
|
block = node.block
|
||||||
@@ -256,27 +261,46 @@ async def _validate_node_input_credentials(
|
|||||||
if not credentials_fields:
|
if not credentials_fields:
|
||||||
continue
|
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():
|
for field_name, credentials_meta_type in credentials_fields.items():
|
||||||
try:
|
try:
|
||||||
|
# Check nodes_input_masks first, then input_default
|
||||||
|
field_value = None
|
||||||
if (
|
if (
|
||||||
nodes_input_masks
|
nodes_input_masks
|
||||||
and (node_input_mask := nodes_input_masks.get(node.id))
|
and (node_input_mask := nodes_input_masks.get(node.id))
|
||||||
and field_name in node_input_mask
|
and field_name in node_input_mask
|
||||||
):
|
):
|
||||||
credentials_meta = credentials_meta_type.model_validate(
|
field_value = node_input_mask[field_name]
|
||||||
node_input_mask[field_name]
|
|
||||||
)
|
|
||||||
elif field_name in node.input_default:
|
elif field_name in node.input_default:
|
||||||
credentials_meta = credentials_meta_type.model_validate(
|
# For optional credentials, don't use input_default - treat as missing
|
||||||
node.input_default[field_name]
|
# This prevents stale credential IDs from failing validation
|
||||||
)
|
if node.credentials_optional:
|
||||||
else:
|
field_value = None
|
||||||
# Missing credentials
|
else:
|
||||||
credential_errors[node.id][
|
field_value = node.input_default[field_name]
|
||||||
field_name
|
|
||||||
] = "These credentials are required"
|
# Check if credentials are missing (None, empty, or not present)
|
||||||
continue
|
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:
|
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}"
|
credential_errors[node.id][field_name] = f"Invalid credentials: {e}"
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -287,6 +311,7 @@ async def _validate_node_input_credentials(
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Handle any errors fetching credentials
|
# Handle any errors fetching credentials
|
||||||
|
# If credentials were explicitly configured but unavailable, it's an error
|
||||||
credential_errors[node.id][
|
credential_errors[node.id][
|
||||||
field_name
|
field_name
|
||||||
] = f"Credentials not available: {e}"
|
] = f"Credentials not available: {e}"
|
||||||
@@ -313,7 +338,19 @@ async def _validate_node_input_credentials(
|
|||||||
] = "Invalid credentials: type/provider mismatch"
|
] = "Invalid credentials: type/provider mismatch"
|
||||||
continue
|
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(
|
def make_node_credentials_input_map(
|
||||||
@@ -355,21 +392,25 @@ async def validate_graph_with_credentials(
|
|||||||
graph: GraphModel,
|
graph: GraphModel,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
nodes_input_masks: Optional[NodesInputMasks] = None,
|
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:
|
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
|
# Get input validation errors
|
||||||
node_input_errors = GraphModel.validate_graph_get_errors(
|
node_input_errors = GraphModel.validate_graph_get_errors(
|
||||||
graph, for_run=True, nodes_input_masks=nodes_input_masks
|
graph, for_run=True, nodes_input_masks=nodes_input_masks
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get credential input/availability/validation errors
|
# Get credential input/availability/validation errors and nodes to skip
|
||||||
node_credential_input_errors = await _validate_node_input_credentials(
|
node_credential_input_errors, nodes_to_skip = (
|
||||||
graph, user_id, nodes_input_masks
|
await _validate_node_input_credentials(graph, user_id, nodes_input_masks)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Merge credential errors with structural errors
|
# 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] = {}
|
||||||
node_input_errors[node_id].update(field_errors)
|
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(
|
async def _construct_starting_node_execution_input(
|
||||||
@@ -386,7 +427,7 @@ async def _construct_starting_node_execution_input(
|
|||||||
user_id: str,
|
user_id: str,
|
||||||
graph_inputs: BlockInput,
|
graph_inputs: BlockInput,
|
||||||
nodes_input_masks: Optional[NodesInputMasks] = None,
|
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.
|
Validates and prepares the input data for executing a graph.
|
||||||
This function checks the graph for starting nodes, validates the input data
|
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]]`
|
node_credentials_map: `dict[node_id, dict[input_name, CredentialsMetaInput]]`
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[tuple[str, BlockInput]]: A list of tuples, each containing the node ID and
|
tuple[
|
||||||
the corresponding input data for that node.
|
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
|
# 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
|
graph, user_id, nodes_input_masks
|
||||||
)
|
)
|
||||||
n_error_nodes = len(validation_errors)
|
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."
|
"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(
|
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,
|
graph_credentials_inputs: Optional[Mapping[str, CredentialsMetaInput]] = None,
|
||||||
nodes_input_masks: Optional[NodesInputMasks] = None,
|
nodes_input_masks: Optional[NodesInputMasks] = None,
|
||||||
is_sub_graph: bool = False,
|
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.
|
Public wrapper that handles graph fetching, credential mapping, and validation+construction.
|
||||||
This centralizes the logic used by both scheduler validation and actual execution.
|
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`.
|
GraphModel: Full graph object for the given `graph_id`.
|
||||||
list[tuple[node_id, BlockInput]]: Starting node IDs with corresponding inputs.
|
list[tuple[node_id, BlockInput]]: Starting node IDs with corresponding inputs.
|
||||||
dict[str, BlockInput]: Node input masks including all passed-in credentials.
|
dict[str, BlockInput]: Node input masks including all passed-in credentials.
|
||||||
|
set[str]: Node IDs that should be skipped (optional credentials not configured).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NotFoundError: If the graph is not found.
|
NotFoundError: If the graph is not found.
|
||||||
@@ -514,14 +559,16 @@ async def validate_and_construct_node_execution_input(
|
|||||||
nodes_input_masks or {},
|
nodes_input_masks or {},
|
||||||
)
|
)
|
||||||
|
|
||||||
starting_nodes_input = await _construct_starting_node_execution_input(
|
starting_nodes_input, nodes_to_skip = (
|
||||||
graph=graph,
|
await _construct_starting_node_execution_input(
|
||||||
user_id=user_id,
|
graph=graph,
|
||||||
graph_inputs=graph_inputs,
|
user_id=user_id,
|
||||||
nodes_input_masks=nodes_input_masks,
|
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(
|
def _merge_nodes_input_masks(
|
||||||
@@ -779,6 +826,9 @@ async def add_graph_execution(
|
|||||||
|
|
||||||
# Use existing execution's compiled input masks
|
# Use existing execution's compiled input masks
|
||||||
compiled_nodes_input_masks = graph_exec.nodes_input_masks or {}
|
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}")
|
logger.info(f"Resuming graph execution #{graph_exec.id} for graph #{graph_id}")
|
||||||
else:
|
else:
|
||||||
@@ -787,7 +837,7 @@ async def add_graph_execution(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create new 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(
|
await validate_and_construct_node_execution_input(
|
||||||
graph_id=graph_id,
|
graph_id=graph_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -836,6 +886,7 @@ async def add_graph_execution(
|
|||||||
try:
|
try:
|
||||||
graph_exec_entry = graph_exec.to_graph_execution_entry(
|
graph_exec_entry = graph_exec.to_graph_execution_entry(
|
||||||
compiled_nodes_input_masks=compiled_nodes_input_masks,
|
compiled_nodes_input_masks=compiled_nodes_input_masks,
|
||||||
|
nodes_to_skip=nodes_to_skip,
|
||||||
execution_context=execution_context,
|
execution_context=execution_context,
|
||||||
)
|
)
|
||||||
logger.info(f"Publishing execution {graph_exec.id} to execution queue")
|
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
|
# 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_validate.return_value = (
|
||||||
mock_graph,
|
mock_graph,
|
||||||
starting_nodes_input,
|
starting_nodes_input,
|
||||||
compiled_nodes_input_masks,
|
compiled_nodes_input_masks,
|
||||||
|
nodes_to_skip,
|
||||||
)
|
)
|
||||||
mock_prisma.is_connected.return_value = True
|
mock_prisma.is_connected.return_value = True
|
||||||
mock_edb.create_graph_execution = mocker.AsyncMock(return_value=mock_graph_exec)
|
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)
|
# Both executions should succeed (though they create different objects)
|
||||||
assert result1 == mock_graph_exec
|
assert result1 == mock_graph_exec
|
||||||
assert result2 == mock_graph_exec_2
|
assert result2 == mock_graph_exec_2
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests for Optional Credentials Feature
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_node_input_credentials_returns_nodes_to_skip(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Test that _validate_node_input_credentials returns nodes_to_skip set
|
||||||
|
for nodes with credentials_optional=True and missing credentials.
|
||||||
|
"""
|
||||||
|
from backend.executor.utils import _validate_node_input_credentials
|
||||||
|
|
||||||
|
# Create a mock node with credentials_optional=True
|
||||||
|
mock_node = mocker.MagicMock()
|
||||||
|
mock_node.id = "node-with-optional-creds"
|
||||||
|
mock_node.credentials_optional = True
|
||||||
|
mock_node.input_default = {} # No credentials configured
|
||||||
|
|
||||||
|
# Create a mock block with credentials field
|
||||||
|
mock_block = mocker.MagicMock()
|
||||||
|
mock_credentials_field_type = mocker.MagicMock()
|
||||||
|
mock_block.input_schema.get_credentials_fields.return_value = {
|
||||||
|
"credentials": mock_credentials_field_type
|
||||||
|
}
|
||||||
|
mock_node.block = mock_block
|
||||||
|
|
||||||
|
# Create mock graph
|
||||||
|
mock_graph = mocker.MagicMock()
|
||||||
|
mock_graph.nodes = [mock_node]
|
||||||
|
|
||||||
|
# Call the function
|
||||||
|
errors, nodes_to_skip = await _validate_node_input_credentials(
|
||||||
|
graph=mock_graph,
|
||||||
|
user_id="test-user-id",
|
||||||
|
nodes_input_masks=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Node should be in nodes_to_skip, not in errors
|
||||||
|
assert mock_node.id in nodes_to_skip
|
||||||
|
assert mock_node.id not in errors
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_node_input_credentials_required_missing_creds_error(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Test that _validate_node_input_credentials returns errors
|
||||||
|
for nodes with credentials_optional=False and missing credentials.
|
||||||
|
"""
|
||||||
|
from backend.executor.utils import _validate_node_input_credentials
|
||||||
|
|
||||||
|
# Create a mock node with credentials_optional=False (required)
|
||||||
|
mock_node = mocker.MagicMock()
|
||||||
|
mock_node.id = "node-with-required-creds"
|
||||||
|
mock_node.credentials_optional = False
|
||||||
|
mock_node.input_default = {} # No credentials configured
|
||||||
|
|
||||||
|
# Create a mock block with credentials field
|
||||||
|
mock_block = mocker.MagicMock()
|
||||||
|
mock_credentials_field_type = mocker.MagicMock()
|
||||||
|
mock_block.input_schema.get_credentials_fields.return_value = {
|
||||||
|
"credentials": mock_credentials_field_type
|
||||||
|
}
|
||||||
|
mock_node.block = mock_block
|
||||||
|
|
||||||
|
# Create mock graph
|
||||||
|
mock_graph = mocker.MagicMock()
|
||||||
|
mock_graph.nodes = [mock_node]
|
||||||
|
|
||||||
|
# Call the function
|
||||||
|
errors, nodes_to_skip = await _validate_node_input_credentials(
|
||||||
|
graph=mock_graph,
|
||||||
|
user_id="test-user-id",
|
||||||
|
nodes_input_masks=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Node should be in errors, not in nodes_to_skip
|
||||||
|
assert mock_node.id in errors
|
||||||
|
assert "credentials" in errors[mock_node.id]
|
||||||
|
assert "required" in errors[mock_node.id]["credentials"].lower()
|
||||||
|
assert mock_node.id not in nodes_to_skip
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_graph_with_credentials_returns_nodes_to_skip(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Test that validate_graph_with_credentials returns nodes_to_skip set
|
||||||
|
from _validate_node_input_credentials.
|
||||||
|
"""
|
||||||
|
from backend.executor.utils import validate_graph_with_credentials
|
||||||
|
|
||||||
|
# Mock _validate_node_input_credentials to return specific values
|
||||||
|
mock_validate = mocker.patch(
|
||||||
|
"backend.executor.utils._validate_node_input_credentials"
|
||||||
|
)
|
||||||
|
expected_errors = {"node1": {"field": "error"}}
|
||||||
|
expected_nodes_to_skip = {"node2", "node3"}
|
||||||
|
mock_validate.return_value = (expected_errors, expected_nodes_to_skip)
|
||||||
|
|
||||||
|
# Mock GraphModel with validate_graph_get_errors method
|
||||||
|
mock_graph = mocker.MagicMock()
|
||||||
|
mock_graph.validate_graph_get_errors.return_value = {}
|
||||||
|
|
||||||
|
# Call the function
|
||||||
|
errors, nodes_to_skip = await validate_graph_with_credentials(
|
||||||
|
graph=mock_graph,
|
||||||
|
user_id="test-user-id",
|
||||||
|
nodes_input_masks=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify nodes_to_skip is passed through
|
||||||
|
assert nodes_to_skip == expected_nodes_to_skip
|
||||||
|
assert "node1" in errors
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_graph_execution_with_nodes_to_skip(mocker: MockerFixture):
|
||||||
|
"""
|
||||||
|
Test that add_graph_execution properly passes nodes_to_skip
|
||||||
|
to the graph execution entry.
|
||||||
|
"""
|
||||||
|
from backend.data.execution import GraphExecutionWithNodes
|
||||||
|
from backend.executor.utils import add_graph_execution
|
||||||
|
|
||||||
|
# Mock data
|
||||||
|
graph_id = "test-graph-id"
|
||||||
|
user_id = "test-user-id"
|
||||||
|
inputs = {"test_input": "test_value"}
|
||||||
|
graph_version = 1
|
||||||
|
|
||||||
|
# Mock the graph object
|
||||||
|
mock_graph = mocker.MagicMock()
|
||||||
|
mock_graph.version = graph_version
|
||||||
|
|
||||||
|
# Starting nodes and masks
|
||||||
|
starting_nodes_input = [("node1", {"input1": "value1"})]
|
||||||
|
compiled_nodes_input_masks = {}
|
||||||
|
nodes_to_skip = {"skipped-node-1", "skipped-node-2"}
|
||||||
|
|
||||||
|
# Mock the graph execution object
|
||||||
|
mock_graph_exec = mocker.MagicMock(spec=GraphExecutionWithNodes)
|
||||||
|
mock_graph_exec.id = "execution-id-123"
|
||||||
|
mock_graph_exec.node_executions = []
|
||||||
|
|
||||||
|
# Track what's passed to to_graph_execution_entry
|
||||||
|
captured_kwargs = {}
|
||||||
|
|
||||||
|
def capture_to_entry(**kwargs):
|
||||||
|
captured_kwargs.update(kwargs)
|
||||||
|
return mocker.MagicMock()
|
||||||
|
|
||||||
|
mock_graph_exec.to_graph_execution_entry.side_effect = capture_to_entry
|
||||||
|
|
||||||
|
# Setup mocks
|
||||||
|
mock_validate = mocker.patch(
|
||||||
|
"backend.executor.utils.validate_and_construct_node_execution_input"
|
||||||
|
)
|
||||||
|
mock_edb = mocker.patch("backend.executor.utils.execution_db")
|
||||||
|
mock_prisma = mocker.patch("backend.executor.utils.prisma")
|
||||||
|
mock_udb = mocker.patch("backend.executor.utils.user_db")
|
||||||
|
mock_gdb = mocker.patch("backend.executor.utils.graph_db")
|
||||||
|
mock_get_queue = mocker.patch("backend.executor.utils.get_async_execution_queue")
|
||||||
|
mock_get_event_bus = mocker.patch(
|
||||||
|
"backend.executor.utils.get_async_execution_event_bus"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Setup returns - include nodes_to_skip in the tuple
|
||||||
|
mock_validate.return_value = (
|
||||||
|
mock_graph,
|
||||||
|
starting_nodes_input,
|
||||||
|
compiled_nodes_input_masks,
|
||||||
|
nodes_to_skip, # This should be passed through
|
||||||
|
)
|
||||||
|
mock_prisma.is_connected.return_value = True
|
||||||
|
mock_edb.create_graph_execution = mocker.AsyncMock(return_value=mock_graph_exec)
|
||||||
|
mock_edb.update_graph_execution_stats = mocker.AsyncMock(
|
||||||
|
return_value=mock_graph_exec
|
||||||
|
)
|
||||||
|
mock_edb.update_node_execution_status_batch = mocker.AsyncMock()
|
||||||
|
|
||||||
|
mock_user = mocker.MagicMock()
|
||||||
|
mock_user.timezone = "UTC"
|
||||||
|
mock_settings = mocker.MagicMock()
|
||||||
|
mock_settings.human_in_the_loop_safe_mode = True
|
||||||
|
|
||||||
|
mock_udb.get_user_by_id = mocker.AsyncMock(return_value=mock_user)
|
||||||
|
mock_gdb.get_graph_settings = mocker.AsyncMock(return_value=mock_settings)
|
||||||
|
mock_get_queue.return_value = mocker.AsyncMock()
|
||||||
|
mock_get_event_bus.return_value = mocker.MagicMock(publish=mocker.AsyncMock())
|
||||||
|
|
||||||
|
# Call the function
|
||||||
|
await add_graph_execution(
|
||||||
|
graph_id=graph_id,
|
||||||
|
user_id=user_id,
|
||||||
|
inputs=inputs,
|
||||||
|
graph_version=graph_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify nodes_to_skip was passed to to_graph_execution_entry
|
||||||
|
assert "nodes_to_skip" in captured_kwargs
|
||||||
|
assert captured_kwargs["nodes_to_skip"] == nodes_to_skip
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from .discord import DiscordOAuthHandler
|
|||||||
from .github import GitHubOAuthHandler
|
from .github import GitHubOAuthHandler
|
||||||
from .google import GoogleOAuthHandler
|
from .google import GoogleOAuthHandler
|
||||||
from .notion import NotionOAuthHandler
|
from .notion import NotionOAuthHandler
|
||||||
|
from .reddit import RedditOAuthHandler
|
||||||
from .twitter import TwitterOAuthHandler
|
from .twitter import TwitterOAuthHandler
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -20,6 +21,7 @@ _ORIGINAL_HANDLERS = [
|
|||||||
GitHubOAuthHandler,
|
GitHubOAuthHandler,
|
||||||
GoogleOAuthHandler,
|
GoogleOAuthHandler,
|
||||||
NotionOAuthHandler,
|
NotionOAuthHandler,
|
||||||
|
RedditOAuthHandler,
|
||||||
TwitterOAuthHandler,
|
TwitterOAuthHandler,
|
||||||
TodoistOAuthHandler,
|
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(
|
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",
|
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():
|
def lint():
|
||||||
|
# Generate Prisma types stub before running pyright to prevent type budget exhaustion
|
||||||
|
run("gen-prisma-stub")
|
||||||
|
|
||||||
lint_step_args: list[list[str]] = [
|
lint_step_args: list[list[str]] = [
|
||||||
["ruff", "check", *TARGET_DIRS, "--exit-zero"],
|
["ruff", "check", *TARGET_DIRS, "--exit-zero"],
|
||||||
["ruff", "format", "--diff", "--check", LIBS_DIR],
|
["ruff", "format", "--diff", "--check", LIBS_DIR],
|
||||||
@@ -49,4 +52,6 @@ def format():
|
|||||||
run("ruff", "format", LIBS_DIR)
|
run("ruff", "format", LIBS_DIR)
|
||||||
run("isort", "--profile", "black", BACKEND_DIR)
|
run("isort", "--profile", "black", BACKEND_DIR)
|
||||||
run("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)
|
run("pyright", *TARGET_DIRS)
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ lint = "linter:lint"
|
|||||||
test = "run_tests:test"
|
test = "run_tests:test"
|
||||||
load-store-agents = "test.load_store_agents:run"
|
load-store-agents = "test.load_store_agents:run"
|
||||||
export-api-schema = "backend.cli.generate_openapi_json:main"
|
export-api-schema = "backend.cli.generate_openapi_json:main"
|
||||||
|
gen-prisma-stub = "gen_prisma_types_stub:main"
|
||||||
oauth-tool = "backend.cli.oauth_tool:cli"
|
oauth-tool = "backend.cli.oauth_tool:cli"
|
||||||
|
|
||||||
[tool.isort]
|
[tool.isort]
|
||||||
@@ -134,6 +135,9 @@ ignore_patterns = []
|
|||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
asyncio_default_fixture_loop_scope = "session"
|
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 = [
|
filterwarnings = [
|
||||||
"ignore:'audioop' is deprecated:DeprecationWarning:discord.player",
|
"ignore:'audioop' is deprecated:DeprecationWarning:discord.player",
|
||||||
"ignore:invalid escape sequence:DeprecationWarning:tweepy.api",
|
"ignore:invalid escape sequence:DeprecationWarning:tweepy.api",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"created_at": "2025-09-04T13:37:00",
|
"created_at": "2025-09-04T13:37:00",
|
||||||
"credentials_input_schema": {
|
"credentials_input_schema": {
|
||||||
"properties": {},
|
"properties": {},
|
||||||
|
"required": [],
|
||||||
"title": "TestGraphCredentialsInputSchema",
|
"title": "TestGraphCredentialsInputSchema",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
{
|
{
|
||||||
"credentials_input_schema": {
|
"credentials_input_schema": {
|
||||||
"properties": {},
|
"properties": {},
|
||||||
|
"required": [],
|
||||||
"title": "TestGraphCredentialsInputSchema",
|
"title": "TestGraphCredentialsInputSchema",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"id": "test-agent-1",
|
"id": "test-agent-1",
|
||||||
"graph_id": "test-agent-1",
|
"graph_id": "test-agent-1",
|
||||||
"graph_version": 1,
|
"graph_version": 1,
|
||||||
|
"owner_user_id": "3e53486c-cf57-477e-ba2a-cb02dc828e1a",
|
||||||
"image_url": null,
|
"image_url": null,
|
||||||
"creator_name": "Test Creator",
|
"creator_name": "Test Creator",
|
||||||
"creator_image_url": "",
|
"creator_image_url": "",
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
"id": "test-agent-2",
|
"id": "test-agent-2",
|
||||||
"graph_id": "test-agent-2",
|
"graph_id": "test-agent-2",
|
||||||
"graph_version": 1,
|
"graph_version": 1,
|
||||||
|
"owner_user_id": "3e53486c-cf57-477e-ba2a-cb02dc828e1a",
|
||||||
"image_url": null,
|
"image_url": null,
|
||||||
"creator_name": "Test Creator",
|
"creator_name": "Test Creator",
|
||||||
"creator_image_url": "",
|
"creator_image_url": "",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"submissions": [
|
"submissions": [
|
||||||
{
|
{
|
||||||
|
"listing_id": "test-listing-id",
|
||||||
"agent_id": "test-agent-id",
|
"agent_id": "test-agent-id",
|
||||||
"agent_version": 1,
|
"agent_version": 1,
|
||||||
"name": "Test Agent",
|
"name": "Test Agent",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ services:
|
|||||||
context: ../
|
context: ../
|
||||||
dockerfile: autogpt_platform/backend/Dockerfile
|
dockerfile: autogpt_platform/backend/Dockerfile
|
||||||
target: migrate
|
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:
|
develop:
|
||||||
watch:
|
watch:
|
||||||
- path: ./
|
- path: ./
|
||||||
|
|||||||
@@ -92,7 +92,6 @@
|
|||||||
"react-currency-input-field": "4.0.3",
|
"react-currency-input-field": "4.0.3",
|
||||||
"react-day-picker": "9.11.1",
|
"react-day-picker": "9.11.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-drag-drop-files": "2.4.0",
|
|
||||||
"react-hook-form": "7.66.0",
|
"react-hook-form": "7.66.0",
|
||||||
"react-icons": "5.5.0",
|
"react-icons": "5.5.0",
|
||||||
"react-markdown": "9.0.3",
|
"react-markdown": "9.0.3",
|
||||||
|
|||||||
112
autogpt_platform/frontend/pnpm-lock.yaml
generated
112
autogpt_platform/frontend/pnpm-lock.yaml
generated
@@ -200,9 +200,6 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 18.3.1
|
specifier: 18.3.1
|
||||||
version: 18.3.1(react@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:
|
react-hook-form:
|
||||||
specifier: 7.66.0
|
specifier: 7.66.0
|
||||||
version: 7.66.0(react@18.3.1)
|
version: 7.66.0(react@18.3.1)
|
||||||
@@ -1004,9 +1001,6 @@ packages:
|
|||||||
'@emotion/memoize@0.8.1':
|
'@emotion/memoize@0.8.1':
|
||||||
resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==}
|
resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==}
|
||||||
|
|
||||||
'@emotion/unitless@0.8.1':
|
|
||||||
resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==}
|
|
||||||
|
|
||||||
'@epic-web/invariant@1.0.0':
|
'@epic-web/invariant@1.0.0':
|
||||||
resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
|
resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
|
||||||
|
|
||||||
@@ -3122,9 +3116,6 @@ packages:
|
|||||||
'@types/statuses@2.0.6':
|
'@types/statuses@2.0.6':
|
||||||
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
||||||
|
|
||||||
'@types/stylis@4.2.7':
|
|
||||||
resolution: {integrity: sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==}
|
|
||||||
|
|
||||||
'@types/tedious@4.0.14':
|
'@types/tedious@4.0.14':
|
||||||
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
|
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
|
||||||
|
|
||||||
@@ -3781,9 +3772,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
camelize@1.0.1:
|
|
||||||
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
|
|
||||||
|
|
||||||
caniuse-lite@1.0.30001762:
|
caniuse-lite@1.0.30001762:
|
||||||
resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==}
|
resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==}
|
||||||
|
|
||||||
@@ -3997,10 +3985,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==}
|
resolution: {integrity: sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
css-color-keywords@1.0.0:
|
|
||||||
resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
|
|
||||||
engines: {node: '>=4'}
|
|
||||||
|
|
||||||
css-loader@6.11.0:
|
css-loader@6.11.0:
|
||||||
resolution: {integrity: sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==}
|
resolution: {integrity: sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==}
|
||||||
engines: {node: '>= 12.13.0'}
|
engines: {node: '>= 12.13.0'}
|
||||||
@@ -4016,9 +4000,6 @@ packages:
|
|||||||
css-select@4.3.0:
|
css-select@4.3.0:
|
||||||
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
|
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
|
||||||
|
|
||||||
css-to-react-native@3.2.0:
|
|
||||||
resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
|
|
||||||
|
|
||||||
css-what@6.2.2:
|
css-what@6.2.2:
|
||||||
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
|
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -6131,10 +6112,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
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:
|
postcss@8.5.6:
|
||||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
@@ -6306,12 +6283,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.3.1
|
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:
|
react-hook-form@7.66.0:
|
||||||
resolution: {integrity: sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==}
|
resolution: {integrity: sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
@@ -6678,9 +6649,6 @@ packages:
|
|||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
shallowequal@1.1.0:
|
|
||||||
resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
|
|
||||||
|
|
||||||
sharp@0.34.5:
|
sharp@0.34.5:
|
||||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
@@ -6894,13 +6862,6 @@ packages:
|
|||||||
style-to-object@1.0.14:
|
style-to-object@1.0.14:
|
||||||
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
|
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:
|
styled-jsx@5.1.6:
|
||||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -6927,9 +6888,6 @@ packages:
|
|||||||
babel-plugin-macros:
|
babel-plugin-macros:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
stylis@4.3.6:
|
|
||||||
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
|
|
||||||
|
|
||||||
sucrase@3.35.1:
|
sucrase@3.35.1:
|
||||||
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
|
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
|
||||||
engines: {node: '>=16 || 14 >=14.17'}
|
engines: {node: '>=16 || 14 >=14.17'}
|
||||||
@@ -7096,9 +7054,6 @@ packages:
|
|||||||
tslib@1.14.1:
|
tslib@1.14.1:
|
||||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||||
|
|
||||||
tslib@2.6.2:
|
|
||||||
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
|
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@@ -8335,10 +8290,10 @@ snapshots:
|
|||||||
'@emotion/is-prop-valid@1.2.2':
|
'@emotion/is-prop-valid@1.2.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emotion/memoize': 0.8.1
|
'@emotion/memoize': 0.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@emotion/memoize@0.8.1': {}
|
'@emotion/memoize@0.8.1':
|
||||||
|
optional: true
|
||||||
'@emotion/unitless@0.8.1': {}
|
|
||||||
|
|
||||||
'@epic-web/invariant@1.0.0': {}
|
'@epic-web/invariant@1.0.0': {}
|
||||||
|
|
||||||
@@ -10734,8 +10689,6 @@ snapshots:
|
|||||||
|
|
||||||
'@types/statuses@2.0.6': {}
|
'@types/statuses@2.0.6': {}
|
||||||
|
|
||||||
'@types/stylis@4.2.7': {}
|
|
||||||
|
|
||||||
'@types/tedious@4.0.14':
|
'@types/tedious@4.0.14':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.10.0
|
'@types/node': 24.10.0
|
||||||
@@ -11432,8 +11385,6 @@ snapshots:
|
|||||||
|
|
||||||
camelcase-css@2.0.1: {}
|
camelcase-css@2.0.1: {}
|
||||||
|
|
||||||
camelize@1.0.1: {}
|
|
||||||
|
|
||||||
caniuse-lite@1.0.30001762: {}
|
caniuse-lite@1.0.30001762: {}
|
||||||
|
|
||||||
case-sensitive-paths-webpack-plugin@2.4.0: {}
|
case-sensitive-paths-webpack-plugin@2.4.0: {}
|
||||||
@@ -11645,8 +11596,6 @@ snapshots:
|
|||||||
randombytes: 2.1.0
|
randombytes: 2.1.0
|
||||||
randomfill: 1.0.4
|
randomfill: 1.0.4
|
||||||
|
|
||||||
css-color-keywords@1.0.0: {}
|
|
||||||
|
|
||||||
css-loader@6.11.0(webpack@5.104.1(esbuild@0.25.12)):
|
css-loader@6.11.0(webpack@5.104.1(esbuild@0.25.12)):
|
||||||
dependencies:
|
dependencies:
|
||||||
icss-utils: 5.1.0(postcss@8.5.6)
|
icss-utils: 5.1.0(postcss@8.5.6)
|
||||||
@@ -11668,12 +11617,6 @@ snapshots:
|
|||||||
domutils: 2.8.0
|
domutils: 2.8.0
|
||||||
nth-check: 2.1.1
|
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-what@6.2.2: {}
|
||||||
|
|
||||||
css.escape@1.5.1: {}
|
css.escape@1.5.1: {}
|
||||||
@@ -12127,8 +12070,8 @@ snapshots:
|
|||||||
'@typescript-eslint/parser': 8.52.0(eslint@8.57.1)(typescript@5.9.3)
|
'@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-node: 0.3.9
|
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)
|
||||||
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)
|
||||||
eslint-plugin-jsx-a11y: 6.10.2(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: 7.37.5(eslint@8.57.1)
|
||||||
eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1)
|
eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1)
|
||||||
@@ -12147,7 +12090,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
'@nolyfill/is-core-module': 1.0.39
|
'@nolyfill/is-core-module': 1.0.39
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@@ -12158,22 +12101,22 @@ snapshots:
|
|||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
unrs-resolver: 1.11.1
|
unrs-resolver: 1.11.1
|
||||||
optionalDependencies:
|
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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
debug: 3.2.7
|
debug: 3.2.7
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@typescript-eslint/parser': 8.52.0(eslint@8.57.1)(typescript@5.9.3)
|
'@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-node: 0.3.9
|
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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
'@rtsao/scc': 1.1.0
|
'@rtsao/scc': 1.1.0
|
||||||
array-includes: 3.1.9
|
array-includes: 3.1.9
|
||||||
@@ -12184,7 +12127,7 @@ snapshots:
|
|||||||
doctrine: 2.1.0
|
doctrine: 2.1.0
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
eslint-import-resolver-node: 0.3.9
|
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
|
hasown: 2.0.2
|
||||||
is-core-module: 2.16.1
|
is-core-module: 2.16.1
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
@@ -14259,12 +14202,6 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.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:
|
postcss@8.5.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.11
|
nanoid: 3.3.11
|
||||||
@@ -14386,13 +14323,6 @@ snapshots:
|
|||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
scheduler: 0.23.2
|
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):
|
react-hook-form@7.66.0(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
@@ -14886,8 +14816,6 @@ snapshots:
|
|||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
to-buffer: 1.2.2
|
to-buffer: 1.2.2
|
||||||
|
|
||||||
shallowequal@1.1.0: {}
|
|
||||||
|
|
||||||
sharp@0.34.5:
|
sharp@0.34.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@img/colour': 1.0.0
|
'@img/colour': 1.0.0
|
||||||
@@ -15178,20 +15106,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
inline-style-parser: 0.2.7
|
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):
|
styled-jsx@5.1.6(@babel/core@7.28.5)(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
client-only: 0.0.1
|
client-only: 0.0.1
|
||||||
@@ -15206,8 +15120,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.28.5
|
||||||
|
|
||||||
stylis@4.3.6: {}
|
|
||||||
|
|
||||||
sucrase@3.35.1:
|
sucrase@3.35.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/gen-mapping': 0.3.13
|
'@jridgewell/gen-mapping': 0.3.13
|
||||||
@@ -15390,8 +15302,6 @@ snapshots:
|
|||||||
|
|
||||||
tslib@1.14.1: {}
|
tslib@1.14.1: {}
|
||||||
|
|
||||||
tslib@2.6.2: {}
|
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
tty-browserify@0.0.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={{
|
formContext={{
|
||||||
showHandles: false,
|
showHandles: false,
|
||||||
size: "large",
|
size: "large",
|
||||||
|
showOptionalToggle: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,18 +81,16 @@ export const RunInputDialog = ({
|
|||||||
Inputs
|
Inputs
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-2">
|
<FormRenderer
|
||||||
<FormRenderer
|
jsonSchema={inputSchema as RJSFSchema}
|
||||||
jsonSchema={inputSchema as RJSFSchema}
|
handleChange={(v) => handleInputChange(v.formData)}
|
||||||
handleChange={(v) => handleInputChange(v.formData)}
|
uiSchema={uiSchema}
|
||||||
uiSchema={uiSchema}
|
initialValues={{}}
|
||||||
initialValues={{}}
|
formContext={{
|
||||||
formContext={{
|
showHandles: false,
|
||||||
showHandles: false,
|
size: "large",
|
||||||
size: "large",
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export const useRunInputDialog = ({
|
|||||||
if (isCredentialFieldSchema(fieldSchema)) {
|
if (isCredentialFieldSchema(fieldSchema)) {
|
||||||
dynamicUiSchema[fieldName] = {
|
dynamicUiSchema[fieldName] = {
|
||||||
...dynamicUiSchema[fieldName],
|
...dynamicUiSchema[fieldName],
|
||||||
"ui:field": "credentials",
|
"ui:field": "custom/credential_field",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -76,12 +76,18 @@ export const useRunInputDialog = ({
|
|||||||
}, [credentialsSchema]);
|
}, [credentialsSchema]);
|
||||||
|
|
||||||
const handleManualRun = async () => {
|
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({
|
await executeGraph({
|
||||||
graphId: flowID ?? "",
|
graphId: flowID ?? "",
|
||||||
graphVersion: flowVersion || null,
|
graphVersion: flowVersion || null,
|
||||||
data: {
|
data: {
|
||||||
inputs: inputValues,
|
inputs: inputValues,
|
||||||
credentials_inputs: credentialValues,
|
credentials_inputs: validCredentials,
|
||||||
source: "builder",
|
source: "builder",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ export const Flow = () => {
|
|||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onNodeDragStop={onNodeDragStop}
|
onNodeDragStop={onNodeDragStop}
|
||||||
|
onNodeContextMenu={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
maxZoom={2}
|
maxZoom={2}
|
||||||
minZoom={0.1}
|
minZoom={0.1}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useGetV2GetSpecificBlocks } from "@/app/api/__generated__/endpoints/def
|
|||||||
import {
|
import {
|
||||||
useGetV1GetExecutionDetails,
|
useGetV1GetExecutionDetails,
|
||||||
useGetV1GetSpecificGraph,
|
useGetV1GetSpecificGraph,
|
||||||
|
useGetV1ListUserGraphs,
|
||||||
} from "@/app/api/__generated__/endpoints/graphs/graphs";
|
} from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||||
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
|
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
|
||||||
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
|
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
|
||||||
@@ -17,6 +18,7 @@ import { useReactFlow } from "@xyflow/react";
|
|||||||
import { useControlPanelStore } from "../../../stores/controlPanelStore";
|
import { useControlPanelStore } from "../../../stores/controlPanelStore";
|
||||||
import { useHistoryStore } from "../../../stores/historyStore";
|
import { useHistoryStore } from "../../../stores/historyStore";
|
||||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||||
|
import { okData } from "@/app/api/helpers";
|
||||||
|
|
||||||
export const useFlow = () => {
|
export const useFlow = () => {
|
||||||
const [isLocked, setIsLocked] = useState(false);
|
const [isLocked, setIsLocked] = useState(false);
|
||||||
@@ -36,6 +38,9 @@ export const useFlow = () => {
|
|||||||
const setGraphExecutionStatus = useGraphStore(
|
const setGraphExecutionStatus = useGraphStore(
|
||||||
useShallow((state) => state.setGraphExecutionStatus),
|
useShallow((state) => state.setGraphExecutionStatus),
|
||||||
);
|
);
|
||||||
|
const setAvailableSubGraphs = useGraphStore(
|
||||||
|
useShallow((state) => state.setAvailableSubGraphs),
|
||||||
|
);
|
||||||
const updateEdgeBeads = useEdgeStore(
|
const updateEdgeBeads = useEdgeStore(
|
||||||
useShallow((state) => state.updateEdgeBeads),
|
useShallow((state) => state.updateEdgeBeads),
|
||||||
);
|
);
|
||||||
@@ -62,6 +67,11 @@ export const useFlow = () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fetch all available graphs for sub-agent update detection
|
||||||
|
const { data: availableGraphs } = useGetV1ListUserGraphs({
|
||||||
|
query: { select: okData },
|
||||||
|
});
|
||||||
|
|
||||||
const { data: graph, isLoading: isGraphLoading } = useGetV1GetSpecificGraph(
|
const { data: graph, isLoading: isGraphLoading } = useGetV1GetSpecificGraph(
|
||||||
flowID ?? "",
|
flowID ?? "",
|
||||||
flowVersion !== null ? { version: flowVersion } : {},
|
flowVersion !== null ? { version: flowVersion } : {},
|
||||||
@@ -116,10 +126,18 @@ export const useFlow = () => {
|
|||||||
}
|
}
|
||||||
}, [graph]);
|
}, [graph]);
|
||||||
|
|
||||||
|
// Update available sub-graphs in store for sub-agent update detection
|
||||||
|
useEffect(() => {
|
||||||
|
if (availableGraphs) {
|
||||||
|
setAvailableSubGraphs(availableGraphs);
|
||||||
|
}
|
||||||
|
}, [availableGraphs, setAvailableSubGraphs]);
|
||||||
|
|
||||||
// adding nodes
|
// adding nodes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (customNodes.length > 0) {
|
if (customNodes.length > 0) {
|
||||||
useNodeStore.getState().setNodes([]);
|
useNodeStore.getState().setNodes([]);
|
||||||
|
useNodeStore.getState().clearResolutionState();
|
||||||
addNodes(customNodes);
|
addNodes(customNodes);
|
||||||
|
|
||||||
// Sync hardcoded values with handle IDs.
|
// Sync hardcoded values with handle IDs.
|
||||||
@@ -203,6 +221,7 @@ export const useFlow = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
useNodeStore.getState().setNodes([]);
|
useNodeStore.getState().setNodes([]);
|
||||||
|
useNodeStore.getState().clearResolutionState();
|
||||||
useEdgeStore.getState().setEdges([]);
|
useEdgeStore.getState().setEdges([]);
|
||||||
useGraphStore.getState().reset();
|
useGraphStore.getState().reset();
|
||||||
useEdgeStore.getState().resetEdgeBeads();
|
useEdgeStore.getState().resetEdgeBeads();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getBezierPath,
|
getBezierPath,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||||
|
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||||
import { XIcon } from "@phosphor-icons/react";
|
import { XIcon } from "@phosphor-icons/react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { NodeExecutionResult } from "@/lib/autogpt-server-api";
|
import { NodeExecutionResult } from "@/lib/autogpt-server-api";
|
||||||
@@ -35,6 +36,8 @@ const CustomEdge = ({
|
|||||||
selected,
|
selected,
|
||||||
}: EdgeProps<CustomEdge>) => {
|
}: EdgeProps<CustomEdge>) => {
|
||||||
const removeConnection = useEdgeStore((state) => state.removeEdge);
|
const removeConnection = useEdgeStore((state) => state.removeEdge);
|
||||||
|
// Subscribe to the brokenEdgeIDs map and check if this edge is broken across any node
|
||||||
|
const isBroken = useNodeStore((state) => state.isEdgeBroken(id));
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
const [edgePath, labelX, labelY] = getBezierPath({
|
const [edgePath, labelX, labelY] = getBezierPath({
|
||||||
@@ -50,6 +53,12 @@ const CustomEdge = ({
|
|||||||
const beadUp = data?.beadUp ?? 0;
|
const beadUp = data?.beadUp ?? 0;
|
||||||
const beadDown = data?.beadDown ?? 0;
|
const beadDown = data?.beadDown ?? 0;
|
||||||
|
|
||||||
|
const handleRemoveEdge = () => {
|
||||||
|
removeConnection(id);
|
||||||
|
// Note: broken edge tracking is cleaned up automatically by useSubAgentUpdateState
|
||||||
|
// when it detects the edge no longer exists
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseEdge
|
<BaseEdge
|
||||||
@@ -57,9 +66,11 @@ const CustomEdge = ({
|
|||||||
markerEnd={markerEnd}
|
markerEnd={markerEnd}
|
||||||
className={cn(
|
className={cn(
|
||||||
isStatic && "!stroke-[1.5px] [stroke-dasharray:6]",
|
isStatic && "!stroke-[1.5px] [stroke-dasharray:6]",
|
||||||
selected
|
isBroken
|
||||||
? "stroke-zinc-800"
|
? "!stroke-red-500 !stroke-[2px] [stroke-dasharray:4]"
|
||||||
: "stroke-zinc-500/50 hover:stroke-zinc-500",
|
: selected
|
||||||
|
? "stroke-zinc-800"
|
||||||
|
: "stroke-zinc-500/50 hover:stroke-zinc-500",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<JSBeads
|
<JSBeads
|
||||||
@@ -70,12 +81,16 @@ const CustomEdge = ({
|
|||||||
/>
|
/>
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => removeConnection(id)}
|
onClick={handleRemoveEdge}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute h-fit min-w-0 p-1 transition-opacity",
|
"absolute h-fit min-w-0 p-1 transition-opacity",
|
||||||
isHovered ? "opacity-100" : "opacity-0",
|
isBroken
|
||||||
|
? "bg-red-500 opacity-100 hover:bg-red-600"
|
||||||
|
: isHovered
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
)}
|
)}
|
||||||
variant="secondary"
|
variant={isBroken ? "primary" : "secondary"}
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||||
pointerEvents: "all",
|
pointerEvents: "all",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Handle, Position } from "@xyflow/react";
|
|||||||
import { useEdgeStore } from "../../../stores/edgeStore";
|
import { useEdgeStore } from "../../../stores/edgeStore";
|
||||||
import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers";
|
import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useNodeStore } from "../../../stores/nodeStore";
|
||||||
|
|
||||||
const InputNodeHandle = ({
|
const InputNodeHandle = ({
|
||||||
handleId,
|
handleId,
|
||||||
@@ -15,6 +16,9 @@ const InputNodeHandle = ({
|
|||||||
const isInputConnected = useEdgeStore((state) =>
|
const isInputConnected = useEdgeStore((state) =>
|
||||||
state.isInputConnected(nodeId ?? "", cleanedHandleId),
|
state.isInputConnected(nodeId ?? "", cleanedHandleId),
|
||||||
);
|
);
|
||||||
|
const isInputBroken = useNodeStore((state) =>
|
||||||
|
state.isInputBroken(nodeId, cleanedHandleId),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Handle
|
<Handle
|
||||||
@@ -27,7 +31,10 @@ const InputNodeHandle = ({
|
|||||||
<CircleIcon
|
<CircleIcon
|
||||||
size={16}
|
size={16}
|
||||||
weight={isInputConnected ? "fill" : "duotone"}
|
weight={isInputConnected ? "fill" : "duotone"}
|
||||||
className={"text-gray-400 opacity-100"}
|
className={cn(
|
||||||
|
"text-gray-400 opacity-100",
|
||||||
|
isInputBroken && "text-red-500",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Handle>
|
</Handle>
|
||||||
@@ -38,14 +45,17 @@ const OutputNodeHandle = ({
|
|||||||
field_name,
|
field_name,
|
||||||
nodeId,
|
nodeId,
|
||||||
hexColor,
|
hexColor,
|
||||||
|
isBroken,
|
||||||
}: {
|
}: {
|
||||||
field_name: string;
|
field_name: string;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
hexColor: string;
|
hexColor: string;
|
||||||
|
isBroken: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const isOutputConnected = useEdgeStore((state) =>
|
const isOutputConnected = useEdgeStore((state) =>
|
||||||
state.isOutputConnected(nodeId, field_name),
|
state.isOutputConnected(nodeId, field_name),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Handle
|
<Handle
|
||||||
type={"source"}
|
type={"source"}
|
||||||
@@ -58,7 +68,10 @@ const OutputNodeHandle = ({
|
|||||||
size={16}
|
size={16}
|
||||||
weight={"duotone"}
|
weight={"duotone"}
|
||||||
color={isOutputConnected ? hexColor : "gray"}
|
color={isOutputConnected ? hexColor : "gray"}
|
||||||
className={cn("text-gray-400 opacity-100")}
|
className={cn(
|
||||||
|
"text-gray-400 opacity-100",
|
||||||
|
isBroken && "text-red-500",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Handle>
|
</Handle>
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
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 { 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 { 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 { 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";
|
||||||
|
import { SubAgentUpdateFeature } from "./components/SubAgentUpdate/SubAgentUpdateFeature";
|
||||||
|
import { useCustomNode } from "./useCustomNode";
|
||||||
|
|
||||||
export type CustomNodeData = {
|
export type CustomNodeData = {
|
||||||
hardcodedValues: {
|
hardcodedValues: {
|
||||||
@@ -44,6 +47,10 @@ export type CustomNode = XYNode<CustomNodeData, "custom">;
|
|||||||
|
|
||||||
export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
||||||
({ data, id: nodeId, selected }) => {
|
({ data, id: nodeId, selected }) => {
|
||||||
|
const { inputSchema, outputSchema } = useCustomNode({ data, nodeId });
|
||||||
|
|
||||||
|
const isAgent = data.uiType === BlockUIType.AGENT;
|
||||||
|
|
||||||
if (data.uiType === BlockUIType.NOTE) {
|
if (data.uiType === BlockUIType.NOTE) {
|
||||||
return (
|
return (
|
||||||
<StickyNoteBlock data={data} selected={selected} nodeId={nodeId} />
|
<StickyNoteBlock data={data} selected={selected} nodeId={nodeId} />
|
||||||
@@ -62,16 +69,6 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
|||||||
|
|
||||||
const isAyrshare = data.uiType === BlockUIType.AYRSHARE;
|
const isAyrshare = data.uiType === BlockUIType.AYRSHARE;
|
||||||
|
|
||||||
const inputSchema =
|
|
||||||
data.uiType === BlockUIType.AGENT
|
|
||||||
? (data.hardcodedValues.input_schema ?? {})
|
|
||||||
: data.inputSchema;
|
|
||||||
|
|
||||||
const outputSchema =
|
|
||||||
data.uiType === BlockUIType.AGENT
|
|
||||||
? (data.hardcodedValues.output_schema ?? {})
|
|
||||||
: data.outputSchema;
|
|
||||||
|
|
||||||
const hasConfigErrors =
|
const hasConfigErrors =
|
||||||
data.errors &&
|
data.errors &&
|
||||||
Object.values(data.errors).some(
|
Object.values(data.errors).some(
|
||||||
@@ -86,12 +83,11 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
|||||||
|
|
||||||
const hasErrors = hasConfigErrors || hasOutputError;
|
const hasErrors = hasConfigErrors || hasOutputError;
|
||||||
|
|
||||||
// Currently all blockTypes design are similar - that's why i am using the same component for all of them
|
const node = (
|
||||||
// If in future - if we need some drastic change in some blockTypes design - we can create separate components for them
|
|
||||||
return (
|
|
||||||
<NodeContainer selected={selected} nodeId={nodeId} hasErrors={hasErrors}>
|
<NodeContainer selected={selected} nodeId={nodeId} hasErrors={hasErrors}>
|
||||||
<div className="rounded-xlarge bg-white">
|
<div className="rounded-xlarge bg-white">
|
||||||
<NodeHeader data={data} nodeId={nodeId} />
|
<NodeHeader data={data} nodeId={nodeId} />
|
||||||
|
{isAgent && <SubAgentUpdateFeature nodeID={nodeId} nodeData={data} />}
|
||||||
{isWebhook && <WebhookDisclaimer nodeId={nodeId} />}
|
{isWebhook && <WebhookDisclaimer nodeId={nodeId} />}
|
||||||
{isAyrshare && <AyrshareConnectButton />}
|
{isAyrshare && <AyrshareConnectButton />}
|
||||||
<FormCreator
|
<FormCreator
|
||||||
@@ -117,6 +113,15 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
|||||||
<NodeExecutionBadge nodeId={nodeId} />
|
<NodeExecutionBadge nodeId={nodeId} />
|
||||||
</NodeContainer>
|
</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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/molecules/DropdownMenu/DropdownMenu";
|
} from "@/components/molecules/DropdownMenu/DropdownMenu";
|
||||||
import { DotsThreeOutlineVerticalIcon } from "@phosphor-icons/react";
|
import {
|
||||||
import { Copy, Trash2, ExternalLink } from "lucide-react";
|
SecondaryDropdownMenuContent,
|
||||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
SecondaryDropdownMenuItem,
|
||||||
import { useCopyPasteStore } from "@/app/(platform)/build/stores/copyPasteStore";
|
SecondaryDropdownMenuSeparator,
|
||||||
|
} from "@/components/molecules/SecondaryMenu/SecondaryMenu";
|
||||||
|
import {
|
||||||
|
ArrowSquareOutIcon,
|
||||||
|
CopyIcon,
|
||||||
|
DotsThreeOutlineVerticalIcon,
|
||||||
|
TrashIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
import { useReactFlow } from "@xyflow/react";
|
import { useReactFlow } from "@xyflow/react";
|
||||||
|
|
||||||
export const NodeContextMenu = ({
|
type Props = {
|
||||||
nodeId,
|
|
||||||
subGraphID,
|
|
||||||
}: {
|
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
subGraphID?: string;
|
subGraphID?: string;
|
||||||
}) => {
|
};
|
||||||
|
|
||||||
|
export const NodeContextMenu = ({ nodeId, subGraphID }: Props) => {
|
||||||
const { deleteElements } = useReactFlow();
|
const { deleteElements } = useReactFlow();
|
||||||
|
|
||||||
const handleCopy = () => {
|
function handleCopy() {
|
||||||
useNodeStore.setState((state) => ({
|
useNodeStore.setState((state) => ({
|
||||||
nodes: state.nodes.map((node) => ({
|
nodes: state.nodes.map((node) => ({
|
||||||
...node,
|
...node,
|
||||||
@@ -30,47 +35,47 @@ export const NodeContextMenu = ({
|
|||||||
|
|
||||||
useCopyPasteStore.getState().copySelectedNodes();
|
useCopyPasteStore.getState().copySelectedNodes();
|
||||||
useCopyPasteStore.getState().pasteNodes();
|
useCopyPasteStore.getState().pasteNodes();
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
function handleDelete() {
|
||||||
deleteElements({ nodes: [{ id: nodeId }] });
|
deleteElements({ nodes: [{ id: nodeId }] });
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger className="py-2">
|
<DropdownMenuTrigger className="py-2">
|
||||||
<DotsThreeOutlineVerticalIcon size={16} weight="fill" />
|
<DotsThreeOutlineVerticalIcon size={16} weight="fill" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<SecondaryDropdownMenuContent side="right" align="start">
|
||||||
side="right"
|
<SecondaryDropdownMenuItem onClick={handleCopy}>
|
||||||
align="start"
|
<CopyIcon size={20} className="mr-2 dark:text-gray-100" />
|
||||||
className="rounded-xlarge"
|
<span className="dark:text-gray-100">Copy</span>
|
||||||
>
|
</SecondaryDropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={handleCopy} className="hover:rounded-xlarge">
|
<SecondaryDropdownMenuSeparator />
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
|
||||||
Copy Node
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
{subGraphID && (
|
{subGraphID && (
|
||||||
<DropdownMenuItem
|
<>
|
||||||
onClick={() => window.open(`/build?flowID=${subGraphID}`)}
|
<SecondaryDropdownMenuItem
|
||||||
className="hover:rounded-xlarge"
|
onClick={() => window.open(`/build?flowID=${subGraphID}`)}
|
||||||
>
|
>
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
<ArrowSquareOutIcon
|
||||||
Open Agent
|
size={20}
|
||||||
</DropdownMenuItem>
|
className="mr-2 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
<span className="dark:text-gray-100">Open agent</span>
|
||||||
|
</SecondaryDropdownMenuItem>
|
||||||
|
<SecondaryDropdownMenuSeparator />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Separator className="my-2" />
|
<SecondaryDropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||||
|
<TrashIcon
|
||||||
<DropdownMenuItem
|
size={20}
|
||||||
onClick={handleDelete}
|
className="mr-2 text-red-500 dark:text-red-400"
|
||||||
className="text-red-600 hover:rounded-xlarge"
|
/>
|
||||||
>
|
<span className="dark:text-red-400">Delete</span>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
</SecondaryDropdownMenuItem>
|
||||||
Delete
|
</SecondaryDropdownMenuContent>
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
</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 { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||||
import { useState } from "react";
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
} 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 = ({
|
type Props = {
|
||||||
data,
|
|
||||||
nodeId,
|
|
||||||
}: {
|
|
||||||
data: CustomNodeData;
|
data: CustomNodeData;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
}) => {
|
};
|
||||||
|
|
||||||
|
export const NodeHeader = ({ data, nodeId }: Props) => {
|
||||||
const updateNodeData = useNodeStore((state) => state.updateNodeData);
|
const updateNodeData = useNodeStore((state) => state.updateNodeData);
|
||||||
const title = (data.metadata?.customized_name as string) || data.title;
|
const title = (data.metadata?.customized_name as string) || data.title;
|
||||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
@@ -69,7 +68,10 @@ export const NodeHeader = ({
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div>
|
<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()}
|
{beautifyString(title).replace("Block", "").trim()}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export const NodeDataViewer: FC<NodeDataViewerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end pt-4">
|
<div className="flex justify-end pt-4">
|
||||||
{outputItems.length > 0 && (
|
{outputItems.length > 1 && (
|
||||||
<OutputActions
|
<OutputActions
|
||||||
items={outputItems.map((item) => ({
|
items={outputItems.map((item) => ({
|
||||||
value: item.value,
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { ArrowUpIcon, WarningIcon } from "@phosphor-icons/react";
|
||||||
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||||
|
import { cn, beautifyString } from "@/lib/utils";
|
||||||
|
import { CustomNodeData } from "../../CustomNode";
|
||||||
|
import { useSubAgentUpdateState } from "./useSubAgentUpdateState";
|
||||||
|
import { IncompatibleUpdateDialog } from "./components/IncompatibleUpdateDialog";
|
||||||
|
import { ResolutionModeBar } from "./components/ResolutionModeBar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline component for the update bar that can be placed after the header.
|
||||||
|
* Use this inside the node content where you want the bar to appear.
|
||||||
|
*/
|
||||||
|
type SubAgentUpdateFeatureProps = {
|
||||||
|
nodeID: string;
|
||||||
|
nodeData: CustomNodeData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SubAgentUpdateFeature({
|
||||||
|
nodeID,
|
||||||
|
nodeData,
|
||||||
|
}: SubAgentUpdateFeatureProps) {
|
||||||
|
const {
|
||||||
|
updateInfo,
|
||||||
|
isInResolutionMode,
|
||||||
|
handleUpdateClick,
|
||||||
|
showIncompatibilityDialog,
|
||||||
|
setShowIncompatibilityDialog,
|
||||||
|
handleConfirmIncompatibleUpdate,
|
||||||
|
} = useSubAgentUpdateState({ nodeID: nodeID, nodeData: nodeData });
|
||||||
|
|
||||||
|
const agentName = nodeData.title || "Agent";
|
||||||
|
|
||||||
|
if (!updateInfo.hasUpdate && !isInResolutionMode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isInResolutionMode ? (
|
||||||
|
<ResolutionModeBar incompatibilities={updateInfo.incompatibilities} />
|
||||||
|
) : (
|
||||||
|
<SubAgentUpdateAvailableBar
|
||||||
|
currentVersion={updateInfo.currentVersion}
|
||||||
|
latestVersion={updateInfo.latestVersion}
|
||||||
|
isCompatible={updateInfo.isCompatible}
|
||||||
|
onUpdate={handleUpdateClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Incompatibility dialog - rendered here since this component owns the state */}
|
||||||
|
{updateInfo.incompatibilities && (
|
||||||
|
<IncompatibleUpdateDialog
|
||||||
|
isOpen={showIncompatibilityDialog}
|
||||||
|
onClose={() => setShowIncompatibilityDialog(false)}
|
||||||
|
onConfirm={handleConfirmIncompatibleUpdate}
|
||||||
|
currentVersion={updateInfo.currentVersion}
|
||||||
|
latestVersion={updateInfo.latestVersion}
|
||||||
|
agentName={beautifyString(agentName)}
|
||||||
|
incompatibilities={updateInfo.incompatibilities}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubAgentUpdateAvailableBarProps = {
|
||||||
|
currentVersion: number;
|
||||||
|
latestVersion: number;
|
||||||
|
isCompatible: boolean;
|
||||||
|
onUpdate: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SubAgentUpdateAvailableBar({
|
||||||
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
|
isCompatible,
|
||||||
|
onUpdate,
|
||||||
|
}: SubAgentUpdateAvailableBarProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2 rounded-t-xl bg-blue-50 px-3 py-2 dark:bg-blue-900/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ArrowUpIcon className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
<span className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
Update available (v{currentVersion} → v{latestVersion})
|
||||||
|
</span>
|
||||||
|
{!isCompatible && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<WarningIcon className="h-4 w-4 text-amber-500" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
<p className="font-medium">Incompatible changes detected</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Click Update to see details
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={isCompatible ? "primary" : "outline"}
|
||||||
|
onClick={onUpdate}
|
||||||
|
className={cn(
|
||||||
|
"h-7 text-xs",
|
||||||
|
!isCompatible && "border-amber-500 text-amber-600 hover:bg-amber-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
WarningIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
PlusCircleIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
|
import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
|
||||||
|
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||||
|
import { beautifyString } from "@/lib/utils";
|
||||||
|
import { IncompatibilityInfo } from "@/app/(platform)/build/hooks/useSubAgentUpdate/types";
|
||||||
|
|
||||||
|
type IncompatibleUpdateDialogProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
currentVersion: number;
|
||||||
|
latestVersion: number;
|
||||||
|
agentName: string;
|
||||||
|
incompatibilities: IncompatibilityInfo;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IncompatibleUpdateDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
|
agentName,
|
||||||
|
incompatibilities,
|
||||||
|
}: IncompatibleUpdateDialogProps) {
|
||||||
|
const hasMissingInputs = incompatibilities.missingInputs.length > 0;
|
||||||
|
const hasMissingOutputs = incompatibilities.missingOutputs.length > 0;
|
||||||
|
const hasNewInputs = incompatibilities.newInputs.length > 0;
|
||||||
|
const hasNewOutputs = incompatibilities.newOutputs.length > 0;
|
||||||
|
const hasNewRequired = incompatibilities.newRequiredInputs.length > 0;
|
||||||
|
const hasTypeMismatches = incompatibilities.inputTypeMismatches.length > 0;
|
||||||
|
|
||||||
|
const hasInputChanges = hasMissingInputs || hasNewInputs;
|
||||||
|
const hasOutputChanges = hasMissingOutputs || hasNewOutputs;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<WarningIcon className="h-5 w-5 text-amber-500" weight="fill" />
|
||||||
|
Incompatible Update
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
controlled={{
|
||||||
|
isOpen,
|
||||||
|
set: async (open) => {
|
||||||
|
if (!open) onClose();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClose={onClose}
|
||||||
|
styling={{ maxWidth: "32rem" }}
|
||||||
|
>
|
||||||
|
<Dialog.Content>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Updating <strong>{beautifyString(agentName)}</strong> from v
|
||||||
|
{currentVersion} to v{latestVersion} will break some connections.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Input changes - two column layout */}
|
||||||
|
{hasInputChanges && (
|
||||||
|
<TwoColumnSection
|
||||||
|
title="Input Changes"
|
||||||
|
leftIcon={
|
||||||
|
<XCircleIcon className="h-4 w-4 text-red-500" weight="fill" />
|
||||||
|
}
|
||||||
|
leftTitle="Removed"
|
||||||
|
leftItems={incompatibilities.missingInputs}
|
||||||
|
rightIcon={
|
||||||
|
<PlusCircleIcon
|
||||||
|
className="h-4 w-4 text-green-500"
|
||||||
|
weight="fill"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
rightTitle="Added"
|
||||||
|
rightItems={incompatibilities.newInputs}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Output changes - two column layout */}
|
||||||
|
{hasOutputChanges && (
|
||||||
|
<TwoColumnSection
|
||||||
|
title="Output Changes"
|
||||||
|
leftIcon={
|
||||||
|
<XCircleIcon className="h-4 w-4 text-red-500" weight="fill" />
|
||||||
|
}
|
||||||
|
leftTitle="Removed"
|
||||||
|
leftItems={incompatibilities.missingOutputs}
|
||||||
|
rightIcon={
|
||||||
|
<PlusCircleIcon
|
||||||
|
className="h-4 w-4 text-green-500"
|
||||||
|
weight="fill"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
rightTitle="Added"
|
||||||
|
rightItems={incompatibilities.newOutputs}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasTypeMismatches && (
|
||||||
|
<SingleColumnSection
|
||||||
|
icon={
|
||||||
|
<XCircleIcon className="h-4 w-4 text-red-500" weight="fill" />
|
||||||
|
}
|
||||||
|
title="Type Changed"
|
||||||
|
description="These connected inputs have a different type:"
|
||||||
|
items={incompatibilities.inputTypeMismatches.map(
|
||||||
|
(m) => `${m.name} (${m.oldType} → ${m.newType})`,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasNewRequired && (
|
||||||
|
<SingleColumnSection
|
||||||
|
icon={
|
||||||
|
<PlusCircleIcon
|
||||||
|
className="h-4 w-4 text-amber-500"
|
||||||
|
weight="fill"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title="New Required Inputs"
|
||||||
|
description="These inputs are now required:"
|
||||||
|
items={incompatibilities.newRequiredInputs}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
If you proceed, you'll need to remove the broken connections
|
||||||
|
before you can save or run your agent.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="ghost" size="small" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="border-amber-700 bg-amber-600 hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
Update Anyway
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TwoColumnSectionProps = {
|
||||||
|
title: string;
|
||||||
|
leftIcon: React.ReactNode;
|
||||||
|
leftTitle: string;
|
||||||
|
leftItems: string[];
|
||||||
|
rightIcon: React.ReactNode;
|
||||||
|
rightTitle: string;
|
||||||
|
rightItems: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function TwoColumnSection({
|
||||||
|
title,
|
||||||
|
leftIcon,
|
||||||
|
leftTitle,
|
||||||
|
leftItems,
|
||||||
|
rightIcon,
|
||||||
|
rightTitle,
|
||||||
|
rightItems,
|
||||||
|
}: TwoColumnSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-gray-200 p-3 dark:border-gray-700">
|
||||||
|
<span className="font-medium">{title}</span>
|
||||||
|
<div className="mt-2 grid grid-cols-2 items-start gap-4">
|
||||||
|
{/* Left column - Breaking changes */}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{leftIcon}
|
||||||
|
<span>{leftTitle}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-1.5 space-y-1">
|
||||||
|
{leftItems.length > 0 ? (
|
||||||
|
leftItems.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
className="text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<code className="rounded bg-red-50 px-1 py-0.5 font-mono text-xs text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||||
|
{item}
|
||||||
|
</code>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<li className="text-sm italic text-gray-400 dark:text-gray-500">
|
||||||
|
None
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column - Possible solutions */}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{rightIcon}
|
||||||
|
<span>{rightTitle}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-1.5 space-y-1">
|
||||||
|
{rightItems.length > 0 ? (
|
||||||
|
rightItems.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
className="text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<code className="rounded bg-green-50 px-1 py-0.5 font-mono text-xs text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||||
|
{item}
|
||||||
|
</code>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<li className="text-sm italic text-gray-400 dark:text-gray-500">
|
||||||
|
None
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SingleColumnSectionProps = {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
items: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function SingleColumnSection({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
items,
|
||||||
|
}: SingleColumnSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-gray-200 p-3 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{icon}
|
||||||
|
<span className="font-medium">{title}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
className="ml-4 list-disc text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<code className="rounded bg-gray-100 px-1 py-0.5 font-mono text-xs dark:bg-gray-800">
|
||||||
|
{item}
|
||||||
|
</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { InfoIcon, WarningIcon } from "@phosphor-icons/react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||||
|
import { IncompatibilityInfo } from "@/app/(platform)/build/hooks/useSubAgentUpdate/types";
|
||||||
|
|
||||||
|
type ResolutionModeBarProps = {
|
||||||
|
incompatibilities: IncompatibilityInfo | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ResolutionModeBar({
|
||||||
|
incompatibilities,
|
||||||
|
}: ResolutionModeBarProps): React.ReactElement {
|
||||||
|
const renderIncompatibilities = () => {
|
||||||
|
if (!incompatibilities) return <span>No incompatibilities</span>;
|
||||||
|
|
||||||
|
const sections: React.ReactNode[] = [];
|
||||||
|
|
||||||
|
if (incompatibilities.missingInputs.length > 0) {
|
||||||
|
sections.push(
|
||||||
|
<div key="missing-inputs" className="mb-1">
|
||||||
|
<span className="font-semibold">Missing inputs: </span>
|
||||||
|
{incompatibilities.missingInputs.map((name, i) => (
|
||||||
|
<React.Fragment key={name}>
|
||||||
|
<code className="font-mono">{name}</code>
|
||||||
|
{i < incompatibilities.missingInputs.length - 1 && ", "}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (incompatibilities.missingOutputs.length > 0) {
|
||||||
|
sections.push(
|
||||||
|
<div key="missing-outputs" className="mb-1">
|
||||||
|
<span className="font-semibold">Missing outputs: </span>
|
||||||
|
{incompatibilities.missingOutputs.map((name, i) => (
|
||||||
|
<React.Fragment key={name}>
|
||||||
|
<code className="font-mono">{name}</code>
|
||||||
|
{i < incompatibilities.missingOutputs.length - 1 && ", "}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (incompatibilities.newRequiredInputs.length > 0) {
|
||||||
|
sections.push(
|
||||||
|
<div key="new-required" className="mb-1">
|
||||||
|
<span className="font-semibold">New required inputs: </span>
|
||||||
|
{incompatibilities.newRequiredInputs.map((name, i) => (
|
||||||
|
<React.Fragment key={name}>
|
||||||
|
<code className="font-mono">{name}</code>
|
||||||
|
{i < incompatibilities.newRequiredInputs.length - 1 && ", "}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (incompatibilities.inputTypeMismatches.length > 0) {
|
||||||
|
sections.push(
|
||||||
|
<div key="type-mismatches" className="mb-1">
|
||||||
|
<span className="font-semibold">Type changed: </span>
|
||||||
|
{incompatibilities.inputTypeMismatches.map((m, i) => (
|
||||||
|
<React.Fragment key={m.name}>
|
||||||
|
<code className="font-mono">{m.name}</code>
|
||||||
|
<span className="text-gray-400">
|
||||||
|
{" "}
|
||||||
|
({m.oldType} → {m.newType})
|
||||||
|
</span>
|
||||||
|
{i < incompatibilities.inputTypeMismatches.length - 1 && ", "}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{sections}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2 rounded-t-xl bg-amber-50 px-3 py-2 dark:bg-amber-900/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<WarningIcon className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||||
|
<span className="text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
Remove incompatible connections
|
||||||
|
</span>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<InfoIcon className="h-4 w-4 cursor-help text-amber-500" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-sm">
|
||||||
|
<p className="mb-2 font-semibold">Incompatible changes:</p>
|
||||||
|
<div className="text-xs">{renderIncompatibilities()}</div>
|
||||||
|
<p className="mt-2 text-xs text-gray-400">
|
||||||
|
{(incompatibilities?.newRequiredInputs.length ?? 0) > 0
|
||||||
|
? "Replace / delete"
|
||||||
|
: "Delete"}{" "}
|
||||||
|
the red connections to continue
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
|
||||||
|
import {
|
||||||
|
useNodeStore,
|
||||||
|
NodeResolutionData,
|
||||||
|
} from "@/app/(platform)/build/stores/nodeStore";
|
||||||
|
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||||
|
import {
|
||||||
|
useSubAgentUpdate,
|
||||||
|
createUpdatedAgentNodeInputs,
|
||||||
|
getBrokenEdgeIDs,
|
||||||
|
} from "@/app/(platform)/build/hooks/useSubAgentUpdate";
|
||||||
|
import { GraphInputSchema, GraphOutputSchema } from "@/lib/autogpt-server-api";
|
||||||
|
import { CustomNodeData } from "../../CustomNode";
|
||||||
|
|
||||||
|
// Stable empty set to avoid creating new references in selectors
|
||||||
|
const EMPTY_SET: Set<string> = new Set();
|
||||||
|
|
||||||
|
type UseSubAgentUpdateParams = {
|
||||||
|
nodeID: string;
|
||||||
|
nodeData: CustomNodeData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSubAgentUpdateState({
|
||||||
|
nodeID,
|
||||||
|
nodeData,
|
||||||
|
}: UseSubAgentUpdateParams) {
|
||||||
|
const [showIncompatibilityDialog, setShowIncompatibilityDialog] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
// Get store actions
|
||||||
|
const updateNodeData = useNodeStore(
|
||||||
|
useShallow((state) => state.updateNodeData),
|
||||||
|
);
|
||||||
|
const setNodeResolutionMode = useNodeStore(
|
||||||
|
useShallow((state) => state.setNodeResolutionMode),
|
||||||
|
);
|
||||||
|
const isNodeInResolutionMode = useNodeStore(
|
||||||
|
useShallow((state) => state.isNodeInResolutionMode),
|
||||||
|
);
|
||||||
|
const setBrokenEdgeIDs = useNodeStore(
|
||||||
|
useShallow((state) => state.setBrokenEdgeIDs),
|
||||||
|
);
|
||||||
|
// Get this node's broken edge IDs from the per-node map
|
||||||
|
// Use EMPTY_SET as fallback to maintain referential stability
|
||||||
|
const brokenEdgeIDs = useNodeStore(
|
||||||
|
(state) => state.brokenEdgeIDs.get(nodeID) || EMPTY_SET,
|
||||||
|
);
|
||||||
|
const getNodeResolutionData = useNodeStore(
|
||||||
|
useShallow((state) => state.getNodeResolutionData),
|
||||||
|
);
|
||||||
|
const connectedEdges = useEdgeStore(
|
||||||
|
useShallow((state) => state.getNodeEdges(nodeID)),
|
||||||
|
);
|
||||||
|
const availableSubGraphs = useGraphStore(
|
||||||
|
useShallow((state) => state.availableSubGraphs),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract agent-specific data
|
||||||
|
const graphID = nodeData.hardcodedValues?.graph_id as string | undefined;
|
||||||
|
const graphVersion = nodeData.hardcodedValues?.graph_version as
|
||||||
|
| number
|
||||||
|
| undefined;
|
||||||
|
const currentInputSchema = nodeData.hardcodedValues?.input_schema as
|
||||||
|
| GraphInputSchema
|
||||||
|
| undefined;
|
||||||
|
const currentOutputSchema = nodeData.hardcodedValues?.output_schema as
|
||||||
|
| GraphOutputSchema
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// Use the sub-agent update hook
|
||||||
|
const updateInfo = useSubAgentUpdate(
|
||||||
|
nodeID,
|
||||||
|
graphID,
|
||||||
|
graphVersion,
|
||||||
|
currentInputSchema,
|
||||||
|
currentOutputSchema,
|
||||||
|
connectedEdges,
|
||||||
|
availableSubGraphs,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isInResolutionMode = isNodeInResolutionMode(nodeID);
|
||||||
|
|
||||||
|
// Handle update button click
|
||||||
|
const handleUpdateClick = useCallback(() => {
|
||||||
|
if (!updateInfo.hasUpdate || !updateInfo.latestGraph) return;
|
||||||
|
|
||||||
|
if (updateInfo.isCompatible) {
|
||||||
|
// Compatible update - apply directly
|
||||||
|
const newHardcodedValues = createUpdatedAgentNodeInputs(
|
||||||
|
nodeData.hardcodedValues,
|
||||||
|
updateInfo.latestGraph,
|
||||||
|
);
|
||||||
|
updateNodeData(nodeID, { hardcodedValues: newHardcodedValues });
|
||||||
|
} else {
|
||||||
|
// Incompatible update - show dialog
|
||||||
|
setShowIncompatibilityDialog(true);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
updateInfo.hasUpdate,
|
||||||
|
updateInfo.latestGraph,
|
||||||
|
updateInfo.isCompatible,
|
||||||
|
nodeData.hardcodedValues,
|
||||||
|
updateNodeData,
|
||||||
|
nodeID,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Handle confirming an incompatible update
|
||||||
|
function handleConfirmIncompatibleUpdate() {
|
||||||
|
if (!updateInfo.latestGraph || !updateInfo.incompatibilities) return;
|
||||||
|
|
||||||
|
const latestGraph = updateInfo.latestGraph;
|
||||||
|
|
||||||
|
// Get the new schemas from the latest graph version
|
||||||
|
const newInputSchema =
|
||||||
|
(latestGraph.input_schema as Record<string, unknown>) || {};
|
||||||
|
const newOutputSchema =
|
||||||
|
(latestGraph.output_schema as Record<string, unknown>) || {};
|
||||||
|
|
||||||
|
// Create the updated hardcoded values but DON'T apply them yet
|
||||||
|
// We'll apply them when resolution is complete
|
||||||
|
const pendingHardcodedValues = createUpdatedAgentNodeInputs(
|
||||||
|
nodeData.hardcodedValues,
|
||||||
|
latestGraph,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get broken edge IDs and store them for this node
|
||||||
|
const brokenIds = getBrokenEdgeIDs(
|
||||||
|
connectedEdges,
|
||||||
|
updateInfo.incompatibilities,
|
||||||
|
nodeID,
|
||||||
|
);
|
||||||
|
setBrokenEdgeIDs(nodeID, brokenIds);
|
||||||
|
|
||||||
|
// Enter resolution mode with both old and new schemas
|
||||||
|
// DON'T apply the update yet - keep old schema so connections remain visible
|
||||||
|
const resolutionData: NodeResolutionData = {
|
||||||
|
incompatibilities: updateInfo.incompatibilities,
|
||||||
|
pendingUpdate: {
|
||||||
|
input_schema: newInputSchema,
|
||||||
|
output_schema: newOutputSchema,
|
||||||
|
},
|
||||||
|
currentSchema: {
|
||||||
|
input_schema: (currentInputSchema as Record<string, unknown>) || {},
|
||||||
|
output_schema: (currentOutputSchema as Record<string, unknown>) || {},
|
||||||
|
},
|
||||||
|
pendingHardcodedValues,
|
||||||
|
};
|
||||||
|
setNodeResolutionMode(nodeID, true, resolutionData);
|
||||||
|
|
||||||
|
setShowIncompatibilityDialog(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if resolution is complete (all broken edges removed)
|
||||||
|
const resolutionData = getNodeResolutionData(nodeID);
|
||||||
|
|
||||||
|
// Auto-check resolution on edge changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInResolutionMode) return;
|
||||||
|
|
||||||
|
// Check if any broken edges still exist
|
||||||
|
const remainingBroken = Array.from(brokenEdgeIDs).filter((edgeId) =>
|
||||||
|
connectedEdges.some((e) => e.id === edgeId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (remainingBroken.length === 0) {
|
||||||
|
// Resolution complete - now apply the pending update
|
||||||
|
if (resolutionData?.pendingHardcodedValues) {
|
||||||
|
updateNodeData(nodeID, {
|
||||||
|
hardcodedValues: resolutionData.pendingHardcodedValues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// setNodeResolutionMode will clean up this node's broken edges automatically
|
||||||
|
setNodeResolutionMode(nodeID, false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isInResolutionMode,
|
||||||
|
brokenEdgeIDs,
|
||||||
|
connectedEdges,
|
||||||
|
resolutionData,
|
||||||
|
nodeID,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateInfo,
|
||||||
|
isInResolutionMode,
|
||||||
|
resolutionData,
|
||||||
|
showIncompatibilityDialog,
|
||||||
|
setShowIncompatibilityDialog,
|
||||||
|
handleUpdateClick,
|
||||||
|
handleConfirmIncompatibleUpdate,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||||
|
import { NodeResolutionData } from "@/app/(platform)/build/stores/nodeStore";
|
||||||
|
import { RJSFSchema } from "@rjsf/utils";
|
||||||
|
|
||||||
export const nodeStyleBasedOnStatus: Record<AgentExecutionStatus, string> = {
|
export const nodeStyleBasedOnStatus: Record<AgentExecutionStatus, string> = {
|
||||||
INCOMPLETE: "ring-slate-300 bg-slate-300",
|
INCOMPLETE: "ring-slate-300 bg-slate-300",
|
||||||
@@ -9,3 +11,48 @@ export const nodeStyleBasedOnStatus: Record<AgentExecutionStatus, string> = {
|
|||||||
TERMINATED: "ring-orange-300 bg-orange-300 ",
|
TERMINATED: "ring-orange-300 bg-orange-300 ",
|
||||||
FAILED: "ring-red-300 bg-red-300",
|
FAILED: "ring-red-300 bg-red-300",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges schemas during resolution mode to include removed inputs/outputs
|
||||||
|
* that still have connections, so users can see and delete them.
|
||||||
|
*/
|
||||||
|
export function mergeSchemaForResolution(
|
||||||
|
currentSchema: Record<string, unknown>,
|
||||||
|
newSchema: Record<string, unknown>,
|
||||||
|
resolutionData: NodeResolutionData,
|
||||||
|
type: "input" | "output",
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const newProps = (newSchema.properties as RJSFSchema) || {};
|
||||||
|
const currentProps = (currentSchema.properties as RJSFSchema) || {};
|
||||||
|
const mergedProps = { ...newProps };
|
||||||
|
const incomp = resolutionData.incompatibilities;
|
||||||
|
|
||||||
|
if (type === "input") {
|
||||||
|
// Add back missing inputs that have connections
|
||||||
|
incomp.missingInputs.forEach((inputName: string) => {
|
||||||
|
if (currentProps[inputName]) {
|
||||||
|
mergedProps[inputName] = currentProps[inputName];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Add back inputs with type mismatches (keep old type so connection works visually)
|
||||||
|
incomp.inputTypeMismatches.forEach(
|
||||||
|
(mismatch: { name: string; oldType: string; newType: string }) => {
|
||||||
|
if (currentProps[mismatch.name]) {
|
||||||
|
mergedProps[mismatch.name] = currentProps[mismatch.name];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Add back missing outputs that have connections
|
||||||
|
incomp.missingOutputs.forEach((outputName: string) => {
|
||||||
|
if (currentProps[outputName]) {
|
||||||
|
mergedProps[outputName] = currentProps[outputName];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...newSchema,
|
||||||
|
properties: mergedProps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||||
|
import { CustomNodeData } from "./CustomNode";
|
||||||
|
import { BlockUIType } from "../../../types";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { mergeSchemaForResolution } from "./helpers";
|
||||||
|
|
||||||
|
export const useCustomNode = ({
|
||||||
|
data,
|
||||||
|
nodeId,
|
||||||
|
}: {
|
||||||
|
data: CustomNodeData;
|
||||||
|
nodeId: string;
|
||||||
|
}) => {
|
||||||
|
const isInResolutionMode = useNodeStore((state) =>
|
||||||
|
state.nodesInResolutionMode.has(nodeId),
|
||||||
|
);
|
||||||
|
const resolutionData = useNodeStore((state) =>
|
||||||
|
state.nodeResolutionData.get(nodeId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isAgent = data.uiType === BlockUIType.AGENT;
|
||||||
|
|
||||||
|
const currentInputSchema = isAgent
|
||||||
|
? (data.hardcodedValues.input_schema ?? {})
|
||||||
|
: data.inputSchema;
|
||||||
|
const currentOutputSchema = isAgent
|
||||||
|
? (data.hardcodedValues.output_schema ?? {})
|
||||||
|
: data.outputSchema;
|
||||||
|
|
||||||
|
const inputSchema = useMemo(() => {
|
||||||
|
if (isAgent && isInResolutionMode && resolutionData) {
|
||||||
|
return mergeSchemaForResolution(
|
||||||
|
resolutionData.currentSchema.input_schema,
|
||||||
|
resolutionData.pendingUpdate.input_schema,
|
||||||
|
resolutionData,
|
||||||
|
"input",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return currentInputSchema;
|
||||||
|
}, [isAgent, isInResolutionMode, resolutionData, currentInputSchema]);
|
||||||
|
|
||||||
|
const outputSchema = useMemo(() => {
|
||||||
|
if (isAgent && isInResolutionMode && resolutionData) {
|
||||||
|
return mergeSchemaForResolution(
|
||||||
|
resolutionData.currentSchema.output_schema,
|
||||||
|
resolutionData.pendingUpdate.output_schema,
|
||||||
|
resolutionData,
|
||||||
|
"output",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return currentOutputSchema;
|
||||||
|
}, [isAgent, isInResolutionMode, resolutionData, currentOutputSchema]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputSchema,
|
||||||
|
outputSchema,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -5,20 +5,16 @@ import { useNodeStore } from "../../../stores/nodeStore";
|
|||||||
import { BlockUIType } from "../../types";
|
import { BlockUIType } from "../../types";
|
||||||
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
|
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
|
||||||
|
|
||||||
export const FormCreator = React.memo(
|
interface FormCreatorProps {
|
||||||
({
|
jsonSchema: RJSFSchema;
|
||||||
jsonSchema,
|
nodeId: string;
|
||||||
nodeId,
|
uiType: BlockUIType;
|
||||||
uiType,
|
showHandles?: boolean;
|
||||||
showHandles = true,
|
className?: string;
|
||||||
className,
|
}
|
||||||
}: {
|
|
||||||
jsonSchema: RJSFSchema;
|
export const FormCreator: React.FC<FormCreatorProps> = React.memo(
|
||||||
nodeId: string;
|
({ jsonSchema, nodeId, uiType, showHandles = true, className }) => {
|
||||||
uiType: BlockUIType;
|
|
||||||
showHandles?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}) => {
|
|
||||||
const updateNodeData = useNodeStore((state) => state.updateNodeData);
|
const updateNodeData = useNodeStore((state) => state.updateNodeData);
|
||||||
|
|
||||||
const getHardCodedValues = useNodeStore(
|
const getHardCodedValues = useNodeStore(
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||||
import { getTypeDisplayInfo } from "./helpers";
|
import { getTypeDisplayInfo } from "./helpers";
|
||||||
import { BlockUIType } from "../../types";
|
import { BlockUIType } from "../../types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useBrokenOutputs } from "./useBrokenOutputs";
|
||||||
|
|
||||||
export const OutputHandler = ({
|
export const OutputHandler = ({
|
||||||
outputSchema,
|
outputSchema,
|
||||||
@@ -27,6 +29,9 @@ export const OutputHandler = ({
|
|||||||
const { isOutputConnected } = useEdgeStore();
|
const { isOutputConnected } = useEdgeStore();
|
||||||
const properties = outputSchema?.properties || {};
|
const properties = outputSchema?.properties || {};
|
||||||
const [isOutputVisible, setIsOutputVisible] = useState(true);
|
const [isOutputVisible, setIsOutputVisible] = useState(true);
|
||||||
|
const brokenOutputs = useBrokenOutputs(nodeId);
|
||||||
|
|
||||||
|
console.log("brokenOutputs", brokenOutputs);
|
||||||
|
|
||||||
const showHandles = uiType !== BlockUIType.OUTPUT;
|
const showHandles = uiType !== BlockUIType.OUTPUT;
|
||||||
|
|
||||||
@@ -44,6 +49,7 @@ export const OutputHandler = ({
|
|||||||
const shouldShow = isConnected || isOutputVisible;
|
const shouldShow = isConnected || isOutputVisible;
|
||||||
const { displayType, colorClass, hexColor } =
|
const { displayType, colorClass, hexColor } =
|
||||||
getTypeDisplayInfo(fieldSchema);
|
getTypeDisplayInfo(fieldSchema);
|
||||||
|
const isBroken = brokenOutputs.has(fullKey);
|
||||||
|
|
||||||
return shouldShow ? (
|
return shouldShow ? (
|
||||||
<div key={fullKey} className="flex flex-col items-end gap-2">
|
<div key={fullKey} className="flex flex-col items-end gap-2">
|
||||||
@@ -64,15 +70,29 @@ export const OutputHandler = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
<Text variant="body" className="text-slate-700">
|
<Text
|
||||||
|
variant="body"
|
||||||
|
className={cn(
|
||||||
|
"text-slate-700",
|
||||||
|
isBroken && "text-red-500 line-through",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{fieldTitle}
|
{fieldTitle}
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="small" as="span" className={colorClass}>
|
<Text
|
||||||
|
variant="small"
|
||||||
|
as="span"
|
||||||
|
className={cn(
|
||||||
|
colorClass,
|
||||||
|
isBroken && "!text-red-500 line-through",
|
||||||
|
)}
|
||||||
|
>
|
||||||
({displayType})
|
({displayType})
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{showHandles && (
|
{showHandles && (
|
||||||
<OutputNodeHandle
|
<OutputNodeHandle
|
||||||
|
isBroken={isBroken}
|
||||||
field_name={fullKey}
|
field_name={fullKey}
|
||||||
nodeId={nodeId}
|
nodeId={nodeId}
|
||||||
hexColor={hexColor}
|
hexColor={hexColor}
|
||||||
|
|||||||
@@ -89,6 +89,18 @@ export function extractOptions(
|
|||||||
|
|
||||||
// get display type and color for schema types [need for type display next to field name]
|
// get display type and color for schema types [need for type display next to field name]
|
||||||
export const getTypeDisplayInfo = (schema: any) => {
|
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) {
|
if (schema?.type === "string" && schema?.format) {
|
||||||
const formatMap: Record<
|
const formatMap: Record<
|
||||||
string,
|
string,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export const uiSchema = {
|
export const uiSchema = {
|
||||||
credentials: {
|
credentials: {
|
||||||
"ui:field": "credentials",
|
"ui:field": "custom/credential_field",
|
||||||
provider: { "ui:widget": "hidden" },
|
provider: { "ui:widget": "hidden" },
|
||||||
type: { "ui:widget": "hidden" },
|
type: { "ui:widget": "hidden" },
|
||||||
id: { "ui:autofocus": true },
|
id: { "ui:autofocus": true },
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get the set of broken output names for a node in resolution mode.
|
||||||
|
*/
|
||||||
|
export function useBrokenOutputs(nodeID: string): Set<string> {
|
||||||
|
// Subscribe to the actual state values, not just methods
|
||||||
|
const isInResolution = useNodeStore((state) =>
|
||||||
|
state.nodesInResolutionMode.has(nodeID),
|
||||||
|
);
|
||||||
|
const resolutionData = useNodeStore((state) =>
|
||||||
|
state.nodeResolutionData.get(nodeID),
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!isInResolution || !resolutionData) {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set(resolutionData.incompatibilities.missingOutputs);
|
||||||
|
}, [isInResolution, resolutionData]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useBlockMenuStore } from "@/app/(platform)/build/stores/blockMenuStore";
|
||||||
|
import { FilterChip } from "../FilterChip";
|
||||||
|
import { categories } from "./constants";
|
||||||
|
import { FilterSheet } from "../FilterSheet/FilterSheet";
|
||||||
|
import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
|
||||||
|
|
||||||
|
export const BlockMenuFilters = () => {
|
||||||
|
const {
|
||||||
|
filters,
|
||||||
|
addFilter,
|
||||||
|
removeFilter,
|
||||||
|
categoryCounts,
|
||||||
|
creators,
|
||||||
|
addCreator,
|
||||||
|
removeCreator,
|
||||||
|
} = useBlockMenuStore();
|
||||||
|
|
||||||
|
const handleFilterClick = (filter: GetV2BuilderSearchFilterAnyOfItem) => {
|
||||||
|
if (filters.includes(filter)) {
|
||||||
|
removeFilter(filter);
|
||||||
|
} else {
|
||||||
|
addFilter(filter);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreatorClick = (creator: string) => {
|
||||||
|
if (creators.includes(creator)) {
|
||||||
|
removeCreator(creator);
|
||||||
|
} else {
|
||||||
|
addCreator(creator);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<FilterSheet categories={categories} />
|
||||||
|
{creators.length > 0 &&
|
||||||
|
creators.map((creator) => (
|
||||||
|
<FilterChip
|
||||||
|
key={creator}
|
||||||
|
name={"Created by " + creator.slice(0, 10) + "..."}
|
||||||
|
selected={creators.includes(creator)}
|
||||||
|
onClick={() => handleCreatorClick(creator)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{categories.map((category) => (
|
||||||
|
<FilterChip
|
||||||
|
key={category.key}
|
||||||
|
name={category.name}
|
||||||
|
selected={filters.includes(category.key)}
|
||||||
|
onClick={() => handleFilterClick(category.key)}
|
||||||
|
number={categoryCounts[category.key] ?? 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
|
||||||
|
import { CategoryKey } from "./types";
|
||||||
|
|
||||||
|
export const categories: Array<{ key: CategoryKey; name: string }> = [
|
||||||
|
{ key: GetV2BuilderSearchFilterAnyOfItem.blocks, name: "Blocks" },
|
||||||
|
{
|
||||||
|
key: GetV2BuilderSearchFilterAnyOfItem.integrations,
|
||||||
|
name: "Integrations",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: GetV2BuilderSearchFilterAnyOfItem.marketplace_agents,
|
||||||
|
name: "Marketplace agents",
|
||||||
|
},
|
||||||
|
{ key: GetV2BuilderSearchFilterAnyOfItem.my_agents, name: "My agents" },
|
||||||
|
];
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
|
||||||
|
|
||||||
|
export type DefaultStateType =
|
||||||
|
| "suggestion"
|
||||||
|
| "all_blocks"
|
||||||
|
| "input_blocks"
|
||||||
|
| "action_blocks"
|
||||||
|
| "output_blocks"
|
||||||
|
| "integrations"
|
||||||
|
| "marketplace_agents"
|
||||||
|
| "my_agents";
|
||||||
|
|
||||||
|
export type CategoryKey = GetV2BuilderSearchFilterAnyOfItem;
|
||||||
|
|
||||||
|
export interface Filters {
|
||||||
|
categories: {
|
||||||
|
blocks: boolean;
|
||||||
|
integrations: boolean;
|
||||||
|
marketplace_agents: boolean;
|
||||||
|
my_agents: boolean;
|
||||||
|
providers: boolean;
|
||||||
|
};
|
||||||
|
createdBy: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CategoryCounts = Record<CategoryKey, number>;
|
||||||
@@ -1,111 +1,14 @@
|
|||||||
import { Text } from "@/components/atoms/Text/Text";
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
import { useBlockMenuSearch } from "./useBlockMenuSearch";
|
|
||||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
|
||||||
import { LoadingSpinner } from "@/components/__legacy__/ui/loading";
|
|
||||||
import { SearchResponseItemsItem } from "@/app/api/__generated__/models/searchResponseItemsItem";
|
|
||||||
import { MarketplaceAgentBlock } from "../MarketplaceAgentBlock";
|
|
||||||
import { Block } from "../Block";
|
|
||||||
import { UGCAgentBlock } from "../UGCAgentBlock";
|
|
||||||
import { getSearchItemType } from "./helper";
|
|
||||||
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
|
|
||||||
import { blockMenuContainerStyle } from "../style";
|
import { blockMenuContainerStyle } from "../style";
|
||||||
import { cn } from "@/lib/utils";
|
import { BlockMenuFilters } from "../BlockMenuFilters/BlockMenuFilters";
|
||||||
import { NoSearchResult } from "../NoSearchResult";
|
import { BlockMenuSearchContent } from "../BlockMenuSearchContent/BlockMenuSearchContent";
|
||||||
|
|
||||||
export const BlockMenuSearch = () => {
|
export const BlockMenuSearch = () => {
|
||||||
const {
|
|
||||||
searchResults,
|
|
||||||
isFetchingNextPage,
|
|
||||||
fetchNextPage,
|
|
||||||
hasNextPage,
|
|
||||||
searchLoading,
|
|
||||||
handleAddLibraryAgent,
|
|
||||||
handleAddMarketplaceAgent,
|
|
||||||
addingLibraryAgentId,
|
|
||||||
addingMarketplaceAgentSlug,
|
|
||||||
} = useBlockMenuSearch();
|
|
||||||
const { searchQuery } = useBlockMenuStore();
|
|
||||||
|
|
||||||
if (searchLoading) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
blockMenuContainerStyle,
|
|
||||||
"flex items-center justify-center",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<LoadingSpinner className="size-13" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchResults.length === 0) {
|
|
||||||
return <NoSearchResult />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={blockMenuContainerStyle}>
|
<div className={blockMenuContainerStyle}>
|
||||||
|
<BlockMenuFilters />
|
||||||
<Text variant="body-medium">Search results</Text>
|
<Text variant="body-medium">Search results</Text>
|
||||||
<InfiniteScroll
|
<BlockMenuSearchContent />
|
||||||
isFetchingNextPage={isFetchingNextPage}
|
|
||||||
fetchNextPage={fetchNextPage}
|
|
||||||
hasNextPage={hasNextPage}
|
|
||||||
loader={<LoadingSpinner className="size-13" />}
|
|
||||||
className="space-y-2.5"
|
|
||||||
>
|
|
||||||
{searchResults.map((item: SearchResponseItemsItem, index: number) => {
|
|
||||||
const { type, data } = getSearchItemType(item);
|
|
||||||
// backend give support to these 3 types only [right now] - we need to give support to integration and ai agent types in follow up PRs
|
|
||||||
switch (type) {
|
|
||||||
case "store_agent":
|
|
||||||
return (
|
|
||||||
<MarketplaceAgentBlock
|
|
||||||
key={index}
|
|
||||||
slug={data.slug}
|
|
||||||
highlightedText={searchQuery}
|
|
||||||
title={data.agent_name}
|
|
||||||
image_url={data.agent_image}
|
|
||||||
creator_name={data.creator}
|
|
||||||
number_of_runs={data.runs}
|
|
||||||
loading={addingMarketplaceAgentSlug === data.slug}
|
|
||||||
onClick={() =>
|
|
||||||
handleAddMarketplaceAgent({
|
|
||||||
creator_name: data.creator,
|
|
||||||
slug: data.slug,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "block":
|
|
||||||
return (
|
|
||||||
<Block
|
|
||||||
key={index}
|
|
||||||
title={data.name}
|
|
||||||
highlightedText={searchQuery}
|
|
||||||
description={data.description}
|
|
||||||
blockData={data}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "library_agent":
|
|
||||||
return (
|
|
||||||
<UGCAgentBlock
|
|
||||||
key={index}
|
|
||||||
title={data.name}
|
|
||||||
highlightedText={searchQuery}
|
|
||||||
image_url={data.image_url}
|
|
||||||
version={data.graph_version}
|
|
||||||
edited_time={data.updated_at}
|
|
||||||
isLoading={addingLibraryAgentId === data.id}
|
|
||||||
onClick={() => handleAddLibraryAgent(data)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</InfiniteScroll>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { SearchResponseItemsItem } from "@/app/api/__generated__/models/searchResponseItemsItem";
|
||||||
|
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||||
|
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||||
|
import { getSearchItemType } from "./helper";
|
||||||
|
import { MarketplaceAgentBlock } from "../MarketplaceAgentBlock";
|
||||||
|
import { Block } from "../Block";
|
||||||
|
import { UGCAgentBlock } from "../UGCAgentBlock";
|
||||||
|
import { useBlockMenuSearchContent } from "./useBlockMenuSearchContent";
|
||||||
|
import { useBlockMenuStore } from "@/app/(platform)/build/stores/blockMenuStore";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { blockMenuContainerStyle } from "../style";
|
||||||
|
import { NoSearchResult } from "../NoSearchResult";
|
||||||
|
|
||||||
|
export const BlockMenuSearchContent = () => {
|
||||||
|
const {
|
||||||
|
searchResults,
|
||||||
|
isFetchingNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
searchLoading,
|
||||||
|
handleAddLibraryAgent,
|
||||||
|
handleAddMarketplaceAgent,
|
||||||
|
addingLibraryAgentId,
|
||||||
|
addingMarketplaceAgentSlug,
|
||||||
|
} = useBlockMenuSearchContent();
|
||||||
|
|
||||||
|
const { searchQuery } = useBlockMenuStore();
|
||||||
|
|
||||||
|
if (searchLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
blockMenuContainerStyle,
|
||||||
|
"flex items-center justify-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LoadingSpinner className="size-13" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchResults.length === 0) {
|
||||||
|
return <NoSearchResult />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InfiniteScroll
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
fetchNextPage={fetchNextPage}
|
||||||
|
hasNextPage={hasNextPage}
|
||||||
|
loader={<LoadingSpinner className="size-13" />}
|
||||||
|
className="space-y-2.5"
|
||||||
|
>
|
||||||
|
{searchResults.map((item: SearchResponseItemsItem, index: number) => {
|
||||||
|
const { type, data } = getSearchItemType(item);
|
||||||
|
// backend give support to these 3 types only [right now] - we need to give support to integration and ai agent types in follow up PRs
|
||||||
|
switch (type) {
|
||||||
|
case "store_agent":
|
||||||
|
return (
|
||||||
|
<MarketplaceAgentBlock
|
||||||
|
key={index}
|
||||||
|
slug={data.slug}
|
||||||
|
highlightedText={searchQuery}
|
||||||
|
title={data.agent_name}
|
||||||
|
image_url={data.agent_image}
|
||||||
|
creator_name={data.creator}
|
||||||
|
number_of_runs={data.runs}
|
||||||
|
loading={addingMarketplaceAgentSlug === data.slug}
|
||||||
|
onClick={() =>
|
||||||
|
handleAddMarketplaceAgent({
|
||||||
|
creator_name: data.creator,
|
||||||
|
slug: data.slug,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "block":
|
||||||
|
return (
|
||||||
|
<Block
|
||||||
|
key={index}
|
||||||
|
title={data.name}
|
||||||
|
highlightedText={searchQuery}
|
||||||
|
description={data.description}
|
||||||
|
blockData={data}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "library_agent":
|
||||||
|
return (
|
||||||
|
<UGCAgentBlock
|
||||||
|
key={index}
|
||||||
|
title={data.name}
|
||||||
|
highlightedText={searchQuery}
|
||||||
|
image_url={data.image_url}
|
||||||
|
version={data.graph_version}
|
||||||
|
edited_time={data.updated_at}
|
||||||
|
isLoading={addingLibraryAgentId === data.id}
|
||||||
|
onClick={() => handleAddLibraryAgent(data)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</InfiniteScroll>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -23,9 +23,19 @@ import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
|||||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
|
||||||
|
|
||||||
|
export const useBlockMenuSearchContent = () => {
|
||||||
|
const {
|
||||||
|
searchQuery,
|
||||||
|
searchId,
|
||||||
|
setSearchId,
|
||||||
|
filters,
|
||||||
|
setCreatorsList,
|
||||||
|
creators,
|
||||||
|
setCategoryCounts,
|
||||||
|
} = useBlockMenuStore();
|
||||||
|
|
||||||
export const useBlockMenuSearch = () => {
|
|
||||||
const { searchQuery, searchId, setSearchId } = useBlockMenuStore();
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { addAgentToBuilder, addLibraryAgentToBuilder } =
|
const { addAgentToBuilder, addLibraryAgentToBuilder } =
|
||||||
useAddAgentToBuilder();
|
useAddAgentToBuilder();
|
||||||
@@ -57,6 +67,8 @@ export const useBlockMenuSearch = () => {
|
|||||||
page_size: 8,
|
page_size: 8,
|
||||||
search_query: searchQuery,
|
search_query: searchQuery,
|
||||||
search_id: searchId,
|
search_id: searchId,
|
||||||
|
filter: filters.length > 0 ? filters : undefined,
|
||||||
|
by_creator: creators.length > 0 ? creators : undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
query: { getNextPageParam: getPaginationNextPageNumber },
|
query: { getNextPageParam: getPaginationNextPageNumber },
|
||||||
@@ -98,6 +110,26 @@ export const useBlockMenuSearch = () => {
|
|||||||
}
|
}
|
||||||
}, [searchQueryData, searchId, setSearchId]);
|
}, [searchQueryData, searchId, setSearchId]);
|
||||||
|
|
||||||
|
// from all the results, we need to get all the unique creators
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchQueryData?.pages?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const latestData = okData(searchQueryData.pages.at(-1));
|
||||||
|
setCategoryCounts(
|
||||||
|
(latestData?.total_items as Record<
|
||||||
|
GetV2BuilderSearchFilterAnyOfItem,
|
||||||
|
number
|
||||||
|
>) || {
|
||||||
|
blocks: 0,
|
||||||
|
integrations: 0,
|
||||||
|
marketplace_agents: 0,
|
||||||
|
my_agents: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setCreatorsList(latestData?.items || []);
|
||||||
|
}, [searchQueryData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchId && !searchQuery) {
|
if (searchId && !searchQuery) {
|
||||||
resetSearchSession();
|
resetSearchSession();
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Button } from "@/components/__legacy__/ui/button";
|
import { Button } from "@/components/__legacy__/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { X } from "lucide-react";
|
import { XIcon } from "@phosphor-icons/react";
|
||||||
import React, { ButtonHTMLAttributes } from "react";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
|
||||||
|
import React, { ButtonHTMLAttributes, useState } from "react";
|
||||||
|
|
||||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
@@ -16,39 +18,51 @@ export const FilterChip: React.FC<Props> = ({
|
|||||||
className,
|
className,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
return (
|
return (
|
||||||
<Button
|
<AnimatePresence mode="wait">
|
||||||
className={cn(
|
<Button
|
||||||
"group w-fit space-x-1 rounded-[1.5rem] border border-zinc-300 bg-transparent px-[0.625rem] py-[0.375rem] shadow-none transition-transform duration-300 ease-in-out",
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
"hover:border-violet-500 hover:bg-transparent focus:ring-0 disabled:cursor-not-allowed",
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
selected && "border-0 bg-violet-700 hover:border",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-sans text-sm font-medium leading-[1.375rem] text-zinc-600 group-hover:text-zinc-600 group-disabled:text-zinc-400",
|
"group w-fit space-x-1 rounded-[1.5rem] border border-zinc-300 bg-transparent px-[0.625rem] py-[0.375rem] shadow-none",
|
||||||
selected && "text-zinc-50",
|
"hover:border-violet-500 hover:bg-transparent focus:ring-0 disabled:cursor-not-allowed",
|
||||||
|
selected && "border-0 bg-violet-700 hover:border",
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
|
{...rest}
|
||||||
>
|
>
|
||||||
{name}
|
<span
|
||||||
</span>
|
className={cn(
|
||||||
{selected && (
|
"font-sans text-sm font-medium leading-[1.375rem] text-zinc-600 group-hover:text-zinc-600 group-disabled:text-zinc-400",
|
||||||
<>
|
selected && "text-zinc-50",
|
||||||
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-zinc-50 transition-all duration-300 ease-in-out group-hover:hidden">
|
|
||||||
<X
|
|
||||||
className="h-3 w-3 rounded-full text-violet-700"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
{number !== undefined && (
|
|
||||||
<span className="hidden h-[1.375rem] items-center rounded-[1.25rem] bg-violet-700 p-[0.375rem] text-zinc-50 transition-all duration-300 ease-in-out animate-in fade-in zoom-in group-hover:flex">
|
|
||||||
{number > 100 ? "100+" : number}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</>
|
>
|
||||||
)}
|
{name}
|
||||||
</Button>
|
</span>
|
||||||
|
{selected && !isHovered && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0.5, scale: 0.5, filter: "blur(20px)" }}
|
||||||
|
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
||||||
|
exit={{ opacity: 0.5, scale: 0.5, filter: "blur(20px)" }}
|
||||||
|
transition={{ duration: 0.3, type: "spring", bounce: 0.2 }}
|
||||||
|
className="flex h-4 w-4 items-center justify-center rounded-full bg-zinc-50"
|
||||||
|
>
|
||||||
|
<XIcon size={12} weight="bold" className="text-violet-700" />
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
{number !== undefined && isHovered && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0.5, scale: 0.5, filter: "blur(10px)" }}
|
||||||
|
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
||||||
|
exit={{ opacity: 0.5, scale: 0.5, filter: "blur(10px)" }}
|
||||||
|
transition={{ duration: 0.3, type: "spring", bounce: 0.2 }}
|
||||||
|
className="flex h-[1.375rem] items-center rounded-[1.25rem] bg-violet-700 p-[0.375rem] text-zinc-50"
|
||||||
|
>
|
||||||
|
{number > 100 ? "100+" : number}
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { FilterChip } from "../FilterChip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CategoryKey } from "../BlockMenuFilters/types";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { XIcon } from "@phosphor-icons/react";
|
||||||
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||||
|
import { Checkbox } from "@/components/__legacy__/ui/checkbox";
|
||||||
|
import { useFilterSheet } from "./useFilterSheet";
|
||||||
|
import { INITIAL_CREATORS_TO_SHOW } from "./constant";
|
||||||
|
|
||||||
|
export function FilterSheet({
|
||||||
|
categories,
|
||||||
|
}: {
|
||||||
|
categories: Array<{ key: CategoryKey; name: string }>;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
isOpen,
|
||||||
|
localCategories,
|
||||||
|
localCreators,
|
||||||
|
displayedCreatorsCount,
|
||||||
|
handleLocalCategoryChange,
|
||||||
|
handleToggleShowMoreCreators,
|
||||||
|
handleLocalCreatorChange,
|
||||||
|
handleClearFilters,
|
||||||
|
handleCloseButton,
|
||||||
|
handleApplyFilters,
|
||||||
|
hasLocalActiveFilters,
|
||||||
|
visibleCreators,
|
||||||
|
creators,
|
||||||
|
handleOpenFilters,
|
||||||
|
hasActiveFilters,
|
||||||
|
} = useFilterSheet();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="m-0 inline w-fit p-0">
|
||||||
|
<FilterChip
|
||||||
|
name={hasActiveFilters() ? "Edit filters" : "All filters"}
|
||||||
|
onClick={handleOpenFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
className={cn(
|
||||||
|
"absolute bottom-2 left-2 top-2 z-20 w-3/4 max-w-[22.5rem] space-y-4 overflow-hidden rounded-[0.75rem] bg-white pb-4 shadow-[0_4px_12px_2px_rgba(0,0,0,0.1)]",
|
||||||
|
)}
|
||||||
|
initial={{ x: "-100%", filter: "blur(10px)" }}
|
||||||
|
animate={{ x: 0, filter: "blur(0px)" }}
|
||||||
|
exit={{ x: "-110%", filter: "blur(10px)" }}
|
||||||
|
transition={{ duration: 0.4, type: "spring", bounce: 0.2 }}
|
||||||
|
>
|
||||||
|
{/* Top section */}
|
||||||
|
<div className="flex items-center justify-between px-5 pt-4">
|
||||||
|
<Text variant="body">Filters</Text>
|
||||||
|
<Button
|
||||||
|
className="p-0"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleCloseButton}
|
||||||
|
>
|
||||||
|
<XIcon size={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="h-[1px] w-full text-zinc-300" />
|
||||||
|
|
||||||
|
{/* Category section */}
|
||||||
|
<div className="space-y-4 px-5">
|
||||||
|
<Text variant="large">Categories</Text>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<div
|
||||||
|
key={category.key}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={category.key}
|
||||||
|
checked={localCategories.includes(category.key)}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
handleLocalCategoryChange(category.key)
|
||||||
|
}
|
||||||
|
className="border border-[#D4D4D4] shadow-none data-[state=checked]:border-none data-[state=checked]:bg-violet-700 data-[state=checked]:text-white"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={category.key}
|
||||||
|
className="font-sans text-sm leading-[1.375rem] text-zinc-600"
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Created by section */}
|
||||||
|
<div className="space-y-4 px-5">
|
||||||
|
<p className="font-sans text-base font-medium text-zinc-800">
|
||||||
|
Created by
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{visibleCreators.map((creator, i) => (
|
||||||
|
<div key={i} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`creator-${creator}`}
|
||||||
|
checked={localCreators.includes(creator)}
|
||||||
|
onCheckedChange={() => handleLocalCreatorChange(creator)}
|
||||||
|
className="border border-[#D4D4D4] shadow-none data-[state=checked]:border-none data-[state=checked]:bg-violet-700 data-[state=checked]:text-white"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`creator-${creator}`}
|
||||||
|
className="font-sans text-sm leading-[1.375rem] text-zinc-600"
|
||||||
|
>
|
||||||
|
{creator}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{creators.length > INITIAL_CREATORS_TO_SHOW && (
|
||||||
|
<Button
|
||||||
|
variant={"link"}
|
||||||
|
className="m-0 p-0 font-sans text-sm font-medium leading-[1.375rem] text-zinc-800 underline hover:text-zinc-600"
|
||||||
|
onClick={handleToggleShowMoreCreators}
|
||||||
|
>
|
||||||
|
{displayedCreatorsCount < creators.length ? "More" : "Less"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer section */}
|
||||||
|
<div className="fixed bottom-0 flex w-full justify-between gap-3 border-t border-zinc-200 bg-white px-5 py-3">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={"outline"}
|
||||||
|
onClick={handleClearFilters}
|
||||||
|
className="rounded-[8px] px-2 py-1.5"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={handleApplyFilters}
|
||||||
|
disabled={!hasLocalActiveFilters()}
|
||||||
|
className="rounded-[8px] px-2 py-1.5"
|
||||||
|
>
|
||||||
|
Apply filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const INITIAL_CREATORS_TO_SHOW = 5;
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useBlockMenuStore } from "@/app/(platform)/build/stores/blockMenuStore";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { INITIAL_CREATORS_TO_SHOW } from "./constant";
|
||||||
|
import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
|
||||||
|
|
||||||
|
export const useFilterSheet = () => {
|
||||||
|
const { filters, creators_list, creators, setFilters, setCreators } =
|
||||||
|
useBlockMenuStore();
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [localCategories, setLocalCategories] =
|
||||||
|
useState<GetV2BuilderSearchFilterAnyOfItem[]>(filters);
|
||||||
|
const [localCreators, setLocalCreators] = useState<string[]>(creators);
|
||||||
|
const [displayedCreatorsCount, setDisplayedCreatorsCount] = useState(
|
||||||
|
INITIAL_CREATORS_TO_SHOW,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLocalCategoryChange = (
|
||||||
|
category: GetV2BuilderSearchFilterAnyOfItem,
|
||||||
|
) => {
|
||||||
|
setLocalCategories((prev) => {
|
||||||
|
if (prev.includes(category)) {
|
||||||
|
return prev.filter((c) => c !== category);
|
||||||
|
}
|
||||||
|
return [...prev, category];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = () => {
|
||||||
|
return filters.length > 0 || creators.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleShowMoreCreators = () => {
|
||||||
|
if (displayedCreatorsCount < creators.length) {
|
||||||
|
setDisplayedCreatorsCount(creators.length);
|
||||||
|
} else {
|
||||||
|
setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLocalCreatorChange = (creator: string) => {
|
||||||
|
setLocalCreators((prev) => {
|
||||||
|
if (prev.includes(creator)) {
|
||||||
|
return prev.filter((c) => c !== creator);
|
||||||
|
}
|
||||||
|
return [...prev, creator];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
setLocalCategories([]);
|
||||||
|
setLocalCreators([]);
|
||||||
|
setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseButton = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setLocalCategories(filters);
|
||||||
|
setLocalCreators(creators);
|
||||||
|
setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyFilters = () => {
|
||||||
|
setFilters(localCategories);
|
||||||
|
setCreators(localCreators);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenFilters = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
setLocalCategories(filters);
|
||||||
|
setLocalCreators(creators);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasLocalActiveFilters = () => {
|
||||||
|
return localCategories.length > 0 || localCreators.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleCreators = creators_list.slice(0, displayedCreatorsCount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
creators,
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
localCategories,
|
||||||
|
localCreators,
|
||||||
|
displayedCreatorsCount,
|
||||||
|
setDisplayedCreatorsCount,
|
||||||
|
handleLocalCategoryChange,
|
||||||
|
handleToggleShowMoreCreators,
|
||||||
|
handleLocalCreatorChange,
|
||||||
|
handleClearFilters,
|
||||||
|
handleCloseButton,
|
||||||
|
handleOpenFilters,
|
||||||
|
handleApplyFilters,
|
||||||
|
hasLocalActiveFilters,
|
||||||
|
visibleCreators,
|
||||||
|
hasActiveFilters,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -25,7 +25,7 @@ export const RightSidebar = () => {
|
|||||||
>
|
>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-200">
|
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-200">
|
||||||
Flow Debug Panel
|
Graph Debug Panel
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ export const RightSidebar = () => {
|
|||||||
{l.source_id}[{l.source_name}] → {l.sink_id}[{l.sink_name}]
|
{l.source_id}[{l.source_name}] → {l.sink_id}[{l.sink_name}]
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-slate-500 dark:text-slate-400">
|
<div className="mt-1 text-slate-500 dark:text-slate-400">
|
||||||
edge_id: {l.id}
|
edge.id: {l.id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -12,7 +12,14 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/__legacy__/ui/popover";
|
} from "@/components/__legacy__/ui/popover";
|
||||||
import { Block, BlockUIType, SpecialBlockID } from "@/lib/autogpt-server-api";
|
import {
|
||||||
|
Block,
|
||||||
|
BlockIORootSchema,
|
||||||
|
BlockUIType,
|
||||||
|
GraphInputSchema,
|
||||||
|
GraphOutputSchema,
|
||||||
|
SpecialBlockID,
|
||||||
|
} from "@/lib/autogpt-server-api";
|
||||||
import { MagnifyingGlassIcon, PlusIcon } from "@radix-ui/react-icons";
|
import { MagnifyingGlassIcon, PlusIcon } from "@radix-ui/react-icons";
|
||||||
import { IconToyBrick } from "@/components/__legacy__/ui/icons";
|
import { IconToyBrick } from "@/components/__legacy__/ui/icons";
|
||||||
import { getPrimaryCategoryColor } from "@/lib/utils";
|
import { getPrimaryCategoryColor } from "@/lib/utils";
|
||||||
@@ -24,8 +31,10 @@ import {
|
|||||||
import { GraphMeta } from "@/lib/autogpt-server-api";
|
import { GraphMeta } from "@/lib/autogpt-server-api";
|
||||||
import jaro from "jaro-winkler";
|
import jaro from "jaro-winkler";
|
||||||
|
|
||||||
type _Block = Block & {
|
type _Block = Omit<Block, "inputSchema" | "outputSchema"> & {
|
||||||
uiKey?: string;
|
uiKey?: string;
|
||||||
|
inputSchema: BlockIORootSchema | GraphInputSchema;
|
||||||
|
outputSchema: BlockIORootSchema | GraphOutputSchema;
|
||||||
hardcodedValues?: Record<string, any>;
|
hardcodedValues?: Record<string, any>;
|
||||||
_cached?: {
|
_cached?: {
|
||||||
blockName: string;
|
blockName: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/__legacy__/ui/button";
|
import { Button } from "@/components/__legacy__/ui/button";
|
||||||
import { LogOut } from "lucide-react";
|
import { LogOut } from "lucide-react";
|
||||||
import { ClockIcon } from "@phosphor-icons/react";
|
import { ClockIcon, WarningIcon } from "@phosphor-icons/react";
|
||||||
import { IconPlay, IconSquare } from "@/components/__legacy__/ui/icons";
|
import { IconPlay, IconSquare } from "@/components/__legacy__/ui/icons";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -13,6 +13,7 @@ interface Props {
|
|||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
isDisabled: boolean;
|
isDisabled: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
resolutionModeActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BuildActionBar: React.FC<Props> = ({
|
export const BuildActionBar: React.FC<Props> = ({
|
||||||
@@ -23,9 +24,30 @@ export const BuildActionBar: React.FC<Props> = ({
|
|||||||
isRunning,
|
isRunning,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
className,
|
className,
|
||||||
|
resolutionModeActive = false,
|
||||||
}) => {
|
}) => {
|
||||||
const buttonClasses =
|
const buttonClasses =
|
||||||
"flex items-center gap-2 text-sm font-medium md:text-lg";
|
"flex items-center gap-2 text-sm font-medium md:text-lg";
|
||||||
|
|
||||||
|
// Show resolution mode message instead of action buttons
|
||||||
|
if (resolutionModeActive) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit select-none items-center justify-center p-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 dark:border-amber-700 dark:bg-amber-900/30">
|
||||||
|
<WarningIcon className="size-5 text-amber-600 dark:text-amber-400" />
|
||||||
|
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||||
|
Remove incompatible connections to continue
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -60,10 +60,16 @@ export function CustomEdge({
|
|||||||
targetY - 5,
|
targetY - 5,
|
||||||
);
|
);
|
||||||
const { deleteElements } = useReactFlow<Node, CustomEdge>();
|
const { deleteElements } = useReactFlow<Node, CustomEdge>();
|
||||||
const { visualizeBeads } = useContext(BuilderContext) ?? {
|
const builderContext = useContext(BuilderContext);
|
||||||
|
const { visualizeBeads } = builderContext ?? {
|
||||||
visualizeBeads: "no",
|
visualizeBeads: "no",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if this edge is broken (during resolution mode)
|
||||||
|
const isBroken =
|
||||||
|
builderContext?.resolutionMode?.active &&
|
||||||
|
builderContext?.resolutionMode?.brokenEdgeIds?.includes(id);
|
||||||
|
|
||||||
const onEdgeRemoveClick = () => {
|
const onEdgeRemoveClick = () => {
|
||||||
deleteElements({ edges: [{ id }] });
|
deleteElements({ edges: [{ id }] });
|
||||||
};
|
};
|
||||||
@@ -171,12 +177,27 @@ export function CustomEdge({
|
|||||||
|
|
||||||
const middle = getPointForT(0.5);
|
const middle = getPointForT(0.5);
|
||||||
|
|
||||||
|
// Determine edge color - red for broken edges
|
||||||
|
const baseColor = data?.edgeColor ?? "#555555";
|
||||||
|
const edgeColor = isBroken ? "#ef4444" : baseColor;
|
||||||
|
// Add opacity to hex color (99 = 60% opacity, 80 = 50% opacity)
|
||||||
|
const strokeColor = isBroken
|
||||||
|
? `${edgeColor}99`
|
||||||
|
: selected
|
||||||
|
? edgeColor
|
||||||
|
: `${edgeColor}80`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseEdge
|
<BaseEdge
|
||||||
path={svgPath}
|
path={svgPath}
|
||||||
markerEnd={markerEnd}
|
markerEnd={markerEnd}
|
||||||
className={`data-sentry-unmask transition-all duration-200 ${data?.isStatic ? "[stroke-dasharray:5_3]" : "[stroke-dasharray:0]"} [stroke-width:${data?.isStatic ? 2.5 : 2}px] hover:[stroke-width:${data?.isStatic ? 3.5 : 3}px] ${selected ? `[stroke:${data?.edgeColor ?? "#555555"}]` : `[stroke:${data?.edgeColor ?? "#555555"}80] hover:[stroke:${data?.edgeColor ?? "#555555"}]`}`}
|
style={{
|
||||||
|
stroke: strokeColor,
|
||||||
|
strokeWidth: data?.isStatic ? 2.5 : 2,
|
||||||
|
strokeDasharray: data?.isStatic ? "5 3" : undefined,
|
||||||
|
}}
|
||||||
|
className="data-sentry-unmask transition-all duration-200"
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d={svgPath}
|
d={svgPath}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
BlockIOSubSchema,
|
BlockIOSubSchema,
|
||||||
BlockUIType,
|
BlockUIType,
|
||||||
Category,
|
Category,
|
||||||
|
GraphInputSchema,
|
||||||
|
GraphOutputSchema,
|
||||||
NodeExecutionResult,
|
NodeExecutionResult,
|
||||||
} from "@/lib/autogpt-server-api";
|
} from "@/lib/autogpt-server-api";
|
||||||
import {
|
import {
|
||||||
@@ -62,14 +64,21 @@ import { NodeGenericInputField, NodeTextBoxInput } from "../NodeInputs";
|
|||||||
import NodeOutputs from "../NodeOutputs";
|
import NodeOutputs from "../NodeOutputs";
|
||||||
import OutputModalComponent from "../OutputModalComponent";
|
import OutputModalComponent from "../OutputModalComponent";
|
||||||
import "./customnode.css";
|
import "./customnode.css";
|
||||||
|
import { SubAgentUpdateBar } from "./SubAgentUpdateBar";
|
||||||
|
import { IncompatibilityDialog } from "./IncompatibilityDialog";
|
||||||
|
import {
|
||||||
|
useSubAgentUpdate,
|
||||||
|
createUpdatedAgentNodeInputs,
|
||||||
|
getBrokenEdgeIDs,
|
||||||
|
} from "../../../hooks/useSubAgentUpdate";
|
||||||
|
|
||||||
export type ConnectionData = Array<{
|
export type ConnectedEdge = {
|
||||||
edge_id: string;
|
id: string;
|
||||||
source: string;
|
source: string;
|
||||||
sourceHandle: string;
|
sourceHandle: string;
|
||||||
target: string;
|
target: string;
|
||||||
targetHandle: string;
|
targetHandle: string;
|
||||||
}>;
|
};
|
||||||
|
|
||||||
export type CustomNodeData = {
|
export type CustomNodeData = {
|
||||||
blockType: string;
|
blockType: string;
|
||||||
@@ -80,7 +89,7 @@ export type CustomNodeData = {
|
|||||||
inputSchema: BlockIORootSchema;
|
inputSchema: BlockIORootSchema;
|
||||||
outputSchema: BlockIORootSchema;
|
outputSchema: BlockIORootSchema;
|
||||||
hardcodedValues: { [key: string]: any };
|
hardcodedValues: { [key: string]: any };
|
||||||
connections: ConnectionData;
|
connections: ConnectedEdge[];
|
||||||
isOutputOpen: boolean;
|
isOutputOpen: boolean;
|
||||||
status?: NodeExecutionResult["status"];
|
status?: NodeExecutionResult["status"];
|
||||||
/** executionResults contains outputs across multiple executions
|
/** executionResults contains outputs across multiple executions
|
||||||
@@ -127,20 +136,199 @@ export const CustomNode = React.memo(
|
|||||||
|
|
||||||
let subGraphID = "";
|
let subGraphID = "";
|
||||||
|
|
||||||
if (data.uiType === BlockUIType.AGENT) {
|
|
||||||
// Display the graph's schema instead AgentExecutorBlock's schema.
|
|
||||||
data.inputSchema = data.hardcodedValues?.input_schema || {};
|
|
||||||
data.outputSchema = data.hardcodedValues?.output_schema || {};
|
|
||||||
subGraphID = data.hardcodedValues?.graph_id || subGraphID;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!builderContext) {
|
if (!builderContext) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"BuilderContext consumer must be inside FlowEditor component",
|
"BuilderContext consumer must be inside FlowEditor component",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { libraryAgent, setIsAnyModalOpen, getNextNodeId } = builderContext;
|
const {
|
||||||
|
libraryAgent,
|
||||||
|
setIsAnyModalOpen,
|
||||||
|
getNextNodeId,
|
||||||
|
availableFlows,
|
||||||
|
resolutionMode,
|
||||||
|
enterResolutionMode,
|
||||||
|
} = builderContext;
|
||||||
|
|
||||||
|
// Check if this node is in resolution mode (moved up for schema merge logic)
|
||||||
|
const isInResolutionMode =
|
||||||
|
resolutionMode.active && resolutionMode.nodeId === id;
|
||||||
|
|
||||||
|
if (data.uiType === BlockUIType.AGENT) {
|
||||||
|
// Display the graph's schema instead AgentExecutorBlock's schema.
|
||||||
|
const currentInputSchema = data.hardcodedValues?.input_schema || {};
|
||||||
|
const currentOutputSchema = data.hardcodedValues?.output_schema || {};
|
||||||
|
subGraphID = data.hardcodedValues?.graph_id || subGraphID;
|
||||||
|
|
||||||
|
// During resolution mode, merge old connected inputs/outputs with new schema
|
||||||
|
if (isInResolutionMode && resolutionMode.pendingUpdate) {
|
||||||
|
const newInputSchema =
|
||||||
|
(resolutionMode.pendingUpdate.input_schema as BlockIORootSchema) ||
|
||||||
|
{};
|
||||||
|
const newOutputSchema =
|
||||||
|
(resolutionMode.pendingUpdate.output_schema as BlockIORootSchema) ||
|
||||||
|
{};
|
||||||
|
|
||||||
|
// Merge input schemas: start with new schema, add old connected inputs that are missing
|
||||||
|
const mergedInputProps = { ...newInputSchema.properties };
|
||||||
|
const incomp = resolutionMode.incompatibilities;
|
||||||
|
if (incomp && currentInputSchema.properties) {
|
||||||
|
// Add back missing inputs that have connections (so user can see/delete them)
|
||||||
|
incomp.missingInputs.forEach((inputName) => {
|
||||||
|
if (currentInputSchema.properties[inputName]) {
|
||||||
|
mergedInputProps[inputName] =
|
||||||
|
currentInputSchema.properties[inputName];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Add back inputs with type mismatches (keep old type so connection still works visually)
|
||||||
|
incomp.inputTypeMismatches.forEach((mismatch) => {
|
||||||
|
if (currentInputSchema.properties[mismatch.name]) {
|
||||||
|
mergedInputProps[mismatch.name] =
|
||||||
|
currentInputSchema.properties[mismatch.name];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge output schemas: start with new schema, add old connected outputs that are missing
|
||||||
|
const mergedOutputProps = { ...newOutputSchema.properties };
|
||||||
|
if (incomp && currentOutputSchema.properties) {
|
||||||
|
incomp.missingOutputs.forEach((outputName) => {
|
||||||
|
if (currentOutputSchema.properties[outputName]) {
|
||||||
|
mergedOutputProps[outputName] =
|
||||||
|
currentOutputSchema.properties[outputName];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
data.inputSchema = {
|
||||||
|
...newInputSchema,
|
||||||
|
properties: mergedInputProps,
|
||||||
|
};
|
||||||
|
data.outputSchema = {
|
||||||
|
...newOutputSchema,
|
||||||
|
properties: mergedOutputProps,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
data.inputSchema = currentInputSchema;
|
||||||
|
data.outputSchema = currentOutputSchema;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setHardcodedValues = useCallback(
|
||||||
|
(values: any) => {
|
||||||
|
updateNodeData(id, { hardcodedValues: values });
|
||||||
|
},
|
||||||
|
[id, updateNodeData],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sub-agent update detection
|
||||||
|
const isAgentBlock = data.uiType === BlockUIType.AGENT;
|
||||||
|
const graphId = isAgentBlock ? data.hardcodedValues?.graph_id : undefined;
|
||||||
|
const graphVersion = isAgentBlock
|
||||||
|
? data.hardcodedValues?.graph_version
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const subAgentUpdate = useSubAgentUpdate(
|
||||||
|
id,
|
||||||
|
graphId,
|
||||||
|
graphVersion,
|
||||||
|
isAgentBlock
|
||||||
|
? (data.hardcodedValues?.input_schema as GraphInputSchema)
|
||||||
|
: undefined,
|
||||||
|
isAgentBlock
|
||||||
|
? (data.hardcodedValues?.output_schema as GraphOutputSchema)
|
||||||
|
: undefined,
|
||||||
|
data.connections,
|
||||||
|
availableFlows,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [showIncompatibilityDialog, setShowIncompatibilityDialog] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
// Helper to check if a handle is broken (for resolution mode)
|
||||||
|
const isInputHandleBroken = useCallback(
|
||||||
|
(handleName: string): boolean => {
|
||||||
|
if (!isInResolutionMode || !resolutionMode.incompatibilities) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const incomp = resolutionMode.incompatibilities;
|
||||||
|
return (
|
||||||
|
incomp.missingInputs.includes(handleName) ||
|
||||||
|
incomp.inputTypeMismatches.some((m) => m.name === handleName)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[isInResolutionMode, resolutionMode.incompatibilities],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isOutputHandleBroken = useCallback(
|
||||||
|
(handleName: string): boolean => {
|
||||||
|
if (!isInResolutionMode || !resolutionMode.incompatibilities) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return resolutionMode.incompatibilities.missingOutputs.includes(
|
||||||
|
handleName,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[isInResolutionMode, resolutionMode.incompatibilities],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle update button click
|
||||||
|
const handleUpdateClick = useCallback(() => {
|
||||||
|
if (!subAgentUpdate.latestGraph) return;
|
||||||
|
|
||||||
|
if (subAgentUpdate.isCompatible) {
|
||||||
|
// Compatible update - directly apply
|
||||||
|
const updatedValues = createUpdatedAgentNodeInputs(
|
||||||
|
data.hardcodedValues,
|
||||||
|
subAgentUpdate.latestGraph,
|
||||||
|
);
|
||||||
|
setHardcodedValues(updatedValues);
|
||||||
|
toast({
|
||||||
|
title: "Agent updated",
|
||||||
|
description: `Updated to version ${subAgentUpdate.latestVersion}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Incompatible update - show dialog
|
||||||
|
setShowIncompatibilityDialog(true);
|
||||||
|
}
|
||||||
|
}, [subAgentUpdate, data.hardcodedValues, setHardcodedValues]);
|
||||||
|
|
||||||
|
// Handle confirm incompatible update
|
||||||
|
const handleConfirmIncompatibleUpdate = useCallback(() => {
|
||||||
|
if (!subAgentUpdate.latestGraph || !subAgentUpdate.incompatibilities) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the updated values but DON'T apply them yet
|
||||||
|
const updatedValues = createUpdatedAgentNodeInputs(
|
||||||
|
data.hardcodedValues,
|
||||||
|
subAgentUpdate.latestGraph,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get broken edge IDs
|
||||||
|
const brokenEdgeIds = getBrokenEdgeIDs(
|
||||||
|
data.connections,
|
||||||
|
subAgentUpdate.incompatibilities,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enter resolution mode with pending update (don't apply schema yet)
|
||||||
|
enterResolutionMode(
|
||||||
|
id,
|
||||||
|
subAgentUpdate.incompatibilities,
|
||||||
|
brokenEdgeIds,
|
||||||
|
updatedValues,
|
||||||
|
);
|
||||||
|
|
||||||
|
setShowIncompatibilityDialog(false);
|
||||||
|
}, [
|
||||||
|
subAgentUpdate,
|
||||||
|
data.hardcodedValues,
|
||||||
|
data.connections,
|
||||||
|
id,
|
||||||
|
enterResolutionMode,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data.executionResults || data.status) {
|
if (data.executionResults || data.status) {
|
||||||
@@ -156,13 +344,6 @@ export const CustomNode = React.memo(
|
|||||||
setIsAnyModalOpen?.(isModalOpen || isOutputModalOpen);
|
setIsAnyModalOpen?.(isModalOpen || isOutputModalOpen);
|
||||||
}, [isModalOpen, isOutputModalOpen, data, setIsAnyModalOpen]);
|
}, [isModalOpen, isOutputModalOpen, data, setIsAnyModalOpen]);
|
||||||
|
|
||||||
const setHardcodedValues = useCallback(
|
|
||||||
(values: any) => {
|
|
||||||
updateNodeData(id, { hardcodedValues: values });
|
|
||||||
},
|
|
||||||
[id, updateNodeData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTitleEdit = useCallback(() => {
|
const handleTitleEdit = useCallback(() => {
|
||||||
setIsEditingTitle(true);
|
setIsEditingTitle(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -255,6 +436,7 @@ export const CustomNode = React.memo(
|
|||||||
isConnected={isOutputHandleConnected(propKey)}
|
isConnected={isOutputHandleConnected(propKey)}
|
||||||
schema={fieldSchema}
|
schema={fieldSchema}
|
||||||
side="right"
|
side="right"
|
||||||
|
isBroken={isOutputHandleBroken(propKey)}
|
||||||
/>
|
/>
|
||||||
{"properties" in fieldSchema &&
|
{"properties" in fieldSchema &&
|
||||||
renderHandles(
|
renderHandles(
|
||||||
@@ -385,6 +567,7 @@ export const CustomNode = React.memo(
|
|||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
schema={propSchema}
|
schema={propSchema}
|
||||||
side="left"
|
side="left"
|
||||||
|
isBroken={isInputHandleBroken(propKey)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
propKey !== "credentials" &&
|
propKey !== "credentials" &&
|
||||||
@@ -873,6 +1056,22 @@ export const CustomNode = React.memo(
|
|||||||
<ContextMenuContent />
|
<ContextMenuContent />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-agent Update Bar - shown below header */}
|
||||||
|
{isAgentBlock && (subAgentUpdate.hasUpdate || isInResolutionMode) && (
|
||||||
|
<SubAgentUpdateBar
|
||||||
|
currentVersion={subAgentUpdate.currentVersion}
|
||||||
|
latestVersion={subAgentUpdate.latestVersion}
|
||||||
|
isCompatible={subAgentUpdate.isCompatible}
|
||||||
|
incompatibilities={
|
||||||
|
isInResolutionMode
|
||||||
|
? resolutionMode.incompatibilities
|
||||||
|
: subAgentUpdate.incompatibilities
|
||||||
|
}
|
||||||
|
onUpdate={handleUpdateClick}
|
||||||
|
isInResolutionMode={isInResolutionMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="mx-5 my-6 rounded-b-xl">
|
<div className="mx-5 my-6 rounded-b-xl">
|
||||||
{/* Input Handles */}
|
{/* Input Handles */}
|
||||||
@@ -1044,9 +1243,24 @@ export const CustomNode = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<>
|
||||||
<ContextMenu.Trigger>{nodeContent()}</ContextMenu.Trigger>
|
<ContextMenu.Root>
|
||||||
</ContextMenu.Root>
|
<ContextMenu.Trigger>{nodeContent()}</ContextMenu.Trigger>
|
||||||
|
</ContextMenu.Root>
|
||||||
|
|
||||||
|
{/* Incompatibility Dialog for sub-agent updates */}
|
||||||
|
{isAgentBlock && subAgentUpdate.incompatibilities && (
|
||||||
|
<IncompatibilityDialog
|
||||||
|
isOpen={showIncompatibilityDialog}
|
||||||
|
onClose={() => setShowIncompatibilityDialog(false)}
|
||||||
|
onConfirm={handleConfirmIncompatibleUpdate}
|
||||||
|
currentVersion={subAgentUpdate.currentVersion}
|
||||||
|
latestVersion={subAgentUpdate.latestVersion}
|
||||||
|
agentName={data.blockType || "Agent"}
|
||||||
|
incompatibilities={subAgentUpdate.incompatibilities}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
(prevProps, nextProps) => {
|
(prevProps, nextProps) => {
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/__legacy__/ui/dialog";
|
||||||
|
import { Button } from "@/components/__legacy__/ui/button";
|
||||||
|
import { AlertTriangle, XCircle, PlusCircle } from "lucide-react";
|
||||||
|
import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
|
||||||
|
import { beautifyString } from "@/lib/utils";
|
||||||
|
import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
|
||||||
|
|
||||||
|
interface IncompatibilityDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
currentVersion: number;
|
||||||
|
latestVersion: number;
|
||||||
|
agentName: string;
|
||||||
|
incompatibilities: IncompatibilityInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncompatibilityDialog: React.FC<IncompatibilityDialogProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
|
agentName,
|
||||||
|
incompatibilities,
|
||||||
|
}) => {
|
||||||
|
const hasMissingInputs = incompatibilities.missingInputs.length > 0;
|
||||||
|
const hasMissingOutputs = incompatibilities.missingOutputs.length > 0;
|
||||||
|
const hasNewInputs = incompatibilities.newInputs.length > 0;
|
||||||
|
const hasNewOutputs = incompatibilities.newOutputs.length > 0;
|
||||||
|
const hasNewRequired = incompatibilities.newRequiredInputs.length > 0;
|
||||||
|
const hasTypeMismatches = incompatibilities.inputTypeMismatches.length > 0;
|
||||||
|
|
||||||
|
const hasInputChanges = hasMissingInputs || hasNewInputs;
|
||||||
|
const hasOutputChanges = hasMissingOutputs || hasNewOutputs;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||||
|
Incompatible Update
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Updating <strong>{beautifyString(agentName)}</strong> from v
|
||||||
|
{currentVersion} to v{latestVersion} will break some connections.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
{/* Input changes - two column layout */}
|
||||||
|
{hasInputChanges && (
|
||||||
|
<TwoColumnSection
|
||||||
|
title="Input Changes"
|
||||||
|
leftIcon={<XCircle className="h-4 w-4 text-red-500" />}
|
||||||
|
leftTitle="Removed"
|
||||||
|
leftItems={incompatibilities.missingInputs}
|
||||||
|
rightIcon={<PlusCircle className="h-4 w-4 text-green-500" />}
|
||||||
|
rightTitle="Added"
|
||||||
|
rightItems={incompatibilities.newInputs}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Output changes - two column layout */}
|
||||||
|
{hasOutputChanges && (
|
||||||
|
<TwoColumnSection
|
||||||
|
title="Output Changes"
|
||||||
|
leftIcon={<XCircle className="h-4 w-4 text-red-500" />}
|
||||||
|
leftTitle="Removed"
|
||||||
|
leftItems={incompatibilities.missingOutputs}
|
||||||
|
rightIcon={<PlusCircle className="h-4 w-4 text-green-500" />}
|
||||||
|
rightTitle="Added"
|
||||||
|
rightItems={incompatibilities.newOutputs}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasTypeMismatches && (
|
||||||
|
<SingleColumnSection
|
||||||
|
icon={<XCircle className="h-4 w-4 text-red-500" />}
|
||||||
|
title="Type Changed"
|
||||||
|
description="These connected inputs have a different type:"
|
||||||
|
items={incompatibilities.inputTypeMismatches.map(
|
||||||
|
(m) => `${m.name} (${m.oldType} → ${m.newType})`,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasNewRequired && (
|
||||||
|
<SingleColumnSection
|
||||||
|
icon={<PlusCircle className="h-4 w-4 text-amber-500" />}
|
||||||
|
title="New Required Inputs"
|
||||||
|
description="These inputs are now required:"
|
||||||
|
items={incompatibilities.newRequiredInputs}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
If you proceed, you'll need to remove the broken connections
|
||||||
|
before you can save or run your agent.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="bg-amber-600 hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
Update Anyway
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TwoColumnSectionProps {
|
||||||
|
title: string;
|
||||||
|
leftIcon: React.ReactNode;
|
||||||
|
leftTitle: string;
|
||||||
|
leftItems: string[];
|
||||||
|
rightIcon: React.ReactNode;
|
||||||
|
rightTitle: string;
|
||||||
|
rightItems: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TwoColumnSection: React.FC<TwoColumnSectionProps> = ({
|
||||||
|
title,
|
||||||
|
leftIcon,
|
||||||
|
leftTitle,
|
||||||
|
leftItems,
|
||||||
|
rightIcon,
|
||||||
|
rightTitle,
|
||||||
|
rightItems,
|
||||||
|
}) => (
|
||||||
|
<div className="rounded-md border border-gray-200 p-3 dark:border-gray-700">
|
||||||
|
<span className="font-medium">{title}</span>
|
||||||
|
<div className="mt-2 grid grid-cols-2 items-start gap-4">
|
||||||
|
{/* Left column - Breaking changes */}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{leftIcon}
|
||||||
|
<span>{leftTitle}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-1.5 space-y-1">
|
||||||
|
{leftItems.length > 0 ? (
|
||||||
|
leftItems.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
className="text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<code className="rounded bg-red-50 px-1 py-0.5 font-mono text-xs text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||||
|
{item}
|
||||||
|
</code>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<li className="text-sm italic text-gray-400 dark:text-gray-500">
|
||||||
|
None
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column - Possible solutions */}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{rightIcon}
|
||||||
|
<span>{rightTitle}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-1.5 space-y-1">
|
||||||
|
{rightItems.length > 0 ? (
|
||||||
|
rightItems.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
className="text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<code className="rounded bg-green-50 px-1 py-0.5 font-mono text-xs text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||||
|
{item}
|
||||||
|
</code>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<li className="text-sm italic text-gray-400 dark:text-gray-500">
|
||||||
|
None
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SingleColumnSectionProps {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
items: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SingleColumnSection: React.FC<SingleColumnSectionProps> = ({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
items,
|
||||||
|
}) => (
|
||||||
|
<div className="rounded-md border border-gray-200 p-3 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{icon}
|
||||||
|
<span className="font-medium">{title}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
className="ml-4 list-disc text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<code className="rounded bg-gray-100 px-1 py-0.5 font-mono text-xs dark:bg-gray-800">
|
||||||
|
{item}
|
||||||
|
</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default IncompatibilityDialog;
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/__legacy__/ui/button";
|
||||||
|
import { ArrowUp, AlertTriangle, Info } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||||
|
import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface SubAgentUpdateBarProps {
|
||||||
|
currentVersion: number;
|
||||||
|
latestVersion: number;
|
||||||
|
isCompatible: boolean;
|
||||||
|
incompatibilities: IncompatibilityInfo | null;
|
||||||
|
onUpdate: () => void;
|
||||||
|
isInResolutionMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SubAgentUpdateBar: React.FC<SubAgentUpdateBarProps> = ({
|
||||||
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
|
isCompatible,
|
||||||
|
incompatibilities,
|
||||||
|
onUpdate,
|
||||||
|
isInResolutionMode = false,
|
||||||
|
}) => {
|
||||||
|
if (isInResolutionMode) {
|
||||||
|
return <ResolutionModeBar incompatibilities={incompatibilities} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2 rounded-t-lg bg-blue-50 px-3 py-2 dark:bg-blue-900/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ArrowUp className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
<span className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
Update available (v{currentVersion} → v{latestVersion})
|
||||||
|
</span>
|
||||||
|
{!isCompatible && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
<p className="font-medium">Incompatible changes detected</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Click Update to see details
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={isCompatible ? "default" : "outline"}
|
||||||
|
onClick={onUpdate}
|
||||||
|
className={cn(
|
||||||
|
"h-7 text-xs",
|
||||||
|
!isCompatible && "border-amber-500 text-amber-600 hover:bg-amber-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ResolutionModeBarProps {
|
||||||
|
incompatibilities: IncompatibilityInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResolutionModeBar: React.FC<ResolutionModeBarProps> = ({
|
||||||
|
incompatibilities,
|
||||||
|
}) => {
|
||||||
|
const formatIncompatibilities = () => {
|
||||||
|
if (!incompatibilities) return "No incompatibilities";
|
||||||
|
|
||||||
|
const items: string[] = [];
|
||||||
|
|
||||||
|
if (incompatibilities.missingInputs.length > 0) {
|
||||||
|
items.push(
|
||||||
|
`Missing inputs: ${incompatibilities.missingInputs.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (incompatibilities.missingOutputs.length > 0) {
|
||||||
|
items.push(
|
||||||
|
`Missing outputs: ${incompatibilities.missingOutputs.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (incompatibilities.newRequiredInputs.length > 0) {
|
||||||
|
items.push(
|
||||||
|
`New required inputs: ${incompatibilities.newRequiredInputs.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (incompatibilities.inputTypeMismatches.length > 0) {
|
||||||
|
const mismatches = incompatibilities.inputTypeMismatches
|
||||||
|
.map((m) => `${m.name} (${m.oldType} → ${m.newType})`)
|
||||||
|
.join(", ");
|
||||||
|
items.push(`Type changed: ${mismatches}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2 rounded-t-lg bg-amber-50 px-3 py-2 dark:bg-amber-900/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||||
|
<span className="text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
Remove incompatible connections
|
||||||
|
</span>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-4 w-4 cursor-help text-amber-500" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-sm whitespace-pre-line">
|
||||||
|
<p className="font-medium">Incompatible changes:</p>
|
||||||
|
<p className="mt-1 text-xs">{formatIncompatibilities()}</p>
|
||||||
|
<p className="mt-2 text-xs text-gray-400">
|
||||||
|
Delete the red connections to continue
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubAgentUpdateBar;
|
||||||
@@ -26,15 +26,17 @@ import {
|
|||||||
applyNodeChanges,
|
applyNodeChanges,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
import { CustomNode } from "../CustomNode/CustomNode";
|
import { ConnectedEdge, CustomNode } from "../CustomNode/CustomNode";
|
||||||
import "./flow.css";
|
import "./flow.css";
|
||||||
import {
|
import {
|
||||||
BlockUIType,
|
BlockUIType,
|
||||||
formatEdgeID,
|
formatEdgeID,
|
||||||
GraphExecutionID,
|
GraphExecutionID,
|
||||||
GraphID,
|
GraphID,
|
||||||
|
GraphMeta,
|
||||||
LibraryAgent,
|
LibraryAgent,
|
||||||
} from "@/lib/autogpt-server-api";
|
} from "@/lib/autogpt-server-api";
|
||||||
|
import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
|
||||||
import { Key, storage } from "@/services/storage/local-storage";
|
import { Key, storage } from "@/services/storage/local-storage";
|
||||||
import { findNewlyAddedBlockCoordinates, getTypeColor } from "@/lib/utils";
|
import { findNewlyAddedBlockCoordinates, getTypeColor } from "@/lib/utils";
|
||||||
import { history } from "../history";
|
import { history } from "../history";
|
||||||
@@ -72,12 +74,30 @@ import { FloatingSafeModeToggle } from "../../FloatingSafeModeToogle";
|
|||||||
// It helps to prevent spamming the history with small movements especially when pressing on a input in a block
|
// It helps to prevent spamming the history with small movements especially when pressing on a input in a block
|
||||||
const MINIMUM_MOVE_BEFORE_LOG = 50;
|
const MINIMUM_MOVE_BEFORE_LOG = 50;
|
||||||
|
|
||||||
|
export type ResolutionModeState = {
|
||||||
|
active: boolean;
|
||||||
|
nodeId: string | null;
|
||||||
|
incompatibilities: IncompatibilityInfo | null;
|
||||||
|
brokenEdgeIds: string[];
|
||||||
|
pendingUpdate: Record<string, unknown> | null; // The hardcoded values to apply after resolution
|
||||||
|
};
|
||||||
|
|
||||||
type BuilderContextType = {
|
type BuilderContextType = {
|
||||||
libraryAgent: LibraryAgent | null;
|
libraryAgent: LibraryAgent | null;
|
||||||
visualizeBeads: "no" | "static" | "animate";
|
visualizeBeads: "no" | "static" | "animate";
|
||||||
setIsAnyModalOpen: (isOpen: boolean) => void;
|
setIsAnyModalOpen: (isOpen: boolean) => void;
|
||||||
getNextNodeId: () => string;
|
getNextNodeId: () => string;
|
||||||
getNodeTitle: (nodeID: string) => string | null;
|
getNodeTitle: (nodeID: string) => string | null;
|
||||||
|
availableFlows: GraphMeta[];
|
||||||
|
resolutionMode: ResolutionModeState;
|
||||||
|
enterResolutionMode: (
|
||||||
|
nodeId: string,
|
||||||
|
incompatibilities: IncompatibilityInfo,
|
||||||
|
brokenEdgeIds: string[],
|
||||||
|
pendingUpdate: Record<string, unknown>,
|
||||||
|
) => void;
|
||||||
|
exitResolutionMode: () => void;
|
||||||
|
applyPendingUpdate: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NodeDimension = {
|
export type NodeDimension = {
|
||||||
@@ -172,6 +192,92 @@ const FlowEditor: React.FC<{
|
|||||||
// It stores the dimension of all nodes with position as well
|
// It stores the dimension of all nodes with position as well
|
||||||
const [nodeDimensions, setNodeDimensions] = useState<NodeDimension>({});
|
const [nodeDimensions, setNodeDimensions] = useState<NodeDimension>({});
|
||||||
|
|
||||||
|
// Resolution mode state for sub-agent incompatible updates
|
||||||
|
const [resolutionMode, setResolutionMode] = useState<ResolutionModeState>({
|
||||||
|
active: false,
|
||||||
|
nodeId: null,
|
||||||
|
incompatibilities: null,
|
||||||
|
brokenEdgeIds: [],
|
||||||
|
pendingUpdate: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const enterResolutionMode = useCallback(
|
||||||
|
(
|
||||||
|
nodeId: string,
|
||||||
|
incompatibilities: IncompatibilityInfo,
|
||||||
|
brokenEdgeIds: string[],
|
||||||
|
pendingUpdate: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
setResolutionMode({
|
||||||
|
active: true,
|
||||||
|
nodeId,
|
||||||
|
incompatibilities,
|
||||||
|
brokenEdgeIds,
|
||||||
|
pendingUpdate,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const exitResolutionMode = useCallback(() => {
|
||||||
|
setResolutionMode({
|
||||||
|
active: false,
|
||||||
|
nodeId: null,
|
||||||
|
incompatibilities: null,
|
||||||
|
brokenEdgeIds: [],
|
||||||
|
pendingUpdate: null,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Apply pending update after resolution mode completes
|
||||||
|
const applyPendingUpdate = useCallback(() => {
|
||||||
|
if (!resolutionMode.nodeId || !resolutionMode.pendingUpdate) return;
|
||||||
|
|
||||||
|
const node = nodes.find((n) => n.id === resolutionMode.nodeId);
|
||||||
|
if (node) {
|
||||||
|
const pendingUpdate = resolutionMode.pendingUpdate as {
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
setNodes((nds) =>
|
||||||
|
nds.map((n) =>
|
||||||
|
n.id === resolutionMode.nodeId
|
||||||
|
? { ...n, data: { ...n.data, hardcodedValues: pendingUpdate } }
|
||||||
|
: n,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
exitResolutionMode();
|
||||||
|
toast({
|
||||||
|
title: "Update complete",
|
||||||
|
description: "Agent has been updated to the new version.",
|
||||||
|
});
|
||||||
|
}, [resolutionMode, nodes, setNodes, exitResolutionMode, toast]);
|
||||||
|
|
||||||
|
// Check if all broken edges have been removed and auto-apply pending update
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resolutionMode.active || resolutionMode.brokenEdgeIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentEdgeIds = new Set(edges.map((e) => e.id));
|
||||||
|
const remainingBrokenEdges = resolutionMode.brokenEdgeIds.filter((id) =>
|
||||||
|
currentEdgeIds.has(id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (remainingBrokenEdges.length === 0) {
|
||||||
|
// All broken edges have been removed, apply pending update
|
||||||
|
applyPendingUpdate();
|
||||||
|
} else if (
|
||||||
|
remainingBrokenEdges.length !== resolutionMode.brokenEdgeIds.length
|
||||||
|
) {
|
||||||
|
// Update the list of broken edges
|
||||||
|
setResolutionMode((prev) => ({
|
||||||
|
...prev,
|
||||||
|
brokenEdgeIds: remainingBrokenEdges,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [edges, resolutionMode, applyPendingUpdate]);
|
||||||
|
|
||||||
// Set page title with or without graph name
|
// Set page title with or without graph name
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = savedAgent
|
document.title = savedAgent
|
||||||
@@ -431,17 +537,19 @@ const FlowEditor: React.FC<{
|
|||||||
...node.data.connections.filter(
|
...node.data.connections.filter(
|
||||||
(conn) =>
|
(conn) =>
|
||||||
!removedEdges.some(
|
!removedEdges.some(
|
||||||
(removedEdge) => removedEdge.id === conn.edge_id,
|
(removedEdge) => removedEdge.id === conn.id,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Add node connections for added edges
|
// Add node connections for added edges
|
||||||
...addedEdges.map((addedEdge) => ({
|
...addedEdges.map(
|
||||||
edge_id: addedEdge.item.id,
|
(addedEdge): ConnectedEdge => ({
|
||||||
source: addedEdge.item.source,
|
id: addedEdge.item.id,
|
||||||
target: addedEdge.item.target,
|
source: addedEdge.item.source,
|
||||||
sourceHandle: addedEdge.item.sourceHandle!,
|
target: addedEdge.item.target,
|
||||||
targetHandle: addedEdge.item.targetHandle!,
|
sourceHandle: addedEdge.item.sourceHandle!,
|
||||||
})),
|
targetHandle: addedEdge.item.targetHandle!,
|
||||||
|
}),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -467,13 +575,15 @@ const FlowEditor: React.FC<{
|
|||||||
data: {
|
data: {
|
||||||
...node.data,
|
...node.data,
|
||||||
connections: [
|
connections: [
|
||||||
...replaceEdges.map((replaceEdge) => ({
|
...replaceEdges.map(
|
||||||
edge_id: replaceEdge.item.id,
|
(replaceEdge): ConnectedEdge => ({
|
||||||
source: replaceEdge.item.source,
|
id: replaceEdge.item.id,
|
||||||
target: replaceEdge.item.target,
|
source: replaceEdge.item.source,
|
||||||
sourceHandle: replaceEdge.item.sourceHandle!,
|
target: replaceEdge.item.target,
|
||||||
targetHandle: replaceEdge.item.targetHandle!,
|
sourceHandle: replaceEdge.item.sourceHandle!,
|
||||||
})),
|
targetHandle: replaceEdge.item.targetHandle!,
|
||||||
|
}),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
@@ -890,8 +1000,23 @@ const FlowEditor: React.FC<{
|
|||||||
setIsAnyModalOpen,
|
setIsAnyModalOpen,
|
||||||
getNextNodeId,
|
getNextNodeId,
|
||||||
getNodeTitle,
|
getNodeTitle,
|
||||||
|
availableFlows,
|
||||||
|
resolutionMode,
|
||||||
|
enterResolutionMode,
|
||||||
|
exitResolutionMode,
|
||||||
|
applyPendingUpdate,
|
||||||
}),
|
}),
|
||||||
[libraryAgent, visualizeBeads, getNextNodeId, getNodeTitle],
|
[
|
||||||
|
libraryAgent,
|
||||||
|
visualizeBeads,
|
||||||
|
getNextNodeId,
|
||||||
|
getNodeTitle,
|
||||||
|
availableFlows,
|
||||||
|
resolutionMode,
|
||||||
|
enterResolutionMode,
|
||||||
|
applyPendingUpdate,
|
||||||
|
exitResolutionMode,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -991,6 +1116,7 @@ const FlowEditor: React.FC<{
|
|||||||
onClickScheduleButton={handleScheduleButton}
|
onClickScheduleButton={handleScheduleButton}
|
||||||
isDisabled={!savedAgent}
|
isDisabled={!savedAgent}
|
||||||
isRunning={isRunning}
|
isRunning={isRunning}
|
||||||
|
resolutionModeActive={resolutionMode.active}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Alert className="absolute bottom-4 left-1/2 z-20 w-auto -translate-x-1/2 select-none">
|
<Alert className="absolute bottom-4 left-1/2 z-20 w-auto -translate-x-1/2 select-none">
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { BlockIOSubSchema } from "@/lib/autogpt-server-api/types";
|
import { BlockIOSubSchema } from "@/lib/autogpt-server-api/types";
|
||||||
import { cn } from "@/lib/utils";
|
import {
|
||||||
import { beautifyString, getTypeBgColor, getTypeTextColor } from "@/lib/utils";
|
cn,
|
||||||
|
beautifyString,
|
||||||
|
getTypeBgColor,
|
||||||
|
getTypeTextColor,
|
||||||
|
getEffectiveType,
|
||||||
|
} from "@/lib/utils";
|
||||||
import { FC, memo, useCallback } from "react";
|
import { FC, memo, useCallback } from "react";
|
||||||
import { Handle, Position } from "@xyflow/react";
|
import { Handle, Position } from "@xyflow/react";
|
||||||
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
|
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
|
||||||
@@ -13,6 +18,7 @@ type HandleProps = {
|
|||||||
side: "left" | "right";
|
side: "left" | "right";
|
||||||
title?: string;
|
title?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
isBroken?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Move the constant out of the component to avoid re-creation on every render.
|
// Move the constant out of the component to avoid re-creation on every render.
|
||||||
@@ -27,18 +33,23 @@ const TYPE_NAME: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Extract and memoize the Dot component so that it doesn't re-render unnecessarily.
|
// Extract and memoize the Dot component so that it doesn't re-render unnecessarily.
|
||||||
const Dot: FC<{ isConnected: boolean; type?: string }> = memo(
|
const Dot: FC<{ isConnected: boolean; type?: string; isBroken?: boolean }> =
|
||||||
({ isConnected, type }) => {
|
memo(({ isConnected, type, isBroken }) => {
|
||||||
const color = isConnected
|
const color = isBroken
|
||||||
? getTypeBgColor(type || "any")
|
? "border-red-500 bg-red-100 dark:bg-red-900/30"
|
||||||
: "border-gray-300 dark:border-gray-600";
|
: isConnected
|
||||||
|
? getTypeBgColor(type || "any")
|
||||||
|
: "border-gray-300 dark:border-gray-600";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${color} m-1 h-4 w-4 rounded-full border-2 bg-white transition-colors duration-100 group-hover:bg-gray-300 dark:bg-slate-800 dark:group-hover:bg-gray-700`}
|
className={cn(
|
||||||
|
"m-1 h-4 w-4 rounded-full border-2 bg-white transition-colors duration-100 group-hover:bg-gray-300 dark:bg-slate-800 dark:group-hover:bg-gray-700",
|
||||||
|
color,
|
||||||
|
isBroken && "opacity-50",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
Dot.displayName = "Dot";
|
Dot.displayName = "Dot";
|
||||||
|
|
||||||
const NodeHandle: FC<HandleProps> = ({
|
const NodeHandle: FC<HandleProps> = ({
|
||||||
@@ -49,24 +60,34 @@ const NodeHandle: FC<HandleProps> = ({
|
|||||||
side,
|
side,
|
||||||
title,
|
title,
|
||||||
className,
|
className,
|
||||||
|
isBroken = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typeClass = `text-sm ${getTypeTextColor(schema.type || "any")} ${
|
// Extract effective type from schema (handles anyOf/oneOf/allOf wrappers)
|
||||||
|
const effectiveType = getEffectiveType(schema);
|
||||||
|
|
||||||
|
const typeClass = `text-sm ${getTypeTextColor(effectiveType || "any")} ${
|
||||||
side === "left" ? "text-left" : "text-right"
|
side === "left" ? "text-left" : "text-right"
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
const label = (
|
const label = (
|
||||||
<div className="flex flex-grow flex-row">
|
<div className={cn("flex flex-grow flex-row", isBroken && "opacity-50")}>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-sentry-unmask text-m green flex items-end pr-2 text-gray-900 dark:text-gray-100",
|
"data-sentry-unmask text-m green flex items-end pr-2 text-gray-900 dark:text-gray-100",
|
||||||
className,
|
className,
|
||||||
|
isBroken && "text-red-500 line-through",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{title || schema.title || beautifyString(keyName.toLowerCase())}
|
{title || schema.title || beautifyString(keyName.toLowerCase())}
|
||||||
{isRequired ? "*" : ""}
|
{isRequired ? "*" : ""}
|
||||||
</span>
|
</span>
|
||||||
<span className={`${typeClass} data-sentry-unmask flex items-end`}>
|
<span
|
||||||
({TYPE_NAME[schema.type as keyof typeof TYPE_NAME] || "any"})
|
className={cn(
|
||||||
|
`${typeClass} data-sentry-unmask flex items-end`,
|
||||||
|
isBroken && "text-red-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
({TYPE_NAME[effectiveType as keyof typeof TYPE_NAME] || "any"})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -84,7 +105,7 @@ const NodeHandle: FC<HandleProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={keyName}
|
key={keyName}
|
||||||
className="handle-container"
|
className={cn("handle-container", isBroken && "pointer-events-none")}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
>
|
>
|
||||||
<Handle
|
<Handle
|
||||||
@@ -92,10 +113,15 @@ const NodeHandle: FC<HandleProps> = ({
|
|||||||
data-testid={`input-handle-${keyName}`}
|
data-testid={`input-handle-${keyName}`}
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
id={keyName}
|
id={keyName}
|
||||||
className="group -ml-[38px]"
|
className={cn("group -ml-[38px]", isBroken && "cursor-not-allowed")}
|
||||||
|
isConnectable={!isBroken}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none flex items-center">
|
<div className="pointer-events-none flex items-center">
|
||||||
<Dot isConnected={isConnected} type={schema.type} />
|
<Dot
|
||||||
|
isConnected={isConnected}
|
||||||
|
type={effectiveType}
|
||||||
|
isBroken={isBroken}
|
||||||
|
/>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
</Handle>
|
</Handle>
|
||||||
@@ -106,7 +132,10 @@ const NodeHandle: FC<HandleProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={keyName}
|
key={keyName}
|
||||||
className="handle-container justify-end"
|
className={cn(
|
||||||
|
"handle-container justify-end",
|
||||||
|
isBroken && "pointer-events-none",
|
||||||
|
)}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
>
|
>
|
||||||
<Handle
|
<Handle
|
||||||
@@ -114,11 +143,16 @@ const NodeHandle: FC<HandleProps> = ({
|
|||||||
data-testid={`output-handle-${keyName}`}
|
data-testid={`output-handle-${keyName}`}
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
id={keyName}
|
id={keyName}
|
||||||
className="group -mr-[38px]"
|
className={cn("group -mr-[38px]", isBroken && "cursor-not-allowed")}
|
||||||
|
isConnectable={!isBroken}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none flex items-center">
|
<div className="pointer-events-none flex items-center">
|
||||||
{label}
|
{label}
|
||||||
<Dot isConnected={isConnected} type={schema.type} />
|
<Dot
|
||||||
|
isConnected={isConnected}
|
||||||
|
type={effectiveType}
|
||||||
|
isBroken={isBroken}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Handle>
|
</Handle>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
ConnectionData,
|
ConnectedEdge,
|
||||||
CustomNodeData,
|
CustomNodeData,
|
||||||
} from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
} from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||||
import { NodeTableInput } from "@/app/(platform)/build/components/legacy-builder/NodeTableInput";
|
import { NodeTableInput } from "@/app/(platform)/build/components/legacy-builder/NodeTableInput";
|
||||||
@@ -65,7 +65,7 @@ type NodeObjectInputTreeProps = {
|
|||||||
selfKey?: string;
|
selfKey?: string;
|
||||||
schema: BlockIORootSchema | BlockIOObjectSubSchema;
|
schema: BlockIORootSchema | BlockIOObjectSubSchema;
|
||||||
object?: { [key: string]: any };
|
object?: { [key: string]: any };
|
||||||
connections: ConnectionData;
|
connections: ConnectedEdge[];
|
||||||
handleInputClick: (key: string) => void;
|
handleInputClick: (key: string) => void;
|
||||||
handleInputChange: (key: string, value: any) => void;
|
handleInputChange: (key: string, value: any) => void;
|
||||||
errors: { [key: string]: string | undefined };
|
errors: { [key: string]: string | undefined };
|
||||||
@@ -585,7 +585,7 @@ const NodeOneOfDiscriminatorField: FC<{
|
|||||||
currentValue?: any;
|
currentValue?: any;
|
||||||
defaultValue?: any;
|
defaultValue?: any;
|
||||||
errors: { [key: string]: string | undefined };
|
errors: { [key: string]: string | undefined };
|
||||||
connections: ConnectionData;
|
connections: ConnectedEdge[];
|
||||||
handleInputChange: (key: string, value: any) => void;
|
handleInputChange: (key: string, value: any) => void;
|
||||||
handleInputClick: (key: string) => void;
|
handleInputClick: (key: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { FC, useCallback, useEffect, useState } from "react";
|
import { FC, useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import NodeHandle from "@/app/(platform)/build/components/legacy-builder/NodeHandle";
|
import NodeHandle from "@/app/(platform)/build/components/legacy-builder/NodeHandle";
|
||||||
import {
|
import type {
|
||||||
BlockIOTableSubSchema,
|
BlockIOTableSubSchema,
|
||||||
TableCellValue,
|
TableCellValue,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/lib/autogpt-server-api/types";
|
} from "@/lib/autogpt-server-api/types";
|
||||||
|
import type { ConnectedEdge } from "./CustomNode/CustomNode";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PlusIcon, XIcon } from "@phosphor-icons/react";
|
import { PlusIcon, XIcon } from "@phosphor-icons/react";
|
||||||
import { Button } from "../../../../../components/atoms/Button/Button";
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
import { Input } from "../../../../../components/atoms/Input/Input";
|
import { Input } from "@/components/atoms/Input/Input";
|
||||||
|
|
||||||
interface NodeTableInputProps {
|
interface NodeTableInputProps {
|
||||||
/** Unique identifier for the node in the builder graph */
|
/** Unique identifier for the node in the builder graph */
|
||||||
@@ -25,13 +26,7 @@ interface NodeTableInputProps {
|
|||||||
/** Validation errors mapped by field key */
|
/** Validation errors mapped by field key */
|
||||||
errors: { [key: string]: string | undefined };
|
errors: { [key: string]: string | undefined };
|
||||||
/** Graph connections between nodes in the builder */
|
/** Graph connections between nodes in the builder */
|
||||||
connections: {
|
connections: ConnectedEdge[];
|
||||||
edge_id: string;
|
|
||||||
source: string;
|
|
||||||
sourceHandle: string;
|
|
||||||
target: string;
|
|
||||||
targetHandle: string;
|
|
||||||
}[];
|
|
||||||
/** Callback when table data changes */
|
/** Callback when table data changes */
|
||||||
handleInputChange: (key: string, value: TableRow[]) => void;
|
handleInputChange: (key: string, value: TableRow[]) => void;
|
||||||
/** Callback when input field is clicked (for builder selection) */
|
/** Callback when input field is clicked (for builder selection) */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Node, Edge, useReactFlow } from "@xyflow/react";
|
import { Node, Edge, useReactFlow } from "@xyflow/react";
|
||||||
import { Key, storage } from "@/services/storage/local-storage";
|
import { Key, storage } from "@/services/storage/local-storage";
|
||||||
|
import { ConnectedEdge } from "./CustomNode/CustomNode";
|
||||||
|
|
||||||
interface CopyableData {
|
interface CopyableData {
|
||||||
nodes: Node[];
|
nodes: Node[];
|
||||||
@@ -111,13 +112,15 @@ export function useCopyPaste(getNextNodeId: () => string) {
|
|||||||
(edge: Edge) =>
|
(edge: Edge) =>
|
||||||
edge.source === node.id || edge.target === node.id,
|
edge.source === node.id || edge.target === node.id,
|
||||||
)
|
)
|
||||||
.map((edge: Edge) => ({
|
.map(
|
||||||
edge_id: edge.id,
|
(edge: Edge): ConnectedEdge => ({
|
||||||
source: edge.source,
|
id: edge.id,
|
||||||
target: edge.target,
|
source: edge.source,
|
||||||
sourceHandle: edge.sourceHandle,
|
target: edge.target,
|
||||||
targetHandle: edge.targetHandle,
|
sourceHandle: edge.sourceHandle!,
|
||||||
}));
|
targetHandle: edge.targetHandle!,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { GraphInputSchema } from "@/lib/autogpt-server-api";
|
||||||
|
import { GraphMetaLike, IncompatibilityInfo } from "./types";
|
||||||
|
|
||||||
|
// Helper type for schema properties - the generated types are too loose
|
||||||
|
type SchemaProperties = Record<string, GraphInputSchema["properties"][string]>;
|
||||||
|
type SchemaRequired = string[];
|
||||||
|
|
||||||
|
// Helper to safely extract schema properties
|
||||||
|
export function getSchemaProperties(schema: unknown): SchemaProperties {
|
||||||
|
if (
|
||||||
|
schema &&
|
||||||
|
typeof schema === "object" &&
|
||||||
|
"properties" in schema &&
|
||||||
|
typeof schema.properties === "object" &&
|
||||||
|
schema.properties !== null
|
||||||
|
) {
|
||||||
|
return schema.properties as SchemaProperties;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSchemaRequired(schema: unknown): SchemaRequired {
|
||||||
|
if (
|
||||||
|
schema &&
|
||||||
|
typeof schema === "object" &&
|
||||||
|
"required" in schema &&
|
||||||
|
Array.isArray(schema.required)
|
||||||
|
) {
|
||||||
|
return schema.required as SchemaRequired;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the updated agent node inputs for a sub-agent node
|
||||||
|
*/
|
||||||
|
export function createUpdatedAgentNodeInputs(
|
||||||
|
currentInputs: Record<string, unknown>,
|
||||||
|
latestSubGraphVersion: GraphMetaLike,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
...currentInputs,
|
||||||
|
graph_version: latestSubGraphVersion.version,
|
||||||
|
input_schema: latestSubGraphVersion.input_schema,
|
||||||
|
output_schema: latestSubGraphVersion.output_schema,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic edge type that works with both builders:
|
||||||
|
* - New builder uses CustomEdge with (formally) optional handles
|
||||||
|
* - Legacy builder uses ConnectedEdge type with required handles */
|
||||||
|
export type EdgeLike = {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
sourceHandle?: string | null;
|
||||||
|
targetHandle?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines which edges are broken after an incompatible update.
|
||||||
|
* Works with both legacy ConnectedEdge and new CustomEdge.
|
||||||
|
*/
|
||||||
|
export function getBrokenEdgeIDs(
|
||||||
|
connections: EdgeLike[],
|
||||||
|
incompatibilities: IncompatibilityInfo,
|
||||||
|
nodeID: string,
|
||||||
|
): string[] {
|
||||||
|
const brokenEdgeIDs: string[] = [];
|
||||||
|
const typeMismatchInputNames = new Set(
|
||||||
|
incompatibilities.inputTypeMismatches.map((m) => m.name),
|
||||||
|
);
|
||||||
|
|
||||||
|
connections.forEach((conn) => {
|
||||||
|
// Check if this connection uses a missing input (node is target)
|
||||||
|
if (
|
||||||
|
conn.target === nodeID &&
|
||||||
|
conn.targetHandle &&
|
||||||
|
incompatibilities.missingInputs.includes(conn.targetHandle)
|
||||||
|
) {
|
||||||
|
brokenEdgeIDs.push(conn.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this connection uses an input with a type mismatch (node is target)
|
||||||
|
if (
|
||||||
|
conn.target === nodeID &&
|
||||||
|
conn.targetHandle &&
|
||||||
|
typeMismatchInputNames.has(conn.targetHandle)
|
||||||
|
) {
|
||||||
|
brokenEdgeIDs.push(conn.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this connection uses a missing output (node is source)
|
||||||
|
if (
|
||||||
|
conn.source === nodeID &&
|
||||||
|
conn.sourceHandle &&
|
||||||
|
incompatibilities.missingOutputs.includes(conn.sourceHandle)
|
||||||
|
) {
|
||||||
|
brokenEdgeIDs.push(conn.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return brokenEdgeIDs;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { useSubAgentUpdate } from "./useSubAgentUpdate";
|
||||||
|
export { createUpdatedAgentNodeInputs, getBrokenEdgeIDs } from "./helpers";
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { GraphMeta as LegacyGraphMeta } from "@/lib/autogpt-server-api";
|
||||||
|
import type { GraphMeta as GeneratedGraphMeta } from "@/app/api/__generated__/models/graphMeta";
|
||||||
|
|
||||||
|
export type SubAgentUpdateInfo<T extends GraphMetaLike = GraphMetaLike> = {
|
||||||
|
hasUpdate: boolean;
|
||||||
|
currentVersion: number;
|
||||||
|
latestVersion: number;
|
||||||
|
latestGraph: T | null;
|
||||||
|
isCompatible: boolean;
|
||||||
|
incompatibilities: IncompatibilityInfo | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Union type for GraphMeta that works with both legacy and new builder
|
||||||
|
export type GraphMetaLike = LegacyGraphMeta | GeneratedGraphMeta;
|
||||||
|
|
||||||
|
export type IncompatibilityInfo = {
|
||||||
|
missingInputs: string[]; // Connected inputs that no longer exist
|
||||||
|
missingOutputs: string[]; // Connected outputs that no longer exist
|
||||||
|
newInputs: string[]; // Inputs that exist in new version but not in current
|
||||||
|
newOutputs: string[]; // Outputs that exist in new version but not in current
|
||||||
|
newRequiredInputs: string[]; // New required inputs not in current version or not required
|
||||||
|
inputTypeMismatches: Array<{
|
||||||
|
name: string;
|
||||||
|
oldType: string;
|
||||||
|
newType: string;
|
||||||
|
}>; // Connected inputs where the type has changed
|
||||||
|
};
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { GraphInputSchema, GraphOutputSchema } from "@/lib/autogpt-server-api";
|
||||||
|
import { getEffectiveType } from "@/lib/utils";
|
||||||
|
import { EdgeLike, getSchemaProperties, getSchemaRequired } from "./helpers";
|
||||||
|
import {
|
||||||
|
GraphMetaLike,
|
||||||
|
IncompatibilityInfo,
|
||||||
|
SubAgentUpdateInfo,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a newer version of a sub-agent is available and determines compatibility
|
||||||
|
*/
|
||||||
|
export function useSubAgentUpdate<T extends GraphMetaLike>(
|
||||||
|
nodeID: string,
|
||||||
|
graphID: string | undefined,
|
||||||
|
graphVersion: number | undefined,
|
||||||
|
currentInputSchema: GraphInputSchema | undefined,
|
||||||
|
currentOutputSchema: GraphOutputSchema | undefined,
|
||||||
|
connections: EdgeLike[],
|
||||||
|
availableGraphs: T[],
|
||||||
|
): SubAgentUpdateInfo<T> {
|
||||||
|
// Find the latest version of the same graph
|
||||||
|
const latestGraph = useMemo(() => {
|
||||||
|
if (!graphID) return null;
|
||||||
|
return availableGraphs.find((graph) => graph.id === graphID) || null;
|
||||||
|
}, [graphID, availableGraphs]);
|
||||||
|
|
||||||
|
// Check if there's an update available
|
||||||
|
const hasUpdate = useMemo(() => {
|
||||||
|
if (!latestGraph || graphVersion === undefined) return false;
|
||||||
|
return latestGraph.version! > graphVersion;
|
||||||
|
}, [latestGraph, graphVersion]);
|
||||||
|
|
||||||
|
// Get connected input and output handles for this specific node
|
||||||
|
const connectedHandles = useMemo(() => {
|
||||||
|
const inputHandles = new Set<string>();
|
||||||
|
const outputHandles = new Set<string>();
|
||||||
|
|
||||||
|
connections.forEach((conn) => {
|
||||||
|
// If this node is the target, the targetHandle is an input on this node
|
||||||
|
if (conn.target === nodeID && conn.targetHandle) {
|
||||||
|
inputHandles.add(conn.targetHandle);
|
||||||
|
}
|
||||||
|
// If this node is the source, the sourceHandle is an output on this node
|
||||||
|
if (conn.source === nodeID && conn.sourceHandle) {
|
||||||
|
outputHandles.add(conn.sourceHandle);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { inputHandles, outputHandles };
|
||||||
|
}, [connections, nodeID]);
|
||||||
|
|
||||||
|
// Check schema compatibility
|
||||||
|
const compatibilityResult = useMemo((): {
|
||||||
|
isCompatible: boolean;
|
||||||
|
incompatibilities: IncompatibilityInfo | null;
|
||||||
|
} => {
|
||||||
|
if (!hasUpdate || !latestGraph) {
|
||||||
|
return { isCompatible: true, incompatibilities: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newInputProps = getSchemaProperties(latestGraph.input_schema);
|
||||||
|
const newOutputProps = getSchemaProperties(latestGraph.output_schema);
|
||||||
|
const newRequiredInputs = getSchemaRequired(latestGraph.input_schema);
|
||||||
|
|
||||||
|
const currentInputProps = getSchemaProperties(currentInputSchema);
|
||||||
|
const currentOutputProps = getSchemaProperties(currentOutputSchema);
|
||||||
|
const currentRequiredInputs = getSchemaRequired(currentInputSchema);
|
||||||
|
|
||||||
|
const incompatibilities: IncompatibilityInfo = {
|
||||||
|
missingInputs: [],
|
||||||
|
missingOutputs: [],
|
||||||
|
newInputs: [],
|
||||||
|
newOutputs: [],
|
||||||
|
newRequiredInputs: [],
|
||||||
|
inputTypeMismatches: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for missing connected inputs and type mismatches
|
||||||
|
connectedHandles.inputHandles.forEach((inputHandle) => {
|
||||||
|
if (!(inputHandle in newInputProps)) {
|
||||||
|
incompatibilities.missingInputs.push(inputHandle);
|
||||||
|
} else {
|
||||||
|
// Check for type mismatch on connected inputs
|
||||||
|
const currentProp = currentInputProps[inputHandle];
|
||||||
|
const newProp = newInputProps[inputHandle];
|
||||||
|
const currentType = getEffectiveType(currentProp);
|
||||||
|
const newType = getEffectiveType(newProp);
|
||||||
|
|
||||||
|
if (currentType && newType && currentType !== newType) {
|
||||||
|
incompatibilities.inputTypeMismatches.push({
|
||||||
|
name: inputHandle,
|
||||||
|
oldType: currentType,
|
||||||
|
newType: newType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for missing connected outputs
|
||||||
|
connectedHandles.outputHandles.forEach((outputHandle) => {
|
||||||
|
if (!(outputHandle in newOutputProps)) {
|
||||||
|
incompatibilities.missingOutputs.push(outputHandle);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for new required inputs that didn't exist or weren't required before
|
||||||
|
newRequiredInputs.forEach((requiredInput) => {
|
||||||
|
const existedBefore = requiredInput in currentInputProps;
|
||||||
|
const wasRequiredBefore = currentRequiredInputs.includes(
|
||||||
|
requiredInput as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existedBefore || !wasRequiredBefore) {
|
||||||
|
incompatibilities.newRequiredInputs.push(requiredInput as string);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for new inputs that don't exist in the current version
|
||||||
|
Object.keys(newInputProps).forEach((inputName) => {
|
||||||
|
if (!(inputName in currentInputProps)) {
|
||||||
|
incompatibilities.newInputs.push(inputName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for new outputs that don't exist in the current version
|
||||||
|
Object.keys(newOutputProps).forEach((outputName) => {
|
||||||
|
if (!(outputName in currentOutputProps)) {
|
||||||
|
incompatibilities.newOutputs.push(outputName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasIncompatibilities =
|
||||||
|
incompatibilities.missingInputs.length > 0 ||
|
||||||
|
incompatibilities.missingOutputs.length > 0 ||
|
||||||
|
incompatibilities.newRequiredInputs.length > 0 ||
|
||||||
|
incompatibilities.inputTypeMismatches.length > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCompatible: !hasIncompatibilities,
|
||||||
|
incompatibilities: hasIncompatibilities ? incompatibilities : null,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
hasUpdate,
|
||||||
|
latestGraph,
|
||||||
|
currentInputSchema,
|
||||||
|
currentOutputSchema,
|
||||||
|
connectedHandles,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasUpdate,
|
||||||
|
currentVersion: graphVersion || 0,
|
||||||
|
latestVersion: latestGraph?.version || 0,
|
||||||
|
latestGraph,
|
||||||
|
isCompatible: compatibilityResult.isCompatible,
|
||||||
|
incompatibilities: compatibilityResult.incompatibilities,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,12 +1,30 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { DefaultStateType } from "../components/NewControlPanel/NewBlockMenu/types";
|
import { DefaultStateType } from "../components/NewControlPanel/NewBlockMenu/types";
|
||||||
|
import { SearchResponseItemsItem } from "@/app/api/__generated__/models/searchResponseItemsItem";
|
||||||
|
import { getSearchItemType } from "../components/NewControlPanel/NewBlockMenu/BlockMenuSearchContent/helper";
|
||||||
|
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
|
||||||
|
import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
|
||||||
|
|
||||||
type BlockMenuStore = {
|
type BlockMenuStore = {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
searchId: string | undefined;
|
searchId: string | undefined;
|
||||||
defaultState: DefaultStateType;
|
defaultState: DefaultStateType;
|
||||||
integration: string | undefined;
|
integration: string | undefined;
|
||||||
|
filters: GetV2BuilderSearchFilterAnyOfItem[];
|
||||||
|
creators: string[];
|
||||||
|
creators_list: string[];
|
||||||
|
categoryCounts: Record<GetV2BuilderSearchFilterAnyOfItem, number>;
|
||||||
|
|
||||||
|
setCategoryCounts: (
|
||||||
|
counts: Record<GetV2BuilderSearchFilterAnyOfItem, number>,
|
||||||
|
) => void;
|
||||||
|
setCreatorsList: (searchData: SearchResponseItemsItem[]) => void;
|
||||||
|
addCreator: (creator: string) => void;
|
||||||
|
setCreators: (creators: string[]) => void;
|
||||||
|
removeCreator: (creator: string) => void;
|
||||||
|
addFilter: (filter: GetV2BuilderSearchFilterAnyOfItem) => void;
|
||||||
|
setFilters: (filters: GetV2BuilderSearchFilterAnyOfItem[]) => void;
|
||||||
|
removeFilter: (filter: GetV2BuilderSearchFilterAnyOfItem) => void;
|
||||||
setSearchQuery: (query: string) => void;
|
setSearchQuery: (query: string) => void;
|
||||||
setSearchId: (id: string | undefined) => void;
|
setSearchId: (id: string | undefined) => void;
|
||||||
setDefaultState: (state: DefaultStateType) => void;
|
setDefaultState: (state: DefaultStateType) => void;
|
||||||
@@ -19,11 +37,44 @@ export const useBlockMenuStore = create<BlockMenuStore>((set) => ({
|
|||||||
searchId: undefined,
|
searchId: undefined,
|
||||||
defaultState: DefaultStateType.SUGGESTION,
|
defaultState: DefaultStateType.SUGGESTION,
|
||||||
integration: undefined,
|
integration: undefined,
|
||||||
|
filters: [],
|
||||||
|
creators: [], // creator filters that are applied to the search results
|
||||||
|
creators_list: [], // all creators that are available to filter by
|
||||||
|
categoryCounts: {
|
||||||
|
blocks: 0,
|
||||||
|
integrations: 0,
|
||||||
|
marketplace_agents: 0,
|
||||||
|
my_agents: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
setCategoryCounts: (counts) => set({ categoryCounts: counts }),
|
||||||
|
setCreatorsList: (searchData) => {
|
||||||
|
const marketplaceAgents = searchData.filter((item) => {
|
||||||
|
return getSearchItemType(item).type === "store_agent";
|
||||||
|
}) as StoreAgent[];
|
||||||
|
|
||||||
|
const newCreators = marketplaceAgents.map((agent) => agent.creator);
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
creators_list: Array.from(
|
||||||
|
new Set([...state.creators_list, ...newCreators]),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
setCreators: (creators) => set({ creators }),
|
||||||
|
setFilters: (filters) => set({ filters }),
|
||||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
setSearchId: (id) => set({ searchId: id }),
|
setSearchId: (id) => set({ searchId: id }),
|
||||||
setDefaultState: (state) => set({ defaultState: state }),
|
setDefaultState: (state) => set({ defaultState: state }),
|
||||||
setIntegration: (integration) => set({ integration }),
|
setIntegration: (integration) => set({ integration }),
|
||||||
|
addFilter: (filter) =>
|
||||||
|
set((state) => ({ filters: [...state.filters, filter] })),
|
||||||
|
removeFilter: (filter) =>
|
||||||
|
set((state) => ({ filters: state.filters.filter((f) => f !== filter) })),
|
||||||
|
addCreator: (creator) =>
|
||||||
|
set((state) => ({ creators: [...state.creators, creator] })),
|
||||||
|
removeCreator: (creator) =>
|
||||||
|
set((state) => ({ creators: state.creators.filter((c) => c !== creator) })),
|
||||||
reset: () =>
|
reset: () =>
|
||||||
set({
|
set({
|
||||||
searchQuery: "",
|
searchQuery: "",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||||
|
import { GraphMeta } from "@/app/api/__generated__/models/graphMeta";
|
||||||
|
|
||||||
interface GraphStore {
|
interface GraphStore {
|
||||||
graphExecutionStatus: AgentExecutionStatus | undefined;
|
graphExecutionStatus: AgentExecutionStatus | undefined;
|
||||||
@@ -17,6 +18,10 @@ interface GraphStore {
|
|||||||
outputSchema: Record<string, any> | null,
|
outputSchema: Record<string, any> | null,
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
|
// Available graphs; used for sub-graph updates
|
||||||
|
availableSubGraphs: GraphMeta[];
|
||||||
|
setAvailableSubGraphs: (graphs: GraphMeta[]) => void;
|
||||||
|
|
||||||
hasInputs: () => boolean;
|
hasInputs: () => boolean;
|
||||||
hasCredentials: () => boolean;
|
hasCredentials: () => boolean;
|
||||||
hasOutputs: () => boolean;
|
hasOutputs: () => boolean;
|
||||||
@@ -29,6 +34,7 @@ export const useGraphStore = create<GraphStore>((set, get) => ({
|
|||||||
inputSchema: null,
|
inputSchema: null,
|
||||||
credentialsInputSchema: null,
|
credentialsInputSchema: null,
|
||||||
outputSchema: null,
|
outputSchema: null,
|
||||||
|
availableSubGraphs: [],
|
||||||
|
|
||||||
setGraphExecutionStatus: (status: AgentExecutionStatus | undefined) => {
|
setGraphExecutionStatus: (status: AgentExecutionStatus | undefined) => {
|
||||||
set({
|
set({
|
||||||
@@ -46,6 +52,8 @@ export const useGraphStore = create<GraphStore>((set, get) => ({
|
|||||||
setGraphSchemas: (inputSchema, credentialsInputSchema, outputSchema) =>
|
setGraphSchemas: (inputSchema, credentialsInputSchema, outputSchema) =>
|
||||||
set({ inputSchema, credentialsInputSchema, outputSchema }),
|
set({ inputSchema, credentialsInputSchema, outputSchema }),
|
||||||
|
|
||||||
|
setAvailableSubGraphs: (graphs) => set({ availableSubGraphs: graphs }),
|
||||||
|
|
||||||
hasOutputs: () => {
|
hasOutputs: () => {
|
||||||
const { outputSchema } = get();
|
const { outputSchema } = get();
|
||||||
return Object.keys(outputSchema?.properties ?? {}).length > 0;
|
return Object.keys(outputSchema?.properties ?? {}).length > 0;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user