Compare commits

..

7 Commits

Author SHA1 Message Date
Reinier van der Leer
e1562ee0f6 Merge branch 'dev' into pwuts/open-2853-add-views-for-presets-run-templates 2025-12-08 19:13:11 +01:00
Reinier van der Leer
d785a89f2e add trigger enable/disable button 2025-12-02 23:46:00 +01:00
Reinier van der Leer
c6ce7b9fe9 merge EditTemplateModal into RunAgentModal 2025-12-02 23:40:10 +01:00
Reinier van der Leer
a72d49cd15 Merge branch 'dev' into pwuts/open-2853-add-views-for-presets-run-templates 2025-12-02 17:05:16 +01:00
Reinier van der Leer
0252afbef7 improve UI; support running presets; fix event handlers 2025-12-02 16:26:26 +01:00
Reinier van der Leer
0778d78e84 EOD: improve trigger support 2025-11-28 19:47:45 +01:00
Reinier van der Leer
a0604b1a06 feat(frontend/library): Add preset/trigger support to Library v3 - EOD: 80% 2025-11-27 21:34:34 +01:00
123 changed files with 2901 additions and 12746 deletions

View File

@@ -1,108 +0,0 @@
{
"action": "created",
"discussion": {
"repository_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT",
"category": {
"id": 12345678,
"node_id": "DIC_kwDOJKSTjM4CXXXX",
"repository_id": 614765452,
"emoji": ":pray:",
"name": "Q&A",
"description": "Ask the community for help",
"created_at": "2023-03-16T09:21:07Z",
"updated_at": "2023-03-16T09:21:07Z",
"slug": "q-a",
"is_answerable": true
},
"answer_html_url": null,
"answer_chosen_at": null,
"answer_chosen_by": null,
"html_url": "https://github.com/Significant-Gravitas/AutoGPT/discussions/9999",
"id": 5000000001,
"node_id": "D_kwDOJKSTjM4AYYYY",
"number": 9999,
"title": "How do I configure custom blocks?",
"user": {
"login": "curious-user",
"id": 22222222,
"node_id": "MDQ6VXNlcjIyMjIyMjIy",
"avatar_url": "https://avatars.githubusercontent.com/u/22222222?v=4",
"url": "https://api.github.com/users/curious-user",
"html_url": "https://github.com/curious-user",
"type": "User",
"site_admin": false
},
"state": "open",
"state_reason": null,
"locked": false,
"comments": 0,
"created_at": "2024-12-01T17:00:00Z",
"updated_at": "2024-12-01T17:00:00Z",
"author_association": "NONE",
"active_lock_reason": null,
"body": "## Question\n\nI'm trying to create a custom block for my specific use case. I've read the documentation but I'm not sure how to:\n\n1. Define the input/output schema\n2. Handle authentication\n3. Test my block locally\n\nCan someone point me to examples or provide guidance?\n\n## Environment\n\n- AutoGPT Platform version: latest\n- Python: 3.11",
"reactions": {
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/discussions/9999/reactions",
"total_count": 0,
"+1": 0,
"-1": 0,
"laugh": 0,
"hooray": 0,
"confused": 0,
"heart": 0,
"rocket": 0,
"eyes": 0
},
"timeline_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/discussions/9999/timeline"
},
"repository": {
"id": 614765452,
"node_id": "R_kgDOJKSTjA",
"name": "AutoGPT",
"full_name": "Significant-Gravitas/AutoGPT",
"private": false,
"owner": {
"login": "Significant-Gravitas",
"id": 130738209,
"node_id": "O_kgDOB8roIQ",
"avatar_url": "https://avatars.githubusercontent.com/u/130738209?v=4",
"url": "https://api.github.com/users/Significant-Gravitas",
"html_url": "https://github.com/Significant-Gravitas",
"type": "Organization",
"site_admin": false
},
"html_url": "https://github.com/Significant-Gravitas/AutoGPT",
"description": "AutoGPT is the vision of accessible AI for everyone, to use and to build on.",
"fork": false,
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT",
"created_at": "2023-03-16T09:21:07Z",
"updated_at": "2024-12-01T17:00:00Z",
"pushed_at": "2024-12-01T12:00:00Z",
"stargazers_count": 170000,
"watchers_count": 170000,
"language": "Python",
"has_discussions": true,
"forks_count": 45000,
"visibility": "public",
"default_branch": "master"
},
"organization": {
"login": "Significant-Gravitas",
"id": 130738209,
"node_id": "O_kgDOB8roIQ",
"url": "https://api.github.com/orgs/Significant-Gravitas",
"avatar_url": "https://avatars.githubusercontent.com/u/130738209?v=4",
"description": ""
},
"sender": {
"login": "curious-user",
"id": 22222222,
"node_id": "MDQ6VXNlcjIyMjIyMjIy",
"avatar_url": "https://avatars.githubusercontent.com/u/22222222?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/curious-user",
"html_url": "https://github.com/curious-user",
"type": "User",
"site_admin": false
}
}

View File

@@ -1,112 +0,0 @@
{
"action": "opened",
"issue": {
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/issues/12345",
"repository_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT",
"labels_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/issues/12345/labels{/name}",
"comments_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/issues/12345/comments",
"events_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/issues/12345/events",
"html_url": "https://github.com/Significant-Gravitas/AutoGPT/issues/12345",
"id": 2000000001,
"node_id": "I_kwDOJKSTjM5wXXXX",
"number": 12345,
"title": "Bug: Application crashes when processing large files",
"user": {
"login": "bug-reporter",
"id": 11111111,
"node_id": "MDQ6VXNlcjExMTExMTEx",
"avatar_url": "https://avatars.githubusercontent.com/u/11111111?v=4",
"url": "https://api.github.com/users/bug-reporter",
"html_url": "https://github.com/bug-reporter",
"type": "User",
"site_admin": false
},
"labels": [
{
"id": 5272676214,
"node_id": "LA_kwDOJKSTjM8AAAABOkandg",
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/labels/bug",
"name": "bug",
"color": "d73a4a",
"default": true,
"description": "Something isn't working"
}
],
"state": "open",
"locked": false,
"assignee": null,
"assignees": [],
"milestone": null,
"comments": 0,
"created_at": "2024-12-01T16:00:00Z",
"updated_at": "2024-12-01T16:00:00Z",
"closed_at": null,
"author_association": "NONE",
"active_lock_reason": null,
"body": "## Description\n\nWhen I try to process a file larger than 100MB, the application crashes with an out of memory error.\n\n## Steps to Reproduce\n\n1. Open the application\n2. Select a file larger than 100MB\n3. Click 'Process'\n4. Application crashes\n\n## Expected Behavior\n\nThe application should handle large files gracefully.\n\n## Environment\n\n- OS: Ubuntu 22.04\n- Python: 3.11\n- AutoGPT Version: 1.0.0",
"reactions": {
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/issues/12345/reactions",
"total_count": 0,
"+1": 0,
"-1": 0,
"laugh": 0,
"hooray": 0,
"confused": 0,
"heart": 0,
"rocket": 0,
"eyes": 0
},
"timeline_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/issues/12345/timeline",
"state_reason": null
},
"repository": {
"id": 614765452,
"node_id": "R_kgDOJKSTjA",
"name": "AutoGPT",
"full_name": "Significant-Gravitas/AutoGPT",
"private": false,
"owner": {
"login": "Significant-Gravitas",
"id": 130738209,
"node_id": "O_kgDOB8roIQ",
"avatar_url": "https://avatars.githubusercontent.com/u/130738209?v=4",
"url": "https://api.github.com/users/Significant-Gravitas",
"html_url": "https://github.com/Significant-Gravitas",
"type": "Organization",
"site_admin": false
},
"html_url": "https://github.com/Significant-Gravitas/AutoGPT",
"description": "AutoGPT is the vision of accessible AI for everyone, to use and to build on.",
"fork": false,
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT",
"created_at": "2023-03-16T09:21:07Z",
"updated_at": "2024-12-01T16:00:00Z",
"pushed_at": "2024-12-01T12:00:00Z",
"stargazers_count": 170000,
"watchers_count": 170000,
"language": "Python",
"forks_count": 45000,
"open_issues_count": 190,
"visibility": "public",
"default_branch": "master"
},
"organization": {
"login": "Significant-Gravitas",
"id": 130738209,
"node_id": "O_kgDOB8roIQ",
"url": "https://api.github.com/orgs/Significant-Gravitas",
"avatar_url": "https://avatars.githubusercontent.com/u/130738209?v=4",
"description": ""
},
"sender": {
"login": "bug-reporter",
"id": 11111111,
"node_id": "MDQ6VXNlcjExMTExMTEx",
"avatar_url": "https://avatars.githubusercontent.com/u/11111111?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/bug-reporter",
"html_url": "https://github.com/bug-reporter",
"type": "User",
"site_admin": false
}
}

View File

@@ -1,97 +0,0 @@
{
"action": "published",
"release": {
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/releases/123456789",
"assets_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/releases/123456789/assets",
"upload_url": "https://uploads.github.com/repos/Significant-Gravitas/AutoGPT/releases/123456789/assets{?name,label}",
"html_url": "https://github.com/Significant-Gravitas/AutoGPT/releases/tag/v1.0.0",
"id": 123456789,
"author": {
"login": "ntindle",
"id": 12345678,
"node_id": "MDQ6VXNlcjEyMzQ1Njc4",
"avatar_url": "https://avatars.githubusercontent.com/u/12345678?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/ntindle",
"html_url": "https://github.com/ntindle",
"type": "User",
"site_admin": false
},
"node_id": "RE_kwDOJKSTjM4HWwAA",
"tag_name": "v1.0.0",
"target_commitish": "master",
"name": "AutoGPT Platform v1.0.0",
"draft": false,
"prerelease": false,
"created_at": "2024-12-01T10:00:00Z",
"published_at": "2024-12-01T12:00:00Z",
"assets": [
{
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/releases/assets/987654321",
"id": 987654321,
"node_id": "RA_kwDOJKSTjM4HWwBB",
"name": "autogpt-v1.0.0.zip",
"label": "Release Package",
"content_type": "application/zip",
"state": "uploaded",
"size": 52428800,
"download_count": 0,
"created_at": "2024-12-01T11:30:00Z",
"updated_at": "2024-12-01T11:35:00Z",
"browser_download_url": "https://github.com/Significant-Gravitas/AutoGPT/releases/download/v1.0.0/autogpt-v1.0.0.zip"
}
],
"tarball_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/tarball/v1.0.0",
"zipball_url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT/zipball/v1.0.0",
"body": "## What's New\n\n- Feature 1: Amazing new capability\n- Feature 2: Performance improvements\n- Bug fixes and stability improvements\n\n## Breaking Changes\n\nNone\n\n## Contributors\n\nThanks to all our contributors!"
},
"repository": {
"id": 614765452,
"node_id": "R_kgDOJKSTjA",
"name": "AutoGPT",
"full_name": "Significant-Gravitas/AutoGPT",
"private": false,
"owner": {
"login": "Significant-Gravitas",
"id": 130738209,
"node_id": "O_kgDOB8roIQ",
"avatar_url": "https://avatars.githubusercontent.com/u/130738209?v=4",
"url": "https://api.github.com/users/Significant-Gravitas",
"html_url": "https://github.com/Significant-Gravitas",
"type": "Organization",
"site_admin": false
},
"html_url": "https://github.com/Significant-Gravitas/AutoGPT",
"description": "AutoGPT is the vision of accessible AI for everyone, to use and to build on.",
"fork": false,
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT",
"created_at": "2023-03-16T09:21:07Z",
"updated_at": "2024-12-01T12:00:00Z",
"pushed_at": "2024-12-01T12:00:00Z",
"stargazers_count": 170000,
"watchers_count": 170000,
"language": "Python",
"forks_count": 45000,
"visibility": "public",
"default_branch": "master"
},
"organization": {
"login": "Significant-Gravitas",
"id": 130738209,
"node_id": "O_kgDOB8roIQ",
"url": "https://api.github.com/orgs/Significant-Gravitas",
"avatar_url": "https://avatars.githubusercontent.com/u/130738209?v=4",
"description": ""
},
"sender": {
"login": "ntindle",
"id": 12345678,
"node_id": "MDQ6VXNlcjEyMzQ1Njc4",
"avatar_url": "https://avatars.githubusercontent.com/u/12345678?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/ntindle",
"html_url": "https://github.com/ntindle",
"type": "User",
"site_admin": false
}
}

View File

@@ -1,53 +0,0 @@
{
"action": "created",
"starred_at": "2024-12-01T15:30:00Z",
"repository": {
"id": 614765452,
"node_id": "R_kgDOJKSTjA",
"name": "AutoGPT",
"full_name": "Significant-Gravitas/AutoGPT",
"private": false,
"owner": {
"login": "Significant-Gravitas",
"id": 130738209,
"node_id": "O_kgDOB8roIQ",
"avatar_url": "https://avatars.githubusercontent.com/u/130738209?v=4",
"url": "https://api.github.com/users/Significant-Gravitas",
"html_url": "https://github.com/Significant-Gravitas",
"type": "Organization",
"site_admin": false
},
"html_url": "https://github.com/Significant-Gravitas/AutoGPT",
"description": "AutoGPT is the vision of accessible AI for everyone, to use and to build on.",
"fork": false,
"url": "https://api.github.com/repos/Significant-Gravitas/AutoGPT",
"created_at": "2023-03-16T09:21:07Z",
"updated_at": "2024-12-01T15:30:00Z",
"pushed_at": "2024-12-01T12:00:00Z",
"stargazers_count": 170001,
"watchers_count": 170001,
"language": "Python",
"forks_count": 45000,
"visibility": "public",
"default_branch": "master"
},
"organization": {
"login": "Significant-Gravitas",
"id": 130738209,
"node_id": "O_kgDOB8roIQ",
"url": "https://api.github.com/orgs/Significant-Gravitas",
"avatar_url": "https://avatars.githubusercontent.com/u/130738209?v=4",
"description": ""
},
"sender": {
"login": "awesome-contributor",
"id": 98765432,
"node_id": "MDQ6VXNlcjk4NzY1NDMy",
"avatar_url": "https://avatars.githubusercontent.com/u/98765432?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/awesome-contributor",
"html_url": "https://github.com/awesome-contributor",
"type": "User",
"site_admin": false
}
}

View File

@@ -159,391 +159,3 @@ class GithubPullRequestTriggerBlock(GitHubTriggerBase, Block):
# --8<-- [end:GithubTriggerExample]
class GithubStarTriggerBlock(GitHubTriggerBase, Block):
"""Trigger block for GitHub star events - useful for milestone celebrations."""
EXAMPLE_PAYLOAD_FILE = (
Path(__file__).parent / "example_payloads" / "star.created.json"
)
class Input(GitHubTriggerBase.Input):
class EventsFilter(BaseModel):
"""
https://docs.github.com/en/webhooks/webhook-events-and-payloads#star
"""
created: bool = False
deleted: bool = False
events: EventsFilter = SchemaField(
title="Events", description="The star events to subscribe to"
)
class Output(GitHubTriggerBase.Output):
event: str = SchemaField(
description="The star event that triggered the webhook ('created' or 'deleted')"
)
starred_at: str = SchemaField(
description="ISO timestamp when the repo was starred (empty if deleted)"
)
stargazers_count: int = SchemaField(
description="Current number of stars on the repository"
)
repository_name: str = SchemaField(
description="Full name of the repository (owner/repo)"
)
repository_url: str = SchemaField(description="URL to the repository")
def __init__(self):
from backend.integrations.webhooks.github import GithubWebhookType
example_payload = json.loads(
self.EXAMPLE_PAYLOAD_FILE.read_text(encoding="utf-8")
)
super().__init__(
id="551e0a35-100b-49b7-89b8-3031322239b6",
description="This block triggers on GitHub star events. "
"Useful for celebrating milestones (e.g., 1k, 10k stars) or tracking engagement.",
categories={BlockCategory.DEVELOPER_TOOLS, BlockCategory.INPUT},
input_schema=GithubStarTriggerBlock.Input,
output_schema=GithubStarTriggerBlock.Output,
webhook_config=BlockWebhookConfig(
provider=ProviderName.GITHUB,
webhook_type=GithubWebhookType.REPO,
resource_format="{repo}",
event_filter_input="events",
event_format="star.{event}",
),
test_input={
"repo": "Significant-Gravitas/AutoGPT",
"events": {"created": True},
"credentials": TEST_CREDENTIALS_INPUT,
"payload": example_payload,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("payload", example_payload),
("triggered_by_user", example_payload["sender"]),
("event", example_payload["action"]),
("starred_at", example_payload.get("starred_at", "")),
("stargazers_count", example_payload["repository"]["stargazers_count"]),
("repository_name", example_payload["repository"]["full_name"]),
("repository_url", example_payload["repository"]["html_url"]),
],
)
async def run(self, input_data: Input, **kwargs) -> BlockOutput: # type: ignore
async for name, value in super().run(input_data, **kwargs):
yield name, value
yield "event", input_data.payload["action"]
yield "starred_at", input_data.payload.get("starred_at", "")
yield "stargazers_count", input_data.payload["repository"]["stargazers_count"]
yield "repository_name", input_data.payload["repository"]["full_name"]
yield "repository_url", input_data.payload["repository"]["html_url"]
class GithubReleaseTriggerBlock(GitHubTriggerBase, Block):
"""Trigger block for GitHub release events - ideal for announcing new versions."""
EXAMPLE_PAYLOAD_FILE = (
Path(__file__).parent / "example_payloads" / "release.published.json"
)
class Input(GitHubTriggerBase.Input):
class EventsFilter(BaseModel):
"""
https://docs.github.com/en/webhooks/webhook-events-and-payloads#release
"""
published: bool = False
unpublished: bool = False
created: bool = False
edited: bool = False
deleted: bool = False
prereleased: bool = False
released: bool = False
events: EventsFilter = SchemaField(
title="Events", description="The release events to subscribe to"
)
class Output(GitHubTriggerBase.Output):
event: str = SchemaField(
description="The release event that triggered the webhook (e.g., 'published')"
)
release: dict = SchemaField(description="The full release object")
release_url: str = SchemaField(description="URL to the release page")
tag_name: str = SchemaField(description="The release tag name (e.g., 'v1.0.0')")
release_name: str = SchemaField(description="Human-readable release name")
body: str = SchemaField(description="Release notes/description")
prerelease: bool = SchemaField(description="Whether this is a prerelease")
draft: bool = SchemaField(description="Whether this is a draft release")
assets: list = SchemaField(description="List of release assets/files")
def __init__(self):
from backend.integrations.webhooks.github import GithubWebhookType
example_payload = json.loads(
self.EXAMPLE_PAYLOAD_FILE.read_text(encoding="utf-8")
)
super().__init__(
id="2052dd1b-74e1-46ac-9c87-c7a0e057b60b",
description="This block triggers on GitHub release events. "
"Perfect for automating announcements to Discord, Twitter, or other platforms.",
categories={BlockCategory.DEVELOPER_TOOLS, BlockCategory.INPUT},
input_schema=GithubReleaseTriggerBlock.Input,
output_schema=GithubReleaseTriggerBlock.Output,
webhook_config=BlockWebhookConfig(
provider=ProviderName.GITHUB,
webhook_type=GithubWebhookType.REPO,
resource_format="{repo}",
event_filter_input="events",
event_format="release.{event}",
),
test_input={
"repo": "Significant-Gravitas/AutoGPT",
"events": {"published": True},
"credentials": TEST_CREDENTIALS_INPUT,
"payload": example_payload,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("payload", example_payload),
("triggered_by_user", example_payload["sender"]),
("event", example_payload["action"]),
("release", example_payload["release"]),
("release_url", example_payload["release"]["html_url"]),
("tag_name", example_payload["release"]["tag_name"]),
("release_name", example_payload["release"]["name"]),
("body", example_payload["release"]["body"]),
("prerelease", example_payload["release"]["prerelease"]),
("draft", example_payload["release"]["draft"]),
("assets", example_payload["release"]["assets"]),
],
)
async def run(self, input_data: Input, **kwargs) -> BlockOutput: # type: ignore
async for name, value in super().run(input_data, **kwargs):
yield name, value
release = input_data.payload["release"]
yield "event", input_data.payload["action"]
yield "release", release
yield "release_url", release["html_url"]
yield "tag_name", release["tag_name"]
yield "release_name", release.get("name", "")
yield "body", release.get("body", "")
yield "prerelease", release["prerelease"]
yield "draft", release["draft"]
yield "assets", release["assets"]
class GithubIssuesTriggerBlock(GitHubTriggerBase, Block):
"""Trigger block for GitHub issues events - great for triage and notifications."""
EXAMPLE_PAYLOAD_FILE = (
Path(__file__).parent / "example_payloads" / "issues.opened.json"
)
class Input(GitHubTriggerBase.Input):
class EventsFilter(BaseModel):
"""
https://docs.github.com/en/webhooks/webhook-events-and-payloads#issues
"""
opened: bool = False
edited: bool = False
deleted: bool = False
closed: bool = False
reopened: bool = False
assigned: bool = False
unassigned: bool = False
labeled: bool = False
unlabeled: bool = False
locked: bool = False
unlocked: bool = False
transferred: bool = False
milestoned: bool = False
demilestoned: bool = False
pinned: bool = False
unpinned: bool = False
events: EventsFilter = SchemaField(
title="Events", description="The issue events to subscribe to"
)
class Output(GitHubTriggerBase.Output):
event: str = SchemaField(
description="The issue event that triggered the webhook (e.g., 'opened')"
)
number: int = SchemaField(description="The issue number")
issue: dict = SchemaField(description="The full issue object")
issue_url: str = SchemaField(description="URL to the issue")
issue_title: str = SchemaField(description="The issue title")
issue_body: str = SchemaField(description="The issue body/description")
labels: list = SchemaField(description="List of labels on the issue")
assignees: list = SchemaField(description="List of assignees")
state: str = SchemaField(description="Issue state ('open' or 'closed')")
def __init__(self):
from backend.integrations.webhooks.github import GithubWebhookType
example_payload = json.loads(
self.EXAMPLE_PAYLOAD_FILE.read_text(encoding="utf-8")
)
super().__init__(
id="b2605464-e486-4bf4-aad3-d8a213c8a48a",
description="This block triggers on GitHub issues events. "
"Useful for automated triage, notifications, and welcoming first-time contributors.",
categories={BlockCategory.DEVELOPER_TOOLS, BlockCategory.INPUT},
input_schema=GithubIssuesTriggerBlock.Input,
output_schema=GithubIssuesTriggerBlock.Output,
webhook_config=BlockWebhookConfig(
provider=ProviderName.GITHUB,
webhook_type=GithubWebhookType.REPO,
resource_format="{repo}",
event_filter_input="events",
event_format="issues.{event}",
),
test_input={
"repo": "Significant-Gravitas/AutoGPT",
"events": {"opened": True},
"credentials": TEST_CREDENTIALS_INPUT,
"payload": example_payload,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("payload", example_payload),
("triggered_by_user", example_payload["sender"]),
("event", example_payload["action"]),
("number", example_payload["issue"]["number"]),
("issue", example_payload["issue"]),
("issue_url", example_payload["issue"]["html_url"]),
("issue_title", example_payload["issue"]["title"]),
("issue_body", example_payload["issue"]["body"]),
("labels", example_payload["issue"]["labels"]),
("assignees", example_payload["issue"]["assignees"]),
("state", example_payload["issue"]["state"]),
],
)
async def run(self, input_data: Input, **kwargs) -> BlockOutput: # type: ignore
async for name, value in super().run(input_data, **kwargs):
yield name, value
issue = input_data.payload["issue"]
yield "event", input_data.payload["action"]
yield "number", issue["number"]
yield "issue", issue
yield "issue_url", issue["html_url"]
yield "issue_title", issue["title"]
yield "issue_body", issue.get("body") or ""
yield "labels", issue["labels"]
yield "assignees", issue["assignees"]
yield "state", issue["state"]
class GithubDiscussionTriggerBlock(GitHubTriggerBase, Block):
"""Trigger block for GitHub discussion events - perfect for community Q&A sync."""
EXAMPLE_PAYLOAD_FILE = (
Path(__file__).parent / "example_payloads" / "discussion.created.json"
)
class Input(GitHubTriggerBase.Input):
class EventsFilter(BaseModel):
"""
https://docs.github.com/en/webhooks/webhook-events-and-payloads#discussion
"""
created: bool = False
edited: bool = False
deleted: bool = False
answered: bool = False
unanswered: bool = False
labeled: bool = False
unlabeled: bool = False
locked: bool = False
unlocked: bool = False
category_changed: bool = False
transferred: bool = False
pinned: bool = False
unpinned: bool = False
events: EventsFilter = SchemaField(
title="Events", description="The discussion events to subscribe to"
)
class Output(GitHubTriggerBase.Output):
event: str = SchemaField(
description="The discussion event that triggered the webhook"
)
number: int = SchemaField(description="The discussion number")
discussion: dict = SchemaField(description="The full discussion object")
discussion_url: str = SchemaField(description="URL to the discussion")
title: str = SchemaField(description="The discussion title")
body: str = SchemaField(description="The discussion body")
category: dict = SchemaField(description="The discussion category object")
category_name: str = SchemaField(description="Name of the category")
state: str = SchemaField(description="Discussion state")
def __init__(self):
from backend.integrations.webhooks.github import GithubWebhookType
example_payload = json.loads(
self.EXAMPLE_PAYLOAD_FILE.read_text(encoding="utf-8")
)
super().__init__(
id="87f847b3-d81a-424e-8e89-acadb5c9d52b",
description="This block triggers on GitHub Discussions events. "
"Great for syncing Q&A to Discord or auto-responding to common questions. "
"Note: Discussions must be enabled on the repository.",
categories={BlockCategory.DEVELOPER_TOOLS, BlockCategory.INPUT},
input_schema=GithubDiscussionTriggerBlock.Input,
output_schema=GithubDiscussionTriggerBlock.Output,
webhook_config=BlockWebhookConfig(
provider=ProviderName.GITHUB,
webhook_type=GithubWebhookType.REPO,
resource_format="{repo}",
event_filter_input="events",
event_format="discussion.{event}",
),
test_input={
"repo": "Significant-Gravitas/AutoGPT",
"events": {"created": True},
"credentials": TEST_CREDENTIALS_INPUT,
"payload": example_payload,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("payload", example_payload),
("triggered_by_user", example_payload["sender"]),
("event", example_payload["action"]),
("number", example_payload["discussion"]["number"]),
("discussion", example_payload["discussion"]),
("discussion_url", example_payload["discussion"]["html_url"]),
("title", example_payload["discussion"]["title"]),
("body", example_payload["discussion"]["body"]),
("category", example_payload["discussion"]["category"]),
("category_name", example_payload["discussion"]["category"]["name"]),
("state", example_payload["discussion"]["state"]),
],
)
async def run(self, input_data: Input, **kwargs) -> BlockOutput: # type: ignore
async for name, value in super().run(input_data, **kwargs):
yield name, value
discussion = input_data.payload["discussion"]
yield "event", input_data.payload["action"]
yield "number", discussion["number"]
yield "discussion", discussion
yield "discussion_url", discussion["html_url"]
yield "title", discussion["title"]
yield "body", discussion.get("body") or ""
yield "category", discussion["category"]
yield "category_name", discussion["category"]["name"]
yield "state", discussion["state"]

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,8 @@
import logging
import re
from collections import Counter
from concurrent.futures import Future
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel
import backend.blocks.llm as llm
from backend.blocks.agent import AgentExecutorBlock
from backend.data.block import (
@@ -23,41 +20,16 @@ from backend.data.dynamic_fields import (
is_dynamic_field,
is_tool_pin,
)
from backend.data.execution import ExecutionContext
from backend.data.model import NodeExecutionStats, SchemaField
from backend.util import json
from backend.util.clients import get_database_manager_async_client
from backend.util.prompt import MAIN_OBJECTIVE_PREFIX
if TYPE_CHECKING:
from backend.data.graph import Link, Node
from backend.executor.manager import ExecutionProcessor
logger = logging.getLogger(__name__)
class ToolInfo(BaseModel):
"""Processed tool call information."""
tool_call: Any # The original tool call object from LLM response
tool_name: str # The function name
tool_def: dict[str, Any] # The tool definition from tool_functions
input_data: dict[str, Any] # Processed input data ready for tool execution
field_mapping: dict[str, str] # Field name mapping for the tool
class ExecutionParams(BaseModel):
"""Tool execution parameters."""
user_id: str
graph_id: str
node_id: str
graph_version: int
graph_exec_id: str
node_exec_id: str
execution_context: "ExecutionContext"
def _get_tool_requests(entry: dict[str, Any]) -> list[str]:
"""
Return a list of tool_call_ids if the entry is a tool request.
@@ -133,50 +105,6 @@ def _create_tool_response(call_id: str, output: Any) -> dict[str, Any]:
return {"role": "tool", "tool_call_id": call_id, "content": content}
def _combine_tool_responses(tool_outputs: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
Combine multiple Anthropic tool responses into a single user message.
For non-Anthropic formats, returns the original list unchanged.
"""
if len(tool_outputs) <= 1:
return tool_outputs
# Anthropic responses have role="user", type="message", and content is a list with tool_result items
anthropic_responses = [
output
for output in tool_outputs
if (
output.get("role") == "user"
and output.get("type") == "message"
and isinstance(output.get("content"), list)
and any(
item.get("type") == "tool_result"
for item in output.get("content", [])
if isinstance(item, dict)
)
)
]
if len(anthropic_responses) > 1:
combined_content = [
item for response in anthropic_responses for item in response["content"]
]
combined_response = {
"role": "user",
"type": "message",
"content": combined_content,
}
non_anthropic_responses = [
output for output in tool_outputs if output not in anthropic_responses
]
return [combined_response] + non_anthropic_responses
return tool_outputs
def _convert_raw_response_to_dict(raw_response: Any) -> dict[str, Any]:
"""
Safely convert raw_response to dictionary format for conversation history.
@@ -276,17 +204,6 @@ class SmartDecisionMakerBlock(Block):
default="localhost:11434",
description="Ollama host for local models",
)
agent_mode_max_iterations: int = SchemaField(
title="Agent Mode Max Iterations",
description="Maximum iterations for agent mode. 0 = traditional mode (single LLM call, yield tool calls for external execution), -1 = infinite agent mode (loop until finished), 1+ = agent mode with max iterations limit.",
advanced=True,
default=0,
)
conversation_compaction: bool = SchemaField(
default=True,
title="Context window auto-compaction",
description="Automatically compact the context window once it hits the limit",
)
@classmethod
def get_missing_links(cls, data: BlockInput, links: list["Link"]) -> set[str]:
@@ -589,7 +506,6 @@ class SmartDecisionMakerBlock(Block):
Returns the response if successful, raises ValueError if validation fails.
"""
resp = await llm.llm_call(
compress_prompt_to_fit=input_data.conversation_compaction,
credentials=credentials,
llm_model=input_data.model,
prompt=current_prompt,
@@ -677,291 +593,6 @@ class SmartDecisionMakerBlock(Block):
return resp
def _process_tool_calls(
self, response, tool_functions: list[dict[str, Any]]
) -> list[ToolInfo]:
"""Process tool calls and extract tool definitions, arguments, and input data.
Returns a list of tool info dicts with:
- tool_call: The original tool call object
- tool_name: The function name
- tool_def: The tool definition from tool_functions
- input_data: Processed input data dict (includes None values)
- field_mapping: Field name mapping for the tool
"""
if not response.tool_calls:
return []
processed_tools = []
for tool_call in response.tool_calls:
tool_name = tool_call.function.name
tool_args = json.loads(tool_call.function.arguments)
tool_def = next(
(
tool
for tool in tool_functions
if tool["function"]["name"] == tool_name
),
None,
)
if not tool_def:
if len(tool_functions) == 1:
tool_def = tool_functions[0]
else:
continue
# Build input data for the tool
input_data = {}
field_mapping = tool_def["function"].get("_field_mapping", {})
if "function" in tool_def and "parameters" in tool_def["function"]:
expected_args = tool_def["function"]["parameters"].get("properties", {})
for clean_arg_name in expected_args:
original_field_name = field_mapping.get(
clean_arg_name, clean_arg_name
)
arg_value = tool_args.get(clean_arg_name)
# Include all expected parameters, even if None (for backward compatibility with tests)
input_data[original_field_name] = arg_value
processed_tools.append(
ToolInfo(
tool_call=tool_call,
tool_name=tool_name,
tool_def=tool_def,
input_data=input_data,
field_mapping=field_mapping,
)
)
return processed_tools
def _update_conversation(
self, prompt: list[dict], response, tool_outputs: list | None = None
):
"""Update conversation history with response and tool outputs."""
# Don't add separate reasoning message with tool calls (breaks Anthropic's tool_use->tool_result pairing)
assistant_message = _convert_raw_response_to_dict(response.raw_response)
has_tool_calls = isinstance(assistant_message.get("content"), list) and any(
item.get("type") == "tool_use"
for item in assistant_message.get("content", [])
)
if response.reasoning and not has_tool_calls:
prompt.append(
{"role": "assistant", "content": f"[Reasoning]: {response.reasoning}"}
)
prompt.append(assistant_message)
if tool_outputs:
prompt.extend(tool_outputs)
async def _execute_single_tool_with_manager(
self,
tool_info: ToolInfo,
execution_params: ExecutionParams,
execution_processor: "ExecutionProcessor",
) -> dict:
"""Execute a single tool using the execution manager for proper integration."""
# Lazy imports to avoid circular dependencies
from backend.data.execution import NodeExecutionEntry
tool_call = tool_info.tool_call
tool_def = tool_info.tool_def
raw_input_data = tool_info.input_data
# Get sink node and field mapping
sink_node_id = tool_def["function"]["_sink_node_id"]
# Use proper database operations for tool execution
db_client = get_database_manager_async_client()
# Get target node
target_node = await db_client.get_node(sink_node_id)
if not target_node:
raise ValueError(f"Target node {sink_node_id} not found")
# Create proper node execution using upsert_execution_input
node_exec_result = None
final_input_data = None
# Add all inputs to the execution
if not raw_input_data:
raise ValueError(f"Tool call has no input data: {tool_call}")
for input_name, input_value in raw_input_data.items():
node_exec_result, final_input_data = await db_client.upsert_execution_input(
node_id=sink_node_id,
graph_exec_id=execution_params.graph_exec_id,
input_name=input_name,
input_data=input_value,
)
assert node_exec_result is not None, "node_exec_result should not be None"
# Create NodeExecutionEntry for execution manager
node_exec_entry = NodeExecutionEntry(
user_id=execution_params.user_id,
graph_exec_id=execution_params.graph_exec_id,
graph_id=execution_params.graph_id,
graph_version=execution_params.graph_version,
node_exec_id=node_exec_result.node_exec_id,
node_id=sink_node_id,
block_id=target_node.block_id,
inputs=final_input_data or {},
execution_context=execution_params.execution_context,
)
# Use the execution manager to execute the tool node
try:
# Get NodeExecutionProgress from the execution manager's running nodes
node_exec_progress = execution_processor.running_node_execution[
sink_node_id
]
# Use the execution manager's own graph stats
graph_stats_pair = (
execution_processor.execution_stats,
execution_processor.execution_stats_lock,
)
# Create a completed future for the task tracking system
node_exec_future = Future()
node_exec_progress.add_task(
node_exec_id=node_exec_result.node_exec_id,
task=node_exec_future,
)
# Execute the node directly since we're in the SmartDecisionMaker context
node_exec_future.set_result(
await execution_processor.on_node_execution(
node_exec=node_exec_entry,
node_exec_progress=node_exec_progress,
nodes_input_masks=None,
graph_stats_pair=graph_stats_pair,
)
)
# Get outputs from database after execution completes using database manager client
node_outputs = await db_client.get_execution_outputs_by_node_exec_id(
node_exec_result.node_exec_id
)
# Create tool response
tool_response_content = (
json.dumps(node_outputs)
if node_outputs
else "Tool executed successfully"
)
return _create_tool_response(tool_call.id, tool_response_content)
except Exception as e:
logger.error(f"Tool execution with manager failed: {e}")
# Return error response
return _create_tool_response(
tool_call.id, f"Tool execution failed: {str(e)}"
)
async def _execute_tools_agent_mode(
self,
input_data,
credentials,
tool_functions: list[dict[str, Any]],
prompt: list[dict],
graph_exec_id: str,
node_id: str,
node_exec_id: str,
user_id: str,
graph_id: str,
graph_version: int,
execution_context: ExecutionContext,
execution_processor: "ExecutionProcessor",
):
"""Execute tools in agent mode with a loop until finished."""
max_iterations = input_data.agent_mode_max_iterations
iteration = 0
# Execution parameters for tool execution
execution_params = ExecutionParams(
user_id=user_id,
graph_id=graph_id,
node_id=node_id,
graph_version=graph_version,
graph_exec_id=graph_exec_id,
node_exec_id=node_exec_id,
execution_context=execution_context,
)
current_prompt = list(prompt)
while max_iterations < 0 or iteration < max_iterations:
iteration += 1
logger.debug(f"Agent mode iteration {iteration}")
# Prepare prompt for this iteration
iteration_prompt = list(current_prompt)
# On the last iteration, add a special system message to encourage completion
if max_iterations > 0 and iteration == max_iterations:
last_iteration_message = {
"role": "system",
"content": f"{MAIN_OBJECTIVE_PREFIX}This is your last iteration ({iteration}/{max_iterations}). "
"Try to complete the task with the information you have. If you cannot fully complete it, "
"provide a summary of what you've accomplished and what remains to be done. "
"Prefer finishing with a clear response rather than making additional tool calls.",
}
iteration_prompt.append(last_iteration_message)
# Get LLM response
try:
response = await self._attempt_llm_call_with_validation(
credentials, input_data, iteration_prompt, tool_functions
)
except Exception as e:
yield "error", f"LLM call failed in agent mode iteration {iteration}: {str(e)}"
return
# Process tool calls
processed_tools = self._process_tool_calls(response, tool_functions)
# If no tool calls, we're done
if not processed_tools:
yield "finished", response.response
self._update_conversation(current_prompt, response)
yield "conversations", current_prompt
return
# Execute tools and collect responses
tool_outputs = []
for tool_info in processed_tools:
try:
tool_response = await self._execute_single_tool_with_manager(
tool_info, execution_params, execution_processor
)
tool_outputs.append(tool_response)
except Exception as e:
logger.error(f"Tool execution failed: {e}")
# Create error response for the tool
error_response = _create_tool_response(
tool_info.tool_call.id, f"Error: {str(e)}"
)
tool_outputs.append(error_response)
tool_outputs = _combine_tool_responses(tool_outputs)
self._update_conversation(current_prompt, response, tool_outputs)
# Yield intermediate conversation state
yield "conversations", current_prompt
# If we reach max iterations, yield the current state
if max_iterations < 0:
yield "finished", f"Agent mode completed after {iteration} iterations"
else:
yield "finished", f"Agent mode completed after {max_iterations} iterations (limit reached)"
yield "conversations", current_prompt
async def run(
self,
input_data: Input,
@@ -972,12 +603,8 @@ class SmartDecisionMakerBlock(Block):
graph_exec_id: str,
node_exec_id: str,
user_id: str,
graph_version: int,
execution_context: ExecutionContext,
execution_processor: "ExecutionProcessor",
**kwargs,
) -> BlockOutput:
tool_functions = await self._create_tool_node_signatures(node_id)
yield "tool_functions", json.dumps(tool_functions)
@@ -1021,52 +648,24 @@ class SmartDecisionMakerBlock(Block):
input_data.prompt = llm.fmt.format_string(input_data.prompt, values)
input_data.sys_prompt = llm.fmt.format_string(input_data.sys_prompt, values)
prefix = "[Main Objective Prompt]: "
if input_data.sys_prompt and not any(
p["role"] == "system" and p["content"].startswith(MAIN_OBJECTIVE_PREFIX)
for p in prompt
p["role"] == "system" and p["content"].startswith(prefix) for p in prompt
):
prompt.append(
{
"role": "system",
"content": MAIN_OBJECTIVE_PREFIX + input_data.sys_prompt,
}
)
prompt.append({"role": "system", "content": prefix + input_data.sys_prompt})
if input_data.prompt and not any(
p["role"] == "user" and p["content"].startswith(MAIN_OBJECTIVE_PREFIX)
for p in prompt
p["role"] == "user" and p["content"].startswith(prefix) for p in prompt
):
prompt.append(
{"role": "user", "content": MAIN_OBJECTIVE_PREFIX + input_data.prompt}
)
prompt.append({"role": "user", "content": prefix + input_data.prompt})
# Execute tools based on the selected mode
if input_data.agent_mode_max_iterations != 0:
# In agent mode, execute tools directly in a loop until finished
async for result in self._execute_tools_agent_mode(
input_data=input_data,
credentials=credentials,
tool_functions=tool_functions,
prompt=prompt,
graph_exec_id=graph_exec_id,
node_id=node_id,
node_exec_id=node_exec_id,
user_id=user_id,
graph_id=graph_id,
graph_version=graph_version,
execution_context=execution_context,
execution_processor=execution_processor,
):
yield result
return
# One-off mode: single LLM call and yield tool calls for external execution
current_prompt = list(prompt)
max_attempts = max(1, int(input_data.retry))
response = None
last_error = None
for _ in range(max_attempts):
for attempt in range(max_attempts):
try:
response = await self._attempt_llm_call_with_validation(
credentials, input_data, current_prompt, tool_functions

View File

@@ -1,11 +1,7 @@
import logging
import threading
from collections import defaultdict
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.data.execution import ExecutionContext
from backend.data.model import ProviderName, User
from backend.server.model import CreateGraph
from backend.server.rest_api import AgentServer
@@ -21,10 +17,10 @@ async def create_graph(s: SpinTestServer, g, u: User):
async def create_credentials(s: SpinTestServer, u: User):
import backend.blocks.llm as llm_module
import backend.blocks.llm as llm
provider = ProviderName.OPENAI
credentials = llm_module.TEST_CREDENTIALS
credentials = llm.TEST_CREDENTIALS
return await s.agent_server.test_create_credentials(u.id, provider, credentials)
@@ -200,6 +196,8 @@ async def test_smart_decision_maker_function_signature(server: SpinTestServer):
@pytest.mark.asyncio
async def test_smart_decision_maker_tracks_llm_stats():
"""Test that SmartDecisionMakerBlock correctly tracks LLM usage stats."""
from unittest.mock import MagicMock, patch
import backend.blocks.llm as llm_module
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
@@ -218,6 +216,7 @@ async def test_smart_decision_maker_tracks_llm_stats():
}
# Mock the _create_tool_node_signatures method to avoid database calls
from unittest.mock import AsyncMock
with patch(
"backend.blocks.llm.llm_call",
@@ -235,19 +234,10 @@ async def test_smart_decision_maker_tracks_llm_stats():
prompt="Should I continue with this task?",
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
agent_mode_max_iterations=0,
)
# Execute the block
outputs = {}
# Create execution context
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests
mock_execution_processor = MagicMock()
async for output_name, output_data in block.run(
input_data,
credentials=llm_module.TEST_CREDENTIALS,
@@ -256,9 +246,6 @@ async def test_smart_decision_maker_tracks_llm_stats():
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
@@ -276,6 +263,8 @@ async def test_smart_decision_maker_tracks_llm_stats():
@pytest.mark.asyncio
async def test_smart_decision_maker_parameter_validation():
"""Test that SmartDecisionMakerBlock correctly validates tool call parameters."""
from unittest.mock import MagicMock, patch
import backend.blocks.llm as llm_module
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
@@ -322,6 +311,8 @@ async def test_smart_decision_maker_parameter_validation():
mock_response_with_typo.reasoning = None
mock_response_with_typo.raw_response = {"role": "assistant", "content": None}
from unittest.mock import AsyncMock
with patch(
"backend.blocks.llm.llm_call",
new_callable=AsyncMock,
@@ -338,17 +329,8 @@ async def test_smart_decision_maker_parameter_validation():
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
retry=2, # Set retry to 2 for testing
agent_mode_max_iterations=0,
)
# Create execution context
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests
mock_execution_processor = MagicMock()
# Should raise ValueError after retries due to typo'd parameter name
with pytest.raises(ValueError) as exc_info:
outputs = {}
@@ -360,9 +342,6 @@ async def test_smart_decision_maker_parameter_validation():
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
@@ -389,6 +368,8 @@ async def test_smart_decision_maker_parameter_validation():
mock_response_missing_required.reasoning = None
mock_response_missing_required.raw_response = {"role": "assistant", "content": None}
from unittest.mock import AsyncMock
with patch(
"backend.blocks.llm.llm_call",
new_callable=AsyncMock,
@@ -404,17 +385,8 @@ async def test_smart_decision_maker_parameter_validation():
prompt="Search for keywords",
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
agent_mode_max_iterations=0,
)
# Create execution context
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests
mock_execution_processor = MagicMock()
# Should raise ValueError due to missing required parameter
with pytest.raises(ValueError) as exc_info:
outputs = {}
@@ -426,9 +398,6 @@ async def test_smart_decision_maker_parameter_validation():
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
@@ -449,6 +418,8 @@ async def test_smart_decision_maker_parameter_validation():
mock_response_valid.reasoning = None
mock_response_valid.raw_response = {"role": "assistant", "content": None}
from unittest.mock import AsyncMock
with patch(
"backend.blocks.llm.llm_call",
new_callable=AsyncMock,
@@ -464,19 +435,10 @@ async def test_smart_decision_maker_parameter_validation():
prompt="Search for keywords",
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
agent_mode_max_iterations=0,
)
# Should succeed - optional parameter missing is OK
outputs = {}
# Create execution context
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests
mock_execution_processor = MagicMock()
async for output_name, output_data in block.run(
input_data,
credentials=llm_module.TEST_CREDENTIALS,
@@ -485,9 +447,6 @@ async def test_smart_decision_maker_parameter_validation():
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
@@ -513,6 +472,8 @@ async def test_smart_decision_maker_parameter_validation():
mock_response_all_params.reasoning = None
mock_response_all_params.raw_response = {"role": "assistant", "content": None}
from unittest.mock import AsyncMock
with patch(
"backend.blocks.llm.llm_call",
new_callable=AsyncMock,
@@ -528,19 +489,10 @@ async def test_smart_decision_maker_parameter_validation():
prompt="Search for keywords",
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
agent_mode_max_iterations=0,
)
# Should succeed with all parameters
outputs = {}
# Create execution context
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests
mock_execution_processor = MagicMock()
async for output_name, output_data in block.run(
input_data,
credentials=llm_module.TEST_CREDENTIALS,
@@ -549,9 +501,6 @@ async def test_smart_decision_maker_parameter_validation():
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
@@ -564,6 +513,8 @@ async def test_smart_decision_maker_parameter_validation():
@pytest.mark.asyncio
async def test_smart_decision_maker_raw_response_conversion():
"""Test that SmartDecisionMaker correctly handles different raw_response types with retry mechanism."""
from unittest.mock import MagicMock, patch
import backend.blocks.llm as llm_module
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
@@ -633,6 +584,7 @@ async def test_smart_decision_maker_raw_response_conversion():
)
# Mock llm_call to return different responses on different calls
from unittest.mock import AsyncMock
with patch(
"backend.blocks.llm.llm_call", new_callable=AsyncMock
@@ -651,19 +603,10 @@ async def test_smart_decision_maker_raw_response_conversion():
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
retry=2,
agent_mode_max_iterations=0,
)
# Should succeed after retry, demonstrating our helper function works
outputs = {}
# Create execution context
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests
mock_execution_processor = MagicMock()
async for output_name, output_data in block.run(
input_data,
credentials=llm_module.TEST_CREDENTIALS,
@@ -672,9 +615,6 @@ async def test_smart_decision_maker_raw_response_conversion():
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
@@ -710,6 +650,8 @@ async def test_smart_decision_maker_raw_response_conversion():
"I'll help you with that." # Ollama returns string
)
from unittest.mock import AsyncMock
with patch(
"backend.blocks.llm.llm_call",
new_callable=AsyncMock,
@@ -724,18 +666,9 @@ async def test_smart_decision_maker_raw_response_conversion():
prompt="Simple prompt",
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
agent_mode_max_iterations=0,
)
outputs = {}
# Create execution context
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests
mock_execution_processor = MagicMock()
async for output_name, output_data in block.run(
input_data,
credentials=llm_module.TEST_CREDENTIALS,
@@ -744,9 +677,6 @@ async def test_smart_decision_maker_raw_response_conversion():
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
@@ -766,6 +696,8 @@ async def test_smart_decision_maker_raw_response_conversion():
"content": "Test response",
} # Dict format
from unittest.mock import AsyncMock
with patch(
"backend.blocks.llm.llm_call",
new_callable=AsyncMock,
@@ -780,18 +712,9 @@ async def test_smart_decision_maker_raw_response_conversion():
prompt="Another test",
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
agent_mode_max_iterations=0,
)
outputs = {}
# Create execution context
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests
mock_execution_processor = MagicMock()
async for output_name, output_data in block.run(
input_data,
credentials=llm_module.TEST_CREDENTIALS,
@@ -800,260 +723,8 @@ async def test_smart_decision_maker_raw_response_conversion():
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
assert "finished" in outputs
assert outputs["finished"] == "Test response"
@pytest.mark.asyncio
async def test_smart_decision_maker_agent_mode():
"""Test that agent mode executes tools directly and loops until finished."""
import backend.blocks.llm as llm_module
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
block = SmartDecisionMakerBlock()
# Mock tool call that requires multiple iterations
mock_tool_call_1 = MagicMock()
mock_tool_call_1.id = "call_1"
mock_tool_call_1.function.name = "search_keywords"
mock_tool_call_1.function.arguments = (
'{"query": "test", "max_keyword_difficulty": 50}'
)
mock_response_1 = MagicMock()
mock_response_1.response = None
mock_response_1.tool_calls = [mock_tool_call_1]
mock_response_1.prompt_tokens = 50
mock_response_1.completion_tokens = 25
mock_response_1.reasoning = "Using search tool"
mock_response_1.raw_response = {
"role": "assistant",
"content": None,
"tool_calls": [{"id": "call_1", "type": "function"}],
}
# Final response with no tool calls (finished)
mock_response_2 = MagicMock()
mock_response_2.response = "Task completed successfully"
mock_response_2.tool_calls = []
mock_response_2.prompt_tokens = 30
mock_response_2.completion_tokens = 15
mock_response_2.reasoning = None
mock_response_2.raw_response = {
"role": "assistant",
"content": "Task completed successfully",
}
# Mock the LLM call to return different responses on each iteration
llm_call_mock = AsyncMock()
llm_call_mock.side_effect = [mock_response_1, mock_response_2]
# Mock tool node signatures
mock_tool_signatures = [
{
"type": "function",
"function": {
"name": "search_keywords",
"_sink_node_id": "test-sink-node-id",
"_field_mapping": {},
"parameters": {
"properties": {
"query": {"type": "string"},
"max_keyword_difficulty": {"type": "integer"},
},
"required": ["query", "max_keyword_difficulty"],
},
},
}
]
# Mock database and execution components
mock_db_client = AsyncMock()
mock_node = MagicMock()
mock_node.block_id = "test-block-id"
mock_db_client.get_node.return_value = mock_node
# Mock upsert_execution_input to return proper NodeExecutionResult and input data
mock_node_exec_result = MagicMock()
mock_node_exec_result.node_exec_id = "test-tool-exec-id"
mock_input_data = {"query": "test", "max_keyword_difficulty": 50}
mock_db_client.upsert_execution_input.return_value = (
mock_node_exec_result,
mock_input_data,
)
# No longer need mock_execute_node since we use execution_processor.on_node_execution
with patch("backend.blocks.llm.llm_call", llm_call_mock), patch.object(
block, "_create_tool_node_signatures", return_value=mock_tool_signatures
), patch(
"backend.blocks.smart_decision_maker.get_database_manager_async_client",
return_value=mock_db_client,
), patch(
"backend.executor.manager.async_update_node_execution_status",
new_callable=AsyncMock,
), patch(
"backend.integrations.creds_manager.IntegrationCredentialsManager"
):
# Create a mock execution context
mock_execution_context = ExecutionContext(
safe_mode=False,
)
# Create a mock execution processor for agent mode tests
mock_execution_processor = AsyncMock()
# Configure the execution processor mock with required attributes
mock_execution_processor.running_node_execution = defaultdict(MagicMock)
mock_execution_processor.execution_stats = MagicMock()
mock_execution_processor.execution_stats_lock = threading.Lock()
# Mock the on_node_execution method to return successful stats
mock_node_stats = MagicMock()
mock_node_stats.error = None # No error
mock_execution_processor.on_node_execution = AsyncMock(
return_value=mock_node_stats
)
# Mock the get_execution_outputs_by_node_exec_id method
mock_db_client.get_execution_outputs_by_node_exec_id.return_value = {
"result": {"status": "success", "data": "search completed"}
}
# Test agent mode with max_iterations = 3
input_data = SmartDecisionMakerBlock.Input(
prompt="Complete this task using tools",
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
agent_mode_max_iterations=3, # Enable agent mode with 3 max iterations
)
outputs = {}
async for output_name, output_data in block.run(
input_data,
credentials=llm_module.TEST_CREDENTIALS,
graph_id="test-graph-id",
node_id="test-node-id",
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
# Verify agent mode behavior
assert "tool_functions" in outputs # tool_functions is yielded in both modes
assert "finished" in outputs
assert outputs["finished"] == "Task completed successfully"
assert "conversations" in outputs
# Verify the conversation includes tool responses
conversations = outputs["conversations"]
assert len(conversations) > 2 # Should have multiple conversation entries
# Verify LLM was called twice (once for tool call, once for finish)
assert llm_call_mock.call_count == 2
# Verify tool was executed via execution processor
assert mock_execution_processor.on_node_execution.call_count == 1
@pytest.mark.asyncio
async def test_smart_decision_maker_traditional_mode_default():
"""Test that default behavior (agent_mode_max_iterations=0) works as traditional mode."""
import backend.blocks.llm as llm_module
from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock
block = SmartDecisionMakerBlock()
# Mock tool call
mock_tool_call = MagicMock()
mock_tool_call.function.name = "search_keywords"
mock_tool_call.function.arguments = (
'{"query": "test", "max_keyword_difficulty": 50}'
)
mock_response = MagicMock()
mock_response.response = None
mock_response.tool_calls = [mock_tool_call]
mock_response.prompt_tokens = 50
mock_response.completion_tokens = 25
mock_response.reasoning = None
mock_response.raw_response = {"role": "assistant", "content": None}
mock_tool_signatures = [
{
"type": "function",
"function": {
"name": "search_keywords",
"_sink_node_id": "test-sink-node-id",
"_field_mapping": {},
"parameters": {
"properties": {
"query": {"type": "string"},
"max_keyword_difficulty": {"type": "integer"},
},
"required": ["query", "max_keyword_difficulty"],
},
},
}
]
with patch(
"backend.blocks.llm.llm_call",
new_callable=AsyncMock,
return_value=mock_response,
), patch.object(
block, "_create_tool_node_signatures", return_value=mock_tool_signatures
):
# Test default behavior (traditional mode)
input_data = SmartDecisionMakerBlock.Input(
prompt="Test prompt",
model=llm_module.LlmModel.GPT4O,
credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore
agent_mode_max_iterations=0, # Traditional mode
)
# Create execution context
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a mock execution processor for tests
mock_execution_processor = MagicMock()
outputs = {}
async for output_name, output_data in block.run(
input_data,
credentials=llm_module.TEST_CREDENTIALS,
graph_id="test-graph-id",
node_id="test-node-id",
graph_exec_id="test-exec-id",
node_exec_id="test-node-exec-id",
user_id="test-user-id",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_data
# Verify traditional mode behavior
assert (
"tool_functions" in outputs
) # Should yield tool_functions in traditional mode
assert (
"tools_^_test-sink-node-id_~_query" in outputs
) # Should yield individual tool parameters
assert "tools_^_test-sink-node-id_~_max_keyword_difficulty" in outputs
assert "conversations" in outputs

View File

@@ -1,7 +1,7 @@
"""Comprehensive tests for SmartDecisionMakerBlock dynamic field handling."""
import json
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from unittest.mock import AsyncMock, Mock, patch
import pytest
@@ -308,47 +308,10 @@ async def test_output_yielding_with_dynamic_fields():
) as mock_llm:
mock_llm.return_value = mock_response
# Mock the database manager to avoid HTTP calls during tool execution
with patch(
"backend.blocks.smart_decision_maker.get_database_manager_async_client"
) as mock_db_manager, patch.object(
# Mock the function signature creation
with patch.object(
block, "_create_tool_node_signatures", new_callable=AsyncMock
) as mock_sig:
# Set up the mock database manager
mock_db_client = AsyncMock()
mock_db_manager.return_value = mock_db_client
# Mock the node retrieval
mock_target_node = Mock()
mock_target_node.id = "test-sink-node-id"
mock_target_node.block_id = "CreateDictionaryBlock"
mock_target_node.block = Mock()
mock_target_node.block.name = "Create Dictionary"
mock_db_client.get_node.return_value = mock_target_node
# Mock the execution result creation
mock_node_exec_result = Mock()
mock_node_exec_result.node_exec_id = "mock-node-exec-id"
mock_final_input_data = {
"values_#_name": "Alice",
"values_#_age": 30,
"values_#_email": "alice@example.com",
}
mock_db_client.upsert_execution_input.return_value = (
mock_node_exec_result,
mock_final_input_data,
)
# Mock the output retrieval
mock_outputs = {
"values_#_name": "Alice",
"values_#_age": 30,
"values_#_email": "alice@example.com",
}
mock_db_client.get_execution_outputs_by_node_exec_id.return_value = (
mock_outputs
)
mock_sig.return_value = [
{
"type": "function",
@@ -374,16 +337,10 @@ async def test_output_yielding_with_dynamic_fields():
prompt="Create a user dictionary",
credentials=llm.TEST_CREDENTIALS_INPUT,
model=llm.LlmModel.GPT4O,
agent_mode_max_iterations=0, # Use traditional mode to test output yielding
)
# Run the block
outputs = {}
from backend.data.execution import ExecutionContext
mock_execution_context = ExecutionContext(safe_mode=False)
mock_execution_processor = MagicMock()
async for output_name, output_value in block.run(
input_data,
credentials=llm.TEST_CREDENTIALS,
@@ -392,9 +349,6 @@ async def test_output_yielding_with_dynamic_fields():
graph_exec_id="test_exec",
node_exec_id="test_node_exec",
user_id="test_user",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_value
@@ -557,108 +511,45 @@ async def test_validation_errors_dont_pollute_conversation():
}
]
# Mock the database manager to avoid HTTP calls during tool execution
with patch(
"backend.blocks.smart_decision_maker.get_database_manager_async_client"
) as mock_db_manager:
# Set up the mock database manager for agent mode
mock_db_client = AsyncMock()
mock_db_manager.return_value = mock_db_client
# Create input data
from backend.blocks import llm
# Mock the node retrieval
mock_target_node = Mock()
mock_target_node.id = "test-sink-node-id"
mock_target_node.block_id = "TestBlock"
mock_target_node.block = Mock()
mock_target_node.block.name = "Test Block"
mock_db_client.get_node.return_value = mock_target_node
input_data = block.input_schema(
prompt="Test prompt",
credentials=llm.TEST_CREDENTIALS_INPUT,
model=llm.LlmModel.GPT4O,
retry=3, # Allow retries
)
# Mock the execution result creation
mock_node_exec_result = Mock()
mock_node_exec_result.node_exec_id = "mock-node-exec-id"
mock_final_input_data = {"correct_param": "value"}
mock_db_client.upsert_execution_input.return_value = (
mock_node_exec_result,
mock_final_input_data,
)
# Run the block
outputs = {}
async for output_name, output_value in block.run(
input_data,
credentials=llm.TEST_CREDENTIALS,
graph_id="test_graph",
node_id="test_node",
graph_exec_id="test_exec",
node_exec_id="test_node_exec",
user_id="test_user",
):
outputs[output_name] = output_value
# Mock the output retrieval
mock_outputs = {"correct_param": "value"}
mock_db_client.get_execution_outputs_by_node_exec_id.return_value = (
mock_outputs
)
# Verify we had 2 LLM calls (initial + retry)
assert call_count == 2
# Create input data
from backend.blocks import llm
# Check the final conversation output
final_conversation = outputs.get("conversations", [])
input_data = block.input_schema(
prompt="Test prompt",
credentials=llm.TEST_CREDENTIALS_INPUT,
model=llm.LlmModel.GPT4O,
retry=3, # Allow retries
agent_mode_max_iterations=1,
)
# The final conversation should NOT contain the validation error message
error_messages = [
msg
for msg in final_conversation
if msg.get("role") == "user"
and "parameter errors" in msg.get("content", "")
]
assert (
len(error_messages) == 0
), "Validation error leaked into final conversation"
# Run the block
outputs = {}
from backend.data.execution import ExecutionContext
mock_execution_context = ExecutionContext(safe_mode=False)
# Create a proper mock execution processor for agent mode
from collections import defaultdict
mock_execution_processor = AsyncMock()
mock_execution_processor.execution_stats = MagicMock()
mock_execution_processor.execution_stats_lock = MagicMock()
# Create a mock NodeExecutionProgress for the sink node
mock_node_exec_progress = MagicMock()
mock_node_exec_progress.add_task = MagicMock()
mock_node_exec_progress.pop_output = MagicMock(
return_value=None
) # No outputs to process
# Set up running_node_execution as a defaultdict that returns our mock for any key
mock_execution_processor.running_node_execution = defaultdict(
lambda: mock_node_exec_progress
)
# Mock the on_node_execution method that gets called during tool execution
mock_node_stats = MagicMock()
mock_node_stats.error = None
mock_execution_processor.on_node_execution.return_value = (
mock_node_stats
)
async for output_name, output_value in block.run(
input_data,
credentials=llm.TEST_CREDENTIALS,
graph_id="test_graph",
node_id="test_node",
graph_exec_id="test_exec",
node_exec_id="test_node_exec",
user_id="test_user",
graph_version=1,
execution_context=mock_execution_context,
execution_processor=mock_execution_processor,
):
outputs[output_name] = output_value
# Verify we had at least 1 LLM call
assert call_count >= 1
# Check the final conversation output
final_conversation = outputs.get("conversations", [])
# The final conversation should NOT contain validation error messages
# Even if retries don't happen in agent mode, we should not leak errors
error_messages = [
msg
for msg in final_conversation
if msg.get("role") == "user"
and "parameter errors" in msg.get("content", "")
]
assert (
len(error_messages) == 0
), "Validation error leaked into final conversation"
# The final conversation should only have the successful response
assert final_conversation[-1]["content"] == "valid"

View File

@@ -1,45 +1,12 @@
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import prisma.types
from pydantic import BaseModel
from backend.data.db import query_raw_with_schema
from backend.util.json import SafeJson
logger = logging.getLogger(__name__)
class AccuracyAlertData(BaseModel):
"""Alert data when accuracy drops significantly."""
graph_id: str
user_id: Optional[str]
drop_percent: float
three_day_avg: float
seven_day_avg: float
detected_at: datetime
class AccuracyLatestData(BaseModel):
"""Latest execution accuracy data point."""
date: datetime
daily_score: Optional[float]
three_day_avg: Optional[float]
seven_day_avg: Optional[float]
fourteen_day_avg: Optional[float]
class AccuracyTrendsResponse(BaseModel):
"""Response model for accuracy trends and alerts."""
latest_data: AccuracyLatestData
alert: Optional[AccuracyAlertData]
historical_data: Optional[list[AccuracyLatestData]] = None
async def log_raw_analytics(
user_id: str,
type: str,
@@ -76,217 +43,3 @@ async def log_raw_metric(
)
return result
async def get_accuracy_trends_and_alerts(
graph_id: str,
days_back: int = 30,
user_id: Optional[str] = None,
drop_threshold: float = 10.0,
include_historical: bool = False,
) -> AccuracyTrendsResponse:
"""Get accuracy trends and detect alerts for a specific graph."""
query_template = """
WITH daily_scores AS (
SELECT
DATE(e."createdAt") as execution_date,
AVG(CASE
WHEN e.stats IS NOT NULL
AND e.stats::json->>'correctness_score' IS NOT NULL
AND e.stats::json->>'correctness_score' != 'null'
THEN (e.stats::json->>'correctness_score')::float * 100
ELSE NULL
END) as daily_score
FROM {schema_prefix}"AgentGraphExecution" e
WHERE e."agentGraphId" = $1::text
AND e."isDeleted" = false
AND e."createdAt" >= $2::timestamp
AND e."executionStatus" IN ('COMPLETED', 'FAILED', 'TERMINATED')
{user_filter}
GROUP BY DATE(e."createdAt")
HAVING COUNT(*) >= 3 -- Need at least 3 executions per day
),
trends AS (
SELECT
execution_date,
daily_score,
AVG(daily_score) OVER (
ORDER BY execution_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) as three_day_avg,
AVG(daily_score) OVER (
ORDER BY execution_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) as seven_day_avg,
AVG(daily_score) OVER (
ORDER BY execution_date
ROWS BETWEEN 13 PRECEDING AND CURRENT ROW
) as fourteen_day_avg
FROM daily_scores
)
SELECT *,
CASE
WHEN three_day_avg IS NOT NULL AND seven_day_avg IS NOT NULL AND seven_day_avg > 0
THEN ((seven_day_avg - three_day_avg) / seven_day_avg * 100)
ELSE NULL
END as drop_percent
FROM trends
ORDER BY execution_date DESC
{limit_clause}
"""
start_date = datetime.now(timezone.utc) - timedelta(days=days_back)
params = [graph_id, start_date]
user_filter = ""
if user_id:
user_filter = 'AND e."userId" = $3::text'
params.append(user_id)
# Determine limit clause
limit_clause = "" if include_historical else "LIMIT 1"
final_query = query_template.format(
schema_prefix="{schema_prefix}",
user_filter=user_filter,
limit_clause=limit_clause,
)
result = await query_raw_with_schema(final_query, *params)
if not result:
return AccuracyTrendsResponse(
latest_data=AccuracyLatestData(
date=datetime.now(timezone.utc),
daily_score=None,
three_day_avg=None,
seven_day_avg=None,
fourteen_day_avg=None,
),
alert=None,
)
latest = result[0]
alert = None
if (
latest["drop_percent"] is not None
and latest["drop_percent"] >= drop_threshold
and latest["three_day_avg"] is not None
and latest["seven_day_avg"] is not None
):
alert = AccuracyAlertData(
graph_id=graph_id,
user_id=user_id,
drop_percent=float(latest["drop_percent"]),
three_day_avg=float(latest["three_day_avg"]),
seven_day_avg=float(latest["seven_day_avg"]),
detected_at=datetime.now(timezone.utc),
)
# Prepare historical data if requested
historical_data = None
if include_historical:
historical_data = []
for row in result:
historical_data.append(
AccuracyLatestData(
date=row["execution_date"],
daily_score=(
float(row["daily_score"])
if row["daily_score"] is not None
else None
),
three_day_avg=(
float(row["three_day_avg"])
if row["three_day_avg"] is not None
else None
),
seven_day_avg=(
float(row["seven_day_avg"])
if row["seven_day_avg"] is not None
else None
),
fourteen_day_avg=(
float(row["fourteen_day_avg"])
if row["fourteen_day_avg"] is not None
else None
),
)
)
return AccuracyTrendsResponse(
latest_data=AccuracyLatestData(
date=latest["execution_date"],
daily_score=(
float(latest["daily_score"])
if latest["daily_score"] is not None
else None
),
three_day_avg=(
float(latest["three_day_avg"])
if latest["three_day_avg"] is not None
else None
),
seven_day_avg=(
float(latest["seven_day_avg"])
if latest["seven_day_avg"] is not None
else None
),
fourteen_day_avg=(
float(latest["fourteen_day_avg"])
if latest["fourteen_day_avg"] is not None
else None
),
),
alert=alert,
historical_data=historical_data,
)
class MarketplaceGraphData(BaseModel):
"""Data structure for marketplace graph monitoring."""
graph_id: str
user_id: Optional[str]
execution_count: int
async def get_marketplace_graphs_for_monitoring(
days_back: int = 30,
min_executions: int = 10,
) -> list[MarketplaceGraphData]:
"""Get published marketplace graphs with recent executions for monitoring."""
query_template = """
WITH marketplace_graphs AS (
SELECT DISTINCT
slv."agentGraphId" as graph_id,
slv."agentGraphVersion" as graph_version
FROM {schema_prefix}"StoreListing" sl
JOIN {schema_prefix}"StoreListingVersion" slv ON sl."activeVersionId" = slv."id"
WHERE sl."hasApprovedVersion" = true
AND sl."isDeleted" = false
)
SELECT DISTINCT
mg.graph_id,
NULL as user_id, -- Marketplace graphs don't have a specific user_id for monitoring
COUNT(*) as execution_count
FROM marketplace_graphs mg
JOIN {schema_prefix}"AgentGraphExecution" e ON e."agentGraphId" = mg.graph_id
WHERE e."createdAt" >= $1::timestamp
AND e."isDeleted" = false
AND e."executionStatus" IN ('COMPLETED', 'FAILED', 'TERMINATED')
GROUP BY mg.graph_id
HAVING COUNT(*) >= $2
ORDER BY execution_count DESC
"""
start_date = datetime.now(timezone.utc) - timedelta(days=days_back)
result = await query_raw_with_schema(query_template, start_date, min_executions)
return [
MarketplaceGraphData(
graph_id=row["graph_id"],
user_id=row["user_id"],
execution_count=int(row["execution_count"]),
)
for row in result
]

View File

@@ -1,10 +1,10 @@
import logging
import queue
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from enum import Enum
from multiprocessing import Manager
from queue import Empty
from typing import (
TYPE_CHECKING,
Annotated,
Any,
AsyncGenerator,
@@ -65,9 +65,6 @@ from .includes import (
)
from .model import CredentialsMetaInput, GraphExecutionStats, NodeExecutionStats
if TYPE_CHECKING:
pass
T = TypeVar("T")
logger = logging.getLogger(__name__)
@@ -839,30 +836,6 @@ async def upsert_execution_output(
await AgentNodeExecutionInputOutput.prisma().create(data=data)
async def get_execution_outputs_by_node_exec_id(
node_exec_id: str,
) -> dict[str, Any]:
"""
Get all execution outputs for a specific node execution ID.
Args:
node_exec_id: The node execution ID to get outputs for
Returns:
Dictionary mapping output names to their data values
"""
outputs = await AgentNodeExecutionInputOutput.prisma().find_many(
where={"referencedByOutputExecId": node_exec_id}
)
result = {}
for output in outputs:
if output.data is not None:
result[output.name] = type_utils.convert(output.data, JsonValue)
return result
async def update_graph_execution_start_time(
graph_exec_id: str,
) -> GraphExecution | None:
@@ -1163,16 +1136,12 @@ class NodeExecutionEntry(BaseModel):
class ExecutionQueue(Generic[T]):
"""
Thread-safe queue for managing node execution within a single graph execution.
Note: Uses queue.Queue (not multiprocessing.Queue) since all access is from
threads within the same process. If migrating back to ProcessPoolExecutor,
replace with multiprocessing.Manager().Queue() for cross-process safety.
Queue for managing the execution of agents.
This will be shared between different processes
"""
def __init__(self):
# Thread-safe queue (not multiprocessing) — see class docstring
self.queue: queue.Queue[T] = queue.Queue()
self.queue = Manager().Queue()
def add(self, execution: T) -> T:
self.queue.put(execution)
@@ -1187,7 +1156,7 @@ class ExecutionQueue(Generic[T]):
def get_or_none(self) -> T | None:
try:
return self.queue.get_nowait()
except queue.Empty:
except Empty:
return None
@@ -1496,35 +1465,3 @@ async def get_graph_execution_by_share_token(
created_at=execution.createdAt,
outputs=outputs,
)
async def get_frequently_executed_graphs(
days_back: int = 30,
min_executions: int = 10,
) -> list[dict]:
"""Get graphs that have been frequently executed for monitoring."""
query_template = """
SELECT DISTINCT
e."agentGraphId" as graph_id,
e."userId" as user_id,
COUNT(*) as execution_count
FROM {schema_prefix}"AgentGraphExecution" e
WHERE e."createdAt" >= $1::timestamp
AND e."isDeleted" = false
AND e."executionStatus" IN ('COMPLETED', 'FAILED', 'TERMINATED')
GROUP BY e."agentGraphId", e."userId"
HAVING COUNT(*) >= $2
ORDER BY execution_count DESC
"""
start_date = datetime.now(timezone.utc) - timedelta(days=days_back)
result = await query_raw_with_schema(query_template, start_date, min_executions)
return [
{
"graph_id": row["graph_id"],
"user_id": row["user_id"],
"execution_count": int(row["execution_count"]),
}
for row in result
]

View File

@@ -1,60 +0,0 @@
"""Tests for ExecutionQueue thread-safety."""
import queue
import threading
import pytest
from backend.data.execution import ExecutionQueue
def test_execution_queue_uses_stdlib_queue():
"""Verify ExecutionQueue uses queue.Queue (not multiprocessing)."""
q = ExecutionQueue()
assert isinstance(q.queue, queue.Queue)
def test_basic_operations():
"""Test add, get, empty, and get_or_none."""
q = ExecutionQueue()
assert q.empty() is True
assert q.get_or_none() is None
result = q.add("item1")
assert result == "item1"
assert q.empty() is False
item = q.get()
assert item == "item1"
assert q.empty() is True
def test_thread_safety():
"""Test concurrent access from multiple threads."""
q = ExecutionQueue()
results = []
num_items = 100
def producer():
for i in range(num_items):
q.add(f"item_{i}")
def consumer():
count = 0
while count < num_items:
item = q.get_or_none()
if item is not None:
results.append(item)
count += 1
producer_thread = threading.Thread(target=producer)
consumer_thread = threading.Thread(target=consumer)
producer_thread.start()
consumer_thread.start()
producer_thread.join(timeout=5)
consumer_thread.join(timeout=5)
assert len(results) == num_items

View File

@@ -3,18 +3,12 @@ from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Callable, Concatenate, ParamSpec, TypeVar, cast
from backend.data import db
from backend.data.analytics import (
get_accuracy_trends_and_alerts,
get_marketplace_graphs_for_monitoring,
)
from backend.data.credit import UsageTransactionMetadata, get_user_credit_model
from backend.data.execution import (
create_graph_execution,
get_block_error_stats,
get_child_graph_executions,
get_execution_kv_data,
get_execution_outputs_by_node_exec_id,
get_frequently_executed_graphs,
get_graph_execution_meta,
get_graph_executions,
get_graph_executions_count,
@@ -148,13 +142,9 @@ class DatabaseManager(AppService):
update_graph_execution_stats = _(update_graph_execution_stats)
upsert_execution_input = _(upsert_execution_input)
upsert_execution_output = _(upsert_execution_output)
get_execution_outputs_by_node_exec_id = _(get_execution_outputs_by_node_exec_id)
get_execution_kv_data = _(get_execution_kv_data)
set_execution_kv_data = _(set_execution_kv_data)
get_block_error_stats = _(get_block_error_stats)
get_accuracy_trends_and_alerts = _(get_accuracy_trends_and_alerts)
get_frequently_executed_graphs = _(get_frequently_executed_graphs)
get_marketplace_graphs_for_monitoring = _(get_marketplace_graphs_for_monitoring)
# Graphs
get_node = _(get_node)
@@ -236,10 +226,6 @@ class DatabaseManagerClient(AppServiceClient):
# Block error monitoring
get_block_error_stats = _(d.get_block_error_stats)
# Execution accuracy monitoring
get_accuracy_trends_and_alerts = _(d.get_accuracy_trends_and_alerts)
get_frequently_executed_graphs = _(d.get_frequently_executed_graphs)
get_marketplace_graphs_for_monitoring = _(d.get_marketplace_graphs_for_monitoring)
# Human In The Loop
has_pending_reviews_for_graph_exec = _(d.has_pending_reviews_for_graph_exec)
@@ -279,7 +265,6 @@ class DatabaseManagerAsyncClient(AppServiceClient):
get_user_integrations = d.get_user_integrations
upsert_execution_input = d.upsert_execution_input
upsert_execution_output = d.upsert_execution_output
get_execution_outputs_by_node_exec_id = d.get_execution_outputs_by_node_exec_id
update_graph_execution_stats = d.update_graph_execution_stats
update_node_execution_status = d.update_node_execution_status
update_node_execution_status_batch = d.update_node_execution_status_batch

View File

@@ -133,8 +133,9 @@ def execute_graph(
cluster_lock: ClusterLock,
):
"""Execute graph using thread-local ExecutionProcessor instance"""
processor: ExecutionProcessor = _tls.processor
return processor.on_graph_execution(graph_exec_entry, cancel_event, cluster_lock)
return _tls.processor.on_graph_execution(
graph_exec_entry, cancel_event, cluster_lock
)
T = TypeVar("T")
@@ -142,8 +143,8 @@ T = TypeVar("T")
async def execute_node(
node: Node,
creds_manager: IntegrationCredentialsManager,
data: NodeExecutionEntry,
execution_processor: "ExecutionProcessor",
execution_stats: NodeExecutionStats | None = None,
nodes_input_masks: Optional[NodesInputMasks] = None,
) -> BlockOutput:
@@ -168,7 +169,6 @@ async def execute_node(
node_id = data.node_id
node_block = node.block
execution_context = data.execution_context
creds_manager = execution_processor.creds_manager
log_metadata = LogMetadata(
logger=_logger,
@@ -212,7 +212,6 @@ async def execute_node(
"node_exec_id": node_exec_id,
"user_id": user_id,
"execution_context": execution_context,
"execution_processor": execution_processor,
}
# Last-minute fetch credentials + acquire a system-wide read-write lock to prevent
@@ -609,8 +608,8 @@ class ExecutionProcessor:
async for output_name, output_data in execute_node(
node=node,
creds_manager=self.creds_manager,
data=node_exec,
execution_processor=self,
execution_stats=stats,
nodes_input_masks=nodes_input_masks,
):
@@ -861,17 +860,12 @@ class ExecutionProcessor:
execution_stats_lock = threading.Lock()
# State holders ----------------------------------------------------
self.running_node_execution: dict[str, NodeExecutionProgress] = defaultdict(
running_node_execution: dict[str, NodeExecutionProgress] = defaultdict(
NodeExecutionProgress
)
self.running_node_evaluation: dict[str, Future] = {}
self.execution_stats = execution_stats
self.execution_stats_lock = execution_stats_lock
running_node_evaluation: dict[str, Future] = {}
execution_queue = ExecutionQueue[NodeExecutionEntry]()
running_node_execution = self.running_node_execution
running_node_evaluation = self.running_node_evaluation
try:
if db_client.get_credits(graph_exec.user_id) <= 0:
raise InsufficientBalanceError(

View File

@@ -33,7 +33,6 @@ from backend.monitoring import (
process_existing_batches,
process_weekly_summary,
report_block_error_rates,
report_execution_accuracy_alerts,
report_late_executions,
)
from backend.util.clients import get_scheduler_client
@@ -242,11 +241,6 @@ def cleanup_expired_files():
run_async(cleanup_expired_files_async())
def execution_accuracy_alerts():
"""Check execution accuracy and send alerts if drops are detected."""
return report_execution_accuracy_alerts()
# Monitoring functions are now imported from monitoring module
@@ -446,17 +440,6 @@ class Scheduler(AppService):
jobstore=Jobstores.EXECUTION.value,
)
# Execution Accuracy Monitoring - configurable interval
self.scheduler.add_job(
execution_accuracy_alerts,
id="report_execution_accuracy_alerts",
trigger="interval",
replace_existing=True,
seconds=config.execution_accuracy_check_interval_hours
* 3600, # Convert hours to seconds
jobstore=Jobstores.EXECUTION.value,
)
self.scheduler.add_listener(job_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)
self.scheduler.add_listener(job_missed_listener, EVENT_JOB_MISSED)
self.scheduler.add_listener(job_max_instances_listener, EVENT_JOB_MAX_INSTANCES)
@@ -604,11 +587,6 @@ class Scheduler(AppService):
"""Manually trigger cleanup of expired cloud storage files."""
return cleanup_expired_files()
@expose
def execute_report_execution_accuracy_alerts(self):
"""Manually trigger execution accuracy alert checking."""
return execution_accuracy_alerts()
class SchedulerClient(AppServiceClient):
@classmethod

View File

@@ -1,6 +1,5 @@
"""Monitoring module for platform health and alerting."""
from .accuracy_monitor import AccuracyMonitor, report_execution_accuracy_alerts
from .block_error_monitor import BlockErrorMonitor, report_block_error_rates
from .late_execution_monitor import (
LateExecutionException,
@@ -14,12 +13,10 @@ from .notification_monitor import (
)
__all__ = [
"AccuracyMonitor",
"BlockErrorMonitor",
"LateExecutionMonitor",
"LateExecutionException",
"NotificationJobArgs",
"report_execution_accuracy_alerts",
"report_block_error_rates",
"report_late_executions",
"process_existing_batches",

View File

@@ -1,107 +0,0 @@
"""Execution accuracy monitoring module."""
import logging
from backend.util.clients import (
get_database_manager_client,
get_notification_manager_client,
)
from backend.util.metrics import DiscordChannel, sentry_capture_error
from backend.util.settings import Config
logger = logging.getLogger(__name__)
config = Config()
class AccuracyMonitor:
"""Monitor execution accuracy trends and send alerts for drops."""
def __init__(self, drop_threshold: float = 10.0):
self.config = config
self.notification_client = get_notification_manager_client()
self.database_client = get_database_manager_client()
self.drop_threshold = drop_threshold
def check_execution_accuracy_alerts(self) -> str:
"""Check marketplace agents for accuracy drops and send alerts."""
try:
logger.info("Checking execution accuracy for marketplace agents")
# Get marketplace graphs using database client
graphs = self.database_client.get_marketplace_graphs_for_monitoring(
days_back=30, min_executions=10
)
alerts_found = 0
for graph_data in graphs:
result = self.database_client.get_accuracy_trends_and_alerts(
graph_id=graph_data.graph_id,
user_id=graph_data.user_id,
days_back=21, # 3 weeks
drop_threshold=self.drop_threshold,
)
if result.alert:
alert = result.alert
# Get graph details for better alert info
try:
graph_info = self.database_client.get_graph_metadata(
graph_id=alert.graph_id
)
graph_name = graph_info.name if graph_info else "Unknown Agent"
except Exception:
graph_name = "Unknown Agent"
# Create detailed alert message
alert_msg = (
f"🚨 **AGENT ACCURACY DROP DETECTED**\n\n"
f"**Agent:** {graph_name}\n"
f"**Graph ID:** `{alert.graph_id}`\n"
f"**Accuracy Drop:** {alert.drop_percent:.1f}%\n"
f"**Recent Performance:**\n"
f" • 3-day average: {alert.three_day_avg:.1f}%\n"
f" • 7-day average: {alert.seven_day_avg:.1f}%\n"
)
if alert.user_id:
alert_msg += f"**Owner:** {alert.user_id}\n"
# Send individual alert for each agent (not batched)
self.notification_client.discord_system_alert(
alert_msg, DiscordChannel.PRODUCT
)
alerts_found += 1
logger.warning(
f"Sent accuracy alert for agent: {graph_name} ({alert.graph_id})"
)
if alerts_found > 0:
return f"Alert sent for {alerts_found} agents with accuracy drops"
logger.info("No execution accuracy alerts detected")
return "No accuracy alerts detected"
except Exception as e:
logger.exception(f"Error checking execution accuracy alerts: {e}")
error = Exception(f"Error checking execution accuracy alerts: {e}")
msg = str(error)
sentry_capture_error(error)
self.notification_client.discord_system_alert(msg, DiscordChannel.PRODUCT)
return msg
def report_execution_accuracy_alerts(drop_threshold: float = 10.0) -> str:
"""
Check execution accuracy and send alerts if drops are detected.
Args:
drop_threshold: Percentage drop threshold to trigger alerts (default 10.0%)
Returns:
Status message indicating results of the check
"""
monitor = AccuracyMonitor(drop_threshold=drop_threshold)
return monitor.check_execution_accuracy_alerts()

View File

@@ -8,10 +8,6 @@ from fastapi import APIRouter, HTTPException, Security
from pydantic import BaseModel, Field
from backend.blocks.llm import LlmModel
from backend.data.analytics import (
AccuracyTrendsResponse,
get_accuracy_trends_and_alerts,
)
from backend.data.execution import (
ExecutionStatus,
GraphExecutionMeta,
@@ -87,18 +83,6 @@ class ExecutionAnalyticsConfig(BaseModel):
recommended_model: str
class AccuracyTrendsRequest(BaseModel):
graph_id: str = Field(..., description="Graph ID to analyze", min_length=1)
user_id: Optional[str] = Field(None, description="Optional user ID filter")
days_back: int = Field(30, description="Number of days to look back", ge=7, le=90)
drop_threshold: float = Field(
10.0, description="Alert threshold percentage", ge=1.0, le=50.0
)
include_historical: bool = Field(
False, description="Include historical data for charts"
)
router = APIRouter(
prefix="/admin",
tags=["admin", "execution_analytics"],
@@ -435,40 +419,3 @@ async def _process_batch(
return await asyncio.gather(
*[process_single_execution(execution) for execution in executions]
)
@router.get(
"/execution_accuracy_trends",
response_model=AccuracyTrendsResponse,
summary="Get Execution Accuracy Trends and Alerts",
)
async def get_execution_accuracy_trends(
graph_id: str,
user_id: Optional[str] = None,
days_back: int = 30,
drop_threshold: float = 10.0,
include_historical: bool = False,
admin_user_id: str = Security(get_user_id),
) -> AccuracyTrendsResponse:
"""
Get execution accuracy trends with moving averages and alert detection.
Simple single-query approach.
"""
logger.info(
f"Admin user {admin_user_id} requesting accuracy trends for graph {graph_id}"
)
try:
result = await get_accuracy_trends_and_alerts(
graph_id=graph_id,
days_back=days_back,
user_id=user_id,
drop_threshold=drop_threshold,
include_historical=include_historical,
)
return result
except Exception as e:
logger.exception(f"Error getting accuracy trends for graph {graph_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,16 +1,9 @@
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from difflib import SequenceMatcher
from typing import Sequence
import prisma
import backend.data.block
import backend.server.v2.library.db as library_db
import backend.server.v2.library.model as library_model
import backend.server.v2.store.db as store_db
import backend.server.v2.store.model as store_model
from backend.blocks import load_all_blocks
from backend.blocks.llm import LlmModel
from backend.data.block import AnyBlockSchema, BlockCategory, BlockInfo, BlockSchema
@@ -21,36 +14,17 @@ from backend.server.v2.builder.model import (
BlockResponse,
BlockType,
CountResponse,
FilterType,
Provider,
ProviderResponse,
SearchEntry,
SearchBlocksResponse,
)
from backend.util.cache import cached
from backend.util.models import Pagination
logger = logging.getLogger(__name__)
llm_models = [name.name.lower().replace("_", " ") for name in LlmModel]
MAX_LIBRARY_AGENT_RESULTS = 100
MAX_MARKETPLACE_AGENT_RESULTS = 100
MIN_SCORE_FOR_FILTERED_RESULTS = 10.0
SearchResultItem = BlockInfo | library_model.LibraryAgent | store_model.StoreAgent
@dataclass
class _ScoredItem:
item: SearchResultItem
filter_type: FilterType
score: float
sort_key: str
@dataclass
class _SearchCacheEntry:
items: list[SearchResultItem]
total_items: dict[FilterType, int]
_static_counts_cache: dict | None = None
_suggested_blocks: list[BlockInfo] | None = None
def get_block_categories(category_blocks: int = 3) -> list[BlockCategoryResponse]:
@@ -156,244 +130,71 @@ def get_block_by_id(block_id: str) -> BlockInfo | None:
return None
async def update_search(user_id: str, search: SearchEntry) -> str:
def search_blocks(
include_blocks: bool = True,
include_integrations: bool = True,
query: str = "",
page: int = 1,
page_size: int = 50,
) -> SearchBlocksResponse:
"""
Upsert a search request for the user and return the search ID.
Get blocks based on the filter and query.
`providers` only applies for `integrations` filter.
"""
if search.search_id:
# Update existing search
await prisma.models.BuilderSearchHistory.prisma().update(
where={
"id": search.search_id,
},
data={
"searchQuery": search.search_query or "",
"filter": search.filter or [], # type: ignore
"byCreator": search.by_creator or [],
},
)
return search.search_id
else:
# Create new search
new_search = await prisma.models.BuilderSearchHistory.prisma().create(
data={
"userId": user_id,
"searchQuery": search.search_query or "",
"filter": search.filter or [], # type: ignore
"byCreator": search.by_creator or [],
}
)
return new_search.id
blocks: list[AnyBlockSchema] = []
query = query.lower()
async def get_recent_searches(user_id: str, limit: int = 5) -> list[SearchEntry]:
"""
Get the user's most recent search requests.
"""
searches = await prisma.models.BuilderSearchHistory.prisma().find_many(
where={
"userId": user_id,
},
order={
"updatedAt": "desc",
},
take=limit,
)
return [
SearchEntry(
search_query=s.searchQuery,
filter=s.filter, # type: ignore
by_creator=s.byCreator,
search_id=s.id,
)
for s in searches
]
async def get_sorted_search_results(
*,
user_id: str,
search_query: str | None,
filters: Sequence[FilterType],
by_creator: Sequence[str] | None = None,
) -> _SearchCacheEntry:
normalized_filters: tuple[FilterType, ...] = tuple(sorted(set(filters or [])))
normalized_creators: tuple[str, ...] = tuple(sorted(set(by_creator or [])))
return await _build_cached_search_results(
user_id=user_id,
search_query=search_query or "",
filters=normalized_filters,
by_creator=normalized_creators,
)
@cached(ttl_seconds=300, shared_cache=True)
async def _build_cached_search_results(
user_id: str,
search_query: str,
filters: tuple[FilterType, ...],
by_creator: tuple[str, ...],
) -> _SearchCacheEntry:
normalized_query = (search_query or "").strip().lower()
include_blocks = "blocks" in filters
include_integrations = "integrations" in filters
include_library_agents = "my_agents" in filters
include_marketplace_agents = "marketplace_agents" in filters
scored_items: list[_ScoredItem] = []
total_items: dict[FilterType, int] = {
"blocks": 0,
"integrations": 0,
"marketplace_agents": 0,
"my_agents": 0,
}
block_results, block_total, integration_total = _collect_block_results(
normalized_query=normalized_query,
include_blocks=include_blocks,
include_integrations=include_integrations,
)
scored_items.extend(block_results)
total_items["blocks"] = block_total
total_items["integrations"] = integration_total
if include_library_agents:
library_response = await library_db.list_library_agents(
user_id=user_id,
search_term=search_query or None,
page=1,
page_size=MAX_LIBRARY_AGENT_RESULTS,
)
total_items["my_agents"] = library_response.pagination.total_items
scored_items.extend(
_build_library_items(
agents=library_response.agents,
normalized_query=normalized_query,
)
)
if include_marketplace_agents:
marketplace_response = await store_db.get_store_agents(
creators=list(by_creator) or None,
search_query=search_query or None,
page=1,
page_size=MAX_MARKETPLACE_AGENT_RESULTS,
)
total_items["marketplace_agents"] = marketplace_response.pagination.total_items
scored_items.extend(
_build_marketplace_items(
agents=marketplace_response.agents,
normalized_query=normalized_query,
)
)
sorted_items = sorted(
scored_items,
key=lambda entry: (-entry.score, entry.sort_key, entry.filter_type),
)
return _SearchCacheEntry(
items=[entry.item for entry in sorted_items],
total_items=total_items,
)
def _collect_block_results(
*,
normalized_query: str,
include_blocks: bool,
include_integrations: bool,
) -> tuple[list[_ScoredItem], int, int]:
results: list[_ScoredItem] = []
total = 0
skip = (page - 1) * page_size
take = page_size
block_count = 0
integration_count = 0
if not include_blocks and not include_integrations:
return results, block_count, integration_count
for block_type in load_all_blocks().values():
block: AnyBlockSchema = block_type()
# Skip disabled blocks
if block.disabled:
continue
block_info = block.get_info()
# Skip blocks that don't match the query
if (
query not in block.name.lower()
and query not in block.description.lower()
and not _matches_llm_model(block.input_schema, query)
):
continue
keep = False
credentials = list(block.input_schema.get_credentials_fields().values())
is_integration = len(credentials) > 0
if is_integration and not include_integrations:
continue
if not is_integration and not include_blocks:
continue
score = _score_block(block, block_info, normalized_query)
if not _should_include_item(score, normalized_query):
continue
filter_type: FilterType = "integrations" if is_integration else "blocks"
if is_integration:
if include_integrations and len(credentials) > 0:
keep = True
integration_count += 1
else:
if include_blocks and len(credentials) == 0:
keep = True
block_count += 1
results.append(
_ScoredItem(
item=block_info,
filter_type=filter_type,
score=score,
sort_key=_get_item_name(block_info),
)
)
return results, block_count, integration_count
def _build_library_items(
*,
agents: list[library_model.LibraryAgent],
normalized_query: str,
) -> list[_ScoredItem]:
results: list[_ScoredItem] = []
for agent in agents:
score = _score_library_agent(agent, normalized_query)
if not _should_include_item(score, normalized_query):
if not keep:
continue
results.append(
_ScoredItem(
item=agent,
filter_type="my_agents",
score=score,
sort_key=_get_item_name(agent),
)
)
return results
def _build_marketplace_items(
*,
agents: list[store_model.StoreAgent],
normalized_query: str,
) -> list[_ScoredItem]:
results: list[_ScoredItem] = []
for agent in agents:
score = _score_store_agent(agent, normalized_query)
if not _should_include_item(score, normalized_query):
total += 1
if skip > 0:
skip -= 1
continue
if take > 0:
take -= 1
blocks.append(block)
results.append(
_ScoredItem(
item=agent,
filter_type="marketplace_agents",
score=score,
sort_key=_get_item_name(agent),
)
)
return results
return SearchBlocksResponse(
blocks=BlockResponse(
blocks=[b.get_info() for b in blocks],
pagination=Pagination(
total_items=total,
total_pages=(total + page_size - 1) // page_size,
current_page=page,
page_size=page_size,
),
),
total_block_count=block_count,
total_integration_count=integration_count,
)
def get_providers(
@@ -450,12 +251,16 @@ async def get_counts(user_id: str) -> CountResponse:
)
@cached(ttl_seconds=3600)
async def _get_static_counts():
"""
Get counts of blocks, integrations, and marketplace agents.
This is cached to avoid unnecessary database queries and calculations.
Can't use functools.cache here because the function is async.
"""
global _static_counts_cache
if _static_counts_cache is not None:
return _static_counts_cache
all_blocks = 0
input_blocks = 0
action_blocks = 0
@@ -482,7 +287,7 @@ async def _get_static_counts():
marketplace_agents = await prisma.models.StoreAgent.prisma().count()
return {
_static_counts_cache = {
"all_blocks": all_blocks,
"input_blocks": input_blocks,
"action_blocks": action_blocks,
@@ -491,6 +296,8 @@ async def _get_static_counts():
"marketplace_agents": marketplace_agents,
}
return _static_counts_cache
def _matches_llm_model(schema_cls: type[BlockSchema], query: str) -> bool:
for field in schema_cls.model_fields.values():
@@ -501,123 +308,6 @@ def _matches_llm_model(schema_cls: type[BlockSchema], query: str) -> bool:
return False
def _score_block(
block: AnyBlockSchema,
block_info: BlockInfo,
normalized_query: str,
) -> float:
if not normalized_query:
return 0.0
name = block_info.name.lower()
description = block_info.description.lower()
score = _score_primary_fields(name, description, normalized_query)
category_text = " ".join(
category.get("category", "").lower() for category in block_info.categories
)
score += _score_additional_field(category_text, normalized_query, 12, 6)
credentials_info = block.input_schema.get_credentials_fields_info().values()
provider_names = [
provider.value.lower()
for info in credentials_info
for provider in info.provider
]
provider_text = " ".join(provider_names)
score += _score_additional_field(provider_text, normalized_query, 15, 6)
if _matches_llm_model(block.input_schema, normalized_query):
score += 20
return score
def _score_library_agent(
agent: library_model.LibraryAgent,
normalized_query: str,
) -> float:
if not normalized_query:
return 0.0
name = agent.name.lower()
description = (agent.description or "").lower()
instructions = (agent.instructions or "").lower()
score = _score_primary_fields(name, description, normalized_query)
score += _score_additional_field(instructions, normalized_query, 15, 6)
score += _score_additional_field(
agent.creator_name.lower(), normalized_query, 10, 5
)
return score
def _score_store_agent(
agent: store_model.StoreAgent,
normalized_query: str,
) -> float:
if not normalized_query:
return 0.0
name = agent.agent_name.lower()
description = agent.description.lower()
sub_heading = agent.sub_heading.lower()
score = _score_primary_fields(name, description, normalized_query)
score += _score_additional_field(sub_heading, normalized_query, 12, 6)
score += _score_additional_field(agent.creator.lower(), normalized_query, 10, 5)
return score
def _score_primary_fields(name: str, description: str, query: str) -> float:
score = 0.0
if name == query:
score += 120
elif name.startswith(query):
score += 90
elif query in name:
score += 60
score += SequenceMatcher(None, name, query).ratio() * 50
if description:
if query in description:
score += 30
score += SequenceMatcher(None, description, query).ratio() * 25
return score
def _score_additional_field(
value: str,
query: str,
contains_weight: float,
similarity_weight: float,
) -> float:
if not value or not query:
return 0.0
score = 0.0
if query in value:
score += contains_weight
score += SequenceMatcher(None, value, query).ratio() * similarity_weight
return score
def _should_include_item(score: float, normalized_query: str) -> bool:
if not normalized_query:
return True
return score >= MIN_SCORE_FOR_FILTERED_RESULTS
def _get_item_name(item: SearchResultItem) -> str:
if isinstance(item, BlockInfo):
return item.name.lower()
if isinstance(item, library_model.LibraryAgent):
return item.name.lower()
return item.agent_name.lower()
@cached(ttl_seconds=3600)
def _get_all_providers() -> dict[ProviderName, Provider]:
providers: dict[ProviderName, Provider] = {}
@@ -639,9 +329,13 @@ def _get_all_providers() -> dict[ProviderName, Provider]:
return providers
@cached(ttl_seconds=3600)
async def get_suggested_blocks(count: int = 5) -> list[BlockInfo]:
suggested_blocks = []
global _suggested_blocks
if _suggested_blocks is not None and len(_suggested_blocks) >= count:
return _suggested_blocks[:count]
_suggested_blocks = []
# Sum the number of executions for each block type
# Prisma cannot group by nested relations, so we do a raw query
# Calculate the cutoff timestamp
@@ -682,7 +376,7 @@ async def get_suggested_blocks(count: int = 5) -> list[BlockInfo]:
# Sort blocks by execution count
blocks.sort(key=lambda x: x[1], reverse=True)
suggested_blocks = [block[0] for block in blocks]
_suggested_blocks = [block[0] for block in blocks]
# Return the top blocks
return suggested_blocks[:count]
return _suggested_blocks[:count]

View File

@@ -18,17 +18,10 @@ FilterType = Literal[
BlockType = Literal["all", "input", "action", "output"]
class SearchEntry(BaseModel):
search_query: str | None = None
filter: list[FilterType] | None = None
by_creator: list[str] | None = None
search_id: str | None = None
# Suggestions
class SuggestionsResponse(BaseModel):
otto_suggestions: list[str]
recent_searches: list[SearchEntry]
recent_searches: list[str]
providers: list[ProviderName]
top_blocks: list[BlockInfo]
@@ -39,7 +32,7 @@ class BlockCategoryResponse(BaseModel):
total_blocks: int
blocks: list[BlockInfo]
model_config = {"use_enum_values": False} # Use enum names like "AI"
model_config = {"use_enum_values": False} # <== use enum names like "AI"
# Input/Action/Output and see all for block categories
@@ -60,11 +53,17 @@ class ProviderResponse(BaseModel):
pagination: Pagination
class SearchBlocksResponse(BaseModel):
blocks: BlockResponse
total_block_count: int
total_integration_count: int
class SearchResponse(BaseModel):
items: list[BlockInfo | library_model.LibraryAgent | store_model.StoreAgent]
search_id: str
total_items: dict[FilterType, int]
pagination: Pagination
page: int
more_pages: bool
class CountResponse(BaseModel):

View File

@@ -6,6 +6,10 @@ from autogpt_libs.auth.dependencies import get_user_id, requires_user
import backend.server.v2.builder.db as builder_db
import backend.server.v2.builder.model as builder_model
import backend.server.v2.library.db as library_db
import backend.server.v2.library.model as library_model
import backend.server.v2.store.db as store_db
import backend.server.v2.store.model as store_model
from backend.integrations.providers import ProviderName
from backend.util.models import Pagination
@@ -41,9 +45,7 @@ def sanitize_query(query: str | None) -> str | None:
summary="Get Builder suggestions",
response_model=builder_model.SuggestionsResponse,
)
async def get_suggestions(
user_id: Annotated[str, fastapi.Security(get_user_id)],
) -> builder_model.SuggestionsResponse:
async def get_suggestions() -> builder_model.SuggestionsResponse:
"""
Get all suggestions for the Blocks Menu.
"""
@@ -53,7 +55,11 @@ async def get_suggestions(
"Help me create a list",
"Help me feed my data to Google Maps",
],
recent_searches=await builder_db.get_recent_searches(user_id),
recent_searches=[
"image generation",
"deepfake",
"competitor analysis",
],
providers=[
ProviderName.TWITTER,
ProviderName.GITHUB,
@@ -141,6 +147,7 @@ async def get_providers(
)
# Not using post method because on frontend, orval doesn't support Infinite Query with POST method.
@router.get(
"/search",
summary="Builder search",
@@ -150,7 +157,7 @@ async def get_providers(
async def search(
user_id: Annotated[str, fastapi.Security(get_user_id)],
search_query: Annotated[str | None, fastapi.Query()] = None,
filter: Annotated[list[builder_model.FilterType] | None, fastapi.Query()] = None,
filter: Annotated[list[str] | None, fastapi.Query()] = None,
search_id: Annotated[str | None, fastapi.Query()] = None,
by_creator: Annotated[list[str] | None, fastapi.Query()] = None,
page: Annotated[int, fastapi.Query()] = 1,
@@ -169,43 +176,69 @@ async def search(
]
search_query = sanitize_query(search_query)
# Get all possible results
cached_results = await builder_db.get_sorted_search_results(
user_id=user_id,
search_query=search_query,
filters=filter,
by_creator=by_creator,
)
# Paginate results
total_combined_items = len(cached_results.items)
pagination = Pagination(
total_items=total_combined_items,
total_pages=(total_combined_items + page_size - 1) // page_size,
current_page=page,
page_size=page_size,
)
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated_items = cached_results.items[start_idx:end_idx]
# Update the search entry by id
search_id = await builder_db.update_search(
user_id,
builder_model.SearchEntry(
search_query=search_query,
filter=filter,
by_creator=by_creator,
search_id=search_id,
# Blocks&Integrations
blocks = builder_model.SearchBlocksResponse(
blocks=builder_model.BlockResponse(
blocks=[],
pagination=Pagination.empty(),
),
total_block_count=0,
total_integration_count=0,
)
if "blocks" in filter or "integrations" in filter:
blocks = builder_db.search_blocks(
include_blocks="blocks" in filter,
include_integrations="integrations" in filter,
query=search_query or "",
page=page,
page_size=page_size,
)
# Library Agents
my_agents = library_model.LibraryAgentResponse(
agents=[],
pagination=Pagination.empty(),
)
if "my_agents" in filter:
my_agents = await library_db.list_library_agents(
user_id=user_id,
search_term=search_query,
page=page,
page_size=page_size,
)
# Marketplace Agents
marketplace_agents = store_model.StoreAgentsResponse(
agents=[],
pagination=Pagination.empty(),
)
if "marketplace_agents" in filter:
marketplace_agents = await store_db.get_store_agents(
creators=by_creator,
search_query=search_query,
page=page,
page_size=page_size,
)
more_pages = False
if (
blocks.blocks.pagination.current_page < blocks.blocks.pagination.total_pages
or my_agents.pagination.current_page < my_agents.pagination.total_pages
or marketplace_agents.pagination.current_page
< marketplace_agents.pagination.total_pages
):
more_pages = True
return builder_model.SearchResponse(
items=paginated_items,
search_id=search_id,
total_items=cached_results.total_items,
pagination=pagination,
items=blocks.blocks.blocks + my_agents.agents + marketplace_agents.agents,
total_items={
"blocks": blocks.total_block_count,
"integrations": blocks.total_integration_count,
"marketplace_agents": marketplace_agents.pagination.total_items,
"my_agents": my_agents.pagination.total_items,
},
page=page,
more_pages=more_pages,
)

View File

@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
router = APIRouter(
tags=["v2", "executions", "review"],
tags=["executions", "review", "private"],
dependencies=[Security(autogpt_auth_lib.requires_user)],
)

View File

@@ -5,13 +5,6 @@ from tiktoken import encoding_for_model
from backend.util import json
# ---------------------------------------------------------------------------#
# CONSTANTS #
# ---------------------------------------------------------------------------#
# Message prefixes for important system messages that should be protected during compression
MAIN_OBJECTIVE_PREFIX = "[Main Objective Prompt]: "
# ---------------------------------------------------------------------------#
# INTERNAL UTILITIES #
# ---------------------------------------------------------------------------#
@@ -70,55 +63,6 @@ def _msg_tokens(msg: dict, enc) -> int:
return WRAPPER + content_tokens + tool_call_tokens
def _is_tool_message(msg: dict) -> bool:
"""Check if a message contains tool calls or results that should be protected."""
content = msg.get("content")
# Check for Anthropic-style tool messages
if isinstance(content, list) and any(
isinstance(item, dict) and item.get("type") in ("tool_use", "tool_result")
for item in content
):
return True
# Check for OpenAI-style tool calls in the message
if "tool_calls" in msg or msg.get("role") == "tool":
return True
return False
def _is_objective_message(msg: dict) -> bool:
"""Check if a message contains objective/system prompts that should be absolutely protected."""
content = msg.get("content", "")
if isinstance(content, str):
# Protect any message with the main objective prefix
return content.startswith(MAIN_OBJECTIVE_PREFIX)
return False
def _truncate_tool_message_content(msg: dict, enc, max_tokens: int) -> None:
"""
Carefully truncate tool message content while preserving tool structure.
Only truncates tool_result content, leaves tool_use intact.
"""
content = msg.get("content")
if not isinstance(content, list):
return
for item in content:
# Only process tool_result items, leave tool_use blocks completely intact
if not (isinstance(item, dict) and item.get("type") == "tool_result"):
continue
result_content = item.get("content", "")
if (
isinstance(result_content, str)
and _tok_len(result_content, enc) > max_tokens
):
item["content"] = _truncate_middle_tokens(result_content, enc, max_tokens)
def _truncate_middle_tokens(text: str, enc, max_tok: int) -> str:
"""
Return *text* shortened to ≈max_tok tokens by keeping the head & tail
@@ -196,21 +140,13 @@ def compress_prompt(
return sum(_msg_tokens(m, enc) for m in msgs)
original_token_count = total_tokens()
if original_token_count + reserve <= target_tokens:
return msgs
# ---- STEP 0 : normalise content --------------------------------------
# Convert non-string payloads to strings so token counting is coherent.
for i, m in enumerate(msgs):
for m in msgs[1:-1]: # keep the first & last intact
if not isinstance(m.get("content"), str) and m.get("content") is not None:
if _is_tool_message(m):
continue
# Keep first and last messages intact (unless they're tool messages)
if i == 0 or i == len(msgs) - 1:
continue
# Reasonable 20k-char ceiling prevents pathological blobs
content_str = json.dumps(m["content"], separators=(",", ":"))
if len(content_str) > 20_000:
@@ -221,45 +157,34 @@ def compress_prompt(
cap = start_cap
while total_tokens() + reserve > target_tokens and cap >= floor_cap:
for m in msgs[1:-1]: # keep first & last intact
if _is_tool_message(m):
# For tool messages, only truncate tool result content, preserve structure
_truncate_tool_message_content(m, enc, cap)
continue
if _is_objective_message(m):
# Never truncate objective messages - they contain the core task
continue
content = m.get("content") or ""
if _tok_len(content, enc) > cap:
m["content"] = _truncate_middle_tokens(content, enc, cap)
if _tok_len(m.get("content") or "", enc) > cap:
m["content"] = _truncate_middle_tokens(m["content"], enc, cap)
cap //= 2 # tighten the screw
# ---- STEP 2 : middle-out deletion -----------------------------------
while total_tokens() + reserve > target_tokens and len(msgs) > 2:
# Identify all deletable messages (not first/last, not tool messages, not objective messages)
deletable_indices = []
for i in range(1, len(msgs) - 1): # Skip first and last
if not _is_tool_message(msgs[i]) and not _is_objective_message(msgs[i]):
deletable_indices.append(i)
if not deletable_indices:
break # nothing more we can drop
# Delete from center outward - find the index closest to center
centre = len(msgs) // 2
to_delete = min(deletable_indices, key=lambda i: abs(i - centre))
del msgs[to_delete]
# Build a symmetrical centre-out index walk: centre, centre+1, centre-1, ...
order = [centre] + [
i
for pair in zip(range(centre + 1, len(msgs) - 1), range(centre - 1, 0, -1))
for i in pair
]
removed = False
for i in order:
msg = msgs[i]
if "tool_calls" in msg or msg.get("role") == "tool":
continue # protect tool shells
del msgs[i]
removed = True
break
if not removed: # nothing more we can drop
break
# ---- STEP 3 : final safety-net trim on first & last ------------------
cap = start_cap
while total_tokens() + reserve > target_tokens and cap >= floor_cap:
for idx in (0, -1): # first and last
if _is_tool_message(msgs[idx]):
# For tool messages at first/last position, truncate tool result content only
_truncate_tool_message_content(msgs[idx], enc, cap)
continue
text = msgs[idx].get("content") or ""
if _tok_len(text, enc) > cap:
msgs[idx]["content"] = _truncate_middle_tokens(text, enc, cap)

View File

@@ -185,12 +185,6 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
description="Number of top blocks with most errors to show when no blocks exceed threshold (0 to disable).",
)
# Execution Accuracy Monitoring
execution_accuracy_check_interval_hours: int = Field(
default=24,
description="Interval in hours between execution accuracy alert checks.",
)
model_config = SettingsConfigDict(
env_file=".env",
extra="allow",

View File

@@ -1,15 +0,0 @@
-- Create BuilderSearchHistory table
CREATE TABLE "BuilderSearchHistory" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"searchQuery" TEXT NOT NULL,
"filter" TEXT[] DEFAULT ARRAY[]::TEXT[],
"byCreator" TEXT[] DEFAULT ARRAY[]::TEXT[],
CONSTRAINT "BuilderSearchHistory_pkey" PRIMARY KEY ("id")
);
-- Define User foreign relation
ALTER TABLE "BuilderSearchHistory" ADD CONSTRAINT "BuilderSearchHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -53,7 +53,6 @@ model User {
Profile Profile[]
UserOnboarding UserOnboarding?
BuilderSearchHistory BuilderSearchHistory[]
StoreListings StoreListing[]
StoreListingReviews StoreListingReview[]
StoreVersionsReviewed StoreListingVersion[]
@@ -115,19 +114,6 @@ model UserOnboarding {
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model BuilderSearchHistory {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
searchQuery String
filter String[] @default([])
byCreator String[] @default([])
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
// This model describes the Agent Graph/Flow (Multi Agent System).
model AgentGraph {
id String @default(uuid())

View File

@@ -41,6 +41,12 @@ export default defineConfig({
useInfiniteQueryParam: "page",
},
},
"getV2List presets": {
query: {
useInfinite: true,
useInfiniteQueryParam: "page",
},
},
"getV1List graph executions": {
query: {
useInfinite: true,

View File

@@ -82,7 +82,7 @@
"lodash": "4.17.21",
"lucide-react": "0.552.0",
"moment": "2.30.1",
"next": "15.4.10",
"next": "15.4.8",
"next-themes": "0.4.6",
"nuqs": "2.7.2",
"party-js": "2.2.0",

View File

@@ -16,7 +16,7 @@ importers:
version: 5.2.2(react-hook-form@7.66.0(react@18.3.1))
'@next/third-parties':
specifier: 15.4.6
version: 15.4.6(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
version: 15.4.6(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
'@phosphor-icons/react':
specifier: 2.1.10
version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -88,7 +88,7 @@ importers:
version: 5.24.13(@rjsf/utils@5.24.13(react@18.3.1))
'@sentry/nextjs':
specifier: 10.27.0
version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.101.3(esbuild@0.25.9))
version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.101.3(esbuild@0.25.9))
'@supabase/ssr':
specifier: 0.7.0
version: 0.7.0(@supabase/supabase-js@2.78.0)
@@ -106,10 +106,10 @@ importers:
version: 0.2.4
'@vercel/analytics':
specifier: 1.5.0
version: 1.5.0(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
version: 1.5.0(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
'@vercel/speed-insights':
specifier: 1.2.0
version: 1.2.0(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
version: 1.2.0(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
'@xyflow/react':
specifier: 12.9.2
version: 12.9.2(@types/react@18.3.17)(immer@10.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -148,7 +148,7 @@ importers:
version: 12.23.24(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
geist:
specifier: 1.5.1
version: 1.5.1(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
version: 1.5.1(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
highlight.js:
specifier: 11.11.1
version: 11.11.1
@@ -171,14 +171,14 @@ importers:
specifier: 2.30.1
version: 2.30.1
next:
specifier: 15.4.10
version: 15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
specifier: 15.4.8
version: 15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-themes:
specifier: 0.4.6
version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
nuqs:
specifier: 2.7.2
version: 2.7.2(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
version: 2.7.2(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
party-js:
specifier: 2.2.0
version: 2.2.0
@@ -284,7 +284,7 @@ importers:
version: 9.1.5(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))
'@storybook/nextjs':
specifier: 9.1.5
version: 9.1.5(esbuild@0.25.9)(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))(type-fest@4.41.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.101.3(esbuild@0.25.9))
version: 9.1.5(esbuild@0.25.9)(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))(type-fest@4.41.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.101.3(esbuild@0.25.9))
'@tanstack/eslint-plugin-query':
specifier: 5.91.2
version: 5.91.2(eslint@8.57.1)(typescript@5.9.3)
@@ -1602,8 +1602,8 @@ packages:
'@neoconfetti/react@1.0.0':
resolution: {integrity: sha512-klcSooChXXOzIm+SE5IISIAn3bYzYfPjbX7D7HoqZL84oAfgREeSg5vSIaSFH+DaGzzvImTyWe1OyrJ67vik4A==}
'@next/env@15.4.10':
resolution: {integrity: sha512-knhmoJ0Vv7VRf6pZEPSnciUG1S4bIhWx+qTYBW/AjxEtlzsiNORPk8sFDCEvqLfmKuey56UB9FL1UdHEV3uBrg==}
'@next/env@15.4.8':
resolution: {integrity: sha512-LydLa2MDI1NMrOFSkO54mTc8iIHSttj6R6dthITky9ylXV2gCGi0bHQjVCtLGRshdRPjyh2kXbxJukDtBWQZtQ==}
'@next/eslint-plugin-next@15.5.2':
resolution: {integrity: sha512-lkLrRVxcftuOsJNhWatf1P2hNVfh98k/omQHrCEPPriUypR6RcS13IvLdIrEvkm9AH2Nu2YpR5vLqBuy6twH3Q==}
@@ -5920,8 +5920,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@15.4.10:
resolution: {integrity: sha512-itVlc79QjpKMFMRhP+kbGKaSG/gZM6RCvwhEbwmCNF06CdDiNaoHcbeg0PqkEa2GOcn8KJ0nnc7+yL7EjoYLHQ==}
next@15.4.8:
resolution: {integrity: sha512-jwOXTz/bo0Pvlf20FSb6VXVeWRssA2vbvq9SdrOPEg9x8E1B27C2rQtvriAn600o9hH61kjrVRexEffv3JybuA==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true
peerDependencies:
@@ -9003,7 +9003,7 @@ snapshots:
'@neoconfetti/react@1.0.0': {}
'@next/env@15.4.10': {}
'@next/env@15.4.8': {}
'@next/eslint-plugin-next@15.5.2':
dependencies:
@@ -9033,9 +9033,9 @@ snapshots:
'@next/swc-win32-x64-msvc@15.4.8':
optional: true
'@next/third-parties@15.4.6(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
'@next/third-parties@15.4.6(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
dependencies:
next: 15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next: 15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
third-party-capital: 1.0.20
@@ -10267,7 +10267,7 @@ snapshots:
'@sentry/core@10.27.0': {}
'@sentry/nextjs@10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.101.3(esbuild@0.25.9))':
'@sentry/nextjs@10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.101.3(esbuild@0.25.9))':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.37.0
@@ -10280,7 +10280,7 @@ snapshots:
'@sentry/react': 10.27.0(react@18.3.1)
'@sentry/vercel-edge': 10.27.0
'@sentry/webpack-plugin': 4.3.0(webpack@5.101.3(esbuild@0.25.9))
next: 15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next: 15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
resolve: 1.22.8
rollup: 4.52.2
stacktrace-parser: 0.1.11
@@ -10642,7 +10642,7 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@storybook/nextjs@9.1.5(esbuild@0.25.9)(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))(type-fest@4.41.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.101.3(esbuild@0.25.9))':
'@storybook/nextjs@9.1.5(esbuild@0.25.9)(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))(type-fest@4.41.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.101.3(esbuild@0.25.9))':
dependencies:
'@babel/core': 7.28.4
'@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.4)
@@ -10666,7 +10666,7 @@ snapshots:
css-loader: 6.11.0(webpack@5.101.3(esbuild@0.25.9))
image-size: 2.0.2
loader-utils: 3.3.1
next: 15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next: 15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
node-polyfill-webpack-plugin: 2.0.1(webpack@5.101.3(esbuild@0.25.9))
postcss: 8.5.6
postcss-loader: 8.2.0(postcss@8.5.6)(typescript@5.9.3)(webpack@5.101.3(esbuild@0.25.9))
@@ -11271,14 +11271,14 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
'@vercel/analytics@1.5.0(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
'@vercel/analytics@1.5.0(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
optionalDependencies:
next: 15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next: 15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
'@vercel/speed-insights@1.2.0(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
'@vercel/speed-insights@1.2.0(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
optionalDependencies:
next: 15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next: 15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
'@vitest/expect@3.2.4':
@@ -12954,9 +12954,9 @@ snapshots:
functions-have-names@1.2.3: {}
geist@1.5.1(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)):
geist@1.5.1(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)):
dependencies:
next: 15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next: 15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
gensync@1.0.0-beta.2: {}
@@ -14226,9 +14226,9 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@next/env': 15.4.10
'@next/env': 15.4.8
'@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001741
postcss: 8.4.31
@@ -14321,12 +14321,12 @@ snapshots:
dependencies:
boolbase: 1.0.0
nuqs@2.7.2(next@15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1):
nuqs@2.7.2(next@15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1):
dependencies:
'@standard-schema/spec': 1.0.0
react: 18.3.1
optionalDependencies:
next: 15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next: 15.4.8(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
oas-kit-common@1.0.8:
dependencies:

View File

@@ -1,16 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/__legacy__/ui/input";
import { Label } from "@/components/__legacy__/ui/label";
@@ -28,12 +18,9 @@ import { useToast } from "@/components/molecules/Toast/use-toast";
import {
usePostV2GenerateExecutionAnalytics,
useGetV2GetExecutionAnalyticsConfiguration,
useGetV2GetExecutionAccuracyTrendsAndAlerts,
} from "@/app/api/__generated__/endpoints/admin/admin";
import type { ExecutionAnalyticsRequest } from "@/app/api/__generated__/models/executionAnalyticsRequest";
import type { ExecutionAnalyticsResponse } from "@/app/api/__generated__/models/executionAnalyticsResponse";
import type { AccuracyTrendsResponse } from "@/app/api/__generated__/models/accuracyTrendsResponse";
import type { AccuracyLatestData } from "@/app/api/__generated__/models/accuracyLatestData";
// Use the generated type with minimal adjustment for form handling
interface FormData extends Omit<ExecutionAnalyticsRequest, "created_after"> {
@@ -46,133 +33,8 @@ export function ExecutionAnalyticsForm() {
const [results, setResults] = useState<ExecutionAnalyticsResponse | null>(
null,
);
const [trendsData, setTrendsData] = useState<AccuracyTrendsResponse | null>(
null,
);
const { toast } = useToast();
// State for accuracy trends query parameters
const [accuracyParams, setAccuracyParams] = useState<{
graph_id: string;
user_id?: string;
days_back: number;
drop_threshold: number;
include_historical?: boolean;
} | null>(null);
// Use the generated API client for accuracy trends (GET)
const { data: accuracyApiResponse, error: accuracyError } =
useGetV2GetExecutionAccuracyTrendsAndAlerts(
accuracyParams || {
graph_id: "",
days_back: 30,
drop_threshold: 10.0,
include_historical: false,
},
{
query: {
enabled: !!accuracyParams?.graph_id,
},
},
);
// Update local state when data changes and handle success/error
useEffect(() => {
if (accuracyError) {
console.error("Failed to fetch trends:", accuracyError);
toast({
title: "Trends Error",
description:
(accuracyError as any)?.message || "Failed to fetch accuracy trends",
variant: "destructive",
});
return;
}
const data = accuracyApiResponse?.data;
if (data && "latest_data" in data) {
setTrendsData(data);
// Check for alerts
if (data.alert) {
toast({
title: "🚨 Accuracy Alert Detected",
description: `${data.alert.drop_percent.toFixed(1)}% accuracy drop detected for this agent`,
variant: "destructive",
});
}
}
}, [accuracyApiResponse, accuracyError, toast]);
// Chart component for accuracy trends
function AccuracyChart({ data }: { data: AccuracyLatestData[] }) {
const chartData = data.map((item) => ({
date: new Date(item.date).toLocaleDateString(),
"Daily Score": item.daily_score,
"3-Day Avg": item.three_day_avg,
"7-Day Avg": item.seven_day_avg,
"14-Day Avg": item.fourteen_day_avg,
}));
return (
<ResponsiveContainer width="100%" height={400}>
<LineChart
data={chartData}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis domain={[0, 100]} />
<Tooltip
formatter={(value) => [`${Number(value).toFixed(2)}%`, ""]}
/>
<Legend />
<Line
type="monotone"
dataKey="Daily Score"
stroke="#3b82f6"
strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="3-Day Avg"
stroke="#10b981"
strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="7-Day Avg"
stroke="#f59e0b"
strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="14-Day Avg"
stroke="#8b5cf6"
strokeWidth={2}
dot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
);
}
// Function to fetch accuracy trends using generated API client
const fetchAccuracyTrends = (graphId: string, userId?: string) => {
if (!graphId.trim()) return;
setAccuracyParams({
graph_id: graphId.trim(),
user_id: userId?.trim() || undefined,
days_back: 30,
drop_threshold: 10.0,
include_historical: showAccuracyChart, // Include historical data when chart is enabled
});
};
// Fetch configuration from API
const {
data: config,
@@ -188,7 +50,6 @@ export function ExecutionAnalyticsForm() {
}
const result = res.data;
setResults(result);
toast({
title: "Analytics Generated",
description: `Processed ${result.processed_executions} executions. ${result.successful_analytics} successful, ${result.failed_analytics} failed, ${result.skipped_executions} skipped.`,
@@ -197,21 +58,11 @@ export function ExecutionAnalyticsForm() {
},
onError: (error: any) => {
console.error("Analytics generation error:", error);
const errorMessage =
error?.message || error?.detail || "An unexpected error occurred";
const isOpenAIError = errorMessage.includes(
"OpenAI API key not configured",
);
toast({
title: isOpenAIError
? "Analytics Generation Skipped"
: "Analytics Generation Failed",
description: isOpenAIError
? "Analytics generation requires OpenAI configuration, but accuracy trends are still available above."
: errorMessage,
variant: isOpenAIError ? "default" : "destructive",
title: "Analytics Generation Failed",
description:
error?.message || error?.detail || "An unexpected error occurred",
variant: "destructive",
});
},
},
@@ -226,9 +77,6 @@ export function ExecutionAnalyticsForm() {
user_prompt: "", // Will use config default when empty
});
// State for accuracy trends chart toggle
const [showAccuracyChart, setShowAccuracyChart] = useState(true);
// Update form defaults when config loads
useEffect(() => {
if (config?.data && config.status === 200 && !formData.model_name) {
@@ -253,11 +101,6 @@ export function ExecutionAnalyticsForm() {
setResults(null);
// Fetch accuracy trends if chart is enabled
if (showAccuracyChart) {
fetchAccuracyTrends(formData.graph_id, formData.user_id || undefined);
}
// Prepare the request payload
const payload: ExecutionAnalyticsRequest = {
graph_id: formData.graph_id.trim(),
@@ -419,18 +262,6 @@ export function ExecutionAnalyticsForm() {
</Label>
</div>
{/* Show Accuracy Chart Checkbox */}
<div className="flex items-center space-x-2">
<Checkbox
id="show_accuracy_chart"
checked={showAccuracyChart}
onCheckedChange={(checked) => setShowAccuracyChart(!!checked)}
/>
<Label htmlFor="show_accuracy_chart" className="text-sm">
Show accuracy trends chart and historical data visualization
</Label>
</div>
{/* Custom System Prompt */}
<div className="space-y-2">
<Label htmlFor="system_prompt">
@@ -539,98 +370,6 @@ export function ExecutionAnalyticsForm() {
</div>
</form>
{/* Accuracy Trends Display */}
{trendsData && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Execution Accuracy Trends</h3>
{/* Alert Section */}
{trendsData.alert && (
<div className="rounded-lg border-l-4 border-red-500 bg-red-50 p-4">
<div className="flex items-start">
<span className="text-2xl">🚨</span>
<div className="ml-3 space-y-2">
<h4 className="text-lg font-semibold text-red-800">
Accuracy Alert Detected
</h4>
<p className="text-red-700">
<strong>
{trendsData.alert.drop_percent.toFixed(1)}% accuracy drop
</strong>{" "}
detected for agent{" "}
<code className="rounded bg-red-100 px-1 text-sm">
{formData.graph_id}
</code>
</p>
<div className="space-y-1 text-sm text-red-600">
<p>
3-day average:{" "}
<strong>
{trendsData.alert.three_day_avg.toFixed(2)}%
</strong>
</p>
<p>
7-day average:{" "}
<strong>
{trendsData.alert.seven_day_avg.toFixed(2)}%
</strong>
</p>
<p>
Detected at:{" "}
<strong>
{new Date(
trendsData.alert.detected_at,
).toLocaleString()}
</strong>
</p>
</div>
</div>
</div>
</div>
)}
{/* Latest Data Summary */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-2xl font-bold text-blue-600">
{trendsData.latest_data.daily_score?.toFixed(2) || "N/A"}
</div>
<div className="text-sm text-gray-600">Daily Score</div>
</div>
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-2xl font-bold text-green-600">
{trendsData.latest_data.three_day_avg?.toFixed(2) || "N/A"}
</div>
<div className="text-sm text-gray-600">3-Day Avg</div>
</div>
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-2xl font-bold text-orange-600">
{trendsData.latest_data.seven_day_avg?.toFixed(2) || "N/A"}
</div>
<div className="text-sm text-gray-600">7-Day Avg</div>
</div>
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-2xl font-bold text-purple-600">
{trendsData.latest_data.fourteen_day_avg?.toFixed(2) || "N/A"}
</div>
<div className="text-sm text-gray-600">14-Day Avg</div>
</div>
</div>
{/* Chart Section - only show when toggle is enabled and historical data exists */}
{showAccuracyChart && trendsData?.historical_data && (
<div className="mt-6">
<h4 className="mb-4 text-lg font-semibold">
Execution Accuracy Trends Chart
</h4>
<div className="rounded-lg border bg-white p-6">
<AccuracyChart data={trendsData.historical_data} />
</div>
</div>
)}
</div>
)}
{results && <AnalyticsResultsTable results={results} />}
</div>
);

View File

@@ -17,13 +17,12 @@ function ExecutionAnalyticsDashboard() {
</div>
<div className="rounded-lg border bg-white p-6 shadow-sm">
<h2 className="mb-4 text-xl font-semibold">
Execution Analytics & Accuracy Monitoring
</h2>
<h2 className="mb-4 text-xl font-semibold">Analytics Generation</h2>
<p className="mb-6 text-gray-600">
Generate missing activity summaries and success scores for agent
executions. After generation, accuracy trends and alerts will
automatically be displayed to help monitor agent health over time.
This tool will identify completed executions missing activity
summaries or success scores and generate them using AI. Only
executions that meet the criteria and are missing these fields will
be processed.
</p>
<Suspense

View File

@@ -1,4 +1,4 @@
import { OAuthPopupResultMessage } from "@/components/renderers/input-renderer/fields/CredentialField/models/OAuthCredentialModal/useOAuthCredentialModal";
import { OAuthPopupResultMessage } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { NextResponse } from "next/server";
// This route is intended to be used as the callback for integration OAuth flows,

View File

@@ -18,7 +18,6 @@ import { parseAsString, useQueryStates } from "nuqs";
import { CustomControls } from "./components/CustomControl";
import { FloatingSafeModeToggle } from "@/components/molecules/FloatingSafeModeToggle/FloatingSafeModeToggle";
import { useGetV1GetSpecificGraph } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
import { okData } from "@/app/api/helpers";
import { TriggerAgentBanner } from "./components/TriggerAgentBanner";
import { resolveCollisions } from "./helpers/resolve-collision";
@@ -34,7 +33,7 @@ export const Flow = () => {
{},
{
query: {
select: okData<GraphModel>,
select: okData,
enabled: !!flowID,
},
},

View File

@@ -1,25 +1,24 @@
import { useCallback } from "react";
import { useReactFlow } from "@xyflow/react";
import { Key, storage } from "@/services/storage/local-storage";
import { v4 as uuidv4 } from "uuid";
import { useNodeStore } from "../../../stores/nodeStore";
import { useEdgeStore } from "../../../stores/edgeStore";
import { CustomNode } from "../nodes/CustomNode/CustomNode";
import { CustomEdge } from "../edges/CustomEdge";
import { useToast } from "@/components/molecules/Toast/use-toast";
interface CopyableData {
nodes: CustomNode[];
edges: CustomEdge[];
}
const CLIPBOARD_PREFIX = "autogpt-flow-data:";
export function useCopyPaste() {
// Only use useReactFlow for viewport (not managed by stores)
const { getViewport } = useReactFlow();
const { toast } = useToast();
const handleCopyPaste = useCallback(
(event: KeyboardEvent) => {
// Prevent copy/paste if any modal is open or if the focus is on an input element
const activeElement = document.activeElement;
const isInputField =
activeElement?.tagName === "INPUT" ||
@@ -29,6 +28,7 @@ export function useCopyPaste() {
if (isInputField) return;
if (event.ctrlKey || event.metaKey) {
// COPY: Ctrl+C or Cmd+C
if (event.key === "c" || event.key === "C") {
const { nodes } = useNodeStore.getState();
const { edges } = useEdgeStore.getState();
@@ -53,102 +53,81 @@ export function useCopyPaste() {
edges: selectedEdges,
};
const clipboardText = `${CLIPBOARD_PREFIX}${JSON.stringify(copiedData)}`;
navigator.clipboard
.writeText(clipboardText)
.then(() => {
toast({
title: "Copied successfully",
description: `${selectedNodes.length} node(s) copied to clipboard`,
});
})
.catch((error) => {
console.error("Failed to copy to clipboard:", error);
});
storage.set(Key.COPIED_FLOW_DATA, JSON.stringify(copiedData));
}
// PASTE: Ctrl+V or Cmd+V
if (event.key === "v" || event.key === "V") {
navigator.clipboard
.readText()
.then((clipboardText) => {
if (!clipboardText.startsWith(CLIPBOARD_PREFIX)) {
return; // Not our data, ignore
}
const copiedDataString = storage.get(Key.COPIED_FLOW_DATA);
if (copiedDataString) {
const copiedData = JSON.parse(copiedDataString) as CopyableData;
const oldToNewIdMap: Record<string, string> = {};
const jsonString = clipboardText.slice(CLIPBOARD_PREFIX.length);
const copiedData = JSON.parse(jsonString) as CopyableData;
const oldToNewIdMap: Record<string, string> = {};
// Get fresh viewport values at paste time to ensure correct positioning
const { x, y, zoom } = getViewport();
const viewportCenter = {
x: (window.innerWidth / 2 - x) / zoom,
y: (window.innerHeight / 2 - y) / zoom,
};
const { x, y, zoom } = getViewport();
const viewportCenter = {
x: (window.innerWidth / 2 - x) / zoom,
y: (window.innerHeight / 2 - y) / zoom,
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
copiedData.nodes.forEach((node) => {
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x);
maxY = Math.max(maxY, node.position.y);
});
const offsetX = viewportCenter.x - (minX + maxX) / 2;
const offsetY = viewportCenter.y - (minY + maxY) / 2;
// Deselect existing nodes first
useNodeStore.setState((state) => ({
nodes: state.nodes.map((node) => ({ ...node, selected: false })),
}));
// Create and add new nodes with UNIQUE IDs using UUID
copiedData.nodes.forEach((node) => {
const newNodeId = uuidv4();
oldToNewIdMap[node.id] = newNodeId;
const newNode: CustomNode = {
...node,
id: newNodeId,
selected: true,
position: {
x: node.position.x + offsetX,
y: node.position.y + offsetY,
},
};
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
copiedData.nodes.forEach((node) => {
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x);
maxY = Math.max(maxY, node.position.y);
});
const offsetX = viewportCenter.x - (minX + maxX) / 2;
const offsetY = viewportCenter.y - (minY + maxY) / 2;
// Deselect existing nodes first
useNodeStore.setState((state) => ({
nodes: state.nodes.map((node) => ({
...node,
selected: false,
})),
}));
// Create and add new nodes with UNIQUE IDs using UUID
copiedData.nodes.forEach((node) => {
const newNodeId = uuidv4();
oldToNewIdMap[node.id] = newNodeId;
const newNode: CustomNode = {
...node,
id: newNodeId,
selected: true,
position: {
x: node.position.x + offsetX,
y: node.position.y + offsetY,
},
};
useNodeStore.getState().addNode(newNode);
});
// Add edges with updated source/target IDs
const { addEdge } = useEdgeStore.getState();
copiedData.edges.forEach((edge) => {
const newSourceId = oldToNewIdMap[edge.source] ?? edge.source;
const newTargetId = oldToNewIdMap[edge.target] ?? edge.target;
addEdge({
source: newSourceId,
target: newTargetId,
sourceHandle: edge.sourceHandle ?? "",
targetHandle: edge.targetHandle ?? "",
data: {
...edge.data,
},
});
});
})
.catch((error) => {
console.error("Failed to read from clipboard:", error);
useNodeStore.getState().addNode(newNode);
});
// Add edges with updated source/target IDs
const { addEdge } = useEdgeStore.getState();
copiedData.edges.forEach((edge) => {
const newSourceId = oldToNewIdMap[edge.source] ?? edge.source;
const newTargetId = oldToNewIdMap[edge.target] ?? edge.target;
addEdge({
source: newSourceId,
target: newTargetId,
sourceHandle: edge.sourceHandle ?? "",
targetHandle: edge.targetHandle ?? "",
data: {
...edge.data,
},
});
});
}
}
}
},
[getViewport, toast],
[getViewport],
);
return handleCopyPaste;

View File

@@ -42,12 +42,11 @@ export const useFlow = () => {
const setBlockMenuOpen = useControlPanelStore(
useShallow((state) => state.setBlockMenuOpen),
);
const [{ flowID, flowVersion, flowExecutionID }, setQueryStates] =
useQueryStates({
flowID: parseAsString,
flowVersion: parseAsInteger,
flowExecutionID: parseAsString,
});
const [{ flowID, flowVersion, flowExecutionID }] = useQueryStates({
flowID: parseAsString,
flowVersion: parseAsInteger,
flowExecutionID: parseAsString,
});
const { data: executionDetails } = useGetV1GetExecutionDetails(
flowID || "",
@@ -103,9 +102,6 @@ export const useFlow = () => {
// load graph schemas
useEffect(() => {
if (graph) {
setQueryStates({
flowVersion: graph.version ?? 1,
});
setGraphSchemas(
graph.input_schema as Record<string, any> | null,
graph.credentials_input_schema as Record<string, any> | null,

View File

@@ -1,7 +1,7 @@
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
import { useGetV2BuilderSearchInfinite } from "@/app/api/__generated__/endpoints/store/store";
import { SearchResponse } from "@/app/api/__generated__/models/searchResponse";
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import { useAddAgentToBuilder } from "../hooks/useAddAgentToBuilder";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { getV2GetSpecificAgent } from "@/app/api/__generated__/endpoints/store/store";
@@ -9,27 +9,16 @@ import {
getGetV2ListLibraryAgentsQueryKey,
usePostV2AddMarketplaceAgent,
} from "@/app/api/__generated__/endpoints/library/library";
import {
getGetV2GetBuilderItemCountsQueryKey,
getGetV2GetBuilderSuggestionsQueryKey,
} from "@/app/api/__generated__/endpoints/default/default";
import { getGetV2GetBuilderItemCountsQueryKey } from "@/app/api/__generated__/endpoints/default/default";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { useToast } from "@/components/molecules/Toast/use-toast";
import * as Sentry from "@sentry/nextjs";
export const useBlockMenuSearch = () => {
const { searchQuery, searchId, setSearchId } = useBlockMenuStore();
const { searchQuery } = useBlockMenuStore();
const { toast } = useToast();
const { addAgentToBuilder, addLibraryAgentToBuilder } =
useAddAgentToBuilder();
const queryClient = getQueryClient();
const resetSearchSession = useCallback(() => {
setSearchId(undefined);
queryClient.invalidateQueries({
queryKey: getGetV2GetBuilderSuggestionsQueryKey(),
});
}, [queryClient, setSearchId]);
const [addingLibraryAgentId, setAddingLibraryAgentId] = useState<
string | null
@@ -49,19 +38,13 @@ export const useBlockMenuSearch = () => {
page: 1,
page_size: 8,
search_query: searchQuery,
search_id: searchId,
},
{
query: {
getNextPageParam: (lastPage) => {
const response = lastPage.data as SearchResponse;
const { pagination } = response;
if (!pagination) {
return undefined;
}
const { current_page, total_pages } = pagination;
return current_page < total_pages ? current_page + 1 : undefined;
getNextPageParam: (lastPage, allPages) => {
const pagination = lastPage.data as SearchResponse;
const isMore = pagination.more_pages;
return isMore ? allPages.length + 1 : undefined;
},
},
},
@@ -70,6 +53,7 @@ export const useBlockMenuSearch = () => {
const { mutateAsync: addMarketplaceAgent } = usePostV2AddMarketplaceAgent({
mutation: {
onSuccess: () => {
const queryClient = getQueryClient();
queryClient.invalidateQueries({
queryKey: getGetV2ListLibraryAgentsQueryKey(),
});
@@ -91,24 +75,6 @@ export const useBlockMenuSearch = () => {
},
});
useEffect(() => {
if (!searchData?.pages?.length) {
return;
}
const latestPage = searchData.pages[searchData.pages.length - 1];
const response = latestPage?.data as SearchResponse;
if (response?.search_id && response.search_id !== searchId) {
setSearchId(response.search_id);
}
}, [searchData, searchId, setSearchId]);
useEffect(() => {
if (searchId && !searchQuery) {
resetSearchSession();
}
}, [resetSearchSession, searchId, searchQuery]);
const allSearchData =
searchData?.pages?.flatMap((page) => {
const response = page.data as SearchResponse;

View File

@@ -1,32 +1,30 @@
import { debounce } from "lodash";
import { useCallback, useEffect, useRef, useState } from "react";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { getGetV2GetBuilderSuggestionsQueryKey } from "@/app/api/__generated__/endpoints/default/default";
const SEARCH_DEBOUNCE_MS = 300;
export const useBlockMenuSearchBar = () => {
const inputRef = useRef<HTMLInputElement>(null);
const [localQuery, setLocalQuery] = useState("");
const { setSearchQuery, setSearchId, searchQuery } = useBlockMenuStore();
const queryClient = getQueryClient();
const { setSearchQuery, setSearchId, searchId, searchQuery } =
useBlockMenuStore();
const clearSearchSession = useCallback(() => {
setSearchId(undefined);
queryClient.invalidateQueries({
queryKey: getGetV2GetBuilderSuggestionsQueryKey(),
});
}, [queryClient, setSearchId]);
const searchIdRef = useRef(searchId);
useEffect(() => {
searchIdRef.current = searchId;
}, [searchId]);
const debouncedSetSearchQuery = useCallback(
debounce((value: string) => {
setSearchQuery(value);
if (value.length === 0) {
clearSearchSession();
setSearchId(undefined);
} else if (!searchIdRef.current) {
setSearchId(crypto.randomUUID());
}
}, SEARCH_DEBOUNCE_MS),
[clearSearchSession, setSearchQuery],
[setSearchQuery, setSearchId],
);
useEffect(() => {
@@ -38,13 +36,13 @@ export const useBlockMenuSearchBar = () => {
const handleClear = () => {
setLocalQuery("");
setSearchQuery("");
clearSearchSession();
setSearchId(undefined);
debouncedSetSearchQuery.cancel();
};
useEffect(() => {
setLocalQuery(searchQuery);
}, [searchQuery]);
}, []);
return {
handleClear,

View File

@@ -1,109 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import { ArrowLeftIcon, ArrowRightIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
interface HorizontalScrollAreaProps {
children: React.ReactNode;
wrapperClassName?: string;
scrollContainerClassName?: string;
scrollAmount?: number;
dependencyList?: React.DependencyList;
}
const defaultDependencies: React.DependencyList = [];
const baseScrollClasses =
"flex gap-2 overflow-x-auto px-8 [scrollbar-width:none] [-ms-overflow-style:'none'] [&::-webkit-scrollbar]:hidden";
export const HorizontalScroll: React.FC<HorizontalScrollAreaProps> = ({
children,
wrapperClassName,
scrollContainerClassName,
scrollAmount = 300,
dependencyList = defaultDependencies,
}) => {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const scrollByDelta = (delta: number) => {
if (!scrollRef.current) {
return;
}
scrollRef.current.scrollBy({ left: delta, behavior: "smooth" });
};
const updateScrollState = () => {
const element = scrollRef.current;
if (!element) {
setCanScrollLeft(false);
setCanScrollRight(false);
return;
}
setCanScrollLeft(element.scrollLeft > 0);
setCanScrollRight(
Math.ceil(element.scrollLeft + element.clientWidth) < element.scrollWidth,
);
};
useEffect(() => {
updateScrollState();
const element = scrollRef.current;
if (!element) {
return;
}
const handleScroll = () => updateScrollState();
element.addEventListener("scroll", handleScroll);
window.addEventListener("resize", handleScroll);
return () => {
element.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleScroll);
};
}, dependencyList);
return (
<div className={wrapperClassName}>
<div className="group relative">
<div
ref={scrollRef}
className={cn(baseScrollClasses, scrollContainerClassName)}
>
{children}
</div>
{canScrollLeft && (
<div className="pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-white via-white/80 to-white/0" />
)}
{canScrollRight && (
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-white via-white/80 to-white/0" />
)}
{canScrollLeft && (
<button
type="button"
aria-label="Scroll left"
className="pointer-events-none absolute left-2 top-5 -translate-y-1/2 opacity-0 transition-opacity duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
onClick={() => scrollByDelta(-scrollAmount)}
>
<ArrowLeftIcon
size={28}
className="rounded-full bg-zinc-700 p-1 text-white drop-shadow"
weight="light"
/>
</button>
)}
{canScrollRight && (
<button
type="button"
aria-label="Scroll right"
className="pointer-events-none absolute right-2 top-5 -translate-y-1/2 opacity-0 transition-opacity duration-200 group-hover:pointer-events-auto group-hover:opacity-100"
onClick={() => scrollByDelta(scrollAmount)}
>
<ArrowRightIcon
size={28}
className="rounded-full bg-zinc-700 p-1 text-white drop-shadow"
weight="light"
/>
</button>
)}
</div>
</div>
);
};

View File

@@ -6,15 +6,10 @@ import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { blockMenuContainerStyle } from "../style";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
import { DefaultStateType } from "../types";
import { SearchHistoryChip } from "../SearchHistoryChip";
import { HorizontalScroll } from "../HorizontalScroll";
export const SuggestionContent = () => {
const { setIntegration, setDefaultState, setSearchQuery, setSearchId } =
useBlockMenuStore();
const { setIntegration, setDefaultState } = useBlockMenuStore();
const { data, isLoading, isError, error, refetch } = useSuggestionContent();
const suggestions = data?.suggestions;
const hasRecentSearches = (suggestions?.recent_searches?.length ?? 0) > 0;
if (isError) {
return (
@@ -34,45 +29,11 @@ export const SuggestionContent = () => {
);
}
const suggestions = data?.suggestions;
return (
<div className={blockMenuContainerStyle}>
<div className="w-full space-y-6 pb-4">
{/* Recent searches */}
{hasRecentSearches && (
<div className="space-y-2.5 px-4">
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
Recent searches
</p>
<HorizontalScroll
wrapperClassName="-mx-8"
scrollContainerClassName="flex gap-2 overflow-x-auto px-8 [scrollbar-width:none] [-ms-overflow-style:'none'] [&::-webkit-scrollbar]:hidden"
dependencyList={[
suggestions?.recent_searches?.length ?? 0,
isLoading,
]}
>
{!isLoading && suggestions
? suggestions.recent_searches.map((entry, index) => (
<SearchHistoryChip
key={entry.search_id || `${entry.search_query}-${index}`}
content={entry.search_query || "Untitled search"}
onClick={() => {
setSearchQuery(entry.search_query || "");
setSearchId(entry.search_id || undefined);
}}
/>
))
: Array(3)
.fill(0)
.map((_, index) => (
<SearchHistoryChip.Skeleton
key={`recent-search-skeleton-${index}`}
/>
))}
</HorizontalScroll>
</div>
)}
{/* Integrations */}
<div className="space-y-2.5 px-4">
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">

View File

@@ -141,6 +141,7 @@ export function ChatCredentialsSetup({
onSelectCredentials={(credMeta) =>
handleCredentialSelect(cred.provider, credMeta)
}
hideIfSingleCredentialAvailable={false}
/>
</div>
);

View File

@@ -10,13 +10,11 @@ import { AgentRunsLoading } from "./components/other/AgentRunsLoading";
import { EmptySchedules } from "./components/other/EmptySchedules";
import { EmptyTasks } from "./components/other/EmptyTasks";
import { EmptyTemplates } from "./components/other/EmptyTemplates";
import { EmptyTriggers } from "./components/other/EmptyTriggers";
import { SectionWrap } from "./components/other/SectionWrap";
import { LoadingSelectedContent } from "./components/selected-views/LoadingSelectedContent";
import { SelectedRunView } from "./components/selected-views/SelectedRunView/SelectedRunView";
import { SelectedScheduleView } from "./components/selected-views/SelectedScheduleView/SelectedScheduleView";
import { SelectedTemplateView } from "./components/selected-views/SelectedTemplateView/SelectedTemplateView";
import { SelectedTriggerView } from "./components/selected-views/SelectedTriggerView/SelectedTriggerView";
import { SelectedScheduleView } from "./components/selected-views/SelectedScheduleView/SelectedScheduleView";
import { SelectedViewLayout } from "./components/selected-views/SelectedViewLayout";
import { SidebarRunsList } from "./components/sidebar/SidebarRunsList/SidebarRunsList";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "./helpers";
@@ -24,13 +22,11 @@ import { useNewAgentLibraryView } from "./useNewAgentLibraryView";
export function NewAgentLibraryView() {
const {
agentId,
agent,
ready,
activeTemplate,
isTemplateLoading,
error,
hasAnyItems,
ready,
error,
agentId,
activeItem,
sidebarLoading,
activeTab,
@@ -38,9 +34,6 @@ export function NewAgentLibraryView() {
handleSelectRun,
handleCountsChange,
handleClearSelectedRun,
onRunInitiated,
onTriggerSetup,
onScheduleCreated,
} = useNewAgentLibraryView();
if (error) {
@@ -70,19 +63,14 @@ export function NewAgentLibraryView() {
/>
</div>
<div className="flex min-h-0 flex-1">
<EmptyTasks
agent={agent}
onRun={onRunInitiated}
onTriggerSetup={onTriggerSetup}
onScheduleCreated={onScheduleCreated}
/>
<EmptyTasks agent={agent} />
</div>
</div>
);
}
return (
<div className="mx-4 grid h-full grid-cols-1 gap-0 pt-3 md:ml-4 md:mr-0 md:gap-4 lg:grid-cols-[25%_70%]">
<div className="ml-4 grid h-full grid-cols-1 gap-0 pt-3 md:gap-4 lg:grid-cols-[25%_70%]">
<SectionWrap className="mb-3 block">
<div
className={cn(
@@ -92,21 +80,15 @@ export function NewAgentLibraryView() {
>
<RunAgentModal
triggerSlot={
<Button
variant="primary"
size="large"
className="w-full"
disabled={isTemplateLoading && activeTab === "templates"}
>
<Button variant="primary" size="large" className="w-full">
<PlusIcon size={20} /> New task
</Button>
}
agent={agent}
onRunCreated={onRunInitiated}
onScheduleCreated={onScheduleCreated}
onTriggerSetup={onTriggerSetup}
initialInputValues={activeTemplate?.inputs}
initialInputCredentials={activeTemplate?.credentials}
onRunCreated={(execution) => handleSelectRun(execution.id, "runs")}
onScheduleCreated={(schedule) =>
handleSelectRun(schedule.id, "scheduled")
}
/>
</div>
@@ -130,17 +112,12 @@ export function NewAgentLibraryView() {
) : activeTab === "templates" ? (
<SelectedTemplateView
agent={agent}
templateId={activeItem}
onClearSelectedRun={handleClearSelectedRun}
onRunCreated={(execution) => handleSelectRun(execution.id, "runs")}
onSwitchToRunsTab={() => setActiveTab("runs")}
/>
) : activeTab === "triggers" ? (
<SelectedTriggerView
agent={agent}
triggerId={activeItem}
onClearSelectedRun={handleClearSelectedRun}
onSwitchToRunsTab={() => setActiveTab("runs")}
presetID={activeItem}
onCreateRun={(runId) => handleSelectRun(runId, "runs")}
onCreateSchedule={(scheduleId) =>
handleSelectRun(scheduleId, "scheduled")
}
onDelete={handleClearSelectedRun}
/>
) : (
<SelectedRunView
@@ -160,18 +137,9 @@ export function NewAgentLibraryView() {
<SelectedViewLayout agentName={agent.name} agentId={agent.id}>
<EmptyTemplates />
</SelectedViewLayout>
) : activeTab === "triggers" ? (
<SelectedViewLayout agentName={agent.name} agentId={agent.id}>
<EmptyTriggers />
</SelectedViewLayout>
) : (
<SelectedViewLayout agentName={agent.name} agentId={agent.id}>
<EmptyTasks
agent={agent}
onRun={onRunInitiated}
onTriggerSetup={onTriggerSetup}
onScheduleCreated={onScheduleCreated}
/>
<EmptyTasks agent={agent} />
</SelectedViewLayout>
)}
</div>

View File

@@ -1,14 +1,13 @@
"use client";
import React from "react";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import type {
BlockIOSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import {
getAgentCredentialsFields,
getAgentInputFields,
getCredentialTypeDisplayName,
renderValue,
} from "./helpers";
@@ -23,21 +22,13 @@ export function AgentInputsReadOnly({
inputs,
credentialInputs,
}: Props) {
const inputFields = getAgentInputFields(agent);
const credentialFieldEntries = Object.entries(
getAgentCredentialsFields(agent),
);
const fields = getAgentInputFields(agent);
const credentialFields = getAgentCredentialsFields(agent);
const inputEntries = Object.entries(fields);
const credentialEntries = Object.entries(credentialFields);
// Take actual input entries as leading; augment with schema from input fields.
// TODO: ensure consistent ordering.
const inputEntries =
inputs &&
Object.entries(inputs).map<[string, [BlockIOSubSchema | undefined, any]]>(
([k, v]) => [k, [inputFields[k], v]],
);
const hasInputs = inputEntries && inputEntries.length > 0;
const hasCredentials = credentialInputs && credentialFieldEntries.length > 0;
const hasInputs = inputs && inputEntries.length > 0;
const hasCredentials = credentialInputs && credentialEntries.length > 0;
if (!hasInputs && !hasCredentials) {
return <div className="text-neutral-600">No input for this run.</div>;
@@ -48,13 +39,11 @@ export function AgentInputsReadOnly({
{/* Regular inputs */}
{hasInputs && (
<div className="flex flex-col gap-4">
{inputEntries.map(([key, [schema, value]]) => (
{inputEntries.map(([key, sub]) => (
<div key={key} className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{schema?.title || key}
</label>
<label className="text-sm font-medium">{sub?.title || key}</label>
<p className="whitespace-pre-wrap break-words text-sm text-neutral-700">
{renderValue(value)}
{renderValue((inputs as Record<string, any>)[key])}
</p>
</div>
))}
@@ -65,18 +54,32 @@ export function AgentInputsReadOnly({
{hasCredentials && (
<div className="flex flex-col gap-6">
{hasInputs && <div className="border-t border-neutral-200 pt-4" />}
{credentialFieldEntries.map(([key, inputSubSchema]) => {
{credentialEntries.map(([key, _sub]) => {
const credential = credentialInputs![key];
if (!credential) return null;
return (
<CredentialsInput
key={key}
schema={{ ...inputSubSchema, discriminator: undefined } as any}
selectedCredentials={credential}
onSelectCredentials={() => {}}
readOnly={true}
/>
<div key={key} className="flex flex-col gap-4">
<h3 className="text-lg font-medium text-neutral-900">
{toDisplayName(credential.provider)} credentials
</h3>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-600">Name</span>
<span className="text-neutral-600">
{getCredentialTypeDisplayName(credential.type)}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-900">
{credential.title || "Untitled"}
</span>
<span className="font-mono text-neutral-400">
{"*".repeat(25)}
</span>
</div>
</div>
</div>
);
})}
</div>

View File

@@ -13,10 +13,11 @@ export function getCredentialTypeDisplayName(type: string): string {
}
export function getAgentInputFields(agent: LibraryAgent): Record<string, any> {
const schema = (agent.trigger_setup_info?.config_schema ??
agent.input_schema) as unknown as {
properties?: Record<string, any>;
} | null;
const schema = agent.trigger_setup_info
? agent.trigger_setup_info.config_schema
: (agent.input_schema as unknown as {
properties?: Record<string, any>;
} | null);
if (!schema || !schema.properties) return {};
const properties = schema.properties as Record<string, any>;
const visibleEntries = Object.entries(properties).filter(

View File

@@ -1,59 +1,189 @@
import {
IconKey,
IconKeyPlus,
IconUserPlus,
} from "@/components/__legacy__/ui/icons";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/__legacy__/ui/select";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import useCredentials from "@/hooks/useCredentials";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { cn } from "@/lib/utils";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { APIKeyCredentialsModal } from "./components/APIKeyCredentialsModal/APIKeyCredentialsModal";
import { CredentialRow } from "./components/CredentialRow/CredentialRow";
import { CredentialsSelect } from "./components/CredentialsSelect/CredentialsSelect";
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal/DeleteConfirmationModal";
import { HostScopedCredentialsModal } from "./components/HotScopedCredentialsModal/HotScopedCredentialsModal";
import { OAuthFlowWaitingModal } from "./components/OAuthWaitingModal/OAuthWaitingModal";
import { PasswordCredentialsModal } from "./components/PasswordCredentialsModal/PasswordCredentialsModal";
import { getCredentialDisplayName } from "./helpers";
import { useCredentialsInputs } from "./useCredentialsInputs";
import { getHostFromUrl } from "@/lib/utils/url";
import { NotionLogoIcon } from "@radix-ui/react-icons";
import { FC, useEffect, useMemo, useState } from "react";
import {
FaDiscord,
FaGithub,
FaGoogle,
FaHubspot,
FaKey,
FaMedium,
FaTwitter,
} from "react-icons/fa";
import { APIKeyCredentialsModal } from "./APIKeyCredentialsModal/APIKeyCredentialsModal";
import { HostScopedCredentialsModal } from "./HotScopedCredentialsModal/HotScopedCredentialsModal";
import { OAuthFlowWaitingModal } from "./OAuthWaitingModal/OAuthWaitingModal";
import { PasswordCredentialsModal } from "./PasswordCredentialsModal/PasswordCredentialsModal";
type UseCredentialsInputsReturn = ReturnType<typeof useCredentialsInputs>;
const fallbackIcon = FaKey;
function isLoaded(
data: UseCredentialsInputsReturn,
): data is Extract<UseCredentialsInputsReturn, { isLoading: false }> {
return data.isLoading === false;
}
// --8<-- [start:ProviderIconsEmbed]
// Provider icons mapping - uses fallback for unknown providers
export const providerIcons: Partial<
Record<string, React.FC<{ className?: string }>>
> = {
aiml_api: fallbackIcon,
anthropic: fallbackIcon,
apollo: fallbackIcon,
e2b: fallbackIcon,
github: FaGithub,
google: FaGoogle,
groq: fallbackIcon,
http: fallbackIcon,
notion: NotionLogoIcon,
nvidia: fallbackIcon,
discord: FaDiscord,
d_id: fallbackIcon,
google_maps: FaGoogle,
jina: fallbackIcon,
ideogram: fallbackIcon,
linear: fallbackIcon,
medium: FaMedium,
mem0: fallbackIcon,
ollama: fallbackIcon,
openai: fallbackIcon,
openweathermap: fallbackIcon,
open_router: fallbackIcon,
llama_api: fallbackIcon,
pinecone: fallbackIcon,
enrichlayer: fallbackIcon,
slant3d: fallbackIcon,
screenshotone: fallbackIcon,
smtp: fallbackIcon,
replicate: fallbackIcon,
reddit: fallbackIcon,
fal: fallbackIcon,
revid: fallbackIcon,
twitter: FaTwitter,
unreal_speech: fallbackIcon,
exa: fallbackIcon,
hubspot: FaHubspot,
smartlead: fallbackIcon,
todoist: fallbackIcon,
zerobounce: fallbackIcon,
};
// --8<-- [end:ProviderIconsEmbed]
type Props = {
export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & (
| {
success: true;
code: string;
state: string;
}
| {
success: false;
message: string;
}
);
export const CredentialsInput: FC<{
schema: BlockIOCredentialsSubSchema;
className?: string;
selectedCredentials?: CredentialsMetaInput;
siblingInputs?: Record<string, any>;
onSelectCredentials: (newValue?: CredentialsMetaInput) => void;
siblingInputs?: Record<string, any>;
hideIfSingleCredentialAvailable?: boolean;
onLoaded?: (loaded: boolean) => void;
readOnly?: boolean;
};
export function CredentialsInput({
}> = ({
schema,
className,
selectedCredentials,
onSelectCredentials,
siblingInputs,
hideIfSingleCredentialAvailable = true,
onLoaded,
readOnly = false,
}: Props) {
const hookData = useCredentialsInputs({
schema,
selectedCredentials,
onSelectCredentials,
siblingInputs,
onLoaded,
readOnly,
});
}) => {
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
useState(false);
const [
isUserPasswordCredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
] = useState(false);
const [isHostScopedCredentialsModalOpen, setHostScopedCredentialsModalOpen] =
useState(false);
const [isOAuth2FlowInProgress, setOAuth2FlowInProgress] = useState(false);
const [oAuthPopupController, setOAuthPopupController] =
useState<AbortController | null>(null);
const [oAuthError, setOAuthError] = useState<string | null>(null);
if (!isLoaded(hookData)) {
const api = useBackendAPI();
const credentials = useCredentials(schema, siblingInputs);
// Report loaded state to parent
useEffect(() => {
if (onLoaded) {
onLoaded(Boolean(credentials && credentials.isLoading === false));
}
}, [credentials, onLoaded]);
// Deselect credentials if they do not exist (e.g. provider was changed)
useEffect(() => {
if (!credentials || !("savedCredentials" in credentials)) return;
if (
selectedCredentials &&
!credentials.savedCredentials.some((c) => c.id === selectedCredentials.id)
) {
onSelectCredentials(undefined);
}
}, [credentials, selectedCredentials, onSelectCredentials]);
const { hasRelevantCredentials, singleCredential } = useMemo(() => {
if (!credentials || !("savedCredentials" in credentials)) {
return {
hasRelevantCredentials: false,
singleCredential: null,
};
}
// Simple logic: if we have any saved credentials, we have relevant credentials
const hasRelevant = credentials.savedCredentials.length > 0;
// Auto-select single credential if only one exists
const single =
credentials.savedCredentials.length === 1
? credentials.savedCredentials[0]
: null;
return {
hasRelevantCredentials: hasRelevant,
singleCredential: single,
};
}, [credentials]);
// If only 1 credential is available, auto-select it and hide this input
useEffect(() => {
if (singleCredential && !selectedCredentials) {
onSelectCredentials(singleCredential);
}
}, [singleCredential, selectedCredentials, onSelectCredentials]);
if (
!credentials ||
credentials.isLoading ||
(singleCredential && hideIfSingleCredentialAvailable)
) {
return null;
}
@@ -64,158 +194,309 @@ export function CredentialsInput({
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
credentialsToShow,
oAuthError,
isAPICredentialsModalOpen,
isUserPasswordCredentialsModalOpen,
isHostScopedCredentialsModalOpen,
isOAuth2FlowInProgress,
oAuthPopupController,
credentialToDelete,
deleteCredentialsMutation,
actionButtonText,
setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
setHostScopedCredentialsModalOpen,
setCredentialToDelete,
handleActionButtonClick,
handleCredentialSelect,
handleDeleteCredential,
handleDeleteConfirm,
} = hookData;
savedCredentials,
oAuthCallback,
} = credentials;
const displayName = toDisplayName(provider);
const hasCredentialsToShow = credentialsToShow.length > 0;
async function handleOAuthLogin() {
setOAuthError(null);
const { login_url, state_token } = await api.oAuthLogin(
provider,
schema.credentials_scopes,
);
setOAuth2FlowInProgress(true);
const popup = window.open(login_url, "_blank", "popup=true");
return (
<div className={cn("mb-6", className)}>
<div className="mb-2 flex items-center gap-2">
<Text variant="large-medium">{displayName} credentials</Text>
{schema.description && (
<InformationTooltip description={schema.description} />
)}
</div>
if (!popup) {
throw new Error(
"Failed to open popup window. Please allow popups for this site.",
);
}
{hasCredentialsToShow ? (
<>
{credentialsToShow.length > 1 && !readOnly ? (
<CredentialsSelect
credentials={credentialsToShow}
provider={provider}
displayName={displayName}
selectedCredentials={selectedCredentials}
onSelectCredential={handleCredentialSelect}
readOnly={readOnly}
/>
) : (
<div className="mb-4 space-y-2">
{credentialsToShow.map((credential) => {
return (
<CredentialRow
key={credential.id}
credential={credential}
provider={provider}
displayName={displayName}
onSelect={() => handleCredentialSelect(credential.id)}
onDelete={() =>
handleDeleteCredential({
id: credential.id,
title: getCredentialDisplayName(
credential,
displayName,
),
})
}
readOnly={readOnly}
/>
);
})}
</div>
)}
{!readOnly && (
<Button
variant="secondary"
size="small"
onClick={handleActionButtonClick}
className="w-fit"
>
{actionButtonText}
const controller = new AbortController();
setOAuthPopupController(controller);
controller.signal.onabort = () => {
console.debug("OAuth flow aborted");
setOAuth2FlowInProgress(false);
popup.close();
};
const handleMessage = async (e: MessageEvent<OAuthPopupResultMessage>) => {
console.debug("Message received:", e.data);
if (
typeof e.data != "object" ||
!("message_type" in e.data) ||
e.data.message_type !== "oauth_popup_result"
) {
console.debug("Ignoring irrelevant message");
return;
}
if (!e.data.success) {
console.error("OAuth flow failed:", e.data.message);
setOAuthError(`OAuth flow failed: ${e.data.message}`);
setOAuth2FlowInProgress(false);
return;
}
if (e.data.state !== state_token) {
console.error("Invalid state token received");
setOAuthError("Invalid state token received");
setOAuth2FlowInProgress(false);
return;
}
try {
console.debug("Processing OAuth callback");
const credentials = await oAuthCallback(e.data.code, e.data.state);
console.debug("OAuth callback processed successfully");
onSelectCredentials({
id: credentials.id,
type: "oauth2",
title: credentials.title,
provider,
});
} catch (error) {
console.error("Error in OAuth callback:", error);
setOAuthError(
// type of error is unkown so we need to use String(error)
`Error in OAuth callback: ${
error instanceof Error ? error.message : String(error)
}`,
);
} finally {
console.debug("Finalizing OAuth flow");
setOAuth2FlowInProgress(false);
controller.abort("success");
}
};
console.debug("Adding message event listener");
window.addEventListener("message", handleMessage, {
signal: controller.signal,
});
setTimeout(
() => {
console.debug("OAuth flow timed out");
controller.abort("timeout");
setOAuth2FlowInProgress(false);
setOAuthError("OAuth flow timed out");
},
5 * 60 * 1000,
);
}
const ProviderIcon = providerIcons[provider] || fallbackIcon;
const modals = (
<>
{supportsApiKey && (
<APIKeyCredentialsModal
schema={schema}
open={isAPICredentialsModalOpen}
onClose={() => setAPICredentialsModalOpen(false)}
onCredentialsCreate={(credsMeta) => {
onSelectCredentials(credsMeta);
setAPICredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
/>
)}
{supportsOAuth2 && (
<OAuthFlowWaitingModal
open={isOAuth2FlowInProgress}
onClose={() => oAuthPopupController?.abort("canceled")}
providerName={providerName}
/>
)}
{supportsUserPassword && (
<PasswordCredentialsModal
schema={schema}
open={isUserPasswordCredentialsModalOpen}
onClose={() => setUserPasswordCredentialsModalOpen(false)}
onCredentialsCreate={(creds) => {
onSelectCredentials(creds);
setUserPasswordCredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
/>
)}
{supportsHostScoped && (
<HostScopedCredentialsModal
schema={schema}
open={isHostScopedCredentialsModalOpen}
onClose={() => setHostScopedCredentialsModalOpen(false)}
onCredentialsCreate={(creds) => {
onSelectCredentials(creds);
setHostScopedCredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
/>
)}
</>
);
const fieldHeader = (
<div className="mb-2 flex gap-1">
<span className="text-m green text-gray-900">
{providerName} Credentials
</span>
<InformationTooltip description={schema.description} />
</div>
);
// Show credentials creation UI when no relevant credentials exist
if (!hasRelevantCredentials) {
return (
<div className="mb-4">
{fieldHeader}
<div className={cn("flex flex-row space-x-2", className)}>
{supportsOAuth2 && (
<Button onClick={handleOAuthLogin} size="small">
<ProviderIcon className="mr-2 h-4 w-4" />
{"Sign in with " + providerName}
</Button>
)}
</>
) : (
!readOnly && (
<Button
variant="secondary"
size="small"
onClick={handleActionButtonClick}
className="w-fit"
>
{actionButtonText}
</Button>
)
)}
{supportsApiKey && (
<Button
onClick={() => setAPICredentialsModalOpen(true)}
size="small"
>
<ProviderIcon className="mr-2 h-4 w-4" />
Enter API key
</Button>
)}
{supportsUserPassword && (
<Button
onClick={() => setUserPasswordCredentialsModalOpen(true)}
size="small"
>
<ProviderIcon className="mr-2 h-4 w-4" />
Enter username and password
</Button>
)}
{supportsHostScoped && credentials.discriminatorValue && (
<Button
onClick={() => setHostScopedCredentialsModalOpen(true)}
size="small"
>
<ProviderIcon className="mr-2 h-4 w-4" />
{`Enter sensitive headers for ${getHostFromUrl(credentials.discriminatorValue)}`}
</Button>
)}
</div>
{modals}
{oAuthError && (
<div className="mt-2 text-red-500">Error: {oAuthError}</div>
)}
</div>
);
}
{!readOnly && (
<>
{supportsApiKey ? (
<APIKeyCredentialsModal
schema={schema}
open={isAPICredentialsModalOpen}
onClose={() => setAPICredentialsModalOpen(false)}
onCredentialsCreate={(credsMeta) => {
onSelectCredentials(credsMeta);
setAPICredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
/>
) : null}
{supportsOAuth2 ? (
<OAuthFlowWaitingModal
open={isOAuth2FlowInProgress}
onClose={() => oAuthPopupController?.abort("canceled")}
providerName={providerName}
/>
) : null}
{supportsUserPassword ? (
<PasswordCredentialsModal
schema={schema}
open={isUserPasswordCredentialsModalOpen}
onClose={() => setUserPasswordCredentialsModalOpen(false)}
onCredentialsCreate={(creds) => {
onSelectCredentials(creds);
setUserPasswordCredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
/>
) : null}
{supportsHostScoped ? (
<HostScopedCredentialsModal
schema={schema}
open={isHostScopedCredentialsModalOpen}
onClose={() => setHostScopedCredentialsModalOpen(false)}
onCredentialsCreate={(creds) => {
onSelectCredentials(creds);
setHostScopedCredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
/>
) : null}
function handleValueChange(newValue: string) {
if (newValue === "sign-in") {
// Trigger OAuth2 sign in flow
handleOAuthLogin();
} else if (newValue === "add-api-key") {
// Open API key dialog
setAPICredentialsModalOpen(true);
} else if (newValue === "add-user-password") {
// Open user password dialog
setUserPasswordCredentialsModalOpen(true);
} else if (newValue === "add-host-scoped") {
// Open host-scoped credentials dialog
setHostScopedCredentialsModalOpen(true);
} else {
const selectedCreds = savedCredentials.find((c) => c.id == newValue)!;
{oAuthError ? (
<Text variant="body" className="mt-2 text-red-500">
Error: {oAuthError}
</Text>
) : null}
onSelectCredentials({
id: selectedCreds.id,
type: selectedCreds.type,
provider: provider,
// title: customTitle, // TODO: add input for title
});
}
}
<DeleteConfirmationModal
credentialToDelete={credentialToDelete}
isDeleting={deleteCredentialsMutation.isPending}
onClose={() => setCredentialToDelete(null)}
onConfirm={handleDeleteConfirm}
/>
</>
// Saved credentials exist
return (
<div>
{fieldHeader}
<Select value={selectedCredentials?.id} onValueChange={handleValueChange}>
<SelectTrigger>
<SelectValue placeholder={schema.placeholder} />
</SelectTrigger>
<SelectContent className="nodrag">
{savedCredentials
.filter((c) => c.type == "oauth2")
.map((credentials, index) => (
<SelectItem key={index} value={credentials.id}>
<ProviderIcon className="mr-2 inline h-4 w-4" />
{credentials.title ||
credentials.username ||
`Your ${providerName} account`}
</SelectItem>
))}
{savedCredentials
.filter((c) => c.type == "api_key")
.map((credentials, index) => (
<SelectItem key={index} value={credentials.id}>
<ProviderIcon className="mr-2 inline h-4 w-4" />
<IconKey className="mr-1.5 inline" />
{credentials.title}
</SelectItem>
))}
{savedCredentials
.filter((c) => c.type == "user_password")
.map((credentials, index) => (
<SelectItem key={index} value={credentials.id}>
<ProviderIcon className="mr-2 inline h-4 w-4" />
<IconUserPlus className="mr-1.5 inline" />
{credentials.title}
</SelectItem>
))}
{savedCredentials
.filter((c) => c.type == "host_scoped")
.map((credentials, index) => (
<SelectItem key={index} value={credentials.id}>
<ProviderIcon className="mr-2 inline h-4 w-4" />
<IconKey className="mr-1.5 inline" />
{credentials.title}
</SelectItem>
))}
<SelectSeparator />
{supportsOAuth2 && (
<SelectItem value="sign-in">
<IconUserPlus className="mr-1.5 inline" />
Sign in with {providerName}
</SelectItem>
)}
{supportsApiKey && (
<SelectItem value="add-api-key">
<IconKeyPlus className="mr-1.5 inline" />
Add new API key
</SelectItem>
)}
{supportsUserPassword && (
<SelectItem value="add-user-password">
<IconUserPlus className="mr-1.5 inline" />
Add new user password
</SelectItem>
)}
{supportsHostScoped && (
<SelectItem value="add-host-scoped">
<IconKey className="mr-1.5 inline" />
Add host-scoped headers
</SelectItem>
)}
</SelectContent>
</Select>
{modals}
{oAuthError && (
<div className="mt-2 text-red-500">Error: {oAuthError}</div>
)}
</div>
);
}
};

View File

@@ -1,15 +1,15 @@
import { Form, FormField } from "@/components/__legacy__/ui/form";
import { Button } from "@/components/atoms/Button/Button";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/components/atoms/Input/Input";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { Form, FormField } from "@/components/__legacy__/ui/form";
import useCredentials from "@/hooks/useCredentials";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
type Props = {
schema: BlockIOCredentialsSubSchema;
@@ -85,7 +85,7 @@ export function PasswordCredentialsModal({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="w-[98%] space-y-2 pt-4"
className="space-y-2 pt-4"
>
<FormField
control={form.control}
@@ -96,6 +96,7 @@ export function PasswordCredentialsModal({
label="Username"
type="text"
placeholder="Enter username..."
size="small"
{...field}
/>
)}
@@ -109,6 +110,7 @@ export function PasswordCredentialsModal({
label="Password"
type="password"
placeholder="Enter password..."
size="small"
{...field}
/>
)}
@@ -122,12 +124,12 @@ export function PasswordCredentialsModal({
label="Name"
type="text"
placeholder="Enter a name for this user login..."
className="mb-8"
size="small"
{...field}
/>
)}
/>
<Button type="submit" className="w-full">
<Button type="submit" size="small" className="min-w-68">
Save & use this user login
</Button>
</form>

View File

@@ -1,102 +0,0 @@
import { IconKey } from "@/components/__legacy__/ui/icons";
import { Text } from "@/components/atoms/Text/Text";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import { cn } from "@/lib/utils";
import { CaretDown, DotsThreeVertical } from "@phosphor-icons/react";
import {
fallbackIcon,
getCredentialDisplayName,
MASKED_KEY_LENGTH,
providerIcons,
} from "../../helpers";
type CredentialRowProps = {
credential: {
id: string;
title?: string;
username?: string;
type: string;
provider: string;
};
provider: string;
displayName: string;
onSelect: () => void;
onDelete: () => void;
readOnly?: boolean;
showCaret?: boolean;
asSelectTrigger?: boolean;
};
export function CredentialRow({
credential,
provider,
displayName,
onSelect,
onDelete,
readOnly = false,
showCaret = false,
asSelectTrigger = false,
}: CredentialRowProps) {
const ProviderIcon = providerIcons[provider] || fallbackIcon;
return (
<div
className={cn(
"flex items-center gap-3 rounded-medium border border-zinc-200 bg-white p-3 transition-colors",
asSelectTrigger ? "border-0 bg-transparent" : readOnly ? "w-fit" : "",
)}
onClick={readOnly || showCaret || asSelectTrigger ? undefined : onSelect}
style={
readOnly || showCaret || asSelectTrigger
? { cursor: showCaret || asSelectTrigger ? "pointer" : "default" }
: undefined
}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-900">
<ProviderIcon className="h-3 w-3 text-white" />
</div>
<IconKey className="h-5 w-5 shrink-0 text-zinc-800" />
<div className="flex min-w-0 flex-1 flex-nowrap items-center gap-4">
<Text variant="body" className="tracking-tight">
{getCredentialDisplayName(credential, displayName)}
</Text>
<Text
variant="large"
className="relative top-1 font-mono tracking-tight"
>
{"*".repeat(MASKED_KEY_LENGTH)}
</Text>
</div>
{showCaret && !asSelectTrigger && (
<CaretDown className="h-4 w-4 shrink-0 text-gray-400" />
)}
{!readOnly && !showCaret && !asSelectTrigger && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="ml-auto shrink-0 rounded p-1 hover:bg-gray-100"
onClick={(e) => e.stopPropagation()}
>
<DotsThreeVertical className="h-5 w-5 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}

View File

@@ -1,86 +0,0 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/__legacy__/ui/select";
import { Text } from "@/components/atoms/Text/Text";
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { useEffect } from "react";
import { getCredentialDisplayName } from "../../helpers";
import { CredentialRow } from "../CredentialRow/CredentialRow";
interface Props {
credentials: Array<{
id: string;
title?: string;
username?: string;
type: string;
provider: string;
}>;
provider: string;
displayName: string;
selectedCredentials?: CredentialsMetaInput;
onSelectCredential: (credentialId: string) => void;
readOnly?: boolean;
}
export function CredentialsSelect({
credentials,
provider,
displayName,
selectedCredentials,
onSelectCredential,
readOnly = false,
}: Props) {
// Auto-select first credential if none is selected
useEffect(() => {
if (!selectedCredentials && credentials.length > 0) {
onSelectCredential(credentials[0].id);
}
}, [selectedCredentials, credentials, onSelectCredential]);
return (
<div className="mb-4 w-full">
<Select
value={selectedCredentials?.id || ""}
onValueChange={(value) => onSelectCredential(value)}
>
<SelectTrigger className="h-auto min-h-12 w-full rounded-medium border-zinc-200 p-0 pr-4 shadow-none">
{selectedCredentials ? (
<SelectValue key={selectedCredentials.id} asChild>
<CredentialRow
credential={{
id: selectedCredentials.id,
title: selectedCredentials.title || undefined,
type: selectedCredentials.type,
provider: selectedCredentials.provider,
}}
provider={provider}
displayName={displayName}
onSelect={() => {}}
onDelete={() => {}}
readOnly={readOnly}
asSelectTrigger={true}
/>
</SelectValue>
) : (
<SelectValue key="placeholder" placeholder="Select credential" />
)}
</SelectTrigger>
<SelectContent>
{credentials.map((credential) => (
<SelectItem key={credential.id} value={credential.id}>
<div className="flex items-center gap-2">
<Text variant="body" className="tracking-tight">
{getCredentialDisplayName(credential, displayName)}
</Text>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -1,49 +0,0 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
interface Props {
credentialToDelete: { id: string; title: string } | null;
isDeleting: boolean;
onClose: () => void;
onConfirm: () => void;
}
export function DeleteConfirmationModal({
credentialToDelete,
isDeleting,
onClose,
onConfirm,
}: Props) {
return (
<Dialog
controlled={{
isOpen: credentialToDelete !== null,
set: (open) => {
if (!open) onClose();
},
}}
title="Delete credential"
styling={{ maxWidth: "32rem" }}
>
<Dialog.Content>
<Text variant="large">
Are you sure you want to delete &quot;{credentialToDelete?.title}
&quot;? This action cannot be undone.
</Text>
<Dialog.Footer>
<Button variant="secondary" onClick={onClose} disabled={isDeleting}>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
loading={isDeleting}
>
Delete
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -1,102 +0,0 @@
import { KeyIcon } from "@phosphor-icons/react";
import { NotionLogoIcon } from "@radix-ui/react-icons";
import {
FaDiscord,
FaGithub,
FaGoogle,
FaHubspot,
FaMedium,
FaTwitter,
} from "react-icons/fa";
export const fallbackIcon = KeyIcon;
export const providerIcons: Partial<
Record<string, React.FC<{ className?: string }>>
> = {
aiml_api: fallbackIcon,
anthropic: fallbackIcon,
apollo: fallbackIcon,
e2b: fallbackIcon,
github: FaGithub,
google: FaGoogle,
groq: fallbackIcon,
http: fallbackIcon,
notion: NotionLogoIcon,
nvidia: fallbackIcon,
discord: FaDiscord,
d_id: fallbackIcon,
google_maps: FaGoogle,
jina: fallbackIcon,
ideogram: fallbackIcon,
linear: fallbackIcon,
medium: FaMedium,
mem0: fallbackIcon,
ollama: fallbackIcon,
openai: fallbackIcon,
openweathermap: fallbackIcon,
open_router: fallbackIcon,
llama_api: fallbackIcon,
pinecone: fallbackIcon,
enrichlayer: fallbackIcon,
slant3d: fallbackIcon,
screenshotone: fallbackIcon,
smtp: fallbackIcon,
replicate: fallbackIcon,
reddit: fallbackIcon,
fal: fallbackIcon,
revid: fallbackIcon,
twitter: FaTwitter,
unreal_speech: fallbackIcon,
exa: fallbackIcon,
hubspot: FaHubspot,
smartlead: fallbackIcon,
todoist: fallbackIcon,
zerobounce: fallbackIcon,
};
export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & (
| {
success: true;
code: string;
state: string;
}
| {
success: false;
message: string;
}
);
export function getActionButtonText(
supportsOAuth2: boolean,
supportsApiKey: boolean,
supportsUserPassword: boolean,
supportsHostScoped: boolean,
hasExistingCredentials: boolean,
): string {
if (hasExistingCredentials) {
if (supportsOAuth2) return "Connect another account";
if (supportsApiKey) return "Use a new API key";
if (supportsUserPassword) return "Add a new username and password";
if (supportsHostScoped) return "Add new headers";
return "Add new credentials";
} else {
if (supportsOAuth2) return "Add account";
if (supportsApiKey) return "Add API key";
if (supportsUserPassword) return "Add username and password";
if (supportsHostScoped) return "Add headers";
return "Add credentials";
}
}
export function getCredentialDisplayName(
credential: { title?: string; username?: string },
displayName: string,
): string {
return (
credential.title || credential.username || `Your ${displayName} account`
);
}
export const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
export const MASKED_KEY_LENGTH = 30;

View File

@@ -1,318 +0,0 @@
import { useDeleteV1DeleteCredentials } from "@/app/api/__generated__/endpoints/integrations/integrations";
import useCredentials from "@/hooks/useCredentials";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { useQueryClient } from "@tanstack/react-query";
import { useContext, useEffect, useMemo, useState } from "react";
import {
getActionButtonText,
OAUTH_TIMEOUT_MS,
OAuthPopupResultMessage,
} from "./helpers";
type Args = {
schema: BlockIOCredentialsSubSchema;
selectedCredentials?: CredentialsMetaInput;
onSelectCredentials: (newValue?: CredentialsMetaInput) => void;
siblingInputs?: Record<string, any>;
onLoaded?: (loaded: boolean) => void;
readOnly?: boolean;
};
export function useCredentialsInputs({
schema,
selectedCredentials,
onSelectCredentials,
siblingInputs,
onLoaded,
readOnly = false,
}: Args) {
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
useState(false);
const [
isUserPasswordCredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
] = useState(false);
const [isHostScopedCredentialsModalOpen, setHostScopedCredentialsModalOpen] =
useState(false);
const [isOAuth2FlowInProgress, setOAuth2FlowInProgress] = useState(false);
const [oAuthPopupController, setOAuthPopupController] =
useState<AbortController | null>(null);
const [oAuthError, setOAuthError] = useState<string | null>(null);
const [credentialToDelete, setCredentialToDelete] = useState<{
id: string;
title: string;
} | null>(null);
const api = useBackendAPI();
const queryClient = useQueryClient();
const credentials = useCredentials(schema, siblingInputs);
const allProviders = useContext(CredentialsProvidersContext);
const deleteCredentialsMutation = useDeleteV1DeleteCredentials({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["/api/integrations/credentials"],
});
queryClient.invalidateQueries({
queryKey: [`/api/integrations/${credentials?.provider}/credentials`],
});
setCredentialToDelete(null);
if (selectedCredentials?.id === credentialToDelete?.id) {
onSelectCredentials(undefined);
}
},
},
});
const rawProvider = credentials
? allProviders?.[credentials.provider as keyof typeof allProviders]
: null;
useEffect(() => {
if (onLoaded) {
onLoaded(Boolean(credentials && credentials.isLoading === false));
}
}, [credentials, onLoaded]);
useEffect(() => {
if (readOnly) return;
if (!credentials || !("savedCredentials" in credentials)) return;
if (
selectedCredentials &&
!credentials.savedCredentials.some((c) => c.id === selectedCredentials.id)
) {
onSelectCredentials(undefined);
}
}, [credentials, selectedCredentials, onSelectCredentials, readOnly]);
const { singleCredential } = useMemo(() => {
if (!credentials || !("savedCredentials" in credentials)) {
return {
singleCredential: null,
};
}
const single =
credentials.savedCredentials.length === 1
? credentials.savedCredentials[0]
: null;
return {
singleCredential: single,
};
}, [credentials]);
useEffect(() => {
if (readOnly) return;
if (singleCredential && !selectedCredentials) {
onSelectCredentials(singleCredential);
}
}, [singleCredential, selectedCredentials, onSelectCredentials, readOnly]);
if (
!credentials ||
credentials.isLoading ||
!("savedCredentials" in credentials)
) {
return {
isLoading: true,
};
}
const {
provider,
providerName,
supportsApiKey,
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
savedCredentials,
oAuthCallback,
} = credentials;
const allSavedCredentials = rawProvider?.savedCredentials || savedCredentials;
const credentialsToShow = (() => {
const creds = [...allSavedCredentials];
if (
!readOnly &&
selectedCredentials &&
!creds.some((c) => c.id === selectedCredentials.id)
) {
creds.push({
id: selectedCredentials.id,
type: selectedCredentials.type,
title: selectedCredentials.title || "Selected credential",
provider: provider,
} as any);
}
return creds;
})();
async function handleOAuthLogin() {
setOAuthError(null);
const { login_url, state_token } = await api.oAuthLogin(
provider,
schema.credentials_scopes,
);
setOAuth2FlowInProgress(true);
const popup = window.open(login_url, "_blank", "popup=true");
if (!popup) {
throw new Error(
"Failed to open popup window. Please allow popups for this site.",
);
}
const controller = new AbortController();
setOAuthPopupController(controller);
controller.signal.onabort = () => {
console.debug("OAuth flow aborted");
setOAuth2FlowInProgress(false);
popup.close();
};
const handleMessage = async (e: MessageEvent<OAuthPopupResultMessage>) => {
console.debug("Message received:", e.data);
if (
typeof e.data != "object" ||
!("message_type" in e.data) ||
e.data.message_type !== "oauth_popup_result"
) {
console.debug("Ignoring irrelevant message");
return;
}
if (!e.data.success) {
console.error("OAuth flow failed:", e.data.message);
setOAuthError(`OAuth flow failed: ${e.data.message}`);
setOAuth2FlowInProgress(false);
return;
}
if (e.data.state !== state_token) {
console.error("Invalid state token received");
setOAuthError("Invalid state token received");
setOAuth2FlowInProgress(false);
return;
}
try {
console.debug("Processing OAuth callback");
const credentials = await oAuthCallback(e.data.code, e.data.state);
console.debug("OAuth callback processed successfully");
onSelectCredentials({
id: credentials.id,
type: "oauth2",
title: credentials.title,
provider,
});
} catch (error) {
console.error("Error in OAuth callback:", error);
setOAuthError(
`Error in OAuth callback: ${
error instanceof Error ? error.message : String(error)
}`,
);
} finally {
console.debug("Finalizing OAuth flow");
setOAuth2FlowInProgress(false);
controller.abort("success");
}
};
console.debug("Adding message event listener");
window.addEventListener("message", handleMessage, {
signal: controller.signal,
});
setTimeout(() => {
console.debug("OAuth flow timed out");
controller.abort("timeout");
setOAuth2FlowInProgress(false);
setOAuthError("OAuth flow timed out");
}, OAUTH_TIMEOUT_MS);
}
function handleActionButtonClick() {
if (supportsOAuth2) {
handleOAuthLogin();
} else if (supportsApiKey) {
setAPICredentialsModalOpen(true);
} else if (supportsUserPassword) {
setUserPasswordCredentialsModalOpen(true);
} else if (supportsHostScoped) {
setHostScopedCredentialsModalOpen(true);
}
}
function handleCredentialSelect(credentialId: string) {
const selectedCreds = credentialsToShow.find((c) => c.id === credentialId);
if (selectedCreds) {
onSelectCredentials({
id: selectedCreds.id,
type: selectedCreds.type,
provider: provider,
title: (selectedCreds as any).title,
});
}
}
function handleDeleteCredential(credential: { id: string; title: string }) {
setCredentialToDelete(credential);
}
function handleDeleteConfirm() {
if (credentialToDelete && credentials) {
deleteCredentialsMutation.mutate({
provider: credentials.provider,
credId: credentialToDelete.id,
});
}
}
return {
isLoading: false as const,
provider,
providerName,
supportsApiKey,
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
credentialsToShow,
selectedCredentials,
oAuthError,
isAPICredentialsModalOpen,
isUserPasswordCredentialsModalOpen,
isHostScopedCredentialsModalOpen,
isOAuth2FlowInProgress,
oAuthPopupController,
credentialToDelete,
deleteCredentialsMutation,
actionButtonText: getActionButtonText(
supportsOAuth2,
supportsApiKey,
supportsUserPassword,
supportsHostScoped,
credentialsToShow.length > 0,
),
setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
setHostScopedCredentialsModalOpen,
setCredentialToDelete,
handleActionButtonClick,
handleCredentialSelect,
handleDeleteCredential,
handleDeleteConfirm,
handleOAuthLogin,
onSelectCredentials,
schema,
siblingInputs,
};
}

View File

@@ -5,15 +5,14 @@ import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutio
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { Button } from "@/components/atoms/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { Input } from "@/components/atoms/Input/Input";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { AlarmIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { ScheduleAgentModal } from "../ScheduleAgentModal/ScheduleAgentModal";
import { AgentCostSection } from "./components/AgentCostSection/AgentCostSection";
import { AgentDetails } from "./components/AgentDetails/AgentDetails";
import { AgentSectionHeader } from "./components/AgentSectionHeader/AgentSectionHeader";
import { ModalHeader } from "./components/ModalHeader/ModalHeader";
import { ModalRunSection } from "./components/ModalRunSection/ModalRunSection";
import { RunActions } from "./components/RunActions/RunActions";
@@ -25,9 +24,15 @@ interface Props {
agent: LibraryAgent;
initialInputValues?: Record<string, any>;
initialInputCredentials?: Record<string, any>;
initialPresetName?: string;
initialPresetDescription?: string;
onRunCreated?: (execution: GraphExecutionMeta) => void;
onTriggerSetup?: (preset: LibraryAgentPreset) => void;
onScheduleCreated?: (schedule: GraphExecutionJobInfo) => void;
editMode?: {
preset: LibraryAgentPreset;
onSaved?: (updatedPreset: LibraryAgentPreset) => void;
};
}
export function RunAgentModal({
@@ -35,9 +40,12 @@ export function RunAgentModal({
agent,
initialInputValues,
initialInputCredentials,
initialPresetName,
initialPresetDescription,
onRunCreated,
onTriggerSetup,
onScheduleCreated,
editMode,
}: Props) {
const {
// UI state
@@ -61,6 +69,11 @@ export function RunAgentModal({
setPresetName,
setPresetDescription,
// Edit mode
hasChanges,
isUpdatingPreset,
handleSave,
// Validation/readiness
allRequiredInputsAreSet,
@@ -77,8 +90,12 @@ export function RunAgentModal({
} = useAgentRunModal(agent, {
onRun: onRunCreated,
onSetupTrigger: onTriggerSetup,
onScheduleCreated,
initialInputValues,
initialInputCredentials,
initialPresetName,
initialPresetDescription,
editMode,
});
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
@@ -87,8 +104,6 @@ export function RunAgentModal({
Object.keys(agentInputFields || {}).length > 0 ||
Object.keys(agentCredentialsInputFields || {}).length > 0;
const isTriggerRunType = defaultRunType.includes("trigger");
function handleInputChange(key: string, value: string) {
setInputValues((prev) => ({
...prev,
@@ -126,6 +141,8 @@ export function RunAgentModal({
onScheduleCreated?.(schedule);
}
const templateOrTrigger = agent.trigger_setup_info ? "Trigger" : "Template";
return (
<>
<Dialog
@@ -134,90 +151,157 @@ export function RunAgentModal({
>
<Dialog.Trigger>{triggerSlot}</Dialog.Trigger>
<Dialog.Content>
{/* Header */}
<ModalHeader agent={agent} />
{/* Content */}
{hasAnySetupFields ? (
<div className="mt-10">
<RunAgentModalContextProvider
value={{
agent,
defaultRunType,
presetName,
setPresetName,
presetDescription,
setPresetDescription,
inputValues,
setInputValue: handleInputChange,
agentInputFields,
inputCredentials,
setInputCredentialsValue: handleCredentialsChange,
agentCredentialsInputFields,
}}
>
<ModalRunSection />
</RunAgentModalContextProvider>
<div className="flex h-full flex-col pb-4">
{/* Header */}
<div className="flex-shrink-0">
<ModalHeader agent={agent} />
<AgentCostSection flowId={agent.graph_id} />
</div>
) : null}
<Dialog.Footer className="mt-6 bg-white pt-4">
<div className="flex items-center justify-end gap-3">
{isTriggerRunType ? null : !allRequiredInputsAreSet ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
Schedule Task
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>
Please set up all required inputs and credentials before
scheduling
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
Schedule Task
</Button>
{/* Scrollable content */}
<div className="flex-1 pr-1" style={{ scrollbarGutter: "stable" }}>
{/* Template Info Section (Edit Mode Only) */}
{editMode && (
<div className="mt-10">
<AgentSectionHeader
title={`${templateOrTrigger} Information`}
/>
<div className="mb-10 mt-4 space-y-4">
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium">
{templateOrTrigger} Name
</label>
<Input
id="template_name"
label="Template Name"
size="small"
hideLabel
value={presetName}
placeholder="Enter template name"
onChange={(e) => setPresetName(e.target.value)}
/>
</div>
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium">
{templateOrTrigger} Description
</label>
<Input
id="template_description"
label="Template Description"
size="small"
hideLabel
value={presetDescription}
placeholder="Enter template description"
onChange={(e) => setPresetDescription(e.target.value)}
/>
</div>
</div>
</div>
)}
<RunActions
defaultRunType={defaultRunType}
onRun={handleRun}
isExecuting={isExecuting}
isSettingUpTrigger={isSettingUpTrigger}
isRunReady={allRequiredInputsAreSet}
/>
{/* Setup Section */}
<div className={editMode ? "mt-8" : "mt-10"}>
{hasAnySetupFields ? (
<RunAgentModalContextProvider
value={{
agent,
defaultRunType,
inputValues,
setInputValue: handleInputChange,
agentInputFields,
inputCredentials,
setInputCredentialsValue: handleCredentialsChange,
agentCredentialsInputFields,
presetEditMode: Boolean(
editMode || agent.trigger_setup_info,
),
presetName,
setPresetName,
presetDescription,
setPresetDescription,
}}
>
<>
<AgentSectionHeader
title={
defaultRunType === "automatic-trigger"
? "Trigger Setup"
: "Agent Setup"
}
/>
<ModalRunSection />
</>
</RunAgentModalContextProvider>
) : null}
</div>
{/* Agent Details Section */}
<div className="mt-8">
<AgentSectionHeader title="Agent Details" />
<AgentDetails agent={agent} />
</div>
</div>
<ScheduleAgentModal
isOpen={isScheduleModalOpen}
onClose={handleCloseScheduleModal}
agent={agent}
inputValues={inputValues}
inputCredentials={inputCredentials}
onScheduleCreated={handleScheduleCreated}
/>
</div>
<Dialog.Footer
className="fixed bottom-1 left-0 z-10 w-full bg-white p-4"
style={{ boxShadow: "0px -8px 10px white" }}
>
<div className="flex items-center justify-end gap-3">
{editMode ? (
<>
<Button
variant="secondary"
onClick={() => setIsOpen(false)}
disabled={isUpdatingPreset}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={
!hasChanges || isUpdatingPreset || !presetName.trim()
}
>
{isUpdatingPreset ? "Saving..." : "Save Changes"}
</Button>
</>
) : (
<>
{(defaultRunType == "manual" ||
defaultRunType == "schedule") && (
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
<AlarmIcon size={16} />
Schedule Agent
</Button>
)}
<RunActions
defaultRunType={defaultRunType}
onRun={handleRun}
isExecuting={isExecuting}
isSettingUpTrigger={isSettingUpTrigger}
isRunReady={allRequiredInputsAreSet}
/>
</>
)}
</div>
{(defaultRunType == "manual" || defaultRunType == "schedule") && (
<ScheduleAgentModal
isOpen={isScheduleModalOpen}
onClose={handleCloseScheduleModal}
agent={agent}
inputValues={inputValues}
inputCredentials={inputCredentials}
onScheduleCreated={handleScheduleCreated}
/>
)}
</Dialog.Footer>
</Dialog.Content>
</Dialog>

View File

@@ -0,0 +1,31 @@
import { Button } from "@/components/atoms/Button/Button";
interface Props {
flowId: string;
}
export function AgentCostSection({ flowId }: Props) {
return (
<div className="mt-6 flex items-center justify-between">
{/* TODO: enable once we have an API to show estimated cost for an agent run */}
{/* <div className="flex items-center gap-2">
<Text variant="body-medium">Cost</Text>
<Text variant="body">{cost}</Text>
</div> */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="small"
as="NextLink"
href={`/build?flowID=${flowId}`}
>
Open in builder
</Button>
{/* TODO: enable once we can easily link to the agent listing page from the library agent response */}
{/* <Button variant="outline" size="small">
View listing <ArrowSquareOutIcon size={16} />
</Button> */}
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import { Badge } from "@/components/atoms/Badge/Badge";
import { formatDate } from "@/lib/utils/time";
interface Props {
agent: LibraryAgent;
}
export function AgentDetails({ agent }: Props) {
return (
<div className="mt-4 flex flex-col gap-5">
<div>
<Text variant="body-medium" className="mb-1 !text-black">
Version
</Text>
<div className="flex items-center gap-2">
<Text variant="body" className="!text-zinc-700">
v{agent.graph_version}
</Text>
{agent.is_latest_version && (
<Badge variant="success" size="small">
Latest
</Badge>
)}
</div>
</div>
<div>
<Text variant="body-medium" className="mb-1 !text-black">
Last Updated
</Text>
<Text variant="body" className="!text-zinc-700">
{formatDate(agent.updated_at)}
</Text>
</div>
{agent.has_external_trigger && (
<div>
<Text variant="body-medium" className="mb-1">
Trigger Type
</Text>
<Text variant="body" className="!text-neutral-700">
External Webhook
</Text>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { Text } from "@/components/atoms/Text/Text";
interface Props {
title: string;
}
export function AgentSectionHeader({ title }: Props) {
return (
<div className="border-t border-zinc-400 px-0 pb-2 pt-1">
<Text variant="label" className="!text-zinc-700">
{title}
</Text>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Badge } from "@/components/atoms/Badge/Badge";
import { Link } from "@/components/atoms/Link/Link";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import { ShowMoreText } from "@/components/molecules/ShowMoreText/ShowMoreText";
import { ClockIcon, InfoIcon } from "@phosphor-icons/react";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
interface ModalHeaderProps {
@@ -10,56 +10,49 @@ interface ModalHeaderProps {
}
export function ModalHeader({ agent }: ModalHeaderProps) {
const creator = agent.marketplace_listing?.creator;
const isUnknownCreator = agent.creator_name === "Unknown";
return (
<div className="flex flex-col gap-4">
<Badge variant="info" className="w-fit">
New Task
</Badge>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Badge variant="info">New Run</Badge>
</div>
<div>
<Text variant="h2">{agent.name}</Text>
{creator ? (
<Link href={`/marketplace/creator/${creator.slug}`} isExternal>
by {creator.name}
</Link>
<Text variant="h3">{agent.name}</Text>
{!isUnknownCreator ? (
<Text variant="body-medium">by {agent.creator_name}</Text>
) : null}
<ShowMoreText
previewLimit={80}
variant="small"
className="mt-4 !text-zinc-700"
>
{agent.description}
</ShowMoreText>
{agent.description ? (
<ShowMoreText
previewLimit={400}
variant="small"
className="mt-4 !text-zinc-700"
>
{agent.description}
</ShowMoreText>
) : null}
{agent.recommended_schedule_cron && !agent.has_external_trigger ? (
<div className="flex flex-col gap-4 rounded-medium border border-blue-100 bg-blue-50 p-4">
<Text variant="lead-semibold" className="text-blue-600">
Tip
</Text>
<Text variant="body">
For best results, run this agent{" "}
{/* Schedule recommendation tip */}
{agent.recommended_schedule_cron && !agent.has_external_trigger && (
<div className="mt-4 flex items-center gap-2">
<ClockIcon className="h-4 w-4 text-gray-500" />
<p className="text-sm text-gray-600">
<strong>Tip:</strong> For best results, run this agent{" "}
{humanizeCronExpression(
agent.recommended_schedule_cron,
).toLowerCase()}
</Text>
</p>
</div>
) : null}
)}
{agent.instructions ? (
<div className="flex flex-col gap-4 rounded-medium border border-purple-100 bg-[#F1EBFE/5] p-4">
<Text variant="lead-semibold" className="text-purple-600">
Instructions
</Text>
<div className="h-px w-full bg-purple-100" />
<Text variant="body">{agent.instructions}</Text>
{/* Setup Instructions */}
{agent.instructions && (
<div className="mt-4 flex items-start gap-2">
<InfoIcon className="mt-0.5 h-4 w-4 flex-shrink-0 text-gray-500" />
<div className="text-sm text-gray-600">
<strong>Setup Instructions:</strong>{" "}
<span className="whitespace-pre-wrap">{agent.instructions}</span>
</div>
</div>
) : null}
)}
</div>
</div>
);

View File

@@ -1,125 +1,172 @@
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { Input } from "@/components/atoms/Input/Input";
import { Text } from "@/components/atoms/Text/Text";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { InfoIcon } from "@phosphor-icons/react";
import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs";
import { useRunAgentModalContext } from "../../context";
import { ModalSection } from "../ModalSection/ModalSection";
import { WebhookTriggerBanner } from "../WebhookTriggerBanner/WebhookTriggerBanner";
import { getCredentialTypeDisplayName } from "./helpers";
export function ModalRunSection() {
const {
agent,
defaultRunType,
presetName,
setPresetName,
presetDescription,
setPresetDescription,
inputValues,
setInputValue,
agentInputFields,
inputCredentials,
setInputCredentialsValue,
agentCredentialsInputFields,
presetEditMode,
presetName,
setPresetName,
presetDescription,
setPresetDescription,
} = useRunAgentModalContext();
const inputFields = Object.entries(agentInputFields || {});
const credentialFields = Object.entries(agentCredentialsInputFields || {});
return (
<div className="flex flex-col gap-4">
{defaultRunType === "automatic-trigger" ||
defaultRunType === "manual-trigger" ? (
<ModalSection
title="Task Trigger"
subtitle="Set up a trigger for the agent to run this task automatically"
>
<WebhookTriggerBanner />
<div className="flex flex-col gap-4">
<div className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
Trigger Name
<InformationTooltip description="Name of the trigger you are setting up" />
</label>
<Input
id="trigger_name"
label="Trigger Name"
size="small"
hideLabel
value={presetName}
placeholder="Enter trigger name"
onChange={(e) => setPresetName(e.target.value)}
/>
</div>
<div className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
Trigger Description
<InformationTooltip description="Description of the trigger you are setting up" />
</label>
<Input
id="trigger_description"
label="Trigger Description"
size="small"
hideLabel
value={presetDescription}
placeholder="Enter trigger description"
onChange={(e) => setPresetDescription(e.target.value)}
/>
</div>
<div className="mb-10 mt-4 flex flex-col gap-4">
{defaultRunType === "automatic-trigger" && <WebhookTriggerBanner />}
{/* Preset/Trigger fields */}
{presetEditMode && (
<div className="flex flex-col gap-4">
<div className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
Trigger Name
<InformationTooltip description="Name of the trigger you are setting up" />
</label>
<Input
id="trigger_name"
label="Trigger Name"
size="small"
hideLabel
value={presetName}
placeholder="Enter trigger name"
onChange={(e) => setPresetName(e.target.value)}
/>
</div>
</ModalSection>
) : null}
{inputFields.length > 0 ? (
<ModalSection
title="Task Inputs"
subtitle="Enter the information you want to provide to the agent for this task"
>
{/* Regular inputs */}
{inputFields.map(([key, inputSubSchema]) => (
<div key={key} className="flex w-full flex-col gap-0 space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
{inputSubSchema.title || key}
<InformationTooltip description={inputSubSchema.description} />
</label>
<RunAgentInputs
schema={inputSubSchema}
value={inputValues[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
data-testid={`agent-input-${key}`}
/>
</div>
))}
</ModalSection>
) : null}
{credentialFields.length > 0 ? (
<ModalSection
title="Task Credentials"
subtitle="These are the credentials the agent will use to perform this task"
>
<div className="space-y-6">
{Object.entries(agentCredentialsInputFields || {}).map(
([key, inputSubSchema]) => (
<CredentialsInput
key={key}
schema={
{ ...inputSubSchema, discriminator: undefined } as any
}
selectedCredentials={
(inputCredentials && inputCredentials[key]) ??
inputSubSchema.default
}
onSelectCredentials={(value) =>
setInputCredentialsValue(key, value)
}
siblingInputs={inputValues}
/>
),
)}
<div className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
Trigger Description
<InformationTooltip description="Description of the trigger you are setting up" />
</label>
<Input
id="trigger_description"
label="Trigger Description"
size="small"
hideLabel
value={presetDescription}
placeholder="Enter trigger description"
onChange={(e) => setPresetDescription(e.target.value)}
/>
</div>
</ModalSection>
) : null}
</div>
)}
{/* Instructions */}
{agent.instructions && (
<div className="mb-4 flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3">
<InfoIcon className="mt-0.5 h-4 w-4 text-blue-600" />
<div>
<h4 className="text-sm font-medium text-blue-900">
How to use this agent
</h4>
<p className="mt-1 whitespace-pre-wrap text-sm text-blue-800">
{agent.instructions}
</p>
</div>
</div>
)}
{/* Credentials inputs */}
{Object.entries(agentCredentialsInputFields || {}).map(
([key, inputSubSchema]) => (
<CredentialsInput
key={key}
schema={{ ...inputSubSchema, discriminator: undefined } as any}
selectedCredentials={
(inputCredentials && inputCredentials[key]) ??
inputSubSchema.default
}
onSelectCredentials={(value) =>
setInputCredentialsValue(key, value)
}
siblingInputs={inputValues}
hideIfSingleCredentialAvailable={!agent.has_external_trigger}
/>
),
)}
{/* Regular inputs */}
{Object.entries(agentInputFields || {}).map(([key, inputSubSchema]) => (
<div key={key} className="flex w-full flex-col gap-0 space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
{inputSubSchema.title || key}
<InformationTooltip description={inputSubSchema.description} />
</label>
<RunAgentInputs
schema={inputSubSchema}
value={inputValues[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
data-testid={`agent-input-${key}`}
/>
</div>
))}
{/* Selected Credentials Preview */}
{Object.keys(inputCredentials).length > 0 && (
<div className="mt-2 flex flex-col gap-6">
{Object.entries(agentCredentialsInputFields || {}).map(
([key, _sub]) => {
const credential = inputCredentials[key];
if (!credential) return null;
return (
<div key={key} className="flex flex-col gap-4">
<Text variant="body-medium" as="h3">
{toDisplayName(credential.provider)} credentials
</Text>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between text-sm">
<Text
variant="body"
as="span"
className="!text-neutral-600"
>
Name
</Text>
<Text
variant="body"
as="span"
className="!text-neutral-600"
>
{getCredentialTypeDisplayName(credential.type)}
</Text>
</div>
<div className="flex items-center justify-between text-sm">
<Text
variant="body"
as="span"
className="!text-neutral-900"
>
{credential.title || "Untitled"}
</Text>
<span className="font-mono text-neutral-400">
{"*".repeat(25)}
</span>
</div>
</div>
</div>
);
},
)}
</div>
)}
</div>
);
}

View File

@@ -1,21 +0,0 @@
import { Text } from "@/components/atoms/Text/Text";
interface Props {
title: string;
subtitle: string;
children: React.ReactNode;
}
export function ModalSection({ title, subtitle, children }: Props) {
return (
<div className="rounded-medium border border-zinc-200 p-6">
<div className="mb-4 flex flex-col gap-1 border-b border-zinc-100 pb-4">
<Text variant="lead-semibold">{title}</Text>
<Text variant="body" className="text-zinc-500">
{subtitle}
</Text>
</div>
{children}
</div>
);
}

View File

@@ -24,10 +24,9 @@ export function RunActions({
disabled={!isRunReady || isExecuting || isSettingUpTrigger}
loading={isExecuting || isSettingUpTrigger}
>
{defaultRunType === "automatic-trigger" ||
defaultRunType === "manual-trigger"
{defaultRunType === "automatic-trigger"
? "Set up Trigger"
: "Start Task"}
: "Run Agent"}
</Button>
</div>
);

View File

@@ -1,6 +1,13 @@
export function WebhookTriggerBanner() {
import { cn } from "@/lib/utils";
export function WebhookTriggerBanner({ className }: { className?: string }) {
return (
<div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div
className={cn(
"rounded-lg border border-blue-200 bg-blue-50 p-4",
className,
)}
>
<div className="flex items-start">
<div className="flex-shrink-0">
<svg

View File

@@ -4,14 +4,9 @@ import React, { createContext, useContext } from "react";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { RunVariant } from "./useAgentRunModal";
export interface RunAgentModalContextValue {
export type RunAgentModalContextValue = {
agent: LibraryAgent;
defaultRunType: RunVariant;
// Preset / Trigger
presetName: string;
setPresetName: (value: string) => void;
presetDescription: string;
setPresetDescription: (value: string) => void;
// Inputs
inputValues: Record<string, any>;
setInputValue: (key: string, value: any) => void;
@@ -20,7 +15,14 @@ export interface RunAgentModalContextValue {
inputCredentials: Record<string, any>;
setInputCredentialsValue: (key: string, value: any | undefined) => void;
agentCredentialsInputFields: Record<string, any>;
}
// Trigger / Preset fields
presetEditMode: boolean; // determines whether to show the name and description fields
presetName: string;
setPresetName: (value: string) => void;
presetDescription: string;
setPresetDescription: (value: string) => void;
};
const RunAgentModalContext = createContext<RunAgentModalContextValue | null>(
null,

View File

@@ -1,100 +0,0 @@
import { ApiError } from "@/lib/autogpt-server-api/helpers";
import Link from "next/link";
import React from "react";
type ValidationErrorDetail = {
type: string;
message?: string;
node_errors?: Record<string, Record<string, string>>;
};
type AgentInfo = {
graph_id: string;
graph_version: number;
};
export function formatValidationError(
error: any,
agentInfo?: AgentInfo,
): string | React.ReactNode {
if (
!(error instanceof ApiError) ||
!error.isGraphValidationError() ||
!error.response?.detail
) {
return error.message || "An unexpected error occurred.";
}
const detail: ValidationErrorDetail = error.response.detail;
// Format validation errors nicely
if (detail.type === "validation_error" && detail.node_errors) {
const nodeErrors = detail.node_errors;
const errorItems: React.ReactNode[] = [];
// Collect all field errors
Object.entries(nodeErrors).forEach(([nodeId, fields]) => {
if (fields && typeof fields === "object") {
Object.entries(fields).forEach(([fieldName, fieldError]) => {
errorItems.push(
<div key={`${nodeId}-${fieldName}`} className="mt-1">
<span className="font-medium">{fieldName}:</span>{" "}
{String(fieldError)}
</div>,
);
});
}
});
if (errorItems.length > 0) {
return (
<div className="space-y-1">
<div className="font-medium text-white">
{detail.message || "Validation failed"}
</div>
<div className="mt-2 space-y-1 text-xs">{errorItems}</div>
{agentInfo && (
<div className="mt-3 text-xs">
Check the agent graph and try to run from there for further
details.{" "}
<Link
href={`/build?flowID=${agentInfo.graph_id}&flowVersion=${agentInfo.graph_version}`}
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer underline hover:no-underline"
>
Open in builder
</Link>
</div>
)}
</div>
);
} else {
return detail.message || "Validation failed";
}
}
return detail.message || error.message || "An unexpected error occurred.";
}
export function showExecutionErrorToast(
toast: (options: {
title: string;
description: string | React.ReactNode;
variant: "destructive";
duration: number;
dismissable: boolean;
}) => void,
error: any,
agentInfo?: AgentInfo,
) {
const errorMessage = formatValidationError(error, agentInfo);
toast({
title: "Failed to execute agent",
description: errorMessage,
variant: "destructive",
duration: 10000, // 10 seconds - long enough to read and close
dismissable: true,
});
}

View File

@@ -0,0 +1,529 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useState, useCallback, useMemo } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { isEmpty } from "@/lib/utils";
import {
usePostV1ExecuteGraphAgent,
getGetV1ListGraphExecutionsInfiniteQueryOptions,
} from "@/app/api/__generated__/endpoints/graphs/graphs";
import {
usePostV1CreateExecutionSchedule as useCreateSchedule,
getGetV1ListExecutionSchedulesForAGraphQueryKey,
} from "@/app/api/__generated__/endpoints/schedules/schedules";
import {
getGetV2ListPresetsQueryKey,
usePostV2SetupTrigger,
usePatchV2UpdateAnExistingPreset,
} from "@/app/api/__generated__/endpoints/presets/presets";
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { useGetV1GetUserTimezone } from "@/app/api/__generated__/endpoints/auth/auth";
import { analytics } from "@/services/analytics";
export type RunVariant =
| "manual"
| "schedule"
| "automatic-trigger"
| "manual-trigger";
interface UseAgentRunModalCallbacks {
onRun?: (execution: GraphExecutionMeta) => void;
onSetupTrigger?: (preset: LibraryAgentPreset) => void;
onScheduleCreated?: (schedule: GraphExecutionJobInfo) => void;
initialInputValues?: Record<string, any>;
initialInputCredentials?: Record<string, any>;
initialPresetName?: string;
initialPresetDescription?: string;
editMode?: {
preset: LibraryAgentPreset;
onSaved?: (updatedPreset: LibraryAgentPreset) => void;
};
}
export function useAgentRunModal(
agent: LibraryAgent,
callbacks?: UseAgentRunModalCallbacks,
) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState(false);
const [showScheduleView, setShowScheduleView] = useState(false);
const [inputValues, setInputValues] = useState<Record<string, any>>(
callbacks?.initialInputValues || callbacks?.editMode?.preset?.inputs || {},
);
const [inputCredentials, setInputCredentials] = useState<Record<string, any>>(
callbacks?.initialInputCredentials ||
callbacks?.editMode?.preset?.credentials ||
{},
);
const [presetName, setPresetName] = useState<string>(
callbacks?.initialPresetName || callbacks?.editMode?.preset?.name || "",
);
const [presetDescription, setPresetDescription] = useState<string>(
callbacks?.initialPresetDescription ||
callbacks?.editMode?.preset?.description ||
"",
);
const defaultScheduleName = useMemo(() => `Run ${agent.name}`, [agent.name]);
const [scheduleName, setScheduleName] = useState(defaultScheduleName);
const [cronExpression, setCronExpression] = useState(
agent.recommended_schedule_cron || "0 9 * * 1",
);
// Get user timezone for scheduling
const { data: userTimezone } = useGetV1GetUserTimezone({
query: {
select: (res) => (res.status === 200 ? res.data.timezone : undefined),
},
});
// Determine the default run type based on agent capabilities
const defaultRunType: RunVariant = agent.has_external_trigger
? "automatic-trigger"
: "manual";
// API mutations
const executeGraphMutation = usePostV1ExecuteGraphAgent({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Agent execution started",
});
callbacks?.onRun?.(response.data);
// Invalidate runs list for this graph
queryClient.invalidateQueries({
queryKey: getGetV1ListGraphExecutionsInfiniteQueryOptions(
agent.graph_id,
).queryKey,
});
analytics.sendDatafastEvent("run_agent", {
name: agent.name,
id: agent.graph_id,
});
setIsOpen(false);
}
},
onError: (error: any) => {
const errorMessage = error.isGraphValidationError()
? error.response.detail.message
: error.message;
toast({
title: "❌ Failed to execute agent",
description: errorMessage || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
const createScheduleMutation = useCreateSchedule({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Schedule created",
});
callbacks?.onScheduleCreated?.(response.data);
// Invalidate schedules list for this graph
queryClient.invalidateQueries({
queryKey: getGetV1ListExecutionSchedulesForAGraphQueryKey(
agent.graph_id,
),
});
analytics.sendDatafastEvent("schedule_agent", {
name: agent.name,
id: agent.graph_id,
cronExpression: cronExpression,
});
setIsOpen(false);
}
},
onError: (error: any) => {
toast({
title: "❌ Failed to create schedule",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
const setupTriggerMutation = usePostV2SetupTrigger({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Trigger setup complete",
});
callbacks?.onSetupTrigger?.(response.data);
// Invalidate preset queries to show the newly created trigger
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({
graph_id: response.data.graph_id,
}),
});
analytics.sendDatafastEvent("setup_trigger", {
name: agent.name,
id: agent.graph_id,
});
setIsOpen(false);
}
},
onError: (error) => {
toast({
title: "❌ Failed to setup trigger",
description: String(error) || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
// Edit mode mutation for updating presets
const updatePresetMutation = usePatchV2UpdateAnExistingPreset({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Template updated successfully",
variant: "default",
});
setIsOpen(false);
callbacks?.editMode?.onSaved?.(response.data);
}
},
onError: (error: any) => {
toast({
title: "Failed to update template",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
// Input schema validation (use trigger schema for triggered agents)
const agentInputSchema = useMemo(() => {
if (agent.trigger_setup_info?.config_schema) {
return agent.trigger_setup_info.config_schema;
}
return agent.input_schema || { properties: {}, required: [] };
}, [agent.input_schema, agent.trigger_setup_info]);
const agentInputFields = useMemo(() => {
if (
!agentInputSchema ||
typeof agentInputSchema !== "object" ||
!("properties" in agentInputSchema) ||
!agentInputSchema.properties
) {
return {};
}
const properties = agentInputSchema.properties as Record<string, any>;
return Object.fromEntries(
Object.entries(properties).filter(
([_, subSchema]: [string, any]) => !subSchema.hidden,
),
);
}, [agentInputSchema]);
const agentCredentialsInputFields = useMemo(() => {
if (
!agent.credentials_input_schema ||
typeof agent.credentials_input_schema !== "object" ||
!("properties" in agent.credentials_input_schema) ||
!agent.credentials_input_schema.properties
) {
return {} as Record<string, any>;
}
return agent.credentials_input_schema.properties as Record<string, any>;
}, [agent.credentials_input_schema]);
// Validation logic
const [allRequiredInputsAreSetRaw, missingInputs] = useMemo(() => {
const nonEmptyInputs = new Set(
Object.keys(inputValues).filter((k) => !isEmpty(inputValues[k])),
);
const requiredInputs = new Set(
(agentInputSchema.required as string[]) || [],
);
const missing = [...requiredInputs].filter(
(input) => !nonEmptyInputs.has(input),
);
return [missing.length === 0, missing];
}, [agentInputSchema.required, inputValues]);
const [allCredentialsAreSet, missingCredentials] = useMemo(() => {
const availableCredentials = new Set(Object.keys(inputCredentials));
const allCredentials = new Set(
Object.keys(agentCredentialsInputFields || {}) ?? [],
);
const missing = [...allCredentials].filter(
(key) => !availableCredentials.has(key),
);
return [missing.length === 0, missing];
}, [agentCredentialsInputFields, inputCredentials]);
const credentialsRequired = useMemo(
() => Object.keys(agentCredentialsInputFields || {}).length > 0,
[agentCredentialsInputFields],
);
// Final readiness flag combining inputs + credentials when credentials are shown
const allRequiredInputsAreSet = useMemo(
() =>
allRequiredInputsAreSetRaw &&
(!credentialsRequired || allCredentialsAreSet),
[allRequiredInputsAreSetRaw, credentialsRequired, allCredentialsAreSet],
);
const notifyMissingRequirements = useCallback(
(needScheduleName: boolean = false) => {
const allMissingFields = (
needScheduleName && !scheduleName ? ["schedule_name"] : []
)
.concat(missingInputs)
.concat(
credentialsRequired && !allCredentialsAreSet
? missingCredentials.map((k) => `credentials:${k}`)
: [],
);
toast({
title: "⚠️ Missing required inputs",
description: `Please provide: ${allMissingFields.map((k) => `"${k}"`).join(", ")}`,
variant: "destructive",
});
},
[
missingInputs,
scheduleName,
toast,
credentialsRequired,
allCredentialsAreSet,
missingCredentials,
],
);
// Action handlers
const handleRun = useCallback(() => {
if (!allRequiredInputsAreSet) {
notifyMissingRequirements();
return;
}
// FIXME: add support for "manual-trigger"
if (defaultRunType === "automatic-trigger") {
// Setup trigger
if (!presetName.trim()) {
toast({
title: "⚠️ Trigger name required",
description: "Please provide a name for your trigger.",
variant: "destructive",
});
return;
}
setupTriggerMutation.mutate({
data: {
name: presetName,
description: presetDescription || `Trigger for ${agent.name}`,
graph_id: agent.graph_id,
graph_version: agent.graph_version,
trigger_config: inputValues,
agent_credentials: inputCredentials,
},
});
} else {
// Manual execution
executeGraphMutation.mutate({
graphId: agent.graph_id,
graphVersion: agent.graph_version,
data: {
inputs: inputValues,
credentials_inputs: inputCredentials,
source: "library",
},
});
}
}, [
allRequiredInputsAreSet,
defaultRunType,
presetName,
inputValues,
inputCredentials,
agent,
presetDescription,
notifyMissingRequirements,
setupTriggerMutation,
executeGraphMutation,
toast,
]);
const handleSchedule = useCallback(() => {
if (!allRequiredInputsAreSet) {
notifyMissingRequirements(true);
return;
}
if (!scheduleName.trim()) {
toast({
title: "⚠️ Schedule name required",
description: "Please provide a name for your schedule.",
variant: "destructive",
});
return;
}
createScheduleMutation.mutate({
graphId: agent.graph_id,
data: {
name: presetName || scheduleName,
cron: cronExpression,
inputs: inputValues,
graph_version: agent.graph_version,
credentials: inputCredentials,
timezone:
userTimezone && userTimezone !== "not-set" ? userTimezone : undefined,
},
});
}, [
allRequiredInputsAreSet,
scheduleName,
cronExpression,
inputValues,
inputCredentials,
agent,
notifyMissingRequirements,
createScheduleMutation,
toast,
userTimezone,
presetName,
]);
function handleShowSchedule() {
// Initialize with sensible defaults when entering schedule view
setScheduleName((prev) => prev || defaultScheduleName);
setCronExpression(
(prev) => prev || agent.recommended_schedule_cron || "0 9 * * 1",
);
setShowScheduleView(true);
}
function handleGoBack() {
setShowScheduleView(false);
// Reset schedule fields on exit
setScheduleName(defaultScheduleName);
setCronExpression(agent.recommended_schedule_cron || "0 9 * * 1");
}
function handleSetScheduleName(name: string) {
setScheduleName(name);
}
function handleSetCronExpression(expression: string) {
setCronExpression(expression);
}
// Edit mode save handler
const handleSave = useCallback(() => {
if (!callbacks?.editMode?.preset) return;
updatePresetMutation.mutate({
presetId: callbacks.editMode.preset.id,
data: {
name: presetName,
description: presetDescription,
inputs: inputValues,
credentials: inputCredentials,
},
});
}, [
callbacks?.editMode?.preset,
presetName,
presetDescription,
inputValues,
inputCredentials,
updatePresetMutation,
]);
// Check if there are changes in edit mode
const hasChanges = useMemo(() => {
if (!callbacks?.editMode?.preset) return false;
const preset = callbacks.editMode.preset;
return (
presetName !== preset.name ||
presetDescription !== preset.description ||
JSON.stringify(inputValues) !== JSON.stringify(preset.inputs || {}) ||
JSON.stringify(inputCredentials) !==
JSON.stringify(preset.credentials || {})
);
}, [
callbacks?.editMode?.preset,
presetName,
presetDescription,
inputValues,
inputCredentials,
]);
const hasInputFields = useMemo(() => {
return Object.keys(agentInputFields).length > 0;
}, [agentInputFields]);
return {
// UI state
isOpen,
setIsOpen,
showScheduleView,
// Run mode
defaultRunType: defaultRunType as RunVariant,
// Form: regular inputs
inputValues,
setInputValues,
// Form: credentials
inputCredentials,
setInputCredentials,
// Preset/trigger labels
presetName,
presetDescription,
setPresetName,
setPresetDescription,
// Scheduling
scheduleName,
cronExpression,
// Validation/readiness
allRequiredInputsAreSet,
missingInputs,
// Schemas for rendering
agentInputFields,
agentCredentialsInputFields,
hasInputFields,
// Async states
isExecuting: executeGraphMutation.isPending,
isCreatingSchedule: createScheduleMutation.isPending,
isSettingUpTrigger: setupTriggerMutation.isPending,
isUpdatingPreset: updatePresetMutation.isPending,
// Actions
handleRun,
handleSchedule,
handleShowSchedule,
handleGoBack,
handleSetScheduleName,
handleSetCronExpression,
handleSave,
hasChanges,
};
}

View File

@@ -1,308 +0,0 @@
import {
getGetV1ListGraphExecutionsQueryKey,
usePostV1ExecuteGraphAgent,
} from "@/app/api/__generated__/endpoints/graphs/graphs";
import {
getGetV2ListPresetsQueryKey,
usePostV2SetupTrigger,
} from "@/app/api/__generated__/endpoints/presets/presets";
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { isEmpty } from "@/lib/utils";
import { analytics } from "@/services/analytics";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useState } from "react";
import { showExecutionErrorToast } from "./errorHelpers";
export type RunVariant =
| "manual"
| "schedule"
| "automatic-trigger"
| "manual-trigger";
interface UseAgentRunModalCallbacks {
onRun?: (execution: GraphExecutionMeta) => void;
onSetupTrigger?: (preset: LibraryAgentPreset) => void;
initialInputValues?: Record<string, any>;
initialInputCredentials?: Record<string, any>;
}
export function useAgentRunModal(
agent: LibraryAgent,
callbacks?: UseAgentRunModalCallbacks,
) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState(false);
const [inputValues, setInputValues] = useState<Record<string, any>>(
callbacks?.initialInputValues || {},
);
const [inputCredentials, setInputCredentials] = useState<Record<string, any>>(
callbacks?.initialInputCredentials || {},
);
const [presetName, setPresetName] = useState<string>("");
const [presetDescription, setPresetDescription] = useState<string>("");
// Determine the default run type based on agent capabilities
const defaultRunType: RunVariant = agent.trigger_setup_info
? agent.trigger_setup_info.credentials_input_name
? "automatic-trigger"
: "manual-trigger"
: "manual";
// Update input values/credentials if template is selected/unselected
useEffect(() => {
setInputValues(callbacks?.initialInputValues || {});
setInputCredentials(callbacks?.initialInputCredentials || {});
}, [callbacks?.initialInputValues, callbacks?.initialInputCredentials]);
// API mutations
const executeGraphMutation = usePostV1ExecuteGraphAgent({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Agent execution started",
});
// Invalidate runs list for this graph
queryClient.invalidateQueries({
queryKey: getGetV1ListGraphExecutionsQueryKey(agent.graph_id),
});
callbacks?.onRun?.(response.data);
analytics.sendDatafastEvent("run_agent", {
name: agent.name,
id: agent.graph_id,
});
setIsOpen(false);
}
},
onError: (error: any) => {
showExecutionErrorToast(toast, error, {
graph_id: agent.graph_id,
graph_version: agent.graph_version,
});
},
},
});
const setupTriggerMutation = usePostV2SetupTrigger({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Trigger setup complete",
});
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({ graph_id: agent.graph_id }),
});
callbacks?.onSetupTrigger?.(response.data);
setIsOpen(false);
}
},
onError: (error: any) => {
toast({
title: "❌ Failed to setup trigger",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
// Input schema validation (use trigger schema for triggered agents)
const agentInputSchema = useMemo(() => {
if (agent.trigger_setup_info?.config_schema) {
return agent.trigger_setup_info.config_schema;
}
return agent.input_schema || { properties: {}, required: [] };
}, [agent.input_schema, agent.trigger_setup_info]);
const agentInputFields = useMemo(() => {
if (
!agentInputSchema ||
typeof agentInputSchema !== "object" ||
!("properties" in agentInputSchema) ||
!agentInputSchema.properties
) {
return {};
}
const properties = agentInputSchema.properties as Record<string, any>;
return Object.fromEntries(
Object.entries(properties).filter(
([_, subSchema]: [string, any]) => !subSchema.hidden,
),
);
}, [agentInputSchema]);
const agentCredentialsInputFields = useMemo(() => {
if (
!agent.credentials_input_schema ||
typeof agent.credentials_input_schema !== "object" ||
!("properties" in agent.credentials_input_schema) ||
!agent.credentials_input_schema.properties
) {
return {} as Record<string, any>;
}
return agent.credentials_input_schema.properties as Record<string, any>;
}, [agent.credentials_input_schema]);
// Validation logic
const [allRequiredInputsAreSetRaw, missingInputs] = useMemo(() => {
const nonEmptyInputs = new Set(
Object.keys(inputValues).filter((k) => !isEmpty(inputValues[k])),
);
const requiredInputs = new Set(
(agentInputSchema.required as string[]) || [],
);
const missing = [...requiredInputs].filter(
(input) => !nonEmptyInputs.has(input),
);
return [missing.length === 0, missing];
}, [agentInputSchema.required, inputValues]);
const [allCredentialsAreSet, missingCredentials] = useMemo(() => {
const availableCredentials = new Set(Object.keys(inputCredentials));
const allCredentials = new Set(
Object.keys(agentCredentialsInputFields || {}) ?? [],
);
const missing = [...allCredentials].filter(
(key) => !availableCredentials.has(key),
);
return [missing.length === 0, missing];
}, [agentCredentialsInputFields, inputCredentials]);
const credentialsRequired = useMemo(
() => Object.keys(agentCredentialsInputFields || {}).length > 0,
[agentCredentialsInputFields],
);
// Final readiness flag combining inputs + credentials when credentials are shown
const allRequiredInputsAreSet = useMemo(
() =>
allRequiredInputsAreSetRaw &&
(!credentialsRequired || allCredentialsAreSet),
[allRequiredInputsAreSetRaw, credentialsRequired, allCredentialsAreSet],
);
const notifyMissingRequirements = useCallback(() => {
const allMissingFields = missingInputs.concat(
credentialsRequired && !allCredentialsAreSet
? missingCredentials.map((k) => `credentials:${k}`)
: [],
);
toast({
title: "⚠️ Missing required inputs",
description: `Please provide: ${allMissingFields.map((k) => `"${k}"`).join(", ")}`,
variant: "destructive",
});
}, [
missingInputs,
toast,
credentialsRequired,
allCredentialsAreSet,
missingCredentials,
]);
// Action handlers
const handleRun = useCallback(() => {
if (!allRequiredInputsAreSet) {
notifyMissingRequirements();
return;
}
if (
defaultRunType === "automatic-trigger" ||
defaultRunType === "manual-trigger"
) {
// Setup trigger
if (!presetName.trim()) {
toast({
title: "⚠️ Trigger name required",
description: "Please provide a name for your trigger.",
variant: "destructive",
});
return;
}
setupTriggerMutation.mutate({
data: {
name: presetName,
description: presetDescription || `Trigger for ${agent.name}`,
graph_id: agent.graph_id,
graph_version: agent.graph_version,
trigger_config: inputValues,
agent_credentials: inputCredentials,
},
});
} else {
// Manual execution
executeGraphMutation.mutate({
graphId: agent.graph_id,
graphVersion: agent.graph_version,
data: {
inputs: inputValues,
credentials_inputs: inputCredentials,
source: "library",
},
});
}
}, [
allRequiredInputsAreSet,
defaultRunType,
inputValues,
inputCredentials,
agent,
presetName,
presetDescription,
notifyMissingRequirements,
setupTriggerMutation,
executeGraphMutation,
toast,
]);
const hasInputFields = useMemo(() => {
return Object.keys(agentInputFields).length > 0;
}, [agentInputFields]);
return {
// UI state
isOpen,
setIsOpen,
// Run mode
defaultRunType: defaultRunType as RunVariant,
// Form: regular inputs
inputValues,
setInputValues,
// Form: credentials
inputCredentials,
setInputCredentials,
// Preset/trigger labels
presetName,
presetDescription,
setPresetName,
setPresetDescription,
// Validation/readiness
allRequiredInputsAreSet,
missingInputs,
// Schemas for rendering
agentInputFields,
agentCredentialsInputFields,
hasInputFields,
// Async states
isExecuting: executeGraphMutation.isPending,
isSettingUpTrigger: setupTriggerMutation.isPending,
// Actions
handleRun,
};
}

View File

@@ -1,58 +1,17 @@
"use client";
import { getV1GetGraphVersion } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { ShowMoreText } from "@/components/molecules/ShowMoreText/ShowMoreText";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { exportAsJSONFile } from "@/lib/utils";
import { formatDate } from "@/lib/utils/time";
import Link from "next/link";
import { RunAgentModal } from "../modals/RunAgentModal/RunAgentModal";
import { RunDetailCard } from "../selected-views/RunDetailCard/RunDetailCard";
import { EmptyTasksIllustration } from "./EmptyTasksIllustration";
type Props = {
agent: LibraryAgent;
onRun?: (run: GraphExecutionMeta) => void;
onTriggerSetup?: (preset: LibraryAgentPreset) => void;
onScheduleCreated?: (schedule: GraphExecutionJobInfo) => void;
};
export function EmptyTasks({
agent,
onRun,
onTriggerSetup,
onScheduleCreated,
}: Props) {
const { toast } = useToast();
async function handleExport() {
try {
const res = await getV1GetGraphVersion(
agent.graph_id,
agent.graph_version,
{ for_export: true },
);
if (res.status === 200) {
const filename = `${agent.name}_v${agent.graph_version}.json`;
exportAsJSONFile(res.data as any, filename);
toast({ title: "Agent exported" });
} else {
toast({ title: "Failed to export agent", variant: "destructive" });
}
} catch (e: any) {
toast({
title: "Failed to export agent",
description: e?.message,
variant: "destructive",
});
}
}
export function EmptyTasks({ agent }: Props) {
const isPublished = Boolean(agent.marketplace_listing);
const createdAt = formatDate(agent.created_at);
const updatedAt = formatDate(agent.updated_at);
@@ -86,9 +45,6 @@ export function EmptyTasks({
</Button>
}
agent={agent}
onRunCreated={onRun}
onTriggerSetup={onTriggerSetup}
onScheduleCreated={onScheduleCreated}
/>
</div>
</div>
@@ -136,15 +92,10 @@ export function EmptyTasks({
) : null}
</div>
<div className="mt-4 flex items-center gap-2">
<Button variant="secondary" size="small" asChild>
<Link
href={`/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}`}
target="_blank"
>
Edit agent
</Link>
<Button variant="secondary" size="small">
Edit agent
</Button>
<Button variant="secondary" size="small" onClick={handleExport}>
<Button variant="secondary" size="small">
Export agent to file
</Button>
</div>

View File

@@ -1,323 +0,0 @@
import { Text } from "@/components/atoms/Text/Text";
export function EmptyTriggers() {
return (
<div className="flex h-full flex-col items-center justify-center gap-20">
<div>
<svg
width="342"
height="211"
viewBox="0 0 342 211"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M212.148 114.272C193.65 114.272 175.622 86.479 175.622 86.479L169.237 78.4039L164.636 72.6762L163.979 71.8311L136.843 37.5588L135.247 35.4931C134.683 34.8358 134.026 34.0847 133.369 33.2396C128.58 26.9485 122.383 15.8687 126.702 10.0471C131.585 3.38044 145.857 -1.3144 155.998 2.15978C166.139 5.63396 170.646 10.5166 170.646 10.5166C170.646 10.5166 184.918 12.3945 193.65 40.1879C202.383 67.9814 227.078 55.7748 236.467 80.9391C242.571 97.3711 230.552 114.366 212.054 114.366L212.148 114.272Z"
fill="#FF9C0F"
/>
<path
d="M212.148 114.554C193.744 114.554 175.622 86.8547 175.434 86.573L135.059 35.5871C134.496 34.9298 133.932 34.1787 133.181 33.2397C121.913 18.3101 125.294 11.4557 126.514 9.67163C131.397 3.09885 145.669 -1.97157 156.092 1.6904C165.481 4.88289 170.176 9.38994 170.74 10.0472C171.397 10.1411 174.683 10.8923 179.003 14.5543C183.322 18.2162 189.331 25.5402 193.838 40.0003C198.251 54.2726 207.172 57.9345 215.716 61.5026C223.791 64.8829 232.148 68.3571 236.749 80.6575C239.66 88.451 238.627 96.7139 234.026 103.381C229.143 110.423 221.162 114.46 212.148 114.46C212.148 114.46 212.148 114.46 212.054 114.46L212.148 114.554ZM148.392 1.12703C139.754 1.12703 130.646 5.16458 126.984 10.1411C123.134 15.3054 127.923 25.3524 133.65 32.958C134.308 33.897 134.965 34.6481 135.434 35.2115L137.031 37.2773L175.81 86.2913C175.998 86.573 193.932 113.897 211.96 113.991C211.96 113.991 211.96 113.991 212.054 113.991C220.88 113.991 228.768 110.047 233.557 103.193C238.064 96.7139 239.096 88.5449 236.28 81.0331C231.772 69.0144 223.885 65.728 215.622 62.1599C206.984 58.4979 197.97 54.7421 193.463 40.282C188.956 26.0097 183.04 18.7796 178.815 15.2115C174.308 11.3618 170.74 10.7984 170.646 10.7984C170.646 10.7984 170.552 10.7984 170.458 10.7984C170.458 10.7984 165.857 5.91576 155.904 2.53548C153.557 1.69041 150.927 1.31482 148.298 1.31482L148.392 1.12703Z"
fill="#101720"
/>
<path
d="M212.148 114.272C193.651 114.272 175.622 86.479 175.622 86.479H170.176L169.143 78.4039L168.862 76.4321L174.59 68.3569C184.261 79.5306 205.294 90.986 219.848 88.9203C234.402 86.7607 236.28 80.9391 236.28 80.9391C242.383 97.371 230.364 114.366 211.867 114.366L212.148 114.272Z"
fill="#101720"
/>
<path
d="M212.148 114.554C194.402 114.554 176.843 88.8264 175.528 86.7606H170.27C170.082 86.7606 169.988 86.6668 169.988 86.479L168.768 76.432C168.768 76.432 168.768 76.3381 168.768 76.2442L174.496 68.1691C174.496 68.1691 174.589 68.0752 174.683 68.0752C174.683 68.0752 174.871 68.0752 174.871 68.1691C184.73 79.5306 205.669 90.7043 219.942 88.5447C234.12 86.479 236.186 80.8451 236.186 80.7513C236.186 80.6574 236.373 80.5635 236.467 80.5635C236.467 80.5635 236.655 80.5635 236.655 80.6573C239.566 88.4508 238.533 96.7137 233.932 103.38C229.05 110.423 221.068 114.46 212.054 114.46L212.148 114.554ZM170.458 86.1973H175.622C175.716 86.1973 175.81 86.1973 175.904 86.2912C176.092 86.5728 194.026 113.897 212.054 113.991C220.881 113.991 228.768 110.047 233.557 103.193C237.97 96.8076 239.003 89.0142 236.467 81.5963C235.528 83.1926 231.96 87.4179 220.035 89.1081C205.669 91.2677 184.73 80.1879 174.683 68.7325L169.143 76.432L170.364 86.1973H170.458Z"
fill="#101720"
/>
<path
d="M131.021 51.3617C131.021 51.3617 129.519 63.5683 134.777 69.0143C140.411 74.8359 154.402 73.5213 154.402 73.5213L152.524 86.4791H175.528L174.683 68.2631L167.078 57.3711C174.308 52.864 171.866 44.6011 167.453 42.1598C166.327 41.5964 165.106 41.3147 163.885 41.5964C157.782 42.911 158.345 53.1457 158.345 53.1457H154.402L150.552 39.0612C145.012 36.6199 138.251 33.5213 135.247 24.8828C135.247 25.1645 133.932 28.4509 133.463 32.6762C132.054 44.3194 127.266 45.0706 126.796 47.8875C126.327 50.6105 131.115 51.3617 131.115 51.3617H131.021Z"
fill="#F48282"
/>
<mask
id="mask0_912_14740"
style={{ maskType: "luminance" }}
maskUnits="userSpaceOnUse"
x="126"
y="24"
width="50"
height="63"
>
<path
d="M131.021 51.3615C131.021 51.3615 129.519 63.5681 134.777 69.0141C140.411 74.8357 154.402 73.5212 154.402 73.5212L152.524 86.4789H175.528L174.683 68.3568L167.078 57.4648C174.308 52.9578 171.866 44.6949 167.453 42.2536C166.327 41.5963 165.106 41.4085 163.885 41.6902C157.782 43.0047 158.345 53.2395 158.345 53.2395H154.402L150.552 39.155C145.012 36.7137 138.251 33.6151 135.247 24.9766C135.247 25.2583 133.932 28.5446 133.463 32.77C132.054 44.4132 127.266 45.1644 126.796 47.9813C126.327 50.7043 131.115 51.4554 131.115 51.4554L131.021 51.3615Z"
fill="white"
/>
</mask>
<g mask="url(#mask0_912_14740)">
<path
d="M154.402 53.2393C154.402 53.2393 160.975 56.3378 161.162 61.9716C161.35 70.3284 149.425 72.3942 142.007 72.6759C132.148 73.0515 145.763 87.6993 145.763 87.6993C145.763 87.6993 170.928 84.5068 171.491 84.5068C172.054 84.5068 183.979 83.0984 183.979 83.0984C183.979 83.0984 181.35 47.6993 181.162 47.4176C180.975 47.136 170.176 36.0562 170.176 36.0562L157.313 42.1594L154.402 53.1454"
fill="#2973E8"
/>
</g>
<path
d="M175.622 86.7604H152.618C152.618 86.7604 152.43 86.7604 152.43 86.6665V86.4787L154.214 73.8966C151.96 74.0844 139.942 74.7416 134.683 69.2956C130.646 65.0703 130.552 56.8074 130.646 53.4271C130.646 52.582 130.646 52.0186 130.74 51.7369C129.988 51.5491 127.829 51.0796 126.89 49.7651C126.514 49.2017 126.327 48.6383 126.42 47.9811C126.608 47.0421 127.172 46.2909 128.017 45.3519C129.613 43.474 132.054 40.6571 133.087 33.1454C133.087 33.0515 133.087 32.8637 133.087 32.7698C133.369 30.2346 134.026 27.5116 134.871 25.0703C134.871 24.8825 135.059 24.7886 135.153 24.7886C135.247 24.7886 135.341 24.7886 135.434 24.9764C138.439 33.521 145.2 36.5257 150.552 38.967C150.552 38.967 150.74 39.0609 150.74 39.1548L154.496 52.9576H157.97C157.97 51.1736 157.97 42.6289 163.791 41.4083C165.012 41.1266 166.327 41.4083 167.641 42.0656C170.176 43.474 171.866 46.5726 171.96 49.6712C171.96 52.8637 170.458 55.6806 167.547 57.5585L174.965 68.1689C174.965 68.1689 174.965 68.2627 174.965 68.3566L175.904 86.5726V86.7604C175.904 86.7604 175.81 86.7604 175.716 86.7604H175.622ZM152.899 86.197H175.341L174.496 68.3566L166.984 57.5585V57.3707C166.984 57.3707 166.984 57.1829 167.078 57.1829C169.988 55.305 171.585 52.6759 171.491 49.5773C171.491 46.6665 169.801 43.7557 167.453 42.4412C166.327 41.8778 165.2 41.5961 164.073 41.8778C158.251 43.0984 158.721 53.0515 158.721 53.1454V53.3332C158.721 53.3332 158.627 53.3332 158.533 53.3332H154.589C154.496 53.3332 154.308 53.3332 154.308 53.1454L150.458 39.2487C145.106 36.8073 138.533 33.8966 135.341 25.7275C134.589 27.9811 134.12 30.4224 133.838 32.6759C133.838 32.7698 133.838 32.9576 133.838 33.0515C132.805 40.6571 130.364 43.5679 128.674 45.4459C127.923 46.2909 127.359 46.9482 127.172 47.7933C127.172 48.2628 127.172 48.7322 127.453 49.1078C128.486 50.4224 131.209 50.8919 131.209 50.8919C131.397 50.8919 131.491 51.0796 131.491 51.1735C131.491 51.1735 131.491 51.9247 131.397 53.1454C131.209 56.4318 131.397 64.6008 135.247 68.6383C140.693 74.2721 154.496 73.0515 154.683 73.0515H154.871V73.2393L152.993 85.9153L152.899 86.197Z"
fill="#101720"
/>
<path
d="M134.402 42.7228C134.402 42.7228 136.092 39.6242 140.129 40.1876C144.167 40.751 144.73 43.3801 144.73 43.3801C144.73 43.3801 142.946 46.5726 139.284 46.1031C135.622 45.6336 134.402 42.7228 134.402 42.7228Z"
fill="#FFFFFE"
/>
<mask
id="mask1_912_14740"
style={{ maskType: "luminance" }}
maskUnits="userSpaceOnUse"
x="134"
y="40"
width="11"
height="7"
>
<path
d="M134.402 42.7228C134.402 42.7228 136.092 39.6242 140.129 40.1876C144.167 40.751 144.73 43.3801 144.73 43.3801C144.73 43.3801 142.946 46.5726 139.284 46.1031C135.622 45.6336 134.402 42.7228 134.402 42.7228Z"
fill="white"
/>
</mask>
<g mask="url(#mask1_912_14740)">
<path
d="M138.064 46.2909C139.516 46.2909 140.693 45.1138 140.693 43.6618C140.693 42.2098 139.516 41.0327 138.064 41.0327C136.612 41.0327 135.435 42.2098 135.435 43.6618C135.435 45.1138 136.612 46.2909 138.064 46.2909Z"
fill="#101720"
/>
</g>
<path
d="M139.19 60.1877C139.002 60.1877 138.908 60.0938 138.908 59.906C138.908 58.4037 139.378 57.2769 140.129 56.7136C140.88 56.1502 142.007 56.1502 143.322 56.7136C143.509 56.7136 143.509 56.9013 143.509 57.0891C143.509 57.2769 143.322 57.2769 143.134 57.2769C142.007 56.8074 141.068 56.8074 140.411 57.2769C139.754 57.7464 139.378 58.6854 139.378 59.9999C139.378 61.3145 139.284 60.2816 139.096 60.2816L139.19 60.1877Z"
fill="#101720"
/>
<path
d="M144.543 39.437C144.543 39.437 144.355 39.437 144.355 39.3431C143.604 38.3102 142.101 37.559 140.317 37.3712C138.345 37.1834 136.561 37.7468 135.435 38.7797C135.341 38.8736 135.153 38.8736 135.059 38.7797C134.965 38.6858 134.965 38.498 135.059 38.4041C136.28 37.1834 138.251 36.6201 140.411 36.8079C142.383 36.9957 143.979 37.8407 144.824 39.0614C144.824 39.1553 144.824 39.3431 144.824 39.437C144.824 39.437 144.73 39.437 144.636 39.437H144.543Z"
fill="#101720"
/>
<path
d="M140.223 40.0942C140.223 40.0942 142.101 40.1881 143.51 41.4088C144.918 42.6294 145.294 43.5684 145.763 43.7562L145.2 44.4135C145.2 44.4135 142.946 40.8454 140.223 40.1881V40.0942Z"
fill="#101720"
/>
<path
d="M135.246 25.2581C135.153 25.2581 135.059 25.2581 134.965 25.0703C134.683 24.2252 134.401 23.3801 134.214 22.3473C134.214 22.1595 134.214 22.0656 134.401 21.9717C134.589 21.9717 134.683 21.9717 134.777 22.1595C134.965 23.0984 135.246 23.9435 135.528 24.7886C135.528 24.9764 135.528 25.0703 135.34 25.1642C135.34 25.1642 135.34 25.1642 135.246 25.1642V25.2581Z"
fill="#101720"
/>
<path
d="M154.495 73.5211C162.101 72.9577 168.955 70.3286 168.955 70.3286L153.744 78.5915L154.495 73.5211Z"
fill="#101720"
/>
<path
d="M153.744 78.7794C153.744 78.7794 153.65 78.7794 153.556 78.7794C153.556 78.7794 153.462 78.5916 153.462 78.4977L154.214 73.4273C154.214 73.3334 154.307 73.2395 154.495 73.1456C162.007 72.5822 168.861 69.9531 168.955 69.9531C169.049 69.9531 169.237 69.9531 169.331 70.1409C169.331 70.2348 169.331 70.4226 169.237 70.5165L154.026 78.7794C154.026 78.7794 154.026 78.7794 153.932 78.7794H153.744ZM154.777 73.8029L154.12 78.0282L166.138 71.5494C163.415 72.3945 159.19 73.4273 154.777 73.8029Z"
fill="#101720"
/>
<path
d="M161.35 49.5773C161.162 49.5773 161.068 49.3895 161.162 49.2018C161.82 46.6665 163.416 45.8215 164.355 45.6337C165.669 45.2581 167.078 45.6337 167.923 46.5726C168.017 46.6665 168.017 46.8543 167.923 46.9482C167.829 47.0421 167.641 47.0421 167.547 46.9482C166.89 46.197 165.669 45.9154 164.543 46.1971C163.791 46.3849 162.383 47.0421 161.82 49.3895C161.82 49.4834 161.632 49.5773 161.538 49.5773H161.35Z"
fill="#101720"
/>
<path
d="M164.448 52.5823C164.448 52.5823 164.26 52.5823 164.26 52.4884C164.26 52.3945 164.26 52.2067 164.26 52.1128C166.514 50.4227 165.199 46.0095 165.199 45.9156C165.199 45.7278 165.199 45.6339 165.387 45.54C165.575 45.54 165.669 45.5401 165.763 45.7278C165.763 45.9156 167.265 50.6105 164.636 52.4884C164.636 52.4884 164.542 52.4884 164.448 52.4884V52.5823Z"
fill="#101720"
/>
<path
d="M131.022 51.3618L133.463 51.7374L130.928 53.3336L131.022 51.3618Z"
fill="#101720"
/>
<path
d="M130.928 53.6151C130.928 53.6151 130.834 53.6151 130.74 53.6151C130.74 53.6151 130.552 53.4274 130.646 53.3335L130.834 51.3616C130.834 51.3616 130.834 51.1738 130.928 51.1738C130.928 51.1738 131.022 51.1738 131.115 51.1738L133.463 51.4555C133.557 51.4555 133.651 51.5494 133.745 51.6433C133.838 51.7372 133.745 51.8311 133.651 51.925L131.115 53.6151C131.115 53.6151 131.022 53.6151 130.928 53.6151ZM131.303 51.7372V52.864L132.618 51.925L131.303 51.7372Z"
fill="#101720"
/>
<path
d="M167.547 14.2725C167.547 14.2725 168.205 26.479 173.463 31.5494C178.721 36.6199 193.557 40.1879 193.557 40.1879L190.928 32.9579L167.453 14.2725H167.547Z"
fill="#101720"
/>
<path
d="M193.65 40.3757C193.087 40.1879 178.721 36.8076 173.369 31.7372C168.111 26.5729 167.359 14.7419 167.359 14.2724C167.359 14.1785 167.359 14.0846 167.547 13.9907C167.641 13.9907 167.735 13.9907 167.829 13.9907L191.303 32.6762C191.303 32.6762 191.303 32.6762 191.303 32.7701L193.932 40.0001C193.932 40.094 193.932 40.1879 193.932 40.2818C193.932 40.2818 193.838 40.3757 193.744 40.3757H193.65ZM167.923 14.8358C168.111 17.371 169.331 27.0424 173.744 31.3616C178.345 35.7748 190.552 39.0611 193.181 39.7184L190.834 33.0518L167.923 14.7419V14.8358Z"
fill="#101720"
/>
<path
d="M165.2 6.29084C165.2 6.29084 163.979 16.2439 170.834 24.9763C177.688 33.7087 191.115 32.9575 191.115 32.9575C191.115 32.9575 204.824 15.7744 193.745 6.29084C177.594 -7.51198 165.2 6.29084 165.2 6.29084Z"
fill="#FF9C0F"
/>
<path
d="M190.27 33.2395C189.049 33.2395 186.42 33.2395 183.416 32.4883C179.566 31.6432 174.12 29.6714 170.552 25.1644C164.073 16.9015 164.73 7.32399 164.824 6.38503V6.19724C164.918 6.10334 177.594 -7.69948 193.744 6.19724C204.824 15.6808 191.209 33.0517 191.115 33.2395C191.115 33.2395 191.021 33.3334 190.927 33.3334C190.927 33.3334 190.646 33.3334 190.176 33.3334L190.27 33.2395ZM165.481 6.38503C165.388 7.41789 164.73 16.7137 171.021 24.7888C177.312 32.8639 189.519 32.6761 190.927 32.6761C192.054 31.2677 203.791 15.3052 193.557 6.57282C178.251 -6.47883 166.608 5.25827 165.481 6.47893V6.38503Z"
fill="#101720"
/>
<path
d="M194.12 27.5121C189.519 27.5121 176.937 23.7563 173.463 15.1178C173.463 14.93 173.463 14.8361 173.65 14.7422C173.838 14.7422 173.932 14.7422 174.026 14.93C177.594 23.5685 190.927 27.2304 194.683 26.9488C194.871 26.9488 194.965 27.0426 194.965 27.2304C194.965 27.4182 194.871 27.5121 194.683 27.5121C194.496 27.5121 194.308 27.5121 194.12 27.5121Z"
fill="#101720"
/>
<path
d="M327.547 78.0281C327.547 78.0281 316.843 189.577 289.895 203.005C262.946 216.526 234.777 185.915 234.777 185.915V209.953H103.228V187.136C103.228 187.136 51.7726 204.319 39.0965 176.15C26.4204 147.981 13.2749 78.0281 13.2749 78.0281L65.2937 77.8403L84.9181 138.122C84.9181 138.122 107.547 110.422 134.026 90.2347C160.411 70.0469 197.782 78.2159 214.026 93.3333C230.364 108.451 259.848 148.92 259.848 148.92L275.247 78.122H327.547V78.0281Z"
fill="#FFFFFE"
/>
<path
d="M234.777 210.235H103.228C103.04 210.235 102.946 210.141 102.946 209.953V187.512C100.693 188.169 88.862 191.831 75.9042 192.488C56.8432 193.521 44.0732 187.887 38.815 176.244C26.3267 148.357 13.0873 78.7793 12.9934 78.0281V77.8403C12.9934 77.8403 13.0873 77.7464 13.1812 77.7464L65.2 77.5586C65.2939 77.5586 65.3878 77.5586 65.4817 77.7464L85.0122 137.559C87.923 134.084 109.425 108.732 133.838 90.0468C161.069 69.2018 198.627 78.6854 214.214 93.1455C229.425 107.23 256.28 143.568 259.754 148.263L274.965 78.0281C274.965 77.9342 275.153 77.8403 275.247 77.8403H327.547C327.735 77.8403 327.829 77.9342 327.829 78.122C327.829 78.3098 327.829 78.122 327.829 78.2159C327.641 79.8121 324.918 107.136 319.097 135.587C311.022 174.929 301.256 197.746 289.989 203.38C265.106 215.868 238.909 190.704 234.965 186.76V210.047C234.965 210.235 234.871 210.329 234.684 210.329L234.777 210.235ZM103.698 210.235H234.59V185.915C234.59 185.821 234.59 185.728 234.777 185.634C234.871 185.634 234.965 185.634 235.059 185.634C235.341 185.915 263.416 215.868 289.895 202.629C315.716 189.765 326.702 84.6947 327.36 78.122H275.623L260.317 148.732C260.317 148.826 260.223 148.92 260.13 148.92C260.036 148.92 259.942 148.92 259.848 148.826C259.566 148.451 230.177 108.263 214.026 93.3332C198.533 78.967 161.256 69.6713 134.308 90.2346C108.205 110.235 85.4817 137.84 85.2939 138.122C85.2939 138.122 85.1061 138.216 85.0122 138.216C84.9183 138.216 84.8244 138.216 84.8244 138.028L65.2 77.9342L13.7446 78.122C14.7774 83.3802 27.4535 149.014 39.4723 175.868C51.8666 203.474 102.759 186.854 103.322 186.667C103.322 186.667 103.51 186.667 103.604 186.667C103.604 186.667 103.698 186.76 103.698 186.854V209.39V210.235Z"
fill="#101720"
/>
<path
d="M104.261 156.338C103.697 164.977 103.322 175.587 103.322 187.137L98.7209 188.263L104.355 156.338H104.261Z"
fill="#101720"
/>
<path
d="M98.7207 188.545C98.7207 188.545 98.6268 188.545 98.5329 188.545C98.5329 188.545 98.439 188.357 98.5329 188.263L104.073 156.432C104.073 156.338 104.073 156.244 104.261 156.15C104.355 156.15 104.448 156.15 104.542 156.15C104.542 156.15 104.636 156.338 104.542 156.432C103.885 166.949 103.603 177.277 103.603 187.136C103.603 196.995 103.603 187.418 103.416 187.418L98.8146 188.545H98.7207ZM103.697 161.221L99.0024 187.887L102.946 186.855C102.946 178.498 103.228 169.953 103.603 161.221H103.697Z"
fill="#101720"
/>
<path
d="M104.261 156.62C104.073 156.62 103.979 156.432 103.979 156.338C104.824 143.286 105.857 134.648 105.857 134.648C105.857 134.46 106.045 134.366 106.139 134.366C106.327 134.366 106.421 134.554 106.421 134.648C106.421 134.648 105.388 143.38 104.543 156.338C104.543 156.526 104.449 156.62 104.261 156.62Z"
fill="#101720"
/>
<path
d="M240.411 191.174L234.778 185.916C234.778 174.93 234.214 163.38 233.557 153.709L240.411 191.08V191.174Z"
fill="#101720"
/>
<path
d="M240.411 191.456C240.411 191.456 240.317 191.456 240.223 191.456L234.589 186.197C234.589 186.197 234.589 186.103 234.589 186.01C234.589 176.714 234.214 165.916 233.369 153.897C233.369 153.709 233.463 153.615 233.65 153.615C233.838 153.615 233.932 153.615 233.932 153.803L240.786 191.174C240.786 191.268 240.786 191.456 240.599 191.456C240.599 191.456 240.599 191.456 240.505 191.456H240.411ZM235.059 185.822L239.941 190.423L234.12 158.592C234.777 168.639 235.059 177.841 235.059 185.822Z"
fill="#101720"
/>
<path
d="M233.557 154.085C233.369 154.085 233.275 153.991 233.275 153.803C232.336 140.658 231.303 130.705 231.303 130.611C231.303 130.423 231.303 130.329 231.585 130.329C231.773 130.329 231.867 130.423 231.867 130.611C231.867 130.705 232.993 140.564 233.932 153.803C233.932 153.991 233.838 154.085 233.651 154.085H233.557Z"
fill="#101720"
/>
<path
d="M233.556 154.084C233.369 154.084 233.275 153.99 233.275 153.803C233.275 153.615 233.369 153.521 233.556 153.521C233.744 153.521 233.838 153.615 233.838 153.803C233.838 153.99 233.744 154.084 233.556 154.084Z"
fill="#101720"
/>
<path
d="M260.505 158.967C256.092 158.967 249.989 149.672 244.73 141.597C243.322 139.531 242.007 137.465 240.787 135.587C240.129 134.648 239.378 133.428 238.439 131.925C229.801 118.498 206.702 82.9112 172.054 82.9112C137.406 82.9112 169.425 82.9111 168.111 83.0989C138.909 85.3525 107.923 118.874 91.3971 136.808C86.1389 142.536 81.5379 147.418 80.505 147.418C78.0637 145.822 61.444 104.507 51.491 78.2163H65.106L84.9182 138.874L85.3877 138.31C85.5755 138.029 108.298 110.423 134.402 90.5168C144.637 82.7233 157.313 78.5919 171.209 78.5919C185.106 78.5919 204.073 84.5074 213.932 93.6154C229.989 108.545 259.472 148.733 259.754 149.108L260.317 149.953L275.81 78.498H292.618C289.237 80.0003 282.759 83.0989 281.726 86.3853C281.35 87.4182 280.505 91.6435 279.097 98.0285C274.965 117.09 266.327 156.808 262.007 158.78C261.632 158.967 261.162 159.061 260.787 159.061L260.505 158.967Z"
fill="#D5C0FC"
/>
<path
d="M13.2748 78.028L65.2935 77.8403C65.2935 77.8403 61.7255 68.3567 67.0776 53.1454C70.5518 43.2863 64.8241 39.906 61.256 41.1266C57.7818 42.3473 53.5565 48.3567 53.5565 48.3567C53.5565 48.3567 47.641 44.9764 43.6973 40.3755C39.7536 35.8684 38.9086 33.0515 36.0917 33.2393C33.2748 33.4271 31.5846 35.1172 32.3358 38.028C32.3358 38.028 30.1762 34.9294 26.2325 34.7417C22.2889 34.5539 22.3827 39.1548 22.3827 39.1548C22.3827 39.1548 16.5611 35.0233 12.7114 36.5257C8.86162 38.028 10.364 42.629 10.364 42.629C10.364 42.629 6.98369 38.028 3.41561 39.5304C-0.0585646 41.0327 -1.8426 48.075 2.94613 55.305C7.73486 62.5351 11.4907 68.9201 13.1809 78.2158L13.2748 78.028Z"
fill="#F48282"
/>
<mask
id="mask2_912_14740"
style={{ maskType: "luminance" }}
maskUnits="userSpaceOnUse"
x="0"
y="33"
width="69"
height="46"
>
<path
d="M13.2748 78.028L65.2935 77.8403C65.2935 77.8403 61.7255 68.3567 67.0776 53.1454C70.5518 43.2863 64.8241 39.906 61.256 41.1266C57.7818 42.3473 53.5565 48.3567 53.5565 48.3567C53.5565 48.3567 47.641 44.9764 43.6973 40.3755C39.7536 35.8684 38.9086 33.0515 36.0917 33.2393C33.3687 33.4271 31.5846 35.1172 32.3358 37.9341C32.3358 37.9341 30.1762 34.8356 26.2325 34.6478C22.3828 34.46 22.3827 39.0609 22.3827 39.0609C22.3827 39.0609 16.5611 34.9294 12.7114 36.4318C8.86162 37.9341 10.364 42.5351 10.364 42.5351C10.364 42.5351 6.98369 37.9341 3.41561 39.4365C-0.0585646 40.9388 -1.8426 47.9811 2.94613 55.2111C7.73486 62.4412 11.4907 68.8262 13.1809 78.1219L13.2748 78.028Z"
fill="white"
/>
</mask>
<g mask="url(#mask2_912_14740)">
<path
d="M33.5564 33.2389C36.1856 33.2389 39.5658 39.6239 42.6644 43.4737C45.763 47.3234 48.1104 49.1075 47.641 50.7976C47.1715 52.4878 45.763 53.5206 45.763 53.5206C45.763 53.5206 54.0259 50.6098 53.5564 47.0418C53.087 43.4737 59.0964 38.3094 59.0964 38.3094C59.0964 38.3094 66.2325 40.8446 65.1057 45.915C63.979 50.9854 59.2842 56.2436 59.0964 63.8493C58.9086 71.4549 59.1903 77.5582 55.7161 80.2812C52.2419 82.9103 70.364 80.7507 70.364 80.7507L73.3687 59.8117L70.7395 34.5535L37.8757 26.5723L33.3687 33.3328L33.5564 33.2389Z"
fill="#2973E8"
/>
<path
d="M24.1669 34.2717C26.3265 34.6473 26.9838 36.1496 29.519 40.1872C32.0542 44.2247 34.965 48.0745 37.0307 49.3891C39.0965 50.7036 40.411 48.6379 39.2843 47.4172C38.1575 46.1966 35.6223 43.2858 34.4016 41.3139C33.181 39.3421 31.303 35.3984 31.303 35.3984L26.6082 31.6426L24.073 34.2717H24.1669Z"
fill="#2973E8"
/>
<path
d="M12.2423 36.0556C14.3081 36.0556 16.0921 38.3091 19.3785 42.5345C22.6649 46.7598 25.3879 49.7645 26.9841 50.7974C28.6743 51.8303 32.0545 51.3608 30.1766 49.2012C28.2048 47.0415 25.7635 44.4124 24.5428 42.5345C23.3222 40.6566 21.7259 38.0274 21.7259 38.0274L18.5334 33.896L12.1484 36.1495L12.2423 36.0556Z"
fill="#2973E8"
/>
<path
d="M2.5708 39.0605C5.01212 39.6239 5.01211 39.3422 7.54732 44.1309C10.0825 48.9197 12.6177 52.2999 15.0591 53.6145C17.5004 54.9291 21.3501 54.3657 18.2515 52.1121C15.153 49.8586 13.3689 47.2295 12.5239 45.8211C11.5849 44.4126 9.23747 39.3422 9.23747 39.3422L5.10601 36.9009L2.6647 38.9666L2.5708 39.0605Z"
fill="#2973E8"
/>
<path
d="M59.754 67.5112C59.5662 68.9197 59.1906 70.0464 58.7211 70.9854C56.3737 72.8633 53.369 73.7084 50.4582 74.3657C49.8948 74.4596 50.0826 75.3986 50.7399 75.3047C52.9934 74.8352 55.247 74.2718 57.2188 73.2389C54.4958 75.7741 49.1437 76.807 37.8761 78.4032C20.693 80.8446 60.1296 82.0652 60.1296 82.0652L60.9747 70.6098L59.754 67.699V67.5112Z"
fill="#2973E8"
/>
<path
d="M48.1105 74.4595C47.5471 74.4595 47.5471 75.3984 48.1105 75.3984C48.6739 75.3984 48.6739 74.4595 48.1105 74.4595Z"
fill="#2973E8"
/>
</g>
<path
d="M13.2748 78.31H13.087C13.087 78.31 13.087 78.2161 13.087 78.1222C11.303 68.7325 7.45323 62.2537 2.94619 55.3053C0.317083 51.2678 -0.621889 46.7607 0.410975 43.1926C0.974356 41.2208 2.10112 39.8123 3.50957 39.249C6.23257 38.1222 8.86168 40.2818 10.0823 41.5964C9.89455 40.094 10.0823 37.371 12.8053 36.3382C16.1856 35.0236 20.8805 37.8405 22.2889 38.7795C22.2889 38.0283 22.6645 36.526 23.6974 35.4931C24.4485 34.8358 25.2936 34.5541 26.4204 34.5541C29.1434 34.648 31.1152 36.2443 32.0542 37.1832C32.0542 36.2443 32.242 35.4931 32.7114 34.8358C33.4626 33.803 34.6833 33.2396 36.2795 33.1457C38.3453 32.9579 39.472 34.4602 41.2561 36.8077C42.0072 37.8405 42.9462 38.9673 44.073 40.3757C47.5471 44.3194 52.5237 47.5119 53.6504 48.1692C54.4016 47.0424 58.0636 42.1598 61.3499 41.033C63.134 40.3757 65.1997 40.9391 66.7021 42.3476C68.0166 43.6621 69.8946 46.8546 67.5471 53.3335C62.195 68.2631 65.6692 77.7466 65.7631 77.8405C65.7631 77.8405 65.7631 78.0283 65.7631 78.1222C65.7631 78.2161 65.6692 78.2161 65.5753 78.2161L13.5565 78.4039L13.2748 78.31ZM5.01192 39.3429C4.54243 39.3429 4.16684 39.3429 3.69736 39.6246C2.4767 40.1879 1.44384 41.5025 0.880459 43.2865C-0.152405 46.7607 0.786561 51.08 3.32177 54.9297C7.82881 61.7842 11.7725 68.2631 13.5565 77.7466L64.918 77.5588C64.3546 75.587 62.1011 66.479 66.8898 53.0518C69.1434 46.8546 67.3593 43.8499 66.2326 42.6293C64.918 41.3147 63.134 40.8452 61.5377 41.4086C58.1575 42.6293 54.026 48.4509 54.026 48.5448C54.026 48.6386 53.7443 48.7325 53.6504 48.5448C53.6504 48.5448 47.641 45.0706 43.6974 40.5635C42.4767 39.1551 41.5377 37.9344 40.8805 36.9955C39.0964 34.648 38.1574 33.4274 36.3734 33.5213C34.965 33.6152 33.8382 34.1785 33.1809 35.0236C32.6175 35.7748 32.5237 36.8077 32.8053 37.9344C32.8053 38.0283 32.8053 38.2161 32.6175 38.2161C32.5236 38.2161 32.3359 38.2161 32.242 38.1222C32.242 38.1222 30.0823 35.1175 26.4204 34.9297C25.4814 34.9297 24.7302 35.1175 24.073 35.6809C22.8523 36.9015 22.7584 39.0612 22.7584 39.0612C22.7584 39.1551 22.7584 39.249 22.5706 39.3429C22.4767 39.3429 22.3828 39.3429 22.2889 39.3429C22.2889 39.3429 16.5612 35.3053 12.8992 36.7138C9.33117 38.1222 10.6457 42.2537 10.7396 42.4415C10.7396 42.5354 10.7396 42.7231 10.6457 42.817C10.5518 42.817 10.364 42.817 10.2701 42.817C10.2701 42.817 7.82882 39.5307 5.01192 39.5307V39.3429Z"
fill="#101720"
/>
<path
d="M38.5332 55.7746C38.4393 55.7746 38.2515 55.6807 38.2515 55.5868C38.2515 55.399 38.2515 55.3051 38.4393 55.3051C38.5332 55.3051 50.458 53.2393 53.2749 48.1689C53.2749 48.075 53.5566 47.9811 53.6505 48.075C53.7444 48.075 53.8383 48.3567 53.7444 48.4506C50.7397 53.7088 39.0027 55.7746 38.4393 55.7746H38.5332Z"
fill="#101720"
/>
<path
d="M40.2234 48.5447H40.0356C40.0356 48.5447 35.2469 43.9438 32.2422 38.1222C32.2422 38.0283 32.2422 37.8405 32.3361 37.7466C32.43 37.7466 32.6178 37.7466 32.7117 37.8405C35.7164 43.4743 40.4112 48.0752 40.4112 48.0752C40.5051 48.1691 40.5051 48.3569 40.4112 48.4508C40.4112 48.4508 40.3173 48.4508 40.2234 48.4508V48.5447Z"
fill="#101720"
/>
<path
d="M30.9276 50.2349H30.7398C30.7398 50.2349 24.6365 44.3194 22.2891 39.3429C22.2891 39.249 22.2891 39.0612 22.383 38.9673C22.4769 38.9673 22.6646 38.9673 22.7585 39.0612C25.106 43.9438 31.0215 49.7654 31.1154 49.8593C31.2093 49.9532 31.2093 50.141 31.1154 50.2349C31.1154 50.2349 31.0215 50.2349 30.9276 50.2349Z"
fill="#101720"
/>
<path
d="M19.6601 53.521C19.6601 53.521 19.5662 53.521 19.4723 53.521C19.4723 53.521 13.2751 49.1079 10.1765 42.629C10.1765 42.5351 10.1765 42.3473 10.2704 42.2534C10.3643 42.2534 10.5521 42.2534 10.646 42.3473C13.6507 48.6384 19.6601 52.9577 19.754 52.9577C19.8479 52.9577 19.9418 53.2393 19.754 53.3332L19.5662 53.4271L19.6601 53.521Z"
fill="#101720"
/>
<path
d="M327.547 78.028L275.528 77.8403C275.528 77.8403 279.096 68.3567 273.744 53.1454C270.27 43.2863 275.998 39.906 279.566 41.1266C283.04 42.3473 287.265 48.3567 287.265 48.3567C287.265 48.3567 293.181 44.9764 297.124 40.3755C301.068 35.8684 301.913 33.0515 304.73 33.2393C307.547 33.4271 309.237 35.1172 308.486 38.028C308.486 38.028 310.646 34.9294 314.589 34.7417C318.439 34.5539 318.439 39.1548 318.439 39.1548C318.439 39.1548 324.261 35.0233 328.11 36.5257C331.96 38.028 330.458 42.629 330.458 42.629C330.458 42.629 333.838 38.028 337.406 39.5304C340.88 41.0327 342.57 48.075 337.876 55.305C333.181 62.5351 329.331 68.9201 327.641 78.2158L327.547 78.028Z"
fill="#F48282"
/>
<mask
id="mask3_912_14740"
style={{ maskType: "luminance" }}
maskUnits="userSpaceOnUse"
x="272"
y="33"
width="69"
height="46"
>
<path
d="M327.547 78.028L275.528 77.8403C275.528 77.8403 279.096 68.3567 273.744 53.1454C270.27 43.2863 275.998 39.906 279.566 41.1266C283.04 42.3473 287.265 48.3567 287.265 48.3567C287.265 48.3567 293.181 44.9764 297.124 40.3755C301.068 35.8684 301.913 33.0515 304.73 33.2393C307.453 33.4271 309.237 35.1172 308.486 37.9341C308.486 37.9341 310.646 34.8356 314.589 34.6478C318.439 34.46 318.439 39.0609 318.439 39.0609C318.439 39.0609 324.261 34.9294 328.11 36.4318C331.96 37.9341 330.458 42.5351 330.458 42.5351C330.458 42.5351 333.838 37.9341 337.406 39.4365C340.88 40.9388 342.57 47.9811 337.876 55.2111C333.181 62.4412 329.331 68.8262 327.641 78.1219L327.547 78.028Z"
fill="white"
/>
</mask>
<g mask="url(#mask3_912_14740)">
<path
d="M307.172 33.2389C304.542 33.2389 301.162 39.6239 298.064 43.4737C294.965 47.3234 292.618 49.1075 293.087 50.7976C293.557 52.4878 294.965 53.5206 294.965 53.5206C294.965 53.5206 286.702 50.6098 287.172 47.0418C287.641 43.4737 281.632 38.3094 281.632 38.3094C281.632 38.3094 274.496 40.8446 275.622 45.915C276.749 50.9854 281.444 56.2436 281.632 63.8493C281.819 71.4549 281.538 77.5582 285.012 80.2812C288.486 82.9103 270.364 80.7507 270.364 80.7507L267.359 59.8117L269.989 34.5535L302.852 26.5723L307.359 33.3328L307.172 33.2389Z"
fill="#2973E8"
/>
<path
d="M316.561 34.2717C314.401 34.6473 313.744 36.1496 311.115 40.1872C308.58 44.2247 305.669 48.0745 303.603 49.3891C301.538 50.7036 300.223 48.6379 301.35 47.4172C302.477 46.1966 305.012 43.2858 306.232 41.3139C307.453 39.3421 309.331 35.3984 309.331 35.3984L314.026 31.6426L316.561 34.2717Z"
fill="#2973E8"
/>
<path
d="M328.58 36.0556C326.514 36.0556 324.73 38.3091 321.444 42.5345C318.064 46.7598 315.435 49.7645 313.838 50.7974C312.242 51.8303 308.674 51.3608 310.646 49.2012C312.618 47.0415 315.059 44.4124 316.28 42.5345C317.5 40.6566 319.097 38.0274 319.097 38.0274L322.289 33.896L328.674 36.1495L328.58 36.0556Z"
fill="#2973E8"
/>
<path
d="M338.251 39.0605C335.81 39.6239 335.81 39.3422 333.275 44.1309C330.74 48.9197 328.204 52.2999 325.763 53.6145C323.322 54.9291 319.472 54.3657 322.571 52.1121C325.669 49.8586 327.453 47.2295 328.298 45.8211C329.143 44.4126 331.585 39.3422 331.585 39.3422L335.716 36.9009L338.157 38.9666L338.251 39.0605Z"
fill="#2973E8"
/>
<path
d="M280.974 67.5112C281.162 68.9197 281.444 70.0464 281.913 70.9854C284.261 72.8633 287.266 73.7084 290.176 74.3657C290.74 74.4596 290.552 75.3986 289.895 75.3047C287.641 74.8352 285.388 74.2718 283.416 73.2389C286.139 75.7741 291.491 76.807 302.759 78.4032C319.942 80.8446 280.505 82.0652 280.505 82.0652L279.66 70.6098L280.881 67.699L280.974 67.5112Z"
fill="#2973E8"
/>
<path
d="M292.618 74.4595C293.181 74.4595 293.181 75.3984 292.618 75.3984C292.054 75.3984 292.054 74.4595 292.618 74.4595Z"
fill="#2973E8"
/>
</g>
<path
d="M327.547 78.3097L275.528 78.1219C275.528 78.1219 275.34 78.1219 275.34 78.028C275.34 77.9341 275.34 77.8402 275.34 77.7463C275.34 77.6524 278.815 68.1688 273.556 53.2392C271.209 46.6665 273.087 43.5679 274.401 42.2533C275.81 40.8449 277.97 40.2815 279.754 40.9388C282.946 42.0655 286.608 46.9482 287.453 48.0749C288.58 47.4176 293.556 44.319 297.031 40.2815C298.251 38.9669 299.096 37.7463 299.848 36.7134C301.632 34.366 302.758 32.8636 304.824 33.0514C306.42 33.1453 307.735 33.8026 308.392 34.7416C308.862 35.3988 309.049 36.2439 309.049 37.089C309.988 36.15 311.866 34.5538 314.683 34.4599C315.81 34.4599 316.749 34.7416 317.406 35.3989C318.439 36.4317 318.721 37.9341 318.815 38.6852C320.223 37.7463 324.918 34.9294 328.298 36.2439C331.115 37.3707 331.303 39.9998 331.021 41.5021C332.242 40.1876 334.871 37.9341 337.594 39.1547C339.002 39.7181 340.129 41.2205 340.693 43.0984C341.725 46.6665 340.786 51.2674 338.157 55.2111C333.65 62.0655 329.707 68.5444 328.017 78.028C328.017 78.028 328.017 78.1219 328.017 78.2158C328.017 78.2158 327.923 78.2158 327.829 78.2158L327.547 78.3097ZM275.904 77.5585L327.265 77.7463C329.049 68.2627 332.993 61.7838 337.5 54.9294C340.035 51.0796 340.974 46.6665 339.941 43.2862C339.378 41.5021 338.439 40.1876 337.125 39.6242C333.838 38.2158 330.552 42.6289 330.552 42.6289C330.552 42.7228 330.364 42.8167 330.176 42.6289C330.082 42.6289 329.988 42.4411 330.082 42.2533C330.082 42.0655 331.491 37.9341 327.923 36.5256C324.261 35.1172 318.627 39.1547 318.533 39.1547C318.533 39.1547 318.345 39.1547 318.251 39.1547C318.251 39.1547 318.063 38.9669 318.063 38.873C318.063 38.7791 318.063 36.7134 316.749 35.4927C316.186 34.9294 315.34 34.6477 314.401 34.7416C310.74 34.9294 308.58 37.9341 308.58 37.9341C308.58 38.028 308.392 38.1219 308.204 38.028C308.11 38.028 308.017 37.8402 308.017 37.7463C308.298 36.6195 308.204 35.5866 307.641 34.8355C306.984 33.9904 305.857 33.427 304.448 33.3331C302.664 33.2392 301.725 34.366 299.941 36.8073C299.19 37.7463 298.251 39.0608 297.125 40.3754C293.181 44.8824 287.265 48.3566 287.171 48.3566C287.078 48.3566 286.89 48.3566 286.796 48.3566C286.796 48.3566 282.664 42.4411 279.284 41.2205C277.688 40.6571 275.81 41.2205 274.589 42.4411C273.369 43.6618 271.678 46.6665 273.838 52.8636C278.627 66.2909 276.373 75.4928 275.81 77.3707L275.904 77.5585Z"
fill="#101720"
/>
<path
d="M302.195 55.7747C301.725 55.7747 289.894 53.6151 286.89 48.4508C286.89 48.3569 286.89 48.1691 286.984 48.0752C287.077 48.0752 287.265 48.0752 287.359 48.1691C290.176 53.1456 302.101 55.2113 302.195 55.3052C302.383 55.3052 302.477 55.493 302.383 55.5869C302.383 55.6808 302.195 55.7747 302.101 55.7747H302.195Z"
fill="#101720"
/>
<path
d="M300.599 48.5449H300.411C300.317 48.451 300.317 48.2632 300.411 48.1693C300.411 48.1693 305.106 43.5684 308.111 37.9346C308.111 37.8407 308.392 37.7468 308.486 37.8406C308.58 37.8406 308.674 38.1223 308.58 38.2162C305.576 43.9439 300.787 48.5449 300.787 48.6388C300.787 48.6388 300.693 48.6388 300.599 48.6388V48.5449Z"
fill="#101720"
/>
<path
d="M309.894 50.2347H309.707C309.613 50.1408 309.613 49.953 309.707 49.8591C309.707 49.8591 315.716 44.0375 318.063 39.061C318.063 38.9671 318.345 38.8732 318.439 38.9671C318.533 38.9671 318.627 39.2488 318.533 39.3427C316.092 44.3192 310.082 50.2347 310.082 50.2347C310.082 50.2347 309.988 50.2347 309.894 50.2347Z"
fill="#101720"
/>
<path
d="M321.162 53.521C321.162 53.521 320.975 53.521 320.975 53.4271C320.975 53.3332 320.975 53.1454 320.975 53.0515C320.975 53.0515 327.078 48.7323 330.083 42.4412C330.083 42.2534 330.364 42.2534 330.458 42.3473C330.552 42.3473 330.646 42.629 330.552 42.7229C327.453 49.2018 321.35 53.521 321.256 53.6149C321.256 53.6149 321.162 53.6149 321.069 53.6149L321.162 53.521Z"
fill="#101720"
/>
</svg>
</div>
<div className="flex flex-col items-center gap-2">
<Text variant="h4" className="text-center text-[1.375rem]">
No triggers yet
</Text>
<Text variant="large" className="text-zinc-700">
Set up automatic triggers for your agent to run tasks automatically
they&apos;ll show up here.
</Text>
</div>
</div>
);
}

View File

@@ -1,14 +0,0 @@
import { cn } from "@/lib/utils";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers";
type Props = {
children: React.ReactNode;
};
export function AnchorLinksWrap({ children }: Props) {
return (
<div className={cn(AGENT_LIBRARY_SECTION_PADDING_X, "hidden lg:block")}>
<nav className="flex gap-8 px-3 pb-1">{children}</nav>
</div>
);
}

View File

@@ -166,7 +166,7 @@ function renderMarkdown(
className="prose prose-sm dark:prose-invert max-w-none"
remarkPlugins={[
remarkGfm, // GitHub Flavored Markdown (tables, task lists, strikethrough)
[remarkMath, { singleDollarTextMath: false }], // Math support for LaTeX
remarkMath, // Math support for LaTeX
]}
rehypePlugins={[
rehypeKatex, // Render math with KaTeX

View File

@@ -15,13 +15,7 @@ export function RunDetailCard({ children, className, title }: Props) {
className,
)}
>
{title ? (
typeof title === "string" ? (
<Text variant="lead-semibold">{title}</Text>
) : (
title
)
) : null}
{title && <Text variant="lead-semibold">{title}</Text>}
{children}
</div>
);

View File

@@ -1,7 +1,6 @@
import { GraphExecution } from "@/app/api/__generated__/models/graphExecution";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import { ClockClockwiseIcon } from "@phosphor-icons/react";
import moment from "moment";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
import { RunStatusBadge } from "../SelectedRunView/components/RunStatusBadge";
@@ -21,20 +20,7 @@ export function RunDetailHeader({ agent, run, scheduleRecurrence }: Props) {
<div className="flex w-full flex-col gap-0">
<div className="flex w-full flex-col flex-wrap items-start justify-between gap-1 md:flex-row md:items-center">
<div className="flex min-w-0 flex-1 flex-col items-start gap-3">
{run?.status ? (
<RunStatusBadge status={run.status} />
) : scheduleRecurrence ? (
<div className="inline-flex items-center gap-1 rounded-md bg-yellow-50 p-1">
<ClockClockwiseIcon
size={16}
className="text-yellow-700"
weight="bold"
/>
<Text variant="small-medium" className="text-yellow-700">
Scheduled
</Text>
</div>
) : null}
{run?.status ? <RunStatusBadge status={run.status} /> : null}
<Text variant="h2" className="truncate text-ellipsis">
{agent.name}
</Text>

View File

@@ -1,11 +0,0 @@
type Props = {
children: React.ReactNode;
};
export function SelectedActionsWrap({ children }: Props) {
return (
<div className="my-0 ml-4 flex flex-row items-center gap-3 lg:mx-0 lg:my-4 lg:flex-col">
{children}
</div>
);
}

View File

@@ -13,11 +13,10 @@ import {
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { PendingReviewsList } from "@/components/organisms/PendingReviewsList/PendingReviewsList";
import { usePendingReviewsForExecution } from "@/hooks/usePendingReviews";
import { isLargeScreen, useBreakpoint } from "@/lib/hooks/useBreakpoint";
import { InfoIcon } from "@phosphor-icons/react";
import { useEffect } from "react";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
import { AgentInputsReadOnly } from "../../modals/AgentInputsReadOnly/AgentInputsReadOnly";
import { AnchorLinksWrap } from "../AnchorLinksWrap";
import { LoadingSelectedContent } from "../LoadingSelectedContent";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
@@ -25,7 +24,6 @@ import { SelectedViewLayout } from "../SelectedViewLayout";
import { RunOutputs } from "./components/RunOutputs";
import { RunSummary } from "./components/RunSummary";
import { SelectedRunActions } from "./components/SelectedRunActions/SelectedRunActions";
import { WebhookTriggerSection } from "./components/WebhookTriggerSection";
import { useSelectedRunView } from "./useSelectedRunView";
const anchorStyles =
@@ -44,11 +42,10 @@ export function SelectedRunView({
onSelectRun,
onClearSelectedRun,
}: Props) {
const { run, preset, isLoading, responseError, httpError } =
useSelectedRunView(agent.graph_id, runId);
const breakpoint = useBreakpoint();
const isLgScreenUp = isLargeScreen(breakpoint);
const { run, isLoading, responseError, httpError } = useSelectedRunView(
agent.graph_id,
runId,
);
const {
pendingReviews,
@@ -93,69 +90,51 @@ export function SelectedRunView({
<div className="flex flex-col gap-4">
<RunDetailHeader agent={agent} run={run} />
{!isLgScreenUp ? (
<SelectedRunActions
agent={agent}
run={run}
onSelectRun={onSelectRun}
onClearSelectedRun={onClearSelectedRun}
/>
) : null}
{preset &&
agent.trigger_setup_info &&
preset.webhook_id &&
preset.webhook && (
<WebhookTriggerSection
preset={preset}
triggerSetupInfo={agent.trigger_setup_info}
/>
)}
{/* Navigation Links */}
<AnchorLinksWrap>
{withSummary && (
<div className={AGENT_LIBRARY_SECTION_PADDING_X}>
<nav className="flex gap-8 px-3 pb-1">
{withSummary && (
<button
onClick={() => scrollToSection("summary")}
className={anchorStyles}
>
Summary
</button>
)}
<button
onClick={() => scrollToSection("summary")}
onClick={() => scrollToSection("output")}
className={anchorStyles}
>
Summary
Output
</button>
)}
<button
onClick={() => scrollToSection("output")}
className={anchorStyles}
>
Output
</button>
<button
onClick={() => scrollToSection("input")}
className={anchorStyles}
>
Your input
</button>
{withReviews && (
<button
onClick={() => scrollToSection("reviews")}
onClick={() => scrollToSection("input")}
className={anchorStyles}
>
Reviews ({pendingReviews.length})
Your input
</button>
)}
</AnchorLinksWrap>
{withReviews && (
<button
onClick={() => scrollToSection("reviews")}
className={anchorStyles}
>
Reviews ({pendingReviews.length})
</button>
)}
</nav>
</div>
{/* Summary Section */}
{withSummary && (
<div id="summary" className="scroll-mt-4">
<RunDetailCard
title={
<div className="flex items-center gap-2">
<Text variant="lead-semibold">Summary</Text>
<div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon
size={16}
size={8}
className="cursor-help text-neutral-500 hover:text-neutral-700"
/>
</TooltipTrigger>
@@ -198,8 +177,8 @@ export function SelectedRunView({
<RunDetailCard title="Your input">
<AgentInputsReadOnly
agent={agent}
inputs={run?.inputs}
credentialInputs={run?.credential_inputs}
inputs={(run as any)?.inputs}
credentialInputs={(run as any)?.credential_inputs}
/>
</RunDetailCard>
</div>
@@ -227,16 +206,14 @@ export function SelectedRunView({
</div>
</SelectedViewLayout>
</div>
{isLgScreenUp ? (
<div className="max-w-[3.75rem] flex-shrink-0">
<SelectedRunActions
agent={agent}
run={run}
onSelectRun={onSelectRun}
onClearSelectedRun={onClearSelectedRun}
/>
</div>
) : null}
<div className="-mt-2 max-w-[3.75rem] flex-shrink-0">
<SelectedRunActions
agent={agent}
run={run}
onSelectRun={onSelectRun}
onClearSelectedRun={onClearSelectedRun}
/>
</div>
</div>
);
}

View File

@@ -1,98 +0,0 @@
"use client";
import { GraphExecution } from "@/app/api/__generated__/models/graphExecution";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useState } from "react";
interface Props {
isOpen: boolean;
onClose: () => void;
onCreate: (name: string, description: string) => Promise<void>;
run?: GraphExecution;
}
export function CreateTemplateModal({ isOpen, onClose, onCreate }: Props) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isCreating, setIsCreating] = useState(false);
async function handleSubmit() {
if (!name.trim()) return;
setIsCreating(true);
try {
await onCreate(name.trim(), description.trim());
setName("");
setDescription("");
onClose();
} finally {
setIsCreating(false);
}
}
function handleCancel() {
setName("");
setDescription("");
onClose();
}
return (
<Dialog
controlled={{ isOpen, set: () => onClose() }}
styling={{ maxWidth: "500px" }}
>
<Dialog.Content>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<Text variant="lead" as="h2" className="!font-medium !text-black">
Create Template
</Text>
<Text variant="body" className="text-zinc-600">
Save this task as a template to reuse later with the same inputs
and credentials.
</Text>
</div>
<div className="flex w-[96%] flex-col gap-4 pl-1">
<Input
id="template-name"
label="Name"
placeholder="Enter template name"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
<Input
type="textarea"
id="template-description"
label="Description"
placeholder="Optional description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
</div>
<Dialog.Footer className="mt-6">
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={handleCancel}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={!name.trim() || isCreating}
loading={isCreating}
>
Create Template
</Button>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -7,55 +7,44 @@ import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import {
ArrowBendLeftUpIcon,
ArrowBendRightDownIcon,
CardsThreeIcon,
EyeIcon,
StopIcon,
} from "@phosphor-icons/react";
import { AgentActionsDropdown } from "../../../AgentActionsDropdown";
import { SelectedActionsWrap } from "../../../SelectedActionsWrap";
import { ShareRunButton } from "../../../ShareRunButton/ShareRunButton";
import { CreateTemplateModal } from "../CreateTemplateModal/CreateTemplateModal";
import { useSelectedRunActions } from "./useSelectedRunActions";
type Props = {
agent: LibraryAgent;
run: GraphExecution | undefined;
scheduleRecurrence?: string;
onSelectRun?: (id: string) => void;
onClearSelectedRun?: () => void;
};
export function SelectedRunActions({
agent,
run,
onSelectRun,
onClearSelectedRun,
}: Props) {
export function SelectedRunActions(props: Props) {
const {
canRunManually,
handleRunAgain,
handleStopRun,
isRunningAgain,
canStop,
isStopping,
openInBuilderHref,
handleCreateTemplate,
isCreateTemplateModalOpen,
setIsCreateTemplateModalOpen,
} = useSelectedRunActions({
agentGraphId: agent.graph_id,
run: run,
agent: agent,
onSelectRun: onSelectRun,
agentGraphId: props.agent.graph_id,
run: props.run,
onSelectRun: props.onSelectRun,
onClearSelectedRun: props.onClearSelectedRun,
});
const shareExecutionResultsEnabled = useGetFlag(Flag.SHARE_EXECUTION_RESULTS);
const isRunning = run?.status === "RUNNING";
const isRunning = props.run?.status === "RUNNING";
if (!run || !agent) return null;
if (!props.run || !props.agent) return null;
return (
<SelectedActionsWrap>
{canRunManually && !isRunning ? (
<div className="my-4 flex flex-col items-center gap-3">
{!isRunning ? (
<Button
variant="icon"
size="icon"
@@ -107,38 +96,23 @@ export function SelectedRunActions({
) : null}
{shareExecutionResultsEnabled && (
<ShareRunButton
graphId={agent.graph_id}
executionId={run.id}
isShared={run.is_shared}
shareToken={run.share_token}
graphId={props.agent.graph_id}
executionId={props.run.id}
isShared={props.run.is_shared}
shareToken={props.run.share_token}
/>
)}
<FloatingSafeModeToggle graph={agent} variant="white" fullWidth={false} />
{canRunManually && (
<>
<Button
variant="icon"
size="icon"
aria-label="Save task as template"
onClick={() => setIsCreateTemplateModalOpen(true)}
title="Create template"
>
<CardsThreeIcon weight="bold" size={18} className="text-zinc-700" />
</Button>
<CreateTemplateModal
isOpen={isCreateTemplateModalOpen}
onClose={() => setIsCreateTemplateModalOpen(false)}
onCreate={handleCreateTemplate}
run={run}
/>
</>
)}
<AgentActionsDropdown
agent={agent}
run={run}
agentGraphId={agent.graph_id}
onClearSelectedRun={onClearSelectedRun}
<FloatingSafeModeToggle
graph={props.agent}
variant="white"
fullWidth={false}
/>
</SelectedActionsWrap>
<AgentActionsDropdown
agent={props.agent}
run={props.run}
agentGraphId={props.agent.graph_id}
onClearSelectedRun={props.onClearSelectedRun}
/>
</div>
);
}

View File

@@ -5,39 +5,26 @@ import {
usePostV1ExecuteGraphAgent,
usePostV1StopGraphExecution,
} from "@/app/api/__generated__/endpoints/graphs/graphs";
import {
getGetV2ListPresetsQueryKey,
usePostV2CreateANewPreset,
} from "@/app/api/__generated__/endpoints/presets/presets";
import type { GraphExecution } from "@/app/api/__generated__/models/graphExecution";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
interface Params {
interface Args {
agentGraphId: string;
run?: GraphExecution;
agent?: LibraryAgent;
onSelectRun?: (id: string) => void;
onClearSelectedRun?: () => void;
}
export function useSelectedRunActions({
agentGraphId,
run,
agent,
onSelectRun,
}: Params) {
export function useSelectedRunActions(args: Args) {
const queryClient = useQueryClient();
const { toast } = useToast();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isCreateTemplateModalOpen, setIsCreateTemplateModalOpen] =
useState(false);
const canStop = run?.status === "RUNNING" || run?.status === "QUEUED";
const canRunManually = !agent?.trigger_setup_info;
const canStop =
args.run?.status === "RUNNING" || args.run?.status === "QUEUED";
const { mutateAsync: stopRun, isPending: isStopping } =
usePostV1StopGraphExecution();
@@ -45,22 +32,19 @@ export function useSelectedRunActions({
const { mutateAsync: executeRun, isPending: isRunningAgain } =
usePostV1ExecuteGraphAgent();
const { mutateAsync: createPreset, isPending: isCreatingTemplate } =
usePostV2CreateANewPreset();
async function handleStopRun() {
try {
await stopRun({
graphId: run?.graph_id ?? "",
graphExecId: run?.id ?? "",
graphId: args.run?.graph_id ?? "",
graphExecId: args.run?.id ?? "",
});
toast({ title: "Run stopped" });
await queryClient.invalidateQueries({
queryKey:
getGetV1ListGraphExecutionsInfiniteQueryOptions(agentGraphId)
.queryKey,
queryKey: getGetV1ListGraphExecutionsInfiniteQueryOptions(
args.agentGraphId,
).queryKey,
});
} catch (error: unknown) {
toast({
@@ -75,7 +59,7 @@ export function useSelectedRunActions({
}
async function handleRunAgain() {
if (!run) {
if (!args.run) {
toast({
title: "Run not found",
description: "Run not found",
@@ -88,11 +72,11 @@ export function useSelectedRunActions({
toast({ title: "Run started" });
const res = await executeRun({
graphId: run.graph_id,
graphVersion: run.graph_version,
graphId: args.run.graph_id,
graphVersion: args.run.graph_version,
data: {
inputs: run.inputs || {},
credentials_inputs: run.credential_inputs || {},
inputs: args.run.inputs || {},
credentials_inputs: args.run.credential_inputs || {},
source: "library",
},
});
@@ -100,12 +84,12 @@ export function useSelectedRunActions({
const newRunId = res?.status === 200 ? (res?.data?.id ?? "") : "";
await queryClient.invalidateQueries({
queryKey:
getGetV1ListGraphExecutionsInfiniteQueryOptions(agentGraphId)
.queryKey,
queryKey: getGetV1ListGraphExecutionsInfiniteQueryOptions(
args.agentGraphId,
).queryKey,
});
if (newRunId && onSelectRun) onSelectRun(newRunId);
if (newRunId && args.onSelectRun) args.onSelectRun(newRunId);
} catch (error: unknown) {
toast({
title: "Failed to start run",
@@ -122,55 +106,9 @@ export function useSelectedRunActions({
setShowDeleteDialog(open);
}
async function handleCreateTemplate(name: string, description: string) {
if (!run) {
toast({
title: "Run not found",
description: "Cannot create template from missing run",
variant: "destructive",
});
return;
}
try {
const res = await createPreset({
data: {
name,
description,
graph_execution_id: run.id,
},
});
if (res.status === 200) {
toast({
title: "Template created",
});
if (agent) {
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({
graph_id: agent.graph_id,
}),
});
}
setIsCreateTemplateModalOpen(false);
}
} catch (error: unknown) {
toast({
title: "Failed to create template",
description:
error instanceof Error
? error.message
: "An unexpected error occurred.",
variant: "destructive",
});
}
}
// Open in builder URL helper
const openInBuilderHref = run
? `/build?flowID=${run.graph_id}&flowVersion=${run.graph_version}&flowExecutionID=${run.id}`
const openInBuilderHref = args.run
? `/build?flowID=${args.run.graph_id}&flowVersion=${args.run.graph_version}&flowExecutionID=${args.run.id}`
: undefined;
return {
@@ -178,14 +116,9 @@ export function useSelectedRunActions({
showDeleteDialog,
canStop,
isStopping,
canRunManually,
isRunningAgain,
handleShowDeleteDialog,
handleStopRun,
handleRunAgain,
handleCreateTemplate,
isCreatingTemplate,
isCreateTemplateModalOpen,
setIsCreateTemplateModalOpen,
} as const;
}

View File

@@ -1,92 +0,0 @@
"use client";
import { GraphTriggerInfo } from "@/app/api/__generated__/models/graphTriggerInfo";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { CopyIcon } from "@phosphor-icons/react";
import { RunDetailCard } from "../../RunDetailCard/RunDetailCard";
interface Props {
preset: LibraryAgentPreset;
triggerSetupInfo: GraphTriggerInfo;
}
function getTriggerStatus(
preset: LibraryAgentPreset,
): "active" | "inactive" | "broken" {
if (!preset.webhook_id || !preset.webhook) return "broken";
return preset.is_active ? "active" : "inactive";
}
export function WebhookTriggerSection({ preset, triggerSetupInfo }: Props) {
const status = getTriggerStatus(preset);
const webhook = preset.webhook;
function handleCopyWebhookUrl() {
if (webhook?.url) {
navigator.clipboard.writeText(webhook.url);
}
}
return (
<RunDetailCard title="Trigger Status">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Text variant="large-medium">Status</Text>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
status === "active"
? "bg-green-100 text-green-800"
: status === "inactive"
? "bg-yellow-100 text-yellow-800"
: "bg-red-100 text-red-800"
}`}
>
{status === "active"
? "Active"
: status === "inactive"
? "Inactive"
: "Broken"}
</span>
</div>
{!preset.webhook_id ? (
<Text variant="body" className="text-red-600">
This trigger is not attached to a webhook. Use &quot;Set up
trigger&quot; to fix this.
</Text>
) : !triggerSetupInfo.credentials_input_name && webhook ? (
<div className="flex flex-col gap-2">
<Text variant="body">
This trigger is ready to be used. Use the Webhook URL below to set
up the trigger connection with the service of your choosing.
</Text>
<div className="flex flex-col gap-1">
<Text variant="small-medium">Webhook URL:</Text>
<div className="flex gap-2 rounded-md bg-gray-50 p-2">
<code className="flex-1 select-all text-sm">{webhook.url}</code>
<Button
variant="outline"
size="icon"
className="size-7 flex-none p-1"
onClick={handleCopyWebhookUrl}
title="Copy webhook URL"
>
<CopyIcon className="size-4" />
</Button>
</div>
</div>
</div>
) : (
<Text variant="body" className="text-muted-foreground">
This agent trigger is{" "}
{preset.is_active
? "ready. When a trigger is received, it will run with the provided settings."
: "disabled. It will not respond to triggers until you enable it."}
</Text>
)}
</div>
</RunDetailCard>
);
}

View File

@@ -1,11 +1,8 @@
"use client";
import { useGetV1GetExecutionDetails } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { useGetV2GetASpecificPreset } from "@/app/api/__generated__/endpoints/presets/presets";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import type { GetV1GetExecutionDetails200 } from "@/app/api/__generated__/models/getV1GetExecutionDetails200";
import type { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { okData } from "@/app/api/helpers";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
export function useSelectedRunView(graphId: string, runId: string) {
const query = useGetV1GetExecutionDetails(graphId, runId, {
@@ -40,18 +37,6 @@ export function useSelectedRunView(graphId: string, runId: string) {
? (query.data?.data as GetV1GetExecutionDetails200)
: undefined;
const presetId =
run && "preset_id" in run && run.preset_id
? (run.preset_id as string)
: undefined;
const presetQuery = useGetV2GetASpecificPreset(presetId || "", {
query: {
enabled: !!presetId,
select: (res) => okData<LibraryAgentPreset>(res),
},
});
const httpError =
status && status !== 200
? { status, statusText: `Request failed: ${status}` }
@@ -59,9 +44,8 @@ export function useSelectedRunView(graphId: string, runId: string) {
return {
run,
preset: presetQuery.data,
isLoading: query.isLoading || presetQuery.isLoading,
responseError: query.error || presetQuery.error,
isLoading: query.isLoading,
responseError: query.error,
httpError,
} as const;
}

View File

@@ -6,10 +6,9 @@ import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner
import { Text } from "@/components/atoms/Text/Text";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
import { isLargeScreen, useBreakpoint } from "@/lib/hooks/useBreakpoint";
import { formatInTimezone, getTimezoneDisplayName } from "@/lib/timezone-utils";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
import { AgentInputsReadOnly } from "../../modals/AgentInputsReadOnly/AgentInputsReadOnly";
import { AnchorLinksWrap } from "../AnchorLinksWrap";
import { LoadingSelectedContent } from "../LoadingSelectedContent";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
@@ -42,9 +41,6 @@ export function SelectedScheduleView({
},
});
const breakpoint = useBreakpoint();
const isLgScreenUp = isLargeScreen(breakpoint);
function scrollToSection(id: string) {
const element = document.getElementById(id);
if (element) {
@@ -87,42 +83,39 @@ export function SelectedScheduleView({
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout agentName={agent.name} agentId={agent.id}>
<div className="flex flex-col gap-4">
<div className="flex w-full flex-col gap-0">
<RunDetailHeader
agent={agent}
run={undefined}
scheduleRecurrence={
schedule
? `${humanizeCronExpression(schedule.cron || "")} · ${getTimezoneDisplayName(schedule.timezone || userTzRes || "UTC")}`
: undefined
}
/>
{schedule && !isLgScreenUp ? (
<div className="mt-4">
<SelectedScheduleActions
<div>
<div className="flex w-full items-center justify-between">
<div className="flex w-full flex-col gap-0">
<RunDetailHeader
agent={agent}
scheduleId={schedule.id}
onDeleted={onClearSelectedRun}
run={undefined}
scheduleRecurrence={
schedule
? `${humanizeCronExpression(schedule.cron || "")} · ${getTimezoneDisplayName(schedule.timezone || userTzRes || "UTC")}`
: undefined
}
/>
</div>
) : null}
</div>
</div>
{/* Navigation Links */}
<AnchorLinksWrap>
<button
onClick={() => scrollToSection("schedule")}
className={anchorStyles}
>
Schedule
</button>
<button
onClick={() => scrollToSection("input")}
className={anchorStyles}
>
Your input
</button>
</AnchorLinksWrap>
<div className={AGENT_LIBRARY_SECTION_PADDING_X}>
<nav className="flex gap-8 px-3 pb-1">
<button
onClick={() => scrollToSection("schedule")}
className={anchorStyles}
>
Schedule
</button>
<button
onClick={() => scrollToSection("input")}
className={anchorStyles}
>
Your input
</button>
</nav>
</div>
{/* Schedule Section */}
<div id="schedule" className="scroll-mt-4">
@@ -181,6 +174,10 @@ export function SelectedScheduleView({
<div id="input" className="scroll-mt-4">
<RunDetailCard title="Your input">
<div className="relative">
{/* {// TODO: re-enable edit inputs modal once the API supports it */}
{/* {schedule && Object.keys(schedule.input_data).length > 0 && (
<EditInputsModal agent={agent} schedule={schedule} />
)} */}
<AgentInputsReadOnly
agent={agent}
inputs={schedule?.input_data}
@@ -192,8 +189,8 @@ export function SelectedScheduleView({
</div>
</SelectedViewLayout>
</div>
{schedule && isLgScreenUp ? (
<div className="max-w-[3.75rem] flex-shrink-0">
{schedule ? (
<div className="-mt-2 max-w-[3.75rem] flex-shrink-0">
<SelectedScheduleActions
agent={agent}
scheduleId={schedule.id}

View File

@@ -3,7 +3,6 @@ import { Button } from "@/components/atoms/Button/Button";
import { EyeIcon } from "@phosphor-icons/react";
import { AgentActionsDropdown } from "../../AgentActionsDropdown";
import { useScheduleDetailHeader } from "../../RunDetailHeader/useScheduleDetailHeader";
import { SelectedActionsWrap } from "../../SelectedActionsWrap";
type Props = {
agent: LibraryAgent;
@@ -20,7 +19,7 @@ export function SelectedScheduleActions({ agent, scheduleId }: Props) {
return (
<>
<SelectedActionsWrap>
<div className="my-4 flex flex-col items-center gap-3">
{openInBuilderHref && (
<Button
variant="icon"
@@ -33,7 +32,7 @@ export function SelectedScheduleActions({ agent, scheduleId }: Props) {
</Button>
)}
<AgentActionsDropdown agent={agent} scheduleId={scheduleId} />
</SelectedActionsWrap>
</div>
</>
);
}

View File

@@ -1,65 +1,174 @@
"use client";
import type { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import React, { useCallback, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Input } from "@/components/atoms/Input/Input";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import { Text } from "@/components/atoms/Text/Text";
import { Button } from "@/components/atoms/Button/Button";
import {
getAgentCredentialsFields,
getAgentInputFields,
} from "../../modals/AgentInputsReadOnly/helpers";
import { CredentialsInput } from "../../modals/CredentialsInputs/CredentialsInputs";
import { RunAgentInputs } from "../../modals/RunAgentInputs/RunAgentInputs";
import { LoadingSelectedContent } from "../LoadingSelectedContent";
PencilIcon,
PlayIcon,
StopIcon,
TrashIcon,
} from "@phosphor-icons/react";
import {
TabsLine,
TabsLineContent,
TabsLineList,
TabsLineTrigger,
} from "@/components/molecules/TabsLine/TabsLine";
import { useToast } from "@/components/molecules/Toast/use-toast";
import {
useDeleteV2DeleteAPreset,
getGetV2ListPresetsQueryKey,
useGetV2GetASpecificPreset,
getGetV2GetASpecificPresetQueryKey,
usePatchV2UpdateAnExistingPreset,
} from "@/app/api/__generated__/endpoints/presets/presets";
import { getGetV1ListGraphExecutionsQueryKey } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { getGetV1ListExecutionSchedulesForAGraphQueryKey } from "@/app/api/__generated__/endpoints/schedules/schedules";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
import { SelectedViewLayout } from "../SelectedViewLayout";
import { SelectedTemplateActions } from "./components/SelectedTemplateActions";
import { WebhookTriggerCard } from "./components/WebhookTriggerCard";
import { useSelectedTemplateView } from "./useSelectedTemplateView";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { AgentInputsReadOnly } from "../../modals/AgentInputsReadOnly/AgentInputsReadOnly";
import { RunAgentModal } from "../../modals/RunAgentModal/RunAgentModal";
import { okData } from "@/app/api/helpers";
interface Props {
interface SelectedTemplateViewProps {
agent: LibraryAgent;
templateId: string;
onClearSelectedRun?: () => void;
onRunCreated?: (execution: GraphExecutionMeta) => void;
onSwitchToRunsTab?: () => void;
presetID: string;
onDelete?: (presetID: string) => void;
onCreateRun?: (runId: string) => void;
onCreateSchedule?: (scheduleId: string) => void;
}
export function SelectedTemplateView({
agent,
templateId,
onClearSelectedRun,
onRunCreated,
onSwitchToRunsTab,
}: Props) {
const {
template,
isLoading,
error,
name,
setName,
description,
setDescription,
inputs,
setInputValue,
credentials,
setCredentialValue,
handleSaveChanges,
handleStartTask,
isSaving,
isStarting,
} = useSelectedTemplateView({
templateId,
graphId: agent.graph_id,
onRunCreated,
presetID,
onDelete,
onCreateRun: _onCreateRun,
onCreateSchedule: _onCreateSchedule,
}: SelectedTemplateViewProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [isDeleting, setIsDeleting] = useState(false);
const templateOrTrigger = agent.trigger_setup_info ? "Trigger" : "Template";
const presetQuery = useGetV2GetASpecificPreset(presetID, {
query: {
enabled: !!agent.graph_id && !!presetID,
// select: okData,
},
});
const preset = useMemo(() => okData(presetQuery.data), [presetQuery.data]);
// Delete preset mutation
const deleteTemplateMutation = useDeleteV2DeleteAPreset({
mutation: {
onSuccess: () => {
toast({
title: `${templateOrTrigger} deleted successfully`,
variant: "default",
});
// Invalidate presets list
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({ graph_id: agent.graph_id }),
});
setIsDeleting(false);
},
onError: (error) => {
toast({
title: `Failed to delete ${templateOrTrigger.toLowerCase()}`,
description: String(error),
variant: "destructive",
});
setIsDeleting(false);
},
},
});
const agentInputFields = getAgentInputFields(agent);
const agentCredentialsFields = getAgentCredentialsFields(agent);
const inputFields = Object.entries(agentInputFields);
const credentialFields = Object.entries(agentCredentialsFields);
const doDeleteTemplate = async () => {
setIsDeleting(true);
deleteTemplateMutation.mutate({ presetId: presetID });
};
// Toggle trigger active status mutation
const toggleTriggerStatusMutation = usePatchV2UpdateAnExistingPreset({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: `Trigger ${preset?.is_active ? "disabled" : "enabled"} successfully`,
variant: "default",
});
// Invalidate preset queries to refresh data
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({ graph_id: agent.graph_id }),
});
queryClient.invalidateQueries({
queryKey: getGetV2GetASpecificPresetQueryKey(presetID),
});
}
},
onError: (error) => {
toast({
title: `Failed to ${preset?.is_active ? "disable" : "enable"} trigger`,
description: String(error),
variant: "destructive",
});
},
},
});
const doToggleTriggerStatus = () => {
if (!preset) return;
toggleTriggerStatusMutation.mutate({
presetId: presetID,
data: {
is_active: !preset.is_active,
},
});
};
const onSave = useCallback(() => {
// Invalidate preset queries to refresh data
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({ graph_id: agent.graph_id }),
});
queryClient.invalidateQueries({
queryKey: getGetV2GetASpecificPresetQueryKey(presetID),
});
}, [queryClient, agent.graph_id, presetID]);
const onCreateRun = useCallback(
(execution: GraphExecutionMeta) => {
// Invalidate runs list
queryClient.invalidateQueries({
queryKey: getGetV1ListGraphExecutionsQueryKey(agent.graph_id),
});
_onCreateRun?.(execution.id);
},
[queryClient, agent.graph_id, _onCreateRun],
);
const onCreateSchedule = useCallback(
(schedule: GraphExecutionJobInfo) => {
// Invalidate schedules list
queryClient.invalidateQueries({
queryKey: getGetV1ListExecutionSchedulesForAGraphQueryKey(
agent.graph_id,
),
});
_onCreateSchedule?.(schedule.id);
},
[queryClient, agent.graph_id, _onCreateSchedule],
);
const isLoading = presetQuery.isLoading;
const error = presetQuery.error;
if (error) {
return (
@@ -69,7 +178,7 @@ export function SelectedTemplateView({
? {
message: String(
(error as unknown as { message?: string })?.message ||
"Failed to load template",
`Failed to load ${templateOrTrigger.toLowerCase()}`,
),
}
: undefined
@@ -87,119 +196,184 @@ export function SelectedTemplateView({
);
}
if (isLoading && !template) {
return <LoadingSelectedContent agentName={agent.name} agentId={agent.id} />;
if (isLoading && !preset) {
return (
<div className="flex-1 space-y-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-32 w-full" />
</div>
);
}
if (!template) {
return null;
}
const templateOrTrigger = agent.trigger_setup_info ? "Trigger" : "Template";
const hasWebhook = !!template.webhook_id && template.webhook;
return (
<div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout agentName={agent.name} agentId={agent.id}>
<div className="flex flex-col gap-4">
<RunDetailHeader agent={agent} run={undefined} />
{hasWebhook && agent.trigger_setup_info && (
<WebhookTriggerCard
template={template}
triggerSetupInfo={agent.trigger_setup_info}
/>
)}
<RunDetailCard title={`${templateOrTrigger} Details`}>
<div className="flex flex-col gap-2">
<Input
id="template-name"
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={`Enter ${templateOrTrigger.toLowerCase()} name`}
/>
<Input
id="template-description"
label="Description"
type="textarea"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={`Enter ${templateOrTrigger.toLowerCase()} description`}
/>
</div>
</RunDetailCard>
{inputFields.length > 0 && (
<RunDetailCard title="Your Input">
<div className="flex flex-col gap-4">
{inputFields.map(([key, inputSubSchema]) => (
<div
key={key}
className="flex w-full flex-col gap-0 space-y-2"
>
<label className="flex items-center gap-1 text-sm font-medium">
{inputSubSchema.title || key}
{inputSubSchema.description && (
<InformationTooltip
description={inputSubSchema.description}
/>
)}
</label>
<RunAgentInputs
schema={inputSubSchema}
value={inputs[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
/>
</div>
))}
</div>
</RunDetailCard>
)}
{credentialFields.length > 0 && (
<RunDetailCard title="Task Credentials">
<div className="flex flex-col gap-6">
{credentialFields.map(([key, inputSubSchema]) => (
<CredentialsInput
key={key}
schema={
{ ...inputSubSchema, discriminator: undefined } as any
}
selectedCredentials={
credentials[key] ?? inputSubSchema.default
}
onSelectCredentials={(value) =>
setCredentialValue(key, value!)
}
siblingInputs={inputs}
/>
))}
</div>
</RunDetailCard>
)}
<div className="flex flex-col gap-6">
<div>
<div className="flex w-full items-center justify-between">
<div className="flex w-full flex-col gap-0">
<div className="flex flex-col gap-2">
<Text variant="h2" className="!text-2xl font-bold">
{preset?.name || "Loading..."}
</Text>
{/* <Text variant="body-medium" className="!text-zinc-500">
{templateOrTrigger} • {agent.name}
</Text> */}
</div>
</div>
</SelectedViewLayout>
</div>
{template ? (
<div className="-mt-2 max-w-[3.75rem] flex-shrink-0">
<SelectedTemplateActions
agent={agent}
templateId={template.id}
onDeleted={onClearSelectedRun}
onSaveChanges={handleSaveChanges}
onStartTask={handleStartTask}
isSaving={isSaving}
isStarting={isStarting}
onSwitchToRunsTab={onSwitchToRunsTab}
/>
{preset ? (
<div className="flex gap-2">
{!agent.has_external_trigger ? (
<RunAgentModal
triggerSlot={
<Button
variant="primary"
size="small"
leftIcon={<PlayIcon size={16} />}
>
Run {templateOrTrigger}
</Button>
}
agent={agent}
initialInputValues={preset.inputs || {}}
initialInputCredentials={preset.credentials || {}}
initialPresetName={preset.name}
initialPresetDescription={preset.description}
onRunCreated={onCreateRun}
onScheduleCreated={onCreateSchedule}
/>
) : null}
<RunAgentModal
triggerSlot={
<Button
variant="secondary"
size="small"
leftIcon={<PencilIcon size={16} />}
>
Edit
</Button>
}
agent={agent}
editMode={{
preset,
onSaved: onSave,
}}
/>
{/* Enable/Disable Trigger Button - only for triggered presets */}
{preset.webhook && (
<Button
variant={preset.is_active ? "destructive" : "primary"}
size="small"
onClick={doToggleTriggerStatus}
disabled={toggleTriggerStatusMutation.isPending}
leftIcon={
preset.is_active ? (
<StopIcon size={16} />
) : (
<PlayIcon size={16} />
)
}
>
{toggleTriggerStatusMutation.isPending
? preset.is_active
? "Disabling..."
: "Enabling..."
: preset.is_active
? "Disable Trigger"
: "Enable Trigger"}
</Button>
)}
<Button
// TODO: add confirmation modal before deleting
variant="destructive"
size="small"
onClick={() => {
doDeleteTemplate();
onDelete?.(presetID);
}}
disabled={isDeleting}
leftIcon={<TrashIcon size={16} />}
>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</div>
) : null}
</div>
) : null}
</div>
<TabsLine defaultValue="input">
<TabsLineList>
<TabsLineTrigger value="input">Your input</TabsLineTrigger>
<TabsLineTrigger value="details">
{templateOrTrigger} details
</TabsLineTrigger>
</TabsLineList>
<TabsLineContent value="input">
<RunDetailCard>
<div className="relative">
<AgentInputsReadOnly
agent={agent}
inputs={preset?.inputs}
credentialInputs={preset?.credentials}
/>
</div>
</RunDetailCard>
</TabsLineContent>
<TabsLineContent value="details">
<RunDetailCard>
{isLoading || !preset ? (
<div className="text-neutral-500">Loading</div>
) : (
<div className="relative flex flex-col gap-8">
<div className="flex flex-col gap-1.5">
<Text variant="body-medium" className="!text-black">
Name
</Text>
<p className="text-sm text-zinc-600">{preset.name}</p>
</div>
<div className="flex flex-col gap-1.5">
<Text variant="body-medium" className="!text-black">
Description
</Text>
<p className="text-sm text-zinc-600">
{preset.description || "No description provided"}
</p>
</div>
<div className="flex flex-col gap-1.5">
<Text variant="body-medium" className="!text-black">
Created
</Text>
<p className="text-sm text-zinc-600">
{new Date(preset.created_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
<div className="flex flex-col gap-1.5">
<Text variant="body-medium" className="!text-black">
Last Updated
</Text>
<p className="text-sm text-zinc-600">
{new Date(preset.updated_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</div>
)}
</RunDetailCard>
</TabsLineContent>
</TabsLine>
</div>
);
}

View File

@@ -1,174 +0,0 @@
"use client";
import {
getGetV2ListPresetsQueryKey,
useDeleteV2DeleteAPreset,
} from "@/app/api/__generated__/endpoints/presets/presets";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import type { LibraryAgentPresetResponse } from "@/app/api/__generated__/models/libraryAgentPresetResponse";
import { okData } from "@/app/api/helpers";
import { Button } from "@/components/atoms/Button/Button";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { FloppyDiskIcon, PlayIcon, TrashIcon } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { AgentActionsDropdown } from "../../AgentActionsDropdown";
interface Props {
agent: LibraryAgent;
templateId: string;
onDeleted?: () => void;
onSaveChanges?: () => void;
onStartTask?: () => void;
isSaving?: boolean;
isStarting?: boolean;
onSwitchToRunsTab?: () => void;
}
export function SelectedTemplateActions({
agent,
templateId,
onDeleted,
onSaveChanges,
onStartTask,
isSaving,
isStarting,
onSwitchToRunsTab,
}: Props) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const deleteMutation = useDeleteV2DeleteAPreset({
mutation: {
onSuccess: async () => {
toast({
title: "Template deleted",
});
const queryKey = getGetV2ListPresetsQueryKey({
graph_id: agent.graph_id,
});
queryClient.invalidateQueries({
queryKey,
});
const queryData = queryClient.getQueryData<{
data: LibraryAgentPresetResponse;
}>(queryKey);
const presets =
okData<LibraryAgentPresetResponse>(queryData)?.presets ?? [];
const templates = presets.filter(
(preset) => !preset.webhook_id || !preset.webhook,
);
setShowDeleteDialog(false);
onDeleted?.();
if (templates.length === 0 && onSwitchToRunsTab) {
onSwitchToRunsTab();
}
},
onError: (error: any) => {
toast({
title: "Failed to delete template",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
function handleDelete() {
deleteMutation.mutate({ presetId: templateId });
}
return (
<>
<div className="my-4 flex flex-col items-center gap-3">
<Button
variant="icon"
size="icon"
aria-label="Save changes"
onClick={onSaveChanges}
disabled={isSaving || isStarting || deleteMutation.isPending}
>
{isSaving ? (
<LoadingSpinner size="small" />
) : (
<FloppyDiskIcon weight="bold" size={18} className="text-zinc-700" />
)}
</Button>
{onStartTask && (
<Button
variant="icon"
size="icon"
aria-label="Start task from template"
onClick={onStartTask}
disabled={isSaving || isStarting || deleteMutation.isPending}
>
{isStarting ? (
<>
<LoadingSpinner size="small" />
</>
) : (
<>
<PlayIcon weight="bold" size={16} />
</>
)}
</Button>
)}
<Button
variant="icon"
size="icon"
aria-label="Delete template"
onClick={() => setShowDeleteDialog(true)}
disabled={isSaving || isStarting || deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<LoadingSpinner size="small" />
) : (
<TrashIcon weight="bold" size={18} />
)}
</Button>
<AgentActionsDropdown agent={agent} />
</div>
<Dialog
controlled={{
isOpen: showDeleteDialog,
set: setShowDeleteDialog,
}}
styling={{ maxWidth: "32rem" }}
title="Delete template"
>
<Dialog.Content>
<Text variant="large">
Are you sure you want to delete this template? This action cannot be
undone.
</Text>
<Dialog.Footer>
<Button
variant="secondary"
onClick={() => setShowDeleteDialog(false)}
disabled={deleteMutation.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
loading={deleteMutation.isPending}
>
Delete
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import React from "react";
import { Button } from "@/components/atoms/Button/Button";
import {
PlayIcon,
PencilIcon,
TrashIcon,
CalendarIcon,
} from "@phosphor-icons/react";
interface Props {
onEdit?: () => void;
onDelete?: () => void;
onRun?: () => void;
onCreateSchedule?: () => void;
isRunning?: boolean;
isDeleting?: boolean;
}
export function TemplateActions({
onEdit,
onDelete,
onRun,
onCreateSchedule,
isRunning = false,
isDeleting = false,
}: Props) {
return (
<div className="flex gap-2">
{onRun && (
<Button
variant="primary"
size="small"
onClick={onRun}
disabled={isRunning || isDeleting}
leftIcon={<PlayIcon size={16} />}
>
{isRunning ? "Running..." : "Run Template"}
</Button>
)}
{onEdit && (
<Button
variant="secondary"
size="small"
onClick={onEdit}
leftIcon={<PencilIcon size={16} />}
>
Edit
</Button>
)}
{onCreateSchedule && (
<Button
variant="secondary"
size="small"
onClick={onCreateSchedule}
leftIcon={<CalendarIcon size={16} />}
>
Schedule
</Button>
)}
{onDelete && (
<Button
variant="destructive"
size="small"
onClick={onDelete}
disabled={isRunning || isDeleting}
leftIcon={<TrashIcon size={16} />}
>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
)}
</div>
);
}

View File

@@ -1,92 +0,0 @@
"use client";
import { GraphTriggerInfo } from "@/app/api/__generated__/models/graphTriggerInfo";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { CopyIcon } from "@phosphor-icons/react";
import { RunDetailCard } from "../../RunDetailCard/RunDetailCard";
interface Props {
template: LibraryAgentPreset;
triggerSetupInfo: GraphTriggerInfo;
}
function getTriggerStatus(
template: LibraryAgentPreset,
): "active" | "inactive" | "broken" {
if (!template.webhook_id || !template.webhook) return "broken";
return template.is_active ? "active" : "inactive";
}
export function WebhookTriggerCard({ template, triggerSetupInfo }: Props) {
const status = getTriggerStatus(template);
const webhook = template.webhook;
function handleCopyWebhookUrl() {
if (webhook?.url) {
navigator.clipboard.writeText(webhook.url);
}
}
return (
<RunDetailCard title="Trigger Status">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Text variant="large-medium">Status</Text>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
status === "active"
? "bg-green-100 text-green-800"
: status === "inactive"
? "bg-yellow-100 text-yellow-800"
: "bg-red-100 text-red-800"
}`}
>
{status === "active"
? "Active"
: status === "inactive"
? "Inactive"
: "Broken"}
</span>
</div>
{!template.webhook_id ? (
<Text variant="body" className="text-red-600">
This trigger is not attached to a webhook. Use &quot;Set up
trigger&quot; to fix this.
</Text>
) : !triggerSetupInfo.credentials_input_name && webhook ? (
<div className="flex flex-col gap-2">
<Text variant="body">
This trigger is ready to be used. Use the Webhook URL below to set
up the trigger connection with the service of your choosing.
</Text>
<div className="flex flex-col gap-1">
<Text variant="body-medium">Webhook URL:</Text>
<div className="flex gap-2 rounded-md bg-gray-50 p-2">
<code className="flex-1 select-all text-sm">{webhook.url}</code>
<Button
variant="outline"
size="icon"
className="size-7 flex-none p-1"
onClick={handleCopyWebhookUrl}
title="Copy webhook URL"
>
<CopyIcon className="size-4" />
</Button>
</div>
</div>
</div>
) : (
<Text variant="body" className="text-muted-foreground">
This agent trigger is{" "}
{template.is_active
? "ready. When a trigger is received, it will run with the provided settings."
: "disabled. It will not respond to triggers until you enable it."}
</Text>
)}
</div>
</RunDetailCard>
);
}

View File

@@ -1,199 +0,0 @@
"use client";
import { getGetV1ListGraphExecutionsInfiniteQueryOptions } from "@/app/api/__generated__/endpoints/graphs/graphs";
import {
getGetV2GetASpecificPresetQueryKey,
getGetV2ListPresetsQueryKey,
useGetV2GetASpecificPreset,
usePatchV2UpdateAnExistingPreset,
usePostV2ExecuteAPreset,
} from "@/app/api/__generated__/endpoints/presets/presets";
import type { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import type { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import type { LibraryAgentPresetUpdatable } from "@/app/api/__generated__/models/libraryAgentPresetUpdatable";
import { okData } from "@/app/api/helpers";
import { useToast } from "@/components/molecules/Toast/use-toast";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
type Args = {
templateId: string;
graphId: string;
onRunCreated?: (execution: GraphExecutionMeta) => void;
};
export function useSelectedTemplateView({
templateId,
graphId,
onRunCreated,
}: Args) {
const { toast } = useToast();
const queryClient = useQueryClient();
const query = useGetV2GetASpecificPreset(templateId, {
query: {
enabled: !!templateId,
select: (res) => okData<LibraryAgentPreset>(res),
},
});
const [name, setName] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [inputs, setInputs] = useState<Record<string, any>>({});
const [credentials, setCredentials] = useState<
Record<string, CredentialsMetaInput>
>({});
useEffect(() => {
if (query.data) {
setName(query.data.name || "");
setDescription(query.data.description || "");
setInputs(query.data.inputs || {});
setCredentials(query.data.credentials || {});
}
}, [query.data]);
const updateMutation = usePatchV2UpdateAnExistingPreset({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Template updated",
});
queryClient.invalidateQueries({
queryKey: getGetV2GetASpecificPresetQueryKey(templateId),
});
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({ graph_id: graphId }),
});
}
},
onError: (error: any) => {
toast({
title: "Failed to update template",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
const executeMutation = usePostV2ExecuteAPreset({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
const execution = okData<GraphExecutionMeta>(response);
if (execution) {
toast({
title: "Task started",
});
queryClient.invalidateQueries({
queryKey:
getGetV1ListGraphExecutionsInfiniteQueryOptions(graphId)
.queryKey,
});
onRunCreated?.(execution);
}
}
},
onError: (error: any) => {
toast({
title: "Failed to start task",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
function handleSaveChanges() {
if (!query.data) return;
const updateData: LibraryAgentPresetUpdatable = {};
if (name !== (query.data.name || "")) {
updateData.name = name;
}
if (description !== (query.data.description || "")) {
updateData.description = description;
}
const inputsChanged =
JSON.stringify(inputs) !== JSON.stringify(query.data.inputs || {});
const credentialsChanged =
JSON.stringify(credentials) !==
JSON.stringify(query.data.credentials || {});
if (inputsChanged || credentialsChanged) {
updateData.inputs = inputs;
updateData.credentials = credentials;
}
updateMutation.mutate({
presetId: templateId,
data: updateData,
});
}
function handleStartTask() {
if (!query.data) return;
const inputsChanged =
JSON.stringify(inputs) !== JSON.stringify(query.data.inputs || {});
const credentialsChanged =
JSON.stringify(credentials) !==
JSON.stringify(query.data.credentials || {});
// Use changed unpersisted inputs if applicable
executeMutation.mutate({
presetId: templateId,
data: {
inputs: inputsChanged ? inputs : undefined,
credential_inputs: credentialsChanged ? credentials : undefined,
},
});
}
function setInputValue(key: string, value: any) {
setInputs((prev) => ({ ...prev, [key]: value }));
}
function setCredentialValue(key: string, value: CredentialsMetaInput) {
setCredentials((prev) => ({ ...prev, [key]: value }));
}
const httpError =
query.isSuccess && !query.data
? { status: 404, statusText: "Not found" }
: undefined;
useEffect(() => {
if (updateMutation.isSuccess && query.data) {
setName(query.data.name || "");
setDescription(query.data.description || "");
setInputs(query.data.inputs || {});
setCredentials(query.data.credentials || {});
}
}, [updateMutation.isSuccess, query.data]);
return {
template: query.data,
isLoading: query.isLoading,
error: query.error || httpError,
name,
setName,
description,
setDescription,
inputs,
setInputValue,
credentials,
setCredentialValue,
handleSaveChanges,
handleStartTask,
isSaving: updateMutation.isPending,
isStarting: executeMutation.isPending,
} as const;
}

View File

@@ -1,196 +0,0 @@
"use client";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Input } from "@/components/atoms/Input/Input";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import {
getAgentCredentialsFields,
getAgentInputFields,
} from "../../modals/AgentInputsReadOnly/helpers";
import { CredentialsInput } from "../../modals/CredentialsInputs/CredentialsInputs";
import { RunAgentInputs } from "../../modals/RunAgentInputs/RunAgentInputs";
import { LoadingSelectedContent } from "../LoadingSelectedContent";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
import { WebhookTriggerCard } from "../SelectedTemplateView/components/WebhookTriggerCard";
import { SelectedViewLayout } from "../SelectedViewLayout";
import { SelectedTriggerActions } from "./components/SelectedTriggerActions";
import { useSelectedTriggerView } from "./useSelectedTriggerView";
interface Props {
agent: LibraryAgent;
triggerId: string;
onClearSelectedRun?: () => void;
onSwitchToRunsTab?: () => void;
}
export function SelectedTriggerView({
agent,
triggerId,
onClearSelectedRun,
onSwitchToRunsTab,
}: Props) {
const {
trigger,
isLoading,
error,
name,
setName,
description,
setDescription,
inputs,
setInputValue,
credentials,
setCredentialValue,
handleSaveChanges,
isSaving,
} = useSelectedTriggerView({
triggerId,
graphId: agent.graph_id,
});
const agentInputFields = getAgentInputFields(agent);
const agentCredentialsFields = getAgentCredentialsFields(agent);
const inputFields = Object.entries(agentInputFields);
const credentialFields = Object.entries(agentCredentialsFields);
if (error) {
return (
<ErrorCard
responseError={
error
? {
message: String(
(error as unknown as { message?: string })?.message ||
"Failed to load trigger",
),
}
: undefined
}
httpError={
(error as any)?.status
? {
status: (error as any).status,
statusText: (error as any).statusText,
}
: undefined
}
context="trigger"
/>
);
}
if (isLoading && !trigger) {
return <LoadingSelectedContent agentName={agent.name} agentId={agent.id} />;
}
if (!trigger) {
return null;
}
const hasWebhook = !!trigger.webhook_id && trigger.webhook;
return (
<div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout agentName={agent.name} agentId={agent.id}>
<div className="flex flex-col gap-4">
<RunDetailHeader agent={agent} run={undefined} />
<RunDetailCard title="Trigger Details">
<div className="flex flex-col gap-2">
<Input
id="trigger-name"
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter trigger name"
/>
<Input
id="trigger-description"
label="Description"
type="textarea"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter trigger description"
/>
</div>
</RunDetailCard>
{hasWebhook && agent.trigger_setup_info && (
<WebhookTriggerCard
template={trigger}
triggerSetupInfo={agent.trigger_setup_info}
/>
)}
{inputFields.length > 0 && (
<RunDetailCard title="Your Input">
<div className="flex flex-col gap-4">
{inputFields.map(([key, inputSubSchema]) => (
<div
key={key}
className="flex w-full flex-col gap-0 space-y-2"
>
<label className="flex items-center gap-1 text-sm font-medium">
{inputSubSchema.title || key}
{inputSubSchema.description && (
<InformationTooltip
description={inputSubSchema.description}
/>
)}
</label>
<RunAgentInputs
schema={inputSubSchema}
value={inputs[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
/>
</div>
))}
</div>
</RunDetailCard>
)}
{credentialFields.length > 0 && (
<RunDetailCard title="Task Credentials">
<div className="flex flex-col gap-6">
{credentialFields.map(([key, inputSubSchema]) => (
<CredentialsInput
key={key}
schema={
{ ...inputSubSchema, discriminator: undefined } as any
}
selectedCredentials={
credentials[key] ?? inputSubSchema.default
}
onSelectCredentials={(value) =>
setCredentialValue(key, value!)
}
siblingInputs={inputs}
/>
))}
</div>
</RunDetailCard>
)}
</div>
</SelectedViewLayout>
</div>
{trigger ? (
<div className="-mt-2 max-w-[3.75rem] flex-shrink-0">
<SelectedTriggerActions
agent={agent}
triggerId={trigger.id}
onDeleted={onClearSelectedRun}
onSaveChanges={handleSaveChanges}
isSaving={isSaving}
onSwitchToRunsTab={onSwitchToRunsTab}
/>
</div>
) : null}
</div>
);
}

View File

@@ -1,151 +0,0 @@
"use client";
import {
getGetV2ListPresetsQueryKey,
useDeleteV2DeleteAPreset,
} from "@/app/api/__generated__/endpoints/presets/presets";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import type { LibraryAgentPresetResponse } from "@/app/api/__generated__/models/libraryAgentPresetResponse";
import { okData } from "@/app/api/helpers";
import { Button } from "@/components/atoms/Button/Button";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { FloppyDiskIcon, TrashIcon } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { AgentActionsDropdown } from "../../AgentActionsDropdown";
interface Props {
agent: LibraryAgent;
triggerId: string;
onDeleted?: () => void;
onSaveChanges?: () => void;
isSaving?: boolean;
onSwitchToRunsTab?: () => void;
}
export function SelectedTriggerActions({
agent,
triggerId,
onDeleted,
onSaveChanges,
isSaving,
onSwitchToRunsTab,
}: Props) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const deleteMutation = useDeleteV2DeleteAPreset({
mutation: {
onSuccess: async () => {
toast({
title: "Trigger deleted",
});
const queryKey = getGetV2ListPresetsQueryKey({
graph_id: agent.graph_id,
});
queryClient.invalidateQueries({
queryKey,
});
const queryData = queryClient.getQueryData<{
data: LibraryAgentPresetResponse;
}>(queryKey);
const presets =
okData<LibraryAgentPresetResponse>(queryData)?.presets ?? [];
const triggers = presets.filter(
(preset) => preset.webhook_id && preset.webhook,
);
setShowDeleteDialog(false);
onDeleted?.();
if (triggers.length === 0 && onSwitchToRunsTab) {
onSwitchToRunsTab();
}
},
onError: (error: any) => {
toast({
title: "Failed to delete trigger",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
function handleDelete() {
deleteMutation.mutate({ presetId: triggerId });
}
return (
<>
<div className="my-4 flex flex-col items-center gap-3">
<Button
variant="icon"
size="icon"
aria-label="Save changes"
onClick={onSaveChanges}
disabled={isSaving || deleteMutation.isPending}
>
{isSaving ? (
<LoadingSpinner size="small" />
) : (
<FloppyDiskIcon weight="bold" size={18} className="text-zinc-700" />
)}
</Button>
<Button
variant="icon"
size="icon"
aria-label="Delete trigger"
onClick={() => setShowDeleteDialog(true)}
disabled={isSaving || deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<LoadingSpinner size="small" />
) : (
<TrashIcon weight="bold" size={18} />
)}
</Button>
<AgentActionsDropdown agent={agent} />
</div>
<Dialog
controlled={{
isOpen: showDeleteDialog,
set: setShowDeleteDialog,
}}
styling={{ maxWidth: "32rem" }}
title="Delete trigger"
>
<Dialog.Content>
<Text variant="large">
Are you sure you want to delete this trigger? This action cannot be
undone.
</Text>
<Dialog.Footer>
<Button
variant="secondary"
onClick={() => setShowDeleteDialog(false)}
disabled={deleteMutation.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
loading={deleteMutation.isPending}
>
Delete
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
</>
);
}

View File

@@ -1,141 +0,0 @@
"use client";
import {
getGetV2GetASpecificPresetQueryKey,
getGetV2ListPresetsQueryKey,
useGetV2GetASpecificPreset,
usePatchV2UpdateAnExistingPreset,
} from "@/app/api/__generated__/endpoints/presets/presets";
import type { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import type { LibraryAgentPresetUpdatable } from "@/app/api/__generated__/models/libraryAgentPresetUpdatable";
import { okData } from "@/app/api/helpers";
import { useToast } from "@/components/molecules/Toast/use-toast";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
type Args = {
triggerId: string;
graphId: string;
};
export function useSelectedTriggerView({ triggerId, graphId }: Args) {
const { toast } = useToast();
const queryClient = useQueryClient();
const query = useGetV2GetASpecificPreset(triggerId, {
query: {
enabled: !!triggerId,
select: (res) => okData<LibraryAgentPreset>(res),
},
});
const [name, setName] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [inputs, setInputs] = useState<Record<string, any>>({});
const [credentials, setCredentials] = useState<
Record<string, CredentialsMetaInput>
>({});
useEffect(() => {
if (query.data) {
setName(query.data.name || "");
setDescription(query.data.description || "");
setInputs(query.data.inputs || {});
setCredentials(query.data.credentials || {});
}
}, [query.data]);
const updateMutation = usePatchV2UpdateAnExistingPreset({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Trigger updated",
});
queryClient.invalidateQueries({
queryKey: getGetV2GetASpecificPresetQueryKey(triggerId),
});
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({ graph_id: graphId }),
});
}
},
onError: (error: any) => {
toast({
title: "Failed to update trigger",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
function handleSaveChanges() {
if (!query.data) return;
const updateData: LibraryAgentPresetUpdatable = {};
if (name !== (query.data.name || "")) {
updateData.name = name;
}
if (description !== (query.data.description || "")) {
updateData.description = description;
}
const inputsChanged =
JSON.stringify(inputs) !== JSON.stringify(query.data.inputs || {});
const credentialsChanged =
JSON.stringify(credentials) !==
JSON.stringify(query.data.credentials || {});
if (inputsChanged || credentialsChanged) {
updateData.inputs = inputs;
updateData.credentials = credentials;
}
updateMutation.mutate({
presetId: triggerId,
data: updateData,
});
}
function setInputValue(key: string, value: any) {
setInputs((prev) => ({ ...prev, [key]: value }));
}
function setCredentialValue(key: string, value: CredentialsMetaInput) {
setCredentials((prev) => ({ ...prev, [key]: value }));
}
const httpError =
query.isSuccess && !query.data
? { status: 404, statusText: "Not found" }
: undefined;
useEffect(() => {
if (updateMutation.isSuccess && query.data) {
setName(query.data.name || "");
setDescription(query.data.description || "");
setInputs(query.data.inputs || {});
setCredentials(query.data.credentials || {});
}
}, [updateMutation.isSuccess, query.data]);
return {
trigger: query.data,
isLoading: query.isLoading,
error: query.error || httpError,
name,
setName,
description,
setDescription,
inputs,
setInputValue,
credentials,
setCredentialValue,
handleSaveChanges,
isSaving: updateMutation.isPending,
} as const;
}

View File

@@ -12,7 +12,7 @@ export function SelectedViewLayout(props: Props) {
return (
<SectionWrap className="relative mb-3 flex min-h-0 flex-1 flex-col">
<div
className={`${AGENT_LIBRARY_SECTION_PADDING_X} flex-shrink-0 border-b border-zinc-100 pb-0 lg:pb-4`}
className={`${AGENT_LIBRARY_SECTION_PADDING_X} flex-shrink-0 border-b border-zinc-100 pb-4`}
>
<Breadcrumbs
items={[

View File

@@ -14,26 +14,21 @@ import {
} from "@/components/molecules/TabsLine/TabsLine";
import { cn } from "@/lib/utils";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
import { RunListItem } from "./components/RunListItem";
import { ScheduleListItem } from "./components/ScheduleListItem";
import { TaskListItem } from "./components/TaskListItem";
import { TemplateListItem } from "./components/TemplateListItem";
import { TriggerListItem } from "./components/TriggerListItem";
import { useSidebarRunsList } from "./useSidebarRunsList";
interface Props {
agent: LibraryAgent;
selectedRunId?: string;
onSelectRun: (
id: string,
tab?: "runs" | "scheduled" | "templates" | "triggers",
) => void;
onSelectRun: (id: string, tab?: "runs" | "scheduled") => void;
onClearSelectedRun?: () => void;
onTabChange?: (tab: "runs" | "scheduled" | "templates" | "triggers") => void;
onTabChange?: (tab: "runs" | "scheduled" | "templates") => void;
onCountsChange?: (info: {
runsCount: number;
schedulesCount: number;
templatesCount: number;
triggersCount: number;
presetsCount: number;
loading?: boolean;
}) => void;
}
@@ -49,17 +44,18 @@ export function SidebarRunsList({
const {
runs,
schedules,
templates,
triggers,
presets,
runsCount,
schedulesCount,
templatesCount,
triggersCount,
presetsCount,
error,
loading,
fetchMoreRuns,
hasMoreRuns,
fetchMoreRuns,
isFetchingMoreRuns,
hasMorePresets,
fetchMorePresets,
isFetchingMorePresets,
tabValue,
} = useSidebarRunsList({
graphId: agent.graph_id,
@@ -90,7 +86,7 @@ export function SidebarRunsList({
<TabsLine
value={tabValue}
onValueChange={(v) => {
const value = v as "runs" | "scheduled" | "templates" | "triggers";
const value = v as "runs" | "scheduled" | "templates";
onTabChange?.(value);
if (value === "runs") {
if (runs && runs.length) {
@@ -105,39 +101,27 @@ export function SidebarRunsList({
onClearSelectedRun?.();
}
} else if (value === "templates") {
onClearSelectedRun?.();
} else if (value === "triggers") {
onClearSelectedRun?.();
if (presets && presets.length) {
onSelectRun(`preset:${presets[0].id}`);
} else {
onClearSelectedRun?.();
}
}
}}
className="flex min-h-0 flex-col overflow-hidden"
>
<div className="relative overflow-hidden">
<div className="pointer-events-none absolute right-0 top-0 z-10 h-[46px] w-12 bg-gradient-to-l from-[#FAFAFA] to-transparent" />
<div className="scrollbar-hide overflow-x-auto">
<TabsLineList
className={cn(AGENT_LIBRARY_SECTION_PADDING_X, "min-w-max")}
>
<TabsLineTrigger value="runs">
Tasks <span className="ml-3 inline-block">{runsCount}</span>
</TabsLineTrigger>
<TabsLineTrigger value="scheduled">
Scheduled{" "}
<span className="ml-3 inline-block">{schedulesCount}</span>
</TabsLineTrigger>
{triggersCount > 0 && (
<TabsLineTrigger value="triggers">
Triggers{" "}
<span className="ml-3 inline-block">{triggersCount}</span>
</TabsLineTrigger>
)}
<TabsLineTrigger value="templates">
Templates{" "}
<span className="ml-3 inline-block">{templatesCount}</span>
</TabsLineTrigger>
</TabsLineList>
</div>
</div>
<TabsLineList className={AGENT_LIBRARY_SECTION_PADDING_X}>
<TabsLineTrigger value="runs">
Tasks <span className="ml-3 inline-block">{runsCount}</span>
</TabsLineTrigger>
<TabsLineTrigger value="scheduled">
Scheduled <span className="ml-3 inline-block">{schedulesCount}</span>
</TabsLineTrigger>
<TabsLineTrigger value="templates">
{agent.trigger_setup_info ? "Triggers" : "Templates"}{" "}
<span className="ml-3 inline-block">{presetsCount}</span>
</TabsLineTrigger>
</TabsLineList>
<>
<TabsLineContent
@@ -156,10 +140,9 @@ export function SidebarRunsList({
itemWrapperClassName="w-auto lg:w-full"
renderItem={(run) => (
<div className="w-[15rem] lg:w-full">
<TaskListItem
<RunListItem
run={run}
title={agent.name}
agent={agent}
selected={selectedRunId === run.id}
onClick={() => onSelectRun && onSelectRun(run.id, "runs")}
/>
@@ -180,7 +163,6 @@ export function SidebarRunsList({
<div className="w-[15rem] lg:w-full" key={s.id}>
<ScheduleListItem
schedule={s}
agent={agent}
selected={selectedRunId === s.id}
onClick={() => onSelectRun(s.id, "scheduled")}
/>
@@ -195,63 +177,32 @@ export function SidebarRunsList({
)}
</div>
</TabsLineContent>
{triggersCount > 0 && (
<TabsLineContent
value="triggers"
className={cn(
"mt-0 flex min-h-0 flex-1 flex-col",
AGENT_LIBRARY_SECTION_PADDING_X,
)}
>
<div className="flex h-full flex-nowrap items-center justify-start gap-4 overflow-x-scroll px-1 pb-4 pt-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300 lg:flex-col lg:gap-3 lg:overflow-y-auto lg:overflow-x-hidden">
{triggers.length > 0 ? (
triggers.map((trigger) => (
<div className="w-[15rem] lg:w-full" key={trigger.id}>
<TriggerListItem
trigger={trigger}
agent={agent}
selected={selectedRunId === trigger.id}
onClick={() => onSelectRun(trigger.id, "triggers")}
/>
</div>
))
) : (
<div className="flex min-h-[50vh] flex-col items-center justify-center">
<Text variant="large" className="text-zinc-700">
No triggers set up
</Text>
</div>
)}
</div>
</TabsLineContent>
)}
<TabsLineContent
value="templates"
className={cn(
"mt-0 flex min-h-0 flex-1 flex-col",
"flex min-h-0 flex-1 flex-col",
AGENT_LIBRARY_SECTION_PADDING_X,
)}
>
<div className="flex h-full flex-nowrap items-center justify-start gap-4 overflow-x-scroll px-1 pb-4 pt-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300 lg:flex-col lg:gap-3 lg:overflow-y-auto lg:overflow-x-hidden">
{templates.length > 0 ? (
templates.map((template) => (
<div className="w-[15rem] lg:w-full" key={template.id}>
<TemplateListItem
template={template}
agent={agent}
selected={selectedRunId === template.id}
onClick={() => onSelectRun(template.id, "templates")}
/>
</div>
))
) : (
<div className="flex min-h-[50vh] flex-col items-center justify-center">
<Text variant="large" className="text-zinc-700">
No templates saved
</Text>
<InfiniteList
items={presets}
hasMore={!!hasMorePresets}
isFetchingMore={isFetchingMorePresets}
onEndReached={fetchMorePresets}
className="flex max-h-[76vh] flex-nowrap items-center justify-start gap-4 overflow-x-scroll px-1 pb-4 pt-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300 lg:flex-col lg:gap-3 lg:overflow-y-auto lg:overflow-x-hidden"
itemWrapperClassName="w-auto lg:w-full"
renderItem={(preset) => (
<div className="w-[15rem] lg:w-full">
<TemplateListItem
preset={preset}
selected={selectedRunId === `preset:${preset.id}`}
onClick={() =>
onSelectRun && onSelectRun(`preset:${preset.id}`)
}
/>
</div>
)}
</div>
/>
</TabsLineContent>
</>
</TabsLine>

View File

@@ -2,7 +2,6 @@
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import {
CheckCircleIcon,
ClockIcon,
@@ -13,9 +12,8 @@ import {
} from "@phosphor-icons/react";
import moment from "moment";
import React from "react";
import { IconWrapper } from "./IconWrapper";
import { SidebarItemCard } from "./SidebarItemCard";
import { TaskActionsDropdown } from "./TaskActionsDropdown";
import { IconWrapper } from "./RunIconWrapper";
import { RunSidebarCard } from "./RunSidebarCard";
const statusIconMap: Record<AgentExecutionStatus, React.ReactNode> = {
INCOMPLETE: (
@@ -55,33 +53,26 @@ const statusIconMap: Record<AgentExecutionStatus, React.ReactNode> = {
),
};
interface Props {
interface RunListItemProps {
run: GraphExecutionMeta;
title: string;
agent: LibraryAgent;
selected?: boolean;
onClick?: () => void;
onDeleted?: () => void;
}
export function TaskListItem({
export function RunListItem({
run,
title,
agent,
selected,
onClick,
onDeleted,
}: Props) {
}: RunListItemProps) {
return (
<SidebarItemCard
<RunSidebarCard
icon={statusIconMap[run.status]}
title={title}
description={moment(run.started_at).fromNow()}
onClick={onClick}
selected={selected}
actions={
<TaskActionsDropdown agent={agent} run={run} onDeleted={onDeleted} />
}
/>
);
}

View File

@@ -4,27 +4,27 @@ import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
import React from "react";
interface Props {
interface RunListItemProps {
title: string;
description?: string;
icon?: React.ReactNode;
statusBadge?: React.ReactNode;
selected?: boolean;
onClick?: () => void;
actions?: React.ReactNode;
}
export function SidebarItemCard({
export function RunSidebarCard({
title,
description,
icon,
statusBadge,
selected,
onClick,
actions,
}: Props) {
}: RunListItemProps) {
return (
<div
<button
className={cn(
"w-full cursor-pointer rounded-large border border-zinc-200 bg-white p-3 text-left ring-1 ring-transparent transition-all duration-150 hover:scale-[1.01] hover:bg-slate-50/50",
"w-full rounded-large border border-zinc-200 bg-white p-3 text-left ring-1 ring-transparent transition-all duration-150 hover:scale-[1.01] hover:bg-slate-50/50",
selected ? "border-slate-800 ring-slate-800" : undefined,
)}
onClick={onClick}
@@ -32,20 +32,20 @@ export function SidebarItemCard({
<div className="flex min-w-0 items-center justify-start gap-3">
{icon}
<div className="flex min-w-0 flex-1 flex-col items-start justify-between gap-0">
<Text
variant="body-medium"
className="block w-full truncate text-ellipsis"
>
{title}
</Text>
<div className="flex w-full items-center justify-between">
<Text
variant="body-medium"
className="block truncate text-ellipsis"
>
{title}
</Text>
{statusBadge}
</div>
<Text variant="body" className="leading-tight !text-zinc-500">
{description}
</Text>
</div>
{actions ? (
<div onClick={(e) => e.stopPropagation()}>{actions}</div>
) : null}
</div>
</div>
</button>
);
}

View File

@@ -1,123 +0,0 @@
"use client";
import {
getGetV1ListExecutionSchedulesForAGraphQueryOptions,
useDeleteV1DeleteExecutionSchedule,
} from "@/app/api/__generated__/endpoints/schedules/schedules";
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { DotsThreeVertical } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
interface Props {
agent: LibraryAgent;
schedule: GraphExecutionJobInfo;
onDeleted?: () => void;
}
export function ScheduleActionsDropdown({ agent, schedule, onDeleted }: Props) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const { mutateAsync: deleteSchedule, isPending: isDeleting } =
useDeleteV1DeleteExecutionSchedule();
async function handleDelete() {
try {
await deleteSchedule({ scheduleId: schedule.id });
toast({ title: "Schedule deleted" });
queryClient.invalidateQueries({
queryKey: getGetV1ListExecutionSchedulesForAGraphQueryOptions(
agent.graph_id,
).queryKey,
});
setShowDeleteDialog(false);
onDeleted?.();
} catch (error: unknown) {
toast({
title: "Failed to delete schedule",
description:
error instanceof Error
? error.message
: "An unexpected error occurred.",
variant: "destructive",
});
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="ml-auto shrink-0 rounded p-1 hover:bg-gray-100"
onClick={(e) => e.stopPropagation()}
aria-label="More actions"
>
<DotsThreeVertical className="h-5 w-5 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowDeleteDialog(true);
}}
className="flex items-center gap-2"
>
Delete schedule
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog
controlled={{
isOpen: showDeleteDialog,
set: setShowDeleteDialog,
}}
styling={{ maxWidth: "32rem" }}
title="Delete schedule"
>
<Dialog.Content>
<div>
<Text variant="large">
Are you sure you want to delete this schedule? This action cannot
be undone.
</Text>
<Dialog.Footer>
<Button
variant="secondary"
disabled={isDeleting}
onClick={() => setShowDeleteDialog(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
loading={isDeleting}
>
Delete Schedule
</Button>
</Dialog.Footer>
</div>
</Dialog.Content>
</Dialog>
</>
);
}

View File

@@ -1,50 +1,38 @@
"use client";
import React from "react";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { ClockClockwiseIcon } from "@phosphor-icons/react";
import moment from "moment";
import { IconWrapper } from "./IconWrapper";
import { ScheduleActionsDropdown } from "./ScheduleActionsDropdown";
import { SidebarItemCard } from "./SidebarItemCard";
import { RunSidebarCard } from "./RunSidebarCard";
import { IconWrapper } from "./RunIconWrapper";
import { ClockClockwiseIcon } from "@phosphor-icons/react";
interface Props {
interface ScheduleListItemProps {
schedule: GraphExecutionJobInfo;
agent: LibraryAgent;
selected?: boolean;
onClick?: () => void;
onDeleted?: () => void;
}
export function ScheduleListItem({
schedule,
agent,
selected,
onClick,
onDeleted,
}: Props) {
}: ScheduleListItemProps) {
return (
<SidebarItemCard
<RunSidebarCard
title={schedule.name}
description={moment(schedule.next_run_time).fromNow()}
onClick={onClick}
selected={selected}
icon={
<IconWrapper className="border-slate-50 bg-yellow-50">
<IconWrapper className="border-slate-50 bg-slate-50">
<ClockClockwiseIcon
size={16}
className="text-yellow-700"
className="text-slate-700"
weight="bold"
/>
</IconWrapper>
}
actions={
<ScheduleActionsDropdown
agent={agent}
schedule={schedule}
onDeleted={onDeleted}
/>
}
/>
);
}

View File

@@ -1,185 +0,0 @@
"use client";
import {
getGetV1ListGraphExecutionsInfiniteQueryOptions,
useDeleteV1DeleteGraphExecution,
} from "@/app/api/__generated__/endpoints/graphs/graphs";
import {
getGetV2ListPresetsQueryKey,
usePostV2CreateANewPreset,
} from "@/app/api/__generated__/endpoints/presets/presets";
import type { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { DotsThreeVertical } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { CreateTemplateModal } from "../../../selected-views/SelectedRunView/components/CreateTemplateModal/CreateTemplateModal";
interface Props {
agent: LibraryAgent;
run: GraphExecutionMeta;
onDeleted?: () => void;
}
export function TaskActionsDropdown({ agent, run, onDeleted }: Props) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isCreateTemplateModalOpen, setIsCreateTemplateModalOpen] =
useState(false);
const { mutateAsync: deleteRun, isPending: isDeletingRun } =
useDeleteV1DeleteGraphExecution();
const { mutateAsync: createPreset } = usePostV2CreateANewPreset();
async function handleDeleteRun() {
try {
await deleteRun({ graphExecId: run.id });
toast({ title: "Task deleted" });
await queryClient.refetchQueries({
queryKey: getGetV1ListGraphExecutionsInfiniteQueryOptions(
agent.graph_id,
).queryKey,
});
setShowDeleteDialog(false);
onDeleted?.();
} catch (error: unknown) {
toast({
title: "Failed to delete task",
description:
error instanceof Error
? error.message
: "An unexpected error occurred.",
variant: "destructive",
});
}
}
async function handleCreateTemplate(name: string, description: string) {
try {
const res = await createPreset({
data: {
name,
description,
graph_execution_id: run.id,
},
});
if (res.status === 200) {
toast({
title: "Template created",
});
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({
graph_id: agent.graph_id,
}),
});
setIsCreateTemplateModalOpen(false);
}
} catch (error: unknown) {
toast({
title: "Failed to create template",
description:
error instanceof Error
? error.message
: "An unexpected error occurred.",
variant: "destructive",
});
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="ml-auto shrink-0 rounded p-1 hover:bg-gray-100"
onClick={(e) => e.stopPropagation()}
aria-label="More actions"
>
<DotsThreeVertical className="h-5 w-5 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setIsCreateTemplateModalOpen(true);
}}
className="flex items-center gap-2"
>
Save as template
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowDeleteDialog(true);
}}
className="flex items-center gap-2"
>
Delete task
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog
controlled={{
isOpen: showDeleteDialog,
set: setShowDeleteDialog,
}}
styling={{ maxWidth: "32rem" }}
title="Delete task"
>
<Dialog.Content>
<div>
<Text variant="large">
Are you sure you want to delete this task? This action cannot be
undone.
</Text>
<Dialog.Footer>
<Button
variant="secondary"
disabled={isDeletingRun}
onClick={() => setShowDeleteDialog(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteRun}
loading={isDeletingRun}
>
Delete Task
</Button>
</Dialog.Footer>
</div>
</Dialog.Content>
</Dialog>
<CreateTemplateModal
isOpen={isCreateTemplateModalOpen}
onClose={() => setIsCreateTemplateModalOpen(false)}
onCreate={handleCreateTemplate}
run={run as any}
/>
</>
);
}

View File

@@ -1,125 +0,0 @@
"use client";
import {
getGetV2ListPresetsQueryKey,
useDeleteV2DeleteAPreset,
} from "@/app/api/__generated__/endpoints/presets/presets";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import type { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { DotsThreeVertical } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
interface Props {
agent: LibraryAgent;
template: LibraryAgentPreset;
onDeleted?: () => void;
}
export function TemplateActionsDropdown({ agent, template, onDeleted }: Props) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const { mutateAsync: deletePreset, isPending: isDeleting } =
useDeleteV2DeleteAPreset();
async function handleDelete() {
try {
await deletePreset({ presetId: template.id });
toast({
title: "Template deleted",
});
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({
graph_id: agent.graph_id,
}),
});
setShowDeleteDialog(false);
onDeleted?.();
} catch (error: unknown) {
toast({
title: "Failed to delete template",
description:
error instanceof Error
? error.message
: "An unexpected error occurred.",
variant: "destructive",
});
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="ml-auto shrink-0 rounded p-1 hover:bg-gray-100"
onClick={(e) => e.stopPropagation()}
aria-label="More actions"
>
<DotsThreeVertical className="h-5 w-5 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowDeleteDialog(true);
}}
className="flex items-center gap-2"
>
Delete template
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog
controlled={{
isOpen: showDeleteDialog,
set: setShowDeleteDialog,
}}
styling={{ maxWidth: "32rem" }}
title="Delete template"
>
<Dialog.Content>
<div>
<Text variant="large">
Are you sure you want to delete this template? This action cannot
be undone.
</Text>
<Dialog.Footer>
<Button
variant="secondary"
disabled={isDeleting}
onClick={() => setShowDeleteDialog(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
loading={isDeleting}
>
Delete Template
</Button>
</Dialog.Footer>
</div>
</Dialog.Content>
</Dialog>
</>
);
}

View File

@@ -1,45 +1,68 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import React from "react";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { FileTextIcon } from "@phosphor-icons/react";
import moment from "moment";
import { IconWrapper } from "./IconWrapper";
import { SidebarItemCard } from "./SidebarItemCard";
import { TemplateActionsDropdown } from "./TemplateActionsDropdown";
import { RunSidebarCard } from "./RunSidebarCard";
import { IconWrapper } from "./RunIconWrapper";
import { LinkIcon, PushPinIcon } from "@phosphor-icons/react";
interface Props {
template: LibraryAgentPreset;
agent: LibraryAgent;
interface TemplateListItemProps {
preset: LibraryAgentPreset;
selected?: boolean;
onClick?: () => void;
onDeleted?: () => void;
}
export function TemplateListItem({
template,
agent,
preset,
selected,
onClick,
onDeleted,
}: Props) {
}: TemplateListItemProps) {
const isTrigger = !!preset.webhook;
const isActive = preset.is_active ?? false;
return (
<SidebarItemCard
icon={
<IconWrapper className="border-blue-50 bg-blue-50">
<FileTextIcon size={16} className="text-zinc-700" weight="bold" />
</IconWrapper>
}
title={template.name}
description={moment(template.updated_at).fromNow()}
<RunSidebarCard
title={preset.name}
description={preset.description || "No description"}
onClick={onClick}
selected={selected}
actions={
<TemplateActionsDropdown
agent={agent}
template={template}
onDeleted={onDeleted}
/>
icon={
<IconWrapper
className={
isTrigger
? isActive
? "border-green-50 bg-green-50"
: "border-gray-50 bg-gray-50"
: "border-blue-50 bg-blue-50"
}
>
{isTrigger ? (
<LinkIcon
size={16}
className={isActive ? "text-green-700" : "text-gray-700"}
weight="bold"
/>
) : (
<PushPinIcon size={16} className="text-blue-700" weight="bold" />
)}
</IconWrapper>
}
statusBadge={
isTrigger ? (
<span
className={`rounded-full px-2 py-0.5 text-xs ${
isActive
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-600"
}`}
>
{isActive ? "Active" : "Inactive"}
</span>
) : (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-800">
Template
</span>
)
}
/>
);

Some files were not shown because too many files have changed in this diff Show More