feat(platform/builder): Builder credentials support + UX improvements (#10323)

- Resolves #10313
- Resolves #10333

Before:


https://github.com/user-attachments/assets/a105b2b0-a90b-4bc6-89da-bef3f5a5fa1f
- No credentials input
- Stuttery experience when panning or zooming the viewport

After:


https://github.com/user-attachments/assets/f58d7864-055f-4e1c-a221-57154467c3aa
- Pretty much the same UX as in the Library, with fully-fledged
credentials input support
- Much smoother when moving around the canvas

### Changes 🏗️

Frontend:
- Add credentials input support to Run UX in Builder
  - Pass run inputs instead of storing them on the input nodes
- Re-implement `RunnerInputUI` using `AgentRunDetailsView`; rename to
`RunnerInputDialog`
    - Make `AgentRunDraftView` more flexible
    - Remove `RunnerInputList`, `RunnerInputBlock`
- Make moving around in the Builder *smooooth* by reducing unnecessary
re-renders
  - Clean up and partially re-write bead management logic
- Replace `request*` fire-and-forget methods in `useAgentGraph` with
direct action async callbacks
- Clean up run input UI components
  - Simplify `RunnerUIWrapper`
- Add `isEmpty` utility function in `@/lib/utils` (expanding on
`_.isEmpty`)
- Fix default value handling in `TypeBasedInput` (**Note:** after all
the changes I've made I'm not sure this is still necessary)
- Improve & clean up Builder test implementations

Backend + API:
- Fix front-end `Node`, `GraphMeta`, and `Block` types
- Small refactor of `Graph` to match naming of some `LibraryAgent`
attributes
- Fix typing of `list_graphs`,
`get_graph_meta_by_store_listing_version_id` endpoints
  - Add `GraphMeta` model and `GraphModel.meta()` shortcut
- Move `POST /library/agents/{library_agent_id}/setup-trigger` to `POST
/library/presets/setup-trigger`

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - Test the new functionality in the Builder:
    - [x] Running an agent with (credentials) inputs from the builder
      - [x] Beads behave correctly
    - [x] Running an agent without any inputs from the builder
    - [x] Scheduling an agent from the builder
    - [x] Adding and searching blocks in the block menu
- [x] Test that all existing `AgentRunDraftView` functionality in the
Library still works the same
    - [x] Run an agent
    - [x] Schedule an agent
    - [x] View past runs
- [x] Run an agent with inputs, then edit the agent's inputs and view
the agent in the Library (should be fine)
This commit is contained in:
Reinier van der Leer
2025-07-11 16:46:06 +01:00
committed by GitHub
parent 309114a727
commit 36f5f24333
35 changed files with 1325 additions and 1641 deletions

View File

@@ -13,7 +13,7 @@ from prisma.types import (
AgentNodeLinkCreateInput,
StoreListingVersionWhereInput,
)
from pydantic import JsonValue, create_model
from pydantic import Field, JsonValue, create_model
from pydantic.fields import computed_field
from backend.blocks.agent import AgentExecutorBlock
@@ -188,6 +188,23 @@ class BaseGraph(BaseDbModel):
)
)
@computed_field
@property
def has_external_trigger(self) -> bool:
return self.webhook_input_node is not None
@property
def webhook_input_node(self) -> Node | None:
return next(
(
node
for node in self.nodes
if node.block.block_type
in (BlockType.WEBHOOK, BlockType.WEBHOOK_MANUAL)
),
None,
)
@staticmethod
def _generate_schema(
*props: tuple[type[AgentInputBlock.Input] | type[AgentOutputBlock.Input], dict],
@@ -325,11 +342,6 @@ class GraphModel(Graph):
user_id: str
nodes: list[NodeModel] = [] # type: ignore
@computed_field
@property
def has_webhook_trigger(self) -> bool:
return self.webhook_input_node is not None
@property
def starting_nodes(self) -> list[NodeModel]:
outbound_nodes = {link.sink_id for link in self.links}
@@ -342,17 +354,12 @@ class GraphModel(Graph):
if node.id not in outbound_nodes or node.id in input_nodes
]
@property
def webhook_input_node(self) -> NodeModel | None:
return next(
(
node
for node in self.nodes
if node.block.block_type
in (BlockType.WEBHOOK, BlockType.WEBHOOK_MANUAL)
),
None,
)
def meta(self) -> "GraphMeta":
"""
Returns a GraphMeta object with metadata about the graph.
This is used to return metadata about the graph without exposing nodes and links.
"""
return GraphMeta.from_graph(self)
def reassign_ids(self, user_id: str, reassign_graph_id: bool = False):
"""
@@ -611,6 +618,18 @@ class GraphModel(Graph):
)
class GraphMeta(Graph):
user_id: str
# Easy work-around to prevent exposing nodes and links in the API response
nodes: list[NodeModel] = Field(default=[], exclude=True) # type: ignore
links: list[Link] = Field(default=[], exclude=True)
@staticmethod
def from_graph(graph: GraphModel) -> "GraphMeta":
return GraphMeta(**graph.model_dump())
# --------------------- CRUD functions --------------------- #
@@ -639,10 +658,10 @@ async def set_node_webhook(node_id: str, webhook_id: str | None) -> NodeModel:
return NodeModel.from_db(node)
async def get_graphs(
async def list_graphs(
user_id: str,
filter_by: Literal["active"] | None = "active",
) -> list[GraphModel]:
) -> list[GraphMeta]:
"""
Retrieves graph metadata objects.
Default behaviour is to get all currently active graphs.
@@ -652,7 +671,7 @@ async def get_graphs(
user_id: The ID of the user that owns the graph.
Returns:
list[GraphModel]: A list of objects representing the retrieved graphs.
list[GraphMeta]: A list of objects representing the retrieved graphs.
"""
where_clause: AgentGraphWhereInput = {"userId": user_id}
@@ -666,13 +685,13 @@ async def get_graphs(
include=AGENT_GRAPH_INCLUDE,
)
graph_models = []
graph_models: list[GraphMeta] = []
for graph in graphs:
try:
graph_model = GraphModel.from_db(graph)
# Trigger serialization to validate that the graph is well formed.
graph_model.model_dump()
graph_models.append(graph_model)
graph_meta = GraphModel.from_db(graph).meta()
# Trigger serialization to validate that the graph is well formed
graph_meta.model_dump()
graph_models.append(graph_meta)
except Exception as e:
logger.error(f"Error processing graph {graph.id}: {e}")
continue

View File

@@ -448,10 +448,10 @@ class DeleteGraphResponse(TypedDict):
tags=["graphs"],
dependencies=[Depends(auth_middleware)],
)
async def get_graphs(
async def list_graphs(
user_id: Annotated[str, Depends(get_user_id)],
) -> Sequence[graph_db.GraphModel]:
return await graph_db.get_graphs(filter_by="active", user_id=user_id)
) -> Sequence[graph_db.GraphMeta]:
return await graph_db.list_graphs(filter_by="active", user_id=user_id)
@v1_router.get(

View File

@@ -270,7 +270,7 @@ def test_get_graphs(
)
mocker.patch(
"backend.server.routers.v1.graph_db.get_graphs",
"backend.server.routers.v1.graph_db.list_graphs",
return_value=[mock_graph],
)

View File

@@ -187,7 +187,7 @@ async def get_library_agent(id: str, user_id: str) -> library_model.LibraryAgent
async def get_library_agent_by_store_version_id(
store_listing_version_id: str,
user_id: str,
):
) -> library_model.LibraryAgent | None:
"""
Get the library agent metadata for a given store listing version ID and user ID.
"""
@@ -202,7 +202,7 @@ async def get_library_agent_by_store_version_id(
)
if not store_listing_version:
logger.warning(f"Store listing version not found: {store_listing_version_id}")
raise store_exceptions.AgentNotFoundError(
raise NotFoundError(
f"Store listing version {store_listing_version_id} not found or invalid"
)
@@ -214,12 +214,9 @@ async def get_library_agent_by_store_version_id(
"agentGraphVersion": store_listing_version.agentGraphVersion,
"isDeleted": False,
},
include={"AgentGraph": True},
include=library_agent_include(user_id),
)
if agent:
return library_model.LibraryAgent.from_db(agent)
else:
return None
return library_model.LibraryAgent.from_db(agent) if agent else None
async def get_library_agent_by_graph_id(

View File

@@ -129,7 +129,7 @@ class LibraryAgent(pydantic.BaseModel):
credentials_input_schema=(
graph.credentials_input_schema if sub_graphs is not None else None
),
has_external_trigger=graph.has_webhook_trigger,
has_external_trigger=graph.has_external_trigger,
trigger_setup_info=(
LibraryAgentTriggerInfo(
provider=trigger_block.webhook_config.provider,
@@ -262,6 +262,19 @@ class LibraryAgentPresetUpdatable(pydantic.BaseModel):
is_active: Optional[bool] = None
class TriggeredPresetSetupRequest(pydantic.BaseModel):
name: str
description: str = ""
graph_id: str
graph_version: int
trigger_config: dict[str, Any]
agent_credentials: dict[str, CredentialsMetaInput] = pydantic.Field(
default_factory=dict
)
class LibraryAgentPreset(LibraryAgentPresetCreatable):
"""Represents a preset configuration for a library agent."""

View File

@@ -1,18 +1,13 @@
import logging
from typing import Any, Optional
from typing import Optional
import autogpt_libs.auth as autogpt_auth_lib
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, status
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from fastapi.responses import Response
from pydantic import BaseModel, Field
import backend.server.v2.library.db as library_db
import backend.server.v2.library.model as library_model
import backend.server.v2.store.exceptions as store_exceptions
from backend.data.graph import get_graph
from backend.data.model import CredentialsMetaInput
from backend.executor.utils import make_node_credentials_input_map
from backend.integrations.webhooks.utils import setup_webhook_for_block
from backend.util.exceptions import NotFoundError
logger = logging.getLogger(__name__)
@@ -113,12 +108,11 @@ async def get_library_agent_by_graph_id(
"/marketplace/{store_listing_version_id}",
summary="Get Agent By Store ID",
tags=["store, library"],
response_model=library_model.LibraryAgent | None,
)
async def get_library_agent_by_store_listing_version_id(
store_listing_version_id: str,
user_id: str = Depends(autogpt_auth_lib.depends.get_user_id),
):
) -> library_model.LibraryAgent | None:
"""
Get Library Agent from Store Listing Version ID.
"""
@@ -295,81 +289,3 @@ async def fork_library_agent(
library_agent_id=library_agent_id,
user_id=user_id,
)
class TriggeredPresetSetupParams(BaseModel):
name: str
description: str = ""
trigger_config: dict[str, Any]
agent_credentials: dict[str, CredentialsMetaInput] = Field(default_factory=dict)
@router.post("/{library_agent_id}/setup-trigger")
async def setup_trigger(
library_agent_id: str = Path(..., description="ID of the library agent"),
params: TriggeredPresetSetupParams = Body(),
user_id: str = Depends(autogpt_auth_lib.depends.get_user_id),
) -> library_model.LibraryAgentPreset:
"""
Sets up a webhook-triggered `LibraryAgentPreset` for a `LibraryAgent`.
Returns the correspondingly created `LibraryAgentPreset` with `webhook_id` set.
"""
library_agent = await library_db.get_library_agent(
id=library_agent_id, user_id=user_id
)
if not library_agent:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Library agent #{library_agent_id} not found",
)
graph = await get_graph(
library_agent.graph_id, version=library_agent.graph_version, user_id=user_id
)
if not graph:
raise HTTPException(
status.HTTP_410_GONE,
f"Graph #{library_agent.graph_id} not accessible (anymore)",
)
if not (trigger_node := graph.webhook_input_node):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Graph #{library_agent.graph_id} does not have a webhook node",
)
trigger_config_with_credentials = {
**params.trigger_config,
**(
make_node_credentials_input_map(graph, params.agent_credentials).get(
trigger_node.id
)
or {}
),
}
new_webhook, feedback = await setup_webhook_for_block(
user_id=user_id,
trigger_block=trigger_node.block,
trigger_config=trigger_config_with_credentials,
)
if not new_webhook:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Could not set up webhook: {feedback}",
)
new_preset = await library_db.create_preset(
user_id=user_id,
preset=library_model.LibraryAgentPresetCreatable(
graph_id=library_agent.graph_id,
graph_version=library_agent.graph_version,
name=params.name,
description=params.description,
inputs=trigger_config_with_credentials,
credentials=params.agent_credentials,
webhook_id=new_webhook.id,
is_active=True,
),
)
return new_preset

View File

@@ -138,6 +138,66 @@ async def create_preset(
)
@router.post("/presets/setup-trigger")
async def setup_trigger(
params: models.TriggeredPresetSetupRequest = Body(),
user_id: str = Depends(autogpt_auth_lib.depends.get_user_id),
) -> models.LibraryAgentPreset:
"""
Sets up a webhook-triggered `LibraryAgentPreset` for a `LibraryAgent`.
Returns the correspondingly created `LibraryAgentPreset` with `webhook_id` set.
"""
graph = await get_graph(
params.graph_id, version=params.graph_version, user_id=user_id
)
if not graph:
raise HTTPException(
status.HTTP_410_GONE,
f"Graph #{params.graph_id} not accessible (anymore)",
)
if not (trigger_node := graph.webhook_input_node):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Graph #{params.graph_id} does not have a webhook node",
)
trigger_config_with_credentials = {
**params.trigger_config,
**(
make_node_credentials_input_map(graph, params.agent_credentials).get(
trigger_node.id
)
or {}
),
}
new_webhook, feedback = await setup_webhook_for_block(
user_id=user_id,
trigger_block=trigger_node.block,
trigger_config=trigger_config_with_credentials,
)
if not new_webhook:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Could not set up webhook: {feedback}",
)
new_preset = await db.create_preset(
user_id=user_id,
preset=models.LibraryAgentPresetCreatable(
graph_id=params.graph_id,
graph_version=params.graph_version,
name=params.name,
description=params.description,
inputs=trigger_config_with_credentials,
credentials=params.agent_credentials,
webhook_id=new_webhook.id,
is_active=True,
),
)
return new_preset
@router.patch(
"/presets/{preset_id}",
summary="Update an existing preset",

View File

@@ -7,10 +7,15 @@ import prisma.errors
import prisma.models
import prisma.types
import backend.data.graph
import backend.server.v2.store.exceptions
import backend.server.v2.store.model
from backend.data.graph import GraphModel, get_sub_graphs
from backend.data.graph import (
GraphMeta,
GraphModel,
get_graph,
get_graph_as_admin,
get_sub_graphs,
)
from backend.data.includes import AGENT_GRAPH_INCLUDE
logger = logging.getLogger(__name__)
@@ -193,9 +198,7 @@ async def get_store_agent_details(
) from e
async def get_available_graph(
store_listing_version_id: str,
):
async def get_available_graph(store_listing_version_id: str) -> GraphMeta:
try:
# Get avaialble, non-deleted store listing version
store_listing_version = (
@@ -215,18 +218,7 @@ async def get_available_graph(
detail=f"Store listing version {store_listing_version_id} not found",
)
graph = GraphModel.from_db(store_listing_version.AgentGraph)
# We return graph meta, without nodes, they cannot be just removed
# because then input_schema would be empty
return {
"id": graph.id,
"version": graph.version,
"is_active": graph.is_active,
"name": graph.name,
"description": graph.description,
"input_schema": graph.input_schema,
"output_schema": graph.output_schema,
}
return GraphModel.from_db(store_listing_version.AgentGraph).meta()
except Exception as e:
logger.error(f"Error getting agent: {e}")
@@ -1024,7 +1016,7 @@ async def get_agent(
if not store_listing_version:
raise ValueError(f"Store listing version {store_listing_version_id} not found")
graph = await backend.data.graph.get_graph(
graph = await get_graph(
user_id=user_id,
graph_id=store_listing_version.agentGraphId,
version=store_listing_version.agentGraphVersion,
@@ -1383,7 +1375,7 @@ async def get_agent_as_admin(
if not store_listing_version:
raise ValueError(f"Store listing version {store_listing_version_id} not found")
graph = await backend.data.graph.get_graph_as_admin(
graph = await get_graph_as_admin(
user_id=user_id,
graph_id=store_listing_version.agentGraphId,
version=store_listing_version.agentGraphVersion,

View File

@@ -7,7 +7,7 @@
"description": "A test graph",
"forked_from_id": null,
"forked_from_version": null,
"has_webhook_trigger": false,
"has_external_trigger": false,
"id": "graph-123",
"input_schema": {
"properties": {},

View File

@@ -8,7 +8,7 @@
"description": "A test graph",
"forked_from_id": null,
"forked_from_version": null,
"has_webhook_trigger": false,
"has_external_trigger": false,
"id": "graph-123",
"input_schema": {
"properties": {},
@@ -16,9 +16,7 @@
"type": "object"
},
"is_active": true,
"links": [],
"name": "Test Graph",
"nodes": [],
"output_schema": {
"properties": {},
"required": [],

View File

@@ -46,13 +46,13 @@ export default function Page() {
setStoreAgent(storeAgent);
});
api
.getAgentMetaByStoreListingVersionId(state?.selectedStoreListingVersionId)
.getGraphMetaByStoreListingVersionID(state.selectedStoreListingVersionId)
.then((agent) => {
setAgent(agent);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const update: { [key: string]: any } = {};
// Set default values from schema
Object.entries(agent.input_schema?.properties || {}).forEach(
Object.entries(agent.input_schema.properties).forEach(
([key, value]) => {
// Skip if already set
if (state.agentInput && state.agentInput[key]) {
@@ -224,7 +224,7 @@ export default function Page() {
<CardTitle className="font-poppins text-lg">Input</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{Object.entries(agent?.input_schema?.properties || {}).map(
{Object.entries(agent?.input_schema.properties || {}).map(
([key, inputSubSchema]) => (
<div key={key} className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">

View File

@@ -512,7 +512,8 @@ export default function AgentRunsPage(): React.ReactElement {
) : selectedView.type == "run" ? (
/* Draft new runs / Create new presets */
<AgentRunDraftView
agent={agent}
graph={graph}
triggerSetupInfo={agent.trigger_setup_info}
onRun={selectRun}
onCreateSchedule={onCreateSchedule}
onCreatePreset={onCreatePreset}
@@ -521,7 +522,8 @@ export default function AgentRunsPage(): React.ReactElement {
) : selectedView.type == "preset" ? (
/* Edit & update presets */
<AgentRunDraftView
agent={agent}
graph={graph}
triggerSetupInfo={agent.trigger_setup_info}
agentPreset={
agentPresets.find((preset) => preset.id == selectedView.id)!
}

View File

@@ -1,4 +1,10 @@
import React, { useCallback, useContext, useEffect, useState } from "react";
import React, {
useCallback,
useContext,
useEffect,
useState,
useRef,
} from "react";
import {
BaseEdge,
EdgeLabelRenderer,
@@ -18,8 +24,8 @@ export type CustomEdgeData = {
edgeColor: string;
sourcePos?: XYPosition;
isStatic?: boolean;
beadUp?: number;
beadDown?: number;
beadUp: number;
beadDown: number;
beadData?: Map<string, NodeExecutionResult["status"]>;
};
@@ -46,6 +52,7 @@ export function CustomEdge({
created: number;
destroyed: number;
}>({ beads: [], created: 0, destroyed: 0 });
const beadsRef = useRef(beads);
const { svgPath, length, getPointForT, getTForDistance } = useBezierPath(
sourceX - 5,
sourceY - 5,
@@ -87,89 +94,80 @@ export function CustomEdge({
[getTForDistance, length, visualizeBeads],
);
beadsRef.current = beads;
useEffect(() => {
if (data?.beadUp === 0 && data?.beadDown === 0) {
const beadUp: number = data?.beadUp ?? 0;
const beadDown: number = data?.beadDown ?? 0;
if (
beadUp === 0 &&
beadDown === 0 &&
(beads.created > 0 || beads.destroyed > 0)
) {
setBeads({ beads: [], created: 0, destroyed: 0 });
return;
}
const beadUp: number = data?.beadUp ?? 0;
// Add beads
setBeads(({ beads, created, destroyed }) => {
const newBeads = [];
for (let i = 0; i < beadUp - created; i++) {
newBeads.push({ t: 0, targetT: 0, startTime: Date.now() });
}
const b = setTargetPositions([...beads, ...newBeads]);
return { beads: b, created: beadUp, destroyed };
});
// Remove beads if not animating
if (visualizeBeads !== "animate") {
if (beadUp > beads.created) {
setBeads(({ beads, created, destroyed }) => {
let destroyedCount = 0;
const newBeads = [];
for (let i = 0; i < beadUp - created; i++) {
newBeads.push({ t: 0, targetT: 0, startTime: Date.now() });
}
const newBeads = beads
.map((bead) => ({ ...bead }))
.filter((bead, index) => {
const beadDown: number = data?.beadDown ?? 0;
const removeCount = beadDown - destroyed;
if (bead.t >= bead.targetT && index < removeCount) {
destroyedCount++;
return false;
}
return true;
});
return {
beads: setTargetPositions(newBeads),
created,
destroyed: destroyed + destroyedCount,
};
const b = setTargetPositions([...beads, ...newBeads]);
return { beads: b, created: beadUp, destroyed };
});
return;
}
// Animate and remove beads
const interval = setInterval(() => {
setBeads(({ beads, created, destroyed }) => {
let destroyedCount = 0;
const interval = setInterval(
({ current: beads }) => {
// If there are no beads visible or moving, stop re-rendering
if (
(beadUp === beads.created && beads.created === beads.destroyed) ||
beads.beads.every((bead) => bead.t >= bead.targetT)
) {
clearInterval(interval);
return;
}
const newBeads = beads
.map((bead) => {
const progressIncrement = deltaTime / animationDuration;
const t = Math.min(
bead.t + bead.targetT * progressIncrement,
bead.targetT,
);
setBeads(({ beads, created, destroyed }) => {
let destroyedCount = 0;
return {
...bead,
t,
};
})
.filter((bead, index) => {
const beadDown: number = data?.beadDown ?? 0;
const removeCount = beadDown - destroyed;
if (bead.t >= bead.targetT && index < removeCount) {
destroyedCount++;
return false;
}
return true;
});
const newBeads = beads
.map((bead) => {
const progressIncrement = deltaTime / animationDuration;
const t = Math.min(
bead.t + bead.targetT * progressIncrement,
bead.targetT,
);
return {
beads: setTargetPositions(newBeads),
created,
destroyed: destroyed + destroyedCount,
};
});
}, deltaTime);
return { ...bead, t };
})
.filter((bead, index) => {
const removeCount = beadDown - destroyed;
if (bead.t >= bead.targetT && index < removeCount) {
destroyedCount++;
return false;
}
return true;
});
return {
beads: setTargetPositions(newBeads),
created,
destroyed: destroyed + destroyedCount,
};
});
},
deltaTime,
beadsRef,
);
return () => clearInterval(interval);
}, [data, setTargetPositions, visualizeBeads]);
}, [data?.beadUp, data?.beadDown, setTargetPositions, visualizeBeads]);
const middle = getPointForT(0.5);

View File

@@ -24,7 +24,6 @@ import {
useReactFlow,
applyEdgeChanges,
applyNodeChanges,
useViewport,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { CustomNode } from "./CustomNode";
@@ -53,7 +52,6 @@ import { useRouter, usePathname, useSearchParams } from "next/navigation";
import RunnerUIWrapper, {
RunnerUIWrapperRef,
} from "@/components/RunnerUIWrapper";
import { CronSchedulerDialog } from "@/components/cron-scheduler-dialog";
import PrimaryActionBar from "@/components/PrimaryActionButton";
import OttoChatWidget from "@/components/OttoChatWidget";
import { useToast } from "@/components/molecules/Toast/use-toast";
@@ -91,6 +89,7 @@ const FlowEditor: React.FC<{
getNode,
deleteElements,
updateNode,
getViewport,
setViewport,
} = useReactFlow<CustomNode, CustomEdge>();
const [nodeId, setNodeId] = useState<number>(1);
@@ -105,18 +104,17 @@ const FlowEditor: React.FC<{
agentDescription,
setAgentDescription,
savedAgent,
availableNodes,
availableBlocks,
availableFlows,
getOutputType,
requestSave,
requestSaveAndRun,
requestStopRun,
scheduleRunner,
saveAgent,
saveAndRun,
stopRun,
createRunSchedule,
isSaving,
isRunning,
isStopping,
isScheduling,
setIsScheduling,
nodes,
setNodes,
edges,
@@ -157,8 +155,6 @@ const FlowEditor: React.FC<{
const runnerUIRef = useRef<RunnerUIWrapperRef>(null);
const [openCron, setOpenCron] = useState(false);
const { toast } = useToast();
const TUTORIAL_STORAGE_KEY = "shepherd-tour";
@@ -193,19 +189,11 @@ const FlowEditor: React.FC<{
startTutorial(emptyNodes, setPinBlocksPopover, setPinSavePopover);
localStorage.setItem(TUTORIAL_STORAGE_KEY, "yes");
}
}, [
availableNodes,
router,
pathname,
params,
setEdges,
setNodes,
nodes.length,
]);
}, [router, pathname, params, setEdges, setNodes, nodes.length]);
useEffect(() => {
if (params.get("open_scheduling") === "true") {
setOpenCron(true);
runnerUIRef.current?.openRunInputDialog();
}
setFlowExecutionID(
(params.get("flowExecutionID") as GraphExecutionID) || undefined,
@@ -223,12 +211,12 @@ const FlowEditor: React.FC<{
if (isUndo) {
event.preventDefault();
handleUndo();
history.undo();
}
if (isRedo) {
event.preventDefault();
handleRedo();
history.redo();
}
};
@@ -274,18 +262,16 @@ const FlowEditor: React.FC<{
// Function to clear status, output, and close the output info dropdown of all nodes
// and reset data beads on edges
const clearNodesStatusAndOutput = useCallback(() => {
setNodes((nds) => {
const newNodes = nds.map((node) => ({
setNodes((nds) =>
nds.map((node) => ({
...node,
data: {
...node.data,
status: undefined,
isOutputOpen: false,
},
}));
return newNodes;
});
})),
);
}, [setNodes]);
const onNodesChange = useCallback(
@@ -352,6 +338,8 @@ const FlowEditor: React.FC<{
edgeColor,
sourcePos: sourceNode!.position,
isStatic: sourceNode!.data.isOutputStatic,
beadUp: 0,
beadDown: 0,
},
...connection,
source: connection.source!,
@@ -463,10 +451,9 @@ const FlowEditor: React.FC<{
return uuidv4();
}, []);
const { x, y, zoom } = useViewport();
// Set the initial view port to center the canvas.
useEffect(() => {
const { x, y } = getViewport();
if (nodes.length <= 0 || x !== 0 || y !== 0) {
return;
}
@@ -492,11 +479,11 @@ const FlowEditor: React.FC<{
y: window.innerHeight / 2 - centerY * zoom,
zoom: zoom,
});
}, [nodes, setViewport, x, y]);
}, [nodes, getViewport, setViewport]);
const addNode = useCallback(
(blockId: string, nodeType: string, hardcodedValues: any = {}) => {
const nodeSchema = availableNodes.find((node) => node.id === blockId);
const nodeSchema = availableBlocks.find((node) => node.id === blockId);
if (!nodeSchema) {
console.error(`Schema not found for block ID: ${blockId}`);
return;
@@ -513,6 +500,7 @@ const FlowEditor: React.FC<{
// Alternative: We could also use D3 force, Intersection for this (React flow Pro examples)
const { x, y } = getViewport();
const viewportCoordinates =
nodeDimensions && Object.keys(nodeDimensions).length > 0
? // we will get all the dimension of nodes, then store
@@ -573,14 +561,13 @@ const FlowEditor: React.FC<{
},
[
nodeId,
getViewport,
setViewport,
availableNodes,
availableBlocks,
addNodes,
nodeDimensions,
deleteElements,
clearNodesStatusAndOutput,
x,
y,
],
);
@@ -593,6 +580,8 @@ const FlowEditor: React.FC<{
const rect = nodeElement.getBoundingClientRect();
const { left, top, width, height } = rect;
const { x, y, zoom } = getViewport();
// Convert screen coordinates to flow coordinates
const flowX = (left - x) / zoom;
const flowY = (top - y) / zoom;
@@ -610,20 +599,12 @@ const FlowEditor: React.FC<{
}, {} as NodeDimension);
setNodeDimensions(newNodeDimensions);
}, [nodes, x, y, zoom]);
}, [nodes, getViewport]);
useEffect(() => {
findNodeDimensions();
}, [nodes, findNodeDimensions]);
const handleUndo = () => {
history.undo();
};
const handleRedo = () => {
history.redo();
};
const handleCopyPaste = useCopyPaste(getNextNodeId);
const handleKeyDown = useCallback(
@@ -653,39 +634,45 @@ const FlowEditor: React.FC<{
clearNodesStatusAndOutput();
}, [clearNodesStatusAndOutput]);
const editorControls: Control[] = [
{
label: "Undo",
icon: <IconUndo2 />,
onClick: handleUndo,
},
{
label: "Redo",
icon: <IconRedo2 />,
onClick: handleRedo,
},
];
const editorControls: Control[] = useMemo(
() => [
{
label: "Undo",
icon: <IconUndo2 />,
onClick: history.undo,
},
{
label: "Redo",
icon: <IconRedo2 />,
onClick: history.redo,
},
],
[],
);
// This function is called after cron expression is created
// So you can collect inputs for scheduling
const afterCronCreation = (cronExpression: string, scheduleName: string) => {
runnerUIRef.current?.collectInputsForScheduling(
cronExpression,
scheduleName,
);
};
// This function Opens up form for creating cron expression
const handleScheduleButton = () => {
const handleRunButton = useCallback(async () => {
if (isRunning) return;
if (!savedAgent) {
toast({
title: `Please save the agent using the button in the left sidebar before running it.`,
duration: 2000,
title: `Please save the agent first, using the button in the left sidebar.`,
});
return;
}
setOpenCron(true);
};
await saveAgent();
runnerUIRef.current?.runOrOpenInput();
}, [isRunning, savedAgent, toast, saveAgent]);
const handleScheduleButton = useCallback(async () => {
if (isScheduling) return;
if (!savedAgent) {
toast({
title: `Please save the agent first, using the button in the left sidebar.`,
});
return;
}
await saveAgent();
runnerUIRef.current?.openRunInputDialog();
}, [isScheduling, savedAgent, toast, saveAgent]);
return (
<FlowContext.Provider
@@ -717,7 +704,7 @@ const FlowEditor: React.FC<{
topChildren={
<BlocksControl
pinBlocksPopover={pinBlocksPopover} // Pass the state to BlocksControl
blocks={availableNodes}
blocks={availableBlocks}
addBlock={addNode}
flows={availableFlows}
nodes={nodes}
@@ -727,7 +714,7 @@ const FlowEditor: React.FC<{
<SaveControl
agentMeta={savedAgent}
canSave={!isSaving && !isRunning && !isStopping}
onSave={() => requestSave()}
onSave={saveAgent}
agentDescription={agentDescription}
onDescriptionChange={setAgentDescription}
agentName={agentName}
@@ -735,38 +722,17 @@ const FlowEditor: React.FC<{
pinSavePopover={pinSavePopover}
/>
}
></ControlPanel>
/>
{!graphHasWebhookNodes ? (
<>
<PrimaryActionBar
className="absolute bottom-0 left-1/2 z-20 -translate-x-1/2"
onClickAgentOutputs={() =>
runnerUIRef.current?.openRunnerOutput()
}
onClickRunAgent={() => {
if (isRunning) return;
if (!savedAgent) {
toast({
title: `Please save the agent using the button in the left sidebar before running it.`,
duration: 2000,
});
return;
}
runnerUIRef.current?.runOrOpenInput();
}}
onClickStopRun={requestStopRun}
onClickScheduleButton={handleScheduleButton}
isScheduling={isScheduling}
isDisabled={!savedAgent}
isRunning={isRunning}
/>
<CronSchedulerDialog
afterCronCreation={afterCronCreation}
open={openCron}
setOpen={setOpenCron}
defaultScheduleName={agentName}
/>
</>
<PrimaryActionBar
className="absolute bottom-0 left-1/2 z-20 -translate-x-1/2"
onClickAgentOutputs={runnerUIRef.current?.openRunnerOutput}
onClickRunAgent={handleRunButton}
onClickStopRun={stopRun}
onClickScheduleButton={handleScheduleButton}
isDisabled={!savedAgent}
isRunning={isRunning}
/>
) : (
<Alert className="absolute bottom-4 left-1/2 z-20 w-auto -translate-x-1/2 select-none">
<AlertTitle>You are building a Trigger Agent</AlertTitle>
@@ -794,16 +760,15 @@ const FlowEditor: React.FC<{
)}
</ReactFlow>
</div>
<RunnerUIWrapper
ref={runnerUIRef}
nodes={nodes}
setNodes={setNodes}
setIsScheduling={setIsScheduling}
isScheduling={isScheduling}
isRunning={isRunning}
scheduleRunner={scheduleRunner}
requestSaveAndRun={requestSaveAndRun}
/>
{savedAgent && (
<RunnerUIWrapper
ref={runnerUIRef}
graph={savedAgent}
nodes={nodes}
createRunSchedule={createRunSchedule}
saveAndRun={saveAndRun}
/>
)}
<Suspense fallback={null}>
<OttoChatWidget
graphID={flowID}

View File

@@ -1,18 +1,17 @@
import React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { FaSpinner } from "react-icons/fa";
import { Clock, LogOut } from "lucide-react";
import { LogOut } from "lucide-react";
import { ClockIcon } from "@phosphor-icons/react";
import { IconPlay, IconSquare } from "@/components/ui/icons";
interface PrimaryActionBarProps {
onClickAgentOutputs: () => void;
onClickAgentOutputs?: () => void;
onClickRunAgent?: () => void;
onClickStopRun: () => void;
onClickScheduleButton?: () => void;
isRunning: boolean;
isDisabled: boolean;
isScheduling: boolean;
className?: string;
}
@@ -23,7 +22,6 @@ const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
onClickScheduleButton,
isRunning,
isDisabled,
isScheduling,
className,
}) => {
const buttonClasses =
@@ -36,15 +34,17 @@ const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
)}
>
<div className="flex gap-1 md:gap-4">
<Button
className={buttonClasses}
variant="outline"
size="primary"
onClick={onClickAgentOutputs}
title="View agent outputs"
>
<LogOut className="hidden size-5 md:flex" /> Agent Outputs
</Button>
{onClickAgentOutputs && (
<Button
className={buttonClasses}
variant="outline"
size="primary"
onClick={onClickAgentOutputs}
title="View agent outputs"
>
<LogOut className="hidden size-5 md:flex" /> Agent Outputs
</Button>
)}
{!isRunning ? (
<Button
@@ -82,15 +82,10 @@ const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
variant="outline"
size="primary"
onClick={onClickScheduleButton}
disabled={isScheduling}
title="Set up a run schedule for the agent"
data-id="primary-action-schedule-agent"
>
{isScheduling ? (
<FaSpinner className="animate-spin" />
) : (
<Clock className="hidden h-5 w-5 md:flex" />
)}
<ClockIcon className="hidden h-5 w-5 md:flex" />
Schedule Run
</Button>
)}

View File

@@ -1,215 +1,108 @@
import React, {
useState,
useCallback,
forwardRef,
useImperativeHandle,
useMemo,
} from "react";
import RunnerOutputUI, { BlockOutput } from "./runner-ui/RunnerOutputUI";
import RunnerInputUI from "./runner-ui/RunnerInputUI";
import { Node } from "@xyflow/react";
import { filterBlocksByType } from "@/lib/utils";
import { CustomNodeData } from "@/components/CustomNode";
import { RunnerInputDialog } from "@/components/runner-ui/RunnerInputUI";
import {
BlockIOObjectSubSchema,
BlockIORootSchema,
BlockUIType,
CredentialsMetaInput,
GraphMeta,
} from "@/lib/autogpt-server-api/types";
import { CustomNode } from "./CustomNode";
interface HardcodedValues {
name: any;
description: any;
value: any;
placeholder_values: any;
}
export interface InputItem {
id: string;
type: "input";
inputSchema: BlockIORootSchema;
hardcodedValues: HardcodedValues;
}
import RunnerOutputUI, {
OutputNodeInfo,
} from "@/components/runner-ui/RunnerOutputUI";
interface RunnerUIWrapperProps {
nodes: Node[];
setNodes: React.Dispatch<React.SetStateAction<CustomNode[]>>;
setIsScheduling: React.Dispatch<React.SetStateAction<boolean>>;
isRunning: boolean;
isScheduling: boolean;
requestSaveAndRun: () => void;
scheduleRunner: (
graph: GraphMeta;
nodes: Node<CustomNodeData>[];
saveAndRun: (
inputs: Record<string, any>,
credentialsInputs: Record<string, CredentialsMetaInput>,
) => void;
createRunSchedule: (
cronExpression: string,
input: InputItem[],
scheduleName: string,
inputs: Record<string, any>,
credentialsInputs: Record<string, CredentialsMetaInput>,
) => Promise<void>;
}
export interface RunnerUIWrapperRef {
openRunnerInput: () => void;
openRunInputDialog: () => void;
openRunnerOutput: () => void;
runOrOpenInput: () => void;
collectInputsForScheduling: (
cronExpression: string,
scheduleName: string,
) => void;
}
const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
(
{
nodes,
setIsScheduling,
setNodes,
isScheduling,
isRunning,
requestSaveAndRun,
scheduleRunner,
},
ref,
) => {
const [isRunnerInputOpen, setIsRunnerInputOpen] = useState(false);
({ graph, nodes, saveAndRun, createRunSchedule }, ref) => {
const [isRunInputDialogOpen, setIsRunInputDialogOpen] = useState(false);
const [isRunnerOutputOpen, setIsRunnerOutputOpen] = useState(false);
const [scheduledInput, setScheduledInput] = useState(false);
const [cronExpression, setCronExpression] = useState("");
const [scheduleName, setScheduleName] = useState("");
const getBlockInputsAndOutputs = useCallback((): {
inputs: InputItem[];
outputs: BlockOutput[];
} => {
const inputBlocks = filterBlocksByType(
nodes,
(node) => node.data.uiType === BlockUIType.INPUT,
);
const graphInputs = graph.input_schema.properties;
const outputBlocks = filterBlocksByType(
nodes,
const graphOutputs = useMemo((): OutputNodeInfo[] => {
const outputNodes = nodes.filter(
(node) => node.data.uiType === BlockUIType.OUTPUT,
);
const inputs = inputBlocks.map(
(node) =>
({
id: node.id,
type: "input" as const,
inputSchema: (node.data.inputSchema as BlockIOObjectSubSchema)
.properties.value as BlockIORootSchema,
hardcodedValues: {
name: (node.data.hardcodedValues as any).name || "",
description: (node.data.hardcodedValues as any).description || "",
value: (node.data.hardcodedValues as any).value,
placeholder_values:
(node.data.hardcodedValues as any).placeholder_values || [],
},
}) satisfies InputItem,
);
const outputs = outputBlocks.map(
return outputNodes.map(
(node) =>
({
metadata: {
name: (node.data.hardcodedValues as any).name || "Output",
name: node.data.hardcodedValues.name || "Output",
description:
(node.data.hardcodedValues as any).description ||
node.data.hardcodedValues.description ||
"Output from the agent",
},
result:
(node.data.executionResults as any)
?.map((result: any) => result?.data?.output)
.join("\n--\n") || "No output yet",
}) satisfies BlockOutput,
}) satisfies OutputNodeInfo,
);
return { inputs, outputs };
}, [nodes]);
const handleInputChange = useCallback(
(nodeId: string, field: string, value: any) => {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
hardcodedValues: {
...(node.data.hardcodedValues as any),
[field]: value,
},
},
};
}
return node;
}),
);
},
[setNodes],
);
const openRunnerInput = () => setIsRunnerInputOpen(true);
const openRunInputDialog = () => setIsRunInputDialogOpen(true);
const openRunnerOutput = () => setIsRunnerOutputOpen(true);
const runOrOpenInput = () => {
const { inputs } = getBlockInputsAndOutputs();
if (inputs.length > 0) {
openRunnerInput();
if (
Object.keys(graphInputs).length > 0 ||
Object.keys(graph.credentials_input_schema.properties).length > 0
) {
openRunInputDialog();
} else {
requestSaveAndRun();
saveAndRun({}, {});
}
};
const collectInputsForScheduling = (
cronExpression: string,
scheduleName: string,
) => {
const { inputs } = getBlockInputsAndOutputs();
setCronExpression(cronExpression);
setScheduleName(scheduleName);
if (inputs.length > 0) {
setScheduledInput(true);
setIsRunnerInputOpen(true);
} else {
scheduleRunner(cronExpression, [], scheduleName);
}
};
useImperativeHandle(ref, () => ({
openRunnerInput,
openRunnerOutput,
runOrOpenInput,
collectInputsForScheduling,
}));
useImperativeHandle(
ref,
() =>
({
openRunInputDialog,
openRunnerOutput,
runOrOpenInput,
}) satisfies RunnerUIWrapperRef,
);
return (
<>
<RunnerInputUI
isOpen={isRunnerInputOpen}
onClose={() => setIsRunnerInputOpen(false)}
blockInputs={getBlockInputsAndOutputs().inputs}
onInputChange={handleInputChange}
onRun={() => {
setIsRunnerInputOpen(false);
requestSaveAndRun();
}}
scheduledInput={scheduledInput}
isScheduling={isScheduling}
onSchedule={async () => {
setIsScheduling(true);
await scheduleRunner(
cronExpression,
getBlockInputsAndOutputs().inputs,
scheduleName,
);
setIsScheduling(false);
setIsRunnerInputOpen(false);
setScheduledInput(false);
}}
isRunning={isRunning}
<RunnerInputDialog
isOpen={isRunInputDialogOpen}
doClose={() => setIsRunInputDialogOpen(false)}
graph={graph}
doRun={saveAndRun}
doCreateSchedule={createRunSchedule}
/>
<RunnerOutputUI
isOpen={isRunnerOutputOpen}
onClose={() => setIsRunnerOutputOpen(false)}
blockOutputs={getBlockInputsAndOutputs().outputs}
doClose={() => setIsRunnerOutputOpen(false)}
outputs={graphOutputs}
/>
</>
);

View File

@@ -169,7 +169,7 @@ export default function AgentRunDetailsView({
] satisfies ButtonAction[])
: []),
...(["success", "failed", "stopped"].includes(runStatus) &&
!graph.has_webhook_trigger &&
!graph.has_external_trigger &&
isEmpty(graph.credentials_input_schema.required) // TODO: enable re-run with credentials - https://linear.app/autogpt/issue/SECRT-1243
? [
{
@@ -198,8 +198,8 @@ export default function AgentRunDetailsView({
runAgain,
stopRun,
deleteRun,
graph.has_webhook_trigger,
graph.credentials_input_schema?.properties,
graph.has_external_trigger,
graph.credentials_input_schema.required,
agent.can_access_graph,
run.graph_id,
run.graph_version,

View File

@@ -4,48 +4,65 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
CredentialsMetaInput,
GraphExecutionID,
LibraryAgent,
GraphMeta,
LibraryAgentPreset,
LibraryAgentPresetID,
LibraryAgentPresetUpdatable,
LibraryAgentTriggerInfo,
Schedule,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import ActionButtonGroup from "@/components/agptui/action-button-group";
import type { ButtonAction } from "@/components/agptui/types";
import { CronSchedulerDialog } from "@/components/cron-scheduler-dialog";
import { CredentialsInput } from "@/components/integrations/credentials-input";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
import SchemaTooltip from "@/components/SchemaTooltip";
import { TypeBasedInput } from "@/components/type-based-input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { IconCross, IconPlay, IconSave } from "@/components/ui/icons";
import { CalendarClockIcon, Trash2Icon } from "lucide-react";
import { CronSchedulerDialog } from "@/components/cron-scheduler-dialog";
import { CredentialsInput } from "@/components/integrations/credentials-input";
import { TypeBasedInput } from "@/components/type-based-input";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
import { cn, isEmpty } from "@/lib/utils";
import SchemaTooltip from "@/components/SchemaTooltip";
import { Input } from "@/components/ui/input";
import {
useToast,
useToastOnFail,
} from "@/components/molecules/Toast/use-toast";
import { isEmpty } from "lodash";
import { CalendarClockIcon, Trash2Icon } from "lucide-react";
export default function AgentRunDraftView({
agent,
graph,
agentPreset,
triggerSetupInfo,
doRun: _doRun,
onRun,
onCreatePreset,
onUpdatePreset,
doDeletePreset,
doCreateSchedule: _doCreateSchedule,
onCreateSchedule,
agentActions,
className,
}: {
agent: LibraryAgent;
agentActions: ButtonAction[];
onRun: (runID: GraphExecutionID) => void;
onCreateSchedule: (schedule: Schedule) => void;
graph: GraphMeta;
triggerSetupInfo?: LibraryAgentTriggerInfo;
agentActions?: ButtonAction[];
doRun?: (
inputs: Record<string, any>,
credentialsInputs: Record<string, CredentialsMetaInput>,
) => Promise<void>;
onRun?: (runID: GraphExecutionID) => void;
doCreateSchedule?: (
cronExpression: string,
scheduleName: string,
inputs: Record<string, any>,
credentialsInputs: Record<string, CredentialsMetaInput>,
) => Promise<void>;
onCreateSchedule?: (schedule: Schedule) => void;
className?: string;
} & (
| {
onCreatePreset: (preset: LibraryAgentPreset) => void;
onCreatePreset?: (preset: LibraryAgentPreset) => void;
agentPreset?: never;
onUpdatePreset?: never;
doDeletePreset?: never;
@@ -84,36 +101,26 @@ export default function AgentRunDraftView({
}, [agentPreset]);
const agentInputSchema = useMemo(
() =>
agent.has_external_trigger
? agent.trigger_setup_info.config_schema
: agent.input_schema,
[agent],
() => triggerSetupInfo?.config_schema ?? graph.input_schema,
[graph, triggerSetupInfo],
);
const agentInputFields = useMemo(
() =>
Object.fromEntries(
Object.entries(agentInputSchema?.properties || {}).filter(
Object.entries(agentInputSchema.properties).filter(
([_, subSchema]) => !subSchema.hidden,
),
),
[agentInputSchema],
);
const agentCredentialsInputFields = useMemo(
() => agent.credentials_input_schema?.properties || {},
[agent],
() => graph.credentials_input_schema.properties,
[graph],
);
const [allRequiredInputsAreSet, missingInputs] = useMemo(() => {
const nonEmptyInputs = new Set(
Object.keys(inputValues).filter((k) => {
const value = inputValues[k];
return (
value !== undefined &&
value !== "" &&
(typeof value !== "object" || !isEmpty(value))
);
}),
Object.keys(inputValues).filter((k) => !isEmpty(inputValues[k])),
);
const requiredInputs = new Set(
agentInputSchema.required as string[] | undefined,
@@ -135,7 +142,7 @@ export default function AgentRunDraftView({
(needPresetName: boolean = true) => {
const allMissingFields = (
needPresetName && !presetName
? [agent.has_external_trigger ? "trigger_name" : "preset_name"]
? [graph.has_external_trigger ? "trigger_name" : "preset_name"]
: []
)
.concat(missingInputs)
@@ -148,29 +155,29 @@ export default function AgentRunDraftView({
[missingInputs, missingCredentials],
);
const doRun = useCallback(() => {
const doRun = useCallback(async () => {
// Manually running webhook-triggered agents is not supported
if (agent.has_external_trigger) return;
if (graph.has_external_trigger) return;
if (!agentPreset || changedPresetAttributes.size > 0) {
if (!allRequiredInputsAreSet || !allCredentialsAreSet) {
notifyMissingInputs(false);
return;
}
if (_doRun) {
await _doRun(inputValues, inputCredentials);
return;
}
// TODO: on executing preset with changes, ask for confirmation and offer save+run
api
.executeGraph(
agent.graph_id,
agent.graph_version,
inputValues,
inputCredentials,
)
.then((newRun) => onRun(newRun.graph_exec_id))
const newRun = await api
.executeGraph(graph.id, graph.version, inputValues, inputCredentials)
.catch(toastOnFail("execute agent"));
if (newRun && onRun) onRun(newRun.graph_exec_id);
} else {
api
await api
.executeLibraryAgentPreset(agentPreset.id)
.then((newRun) => onRun(newRun.id))
.then((newRun) => onRun && onRun(newRun.id))
.catch(toastOnFail("execute agent preset"));
}
// Mark run agent onboarding step as completed
@@ -179,7 +186,7 @@ export default function AgentRunDraftView({
}
}, [
api,
agent,
graph,
inputValues,
inputCredentials,
onRun,
@@ -188,7 +195,7 @@ export default function AgentRunDraftView({
completeOnboardingStep,
]);
const doCreatePreset = useCallback(() => {
const doCreatePreset = useCallback(async () => {
if (!onCreatePreset) return;
if (!presetName || !allRequiredInputsAreSet || !allCredentialsAreSet) {
@@ -196,12 +203,12 @@ export default function AgentRunDraftView({
return;
}
api
await api
.createLibraryAgentPreset({
name: presetName,
description: presetDescription,
graph_id: agent.graph_id,
graph_version: agent.graph_version,
graph_id: graph.id,
graph_version: graph.version,
inputs: inputValues,
credentials: inputCredentials,
})
@@ -212,7 +219,7 @@ export default function AgentRunDraftView({
.catch(toastOnFail("save agent preset"));
}, [
api,
agent,
graph,
presetName,
presetDescription,
inputValues,
@@ -224,7 +231,7 @@ export default function AgentRunDraftView({
completeOnboardingStep,
]);
const doUpdatePreset = useCallback(() => {
const doUpdatePreset = useCallback(async () => {
if (!agentPreset || changedPresetAttributes.size == 0) return;
if (!presetName || !allRequiredInputsAreSet || !allCredentialsAreSet) {
@@ -243,7 +250,7 @@ export default function AgentRunDraftView({
updatePreset["inputs"] = inputValues;
updatePreset["credentials"] = inputCredentials;
}
api
await api
.updateLibraryAgentPreset(agentPreset.id, updatePreset)
.then((updatedPreset) => {
onUpdatePreset(updatedPreset);
@@ -252,7 +259,7 @@ export default function AgentRunDraftView({
.catch(toastOnFail("update agent preset"));
}, [
api,
agent,
graph,
presetName,
presetDescription,
inputValues,
@@ -275,19 +282,16 @@ export default function AgentRunDraftView({
[agentPreset, api, onUpdatePreset],
);
const doSetupTrigger = useCallback(() => {
const doSetupTrigger = useCallback(async () => {
// Setting up a trigger for non-webhook-triggered agents is not supported
if (!agent.has_external_trigger || !onCreatePreset) return;
if (!triggerSetupInfo || !onCreatePreset) return;
if (!presetName || !allRequiredInputsAreSet || !allCredentialsAreSet) {
notifyMissingInputs();
return;
}
const credentialsInputName =
agent.trigger_setup_info.credentials_input_name;
if (!credentialsInputName) {
if (!triggerSetupInfo.credentials_input_name) {
// FIXME: implement support for manual-setup webhooks
toast({
variant: "destructive",
@@ -297,10 +301,12 @@ export default function AgentRunDraftView({
return;
}
api
.setupAgentTrigger(agent.id, {
await api
.setupAgentTrigger({
name: presetName,
description: presetDescription,
graph_id: graph.id,
graph_version: graph.version,
trigger_config: inputValues,
agent_credentials: inputCredentials,
})
@@ -316,7 +322,7 @@ export default function AgentRunDraftView({
}
}, [
api,
agent,
graph,
presetName,
presetDescription,
inputValues,
@@ -330,7 +336,7 @@ export default function AgentRunDraftView({
const openScheduleDialog = useCallback(() => {
// Scheduling is not supported for webhook-triggered agents
if (agent.has_external_trigger) return;
if (graph.has_external_trigger) return;
if (!allRequiredInputsAreSet || !allCredentialsAreSet) {
notifyMissingInputs(false);
@@ -339,36 +345,46 @@ export default function AgentRunDraftView({
setCronScheduleDialogOpen(true);
}, [
agent,
graph,
allRequiredInputsAreSet,
allCredentialsAreSet,
notifyMissingInputs,
]);
const doSetupSchedule = useCallback(
(cronExpression: string, scheduleName: string) => {
async (cronExpression: string, scheduleName: string) => {
// Scheduling is not supported for webhook-triggered agents
if (agent.has_external_trigger) return;
if (graph.has_external_trigger) return;
api
if (_doCreateSchedule) {
await _doCreateSchedule(
cronExpression,
scheduleName || graph.name,
inputValues,
inputCredentials,
);
return;
}
const schedule = await api
.createGraphExecutionSchedule({
graph_id: agent.graph_id,
graph_version: agent.graph_version,
name: scheduleName || agent.name,
graph_id: graph.id,
graph_version: graph.version,
name: scheduleName || graph.name,
cron: cronExpression,
inputs: inputValues,
credentials: inputCredentials,
})
.then((schedule) => onCreateSchedule(schedule))
.catch(toastOnFail("set up agent run schedule"));
if (schedule && onCreateSchedule) onCreateSchedule(schedule);
},
[api, agent, inputValues, inputCredentials, onCreateSchedule, toastOnFail],
[api, graph, inputValues, inputCredentials, onCreateSchedule, toastOnFail],
);
const runActions: ButtonAction[] = useMemo(
() => [
// "Regular" agent: [run] + [save as preset] buttons
...(!agent.has_external_trigger
...(!graph.has_external_trigger
? ([
{
label: (
@@ -378,6 +394,7 @@ export default function AgentRunDraftView({
),
variant: "accent",
callback: doRun,
extraProps: { "data-testid": "agent-run-button" },
},
{
label: (
@@ -403,7 +420,7 @@ export default function AgentRunDraftView({
] satisfies ButtonAction[])
: []),
// Triggered agent: [setup] button
...(agent.has_external_trigger && !agentPreset?.webhook_id
...(graph.has_external_trigger && !agentPreset?.webhook_id
? ([
{
label: (
@@ -466,7 +483,7 @@ export default function AgentRunDraftView({
label: (
<>
<Trash2Icon className="mr-2 size-4" />
Delete {agent.has_external_trigger ? "trigger" : "preset"}
Delete {graph.has_external_trigger ? "trigger" : "preset"}
</>
),
callback: () => doDeletePreset(agentPreset.id),
@@ -475,7 +492,7 @@ export default function AgentRunDraftView({
: []),
],
[
agent.has_external_trigger,
graph.has_external_trigger,
agentPreset,
doRun,
doSetupTrigger,
@@ -491,26 +508,26 @@ export default function AgentRunDraftView({
);
return (
<div className="agpt-div flex gap-6">
<div className={cn("agpt-div flex gap-6", className)}>
<div className="flex flex-1 flex-col gap-4">
<Card className="agpt-box">
<CardHeader>
<CardTitle className="font-poppins text-lg">Input</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{(agentPreset || agent.has_external_trigger) && (
{(agentPreset || graph.has_external_trigger) && (
<>
{/* Preset name and description */}
<div className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
{agent.has_external_trigger ? "Trigger" : "Preset"} Name
{graph.has_external_trigger ? "Trigger" : "Preset"} Name
<SchemaTooltip
description={`Name of the ${agent.has_external_trigger ? "trigger" : "preset"} you are setting up`}
description={`Name of the ${graph.has_external_trigger ? "trigger" : "preset"} you are setting up`}
/>
</label>
<Input
value={presetName}
placeholder={`Enter ${agent.has_external_trigger ? "trigger" : "preset"} name`}
placeholder={`Enter ${graph.has_external_trigger ? "trigger" : "preset"} name`}
onChange={(e) => {
setPresetName(e.target.value);
setChangedPresetAttributes((prev) => prev.add("name"));
@@ -519,15 +536,15 @@ export default function AgentRunDraftView({
</div>
<div className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
{agent.has_external_trigger ? "Trigger" : "Preset"}{" "}
{graph.has_external_trigger ? "Trigger" : "Preset"}{" "}
Description
<SchemaTooltip
description={`Description of the ${agent.has_external_trigger ? "trigger" : "preset"} you are setting up`}
description={`Description of the ${graph.has_external_trigger ? "trigger" : "preset"} you are setting up`}
/>
</label>
<Input
value={presetDescription}
placeholder={`Enter ${agent.has_external_trigger ? "trigger" : "preset"} description`}
placeholder={`Enter ${graph.has_external_trigger ? "trigger" : "preset"} description`}
onChange={(e) => {
setPresetDescription(e.target.value);
setChangedPresetAttributes((prev) =>
@@ -565,7 +582,7 @@ export default function AgentRunDraftView({
);
}}
hideIfSingleCredentialAvailable={
!agentPreset && !agent.has_external_trigger
!agentPreset && !graph.has_external_trigger
}
/>
),
@@ -590,6 +607,7 @@ export default function AgentRunDraftView({
}));
setChangedPresetAttributes((prev) => prev.add("inputs"));
}}
data-testid={`agent-input-${key}`}
/>
</div>
))}
@@ -601,17 +619,19 @@ export default function AgentRunDraftView({
<aside className="w-48 xl:w-56">
<div className="flex flex-col gap-8">
<ActionButtonGroup
title={`${agent.has_external_trigger ? "Trigger" : agentPreset ? "Preset" : "Run"} actions`}
title={`${graph.has_external_trigger ? "Trigger" : agentPreset ? "Preset" : "Run"} actions`}
actions={runActions}
/>
<CronSchedulerDialog
open={cronScheduleDialogOpen}
setOpen={setCronScheduleDialogOpen}
afterCronCreation={doSetupSchedule}
defaultScheduleName={agent.name}
defaultScheduleName={graph.name}
/>
<ActionButtonGroup title="Agent actions" actions={agentActions} />
{agentActions && agentActions.length > 0 && (
<ActionButtonGroup title="Agent actions" actions={agentActions} />
)}
</div>
</aside>
</div>

View File

@@ -24,6 +24,7 @@ export default function ActionButtonGroup({
variant={action.variant ?? "outline"}
disabled={action.disabled}
onClick={action.callback}
{...action.extraProps}
>
{action.label}
</Button>
@@ -36,6 +37,7 @@ export default function ActionButtonGroup({
"pointer-events-none border-zinc-400 text-zinc-400",
)}
href={action.href}
{...action.extraProps}
>
{action.label}
</Link>

View File

@@ -1,8 +1,19 @@
import type { ButtonProps } from "@/components/agptui/Button";
import type { LinkProps } from "next/link";
import React from "react";
export type ButtonAction = {
label: React.ReactNode;
variant?: ButtonProps["variant"];
disabled?: boolean;
} & ({ callback: () => void } | { href: string });
extraProps?: Record<`data-${string}`, string>;
} & (
| {
callback: () => void;
extraProps?: Partial<ButtonProps>;
}
| {
href: string;
extraProps?: Partial<LinkProps>;
}
);

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo } from "react";
import React, { useCallback, useMemo, useState, useDeferredValue } from "react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
@@ -24,8 +24,36 @@ import {
import { GraphMeta } from "@/lib/autogpt-server-api";
import jaro from "jaro-winkler";
type _Block = Block & {
uiKey?: string;
hardcodedValues?: Record<string, any>;
_cached?: {
blockName: string;
beautifiedName: string;
description: string;
};
};
// Hook to preprocess blocks with cached expensive operations
const useSearchableBlocks = (blocks: _Block[]): _Block[] => {
return useMemo(
() =>
blocks.map((block) => {
if (!block._cached) {
block._cached = {
blockName: block.name.toLowerCase(),
beautifiedName: beautifyString(block.name).toLowerCase(),
description: block.description.toLowerCase(),
};
}
return block;
}),
[blocks],
);
};
interface BlocksControlProps {
blocks: Block[];
blocks: _Block[];
addBlock: (
id: string,
name: string,
@@ -45,16 +73,19 @@ interface BlocksControlProps {
* @param {(id: string, name: string) => void} BlocksControlProps.addBlock - A function to call when a block is added.
* @returns The rendered BlocksControl component.
*/
export const BlocksControl: React.FC<BlocksControlProps> = ({
blocks,
export function BlocksControl({
blocks: _blocks,
addBlock,
pinBlocksPopover,
flows,
nodes,
}) => {
}: BlocksControlProps) {
const [searchQuery, setSearchQuery] = useState("");
const deferredSearchQuery = useDeferredValue(searchQuery);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const blocks = useSearchableBlocks(_blocks);
const graphHasWebhookNodes = nodes.some((n) =>
[BlockUIType.WEBHOOK, BlockUIType.WEBHOOK_MANUAL].includes(n.data.uiType),
);
@@ -66,9 +97,10 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
const blockList = blocks
.filter((b) => b.uiType !== BlockUIType.AGENT)
.sort((a, b) => a.name.localeCompare(b.name));
const agentBlockList = flows.map(
(flow) =>
({
const agentBlockList = flows
.map(
(flow): _Block => ({
id: SpecialBlockID.AGENT,
name: flow.name,
description:
@@ -79,75 +111,32 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
outputSchema: flow.output_schema,
staticOutput: false,
uiType: BlockUIType.AGENT,
uiKey: flow.id,
costs: [],
uiKey: flow.id,
hardcodedValues: {
graph_id: flow.id,
graph_version: flow.version,
input_schema: flow.input_schema,
output_schema: flow.output_schema,
},
}) satisfies Block,
);
/**
* Evaluates how well a block matches the search query and returns a relevance score.
* The scoring algorithm works as follows:
* - Returns 1 if no query (all blocks match equally)
* - Normalized query for case-insensitive matching
* - Returns 3 for exact substring matches in block name (highest priority)
* - Returns 2 when all query words appear in the block name (regardless of order)
* - Returns 1.X for blocks with names similar to query using Jaro-Winkler distance (X is similarity score)
* - Returns 0.5 when all query words appear in the block description (lowest priority)
* - Returns 0 for no match
*
* Higher scores will appear first in search results.
*/
const matchesSearch = (block: Block, query: string): number => {
if (!query) return 1;
const normalizedQuery = query.toLowerCase().trim();
const queryWords = normalizedQuery.split(/\s+/);
const blockName = block.name.toLowerCase();
const beautifiedName = beautifyString(block.name).toLowerCase();
const description = block.description.toLowerCase();
// 1. Exact match in name (highest priority)
if (
blockName.includes(normalizedQuery) ||
beautifiedName.includes(normalizedQuery)
) {
return 3;
}
// 2. All query words in name (regardless of order)
const allWordsInName = queryWords.every(
(word) => blockName.includes(word) || beautifiedName.includes(word),
}),
)
.map(
(agentBlock): _Block => ({
...agentBlock,
_cached: {
blockName: agentBlock.name.toLowerCase(),
beautifiedName: beautifyString(agentBlock.name).toLowerCase(),
description: agentBlock.description.toLowerCase(),
},
}),
);
if (allWordsInName) return 2;
// 3. Similarity with name (Jaro-Winkler)
const similarityThreshold = 0.65;
const nameSimilarity = jaro(blockName, normalizedQuery);
const beautifiedSimilarity = jaro(beautifiedName, normalizedQuery);
const maxSimilarity = Math.max(nameSimilarity, beautifiedSimilarity);
if (maxSimilarity > similarityThreshold) {
return 1 + maxSimilarity; // Score between 1 and 2
}
// 4. All query words in description (lower priority)
const allWordsInDescription = queryWords.every((word) =>
description.includes(word),
);
if (allWordsInDescription) return 0.5;
return 0;
};
return blockList
.concat(agentBlockList)
.map((block) => ({
block,
score: matchesSearch(block, searchQuery),
score: blockScoreForQuery(block, deferredSearchQuery),
}))
.filter(
({ block, score }) =>
@@ -173,26 +162,28 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
}, [
blocks,
flows,
searchQuery,
selectedCategory,
deferredSearchQuery,
graphHasInputNodes,
graphHasWebhookNodes,
]);
const resetFilters = React.useCallback(() => {
const resetFilters = useCallback(() => {
setSearchQuery("");
setSelectedCategory(null);
}, []);
// Extract unique categories from blocks
const categories = Array.from(
new Set([
null,
...blocks
.flatMap((block) => block.categories.map((cat) => cat.category))
.sort(),
]),
);
const categories = useMemo(() => {
return Array.from(
new Set([
null,
...blocks
.flatMap((block) => block.categories.map((cat) => cat.category))
.sort(),
]),
);
}, [blocks]);
return (
<Popover
@@ -336,4 +327,57 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
</PopoverContent>
</Popover>
);
};
}
/**
* Evaluates how well a block matches the search query and returns a relevance score.
* The scoring algorithm works as follows:
* - Returns 1 if no query (all blocks match equally)
* - Normalized query for case-insensitive matching
* - Returns 3 for exact substring matches in block name (highest priority)
* - Returns 2 when all query words appear in the block name (regardless of order)
* - Returns 1.X for blocks with names similar to query using Jaro-Winkler distance (X is similarity score)
* - Returns 0.5 when all query words appear in the block description (lowest priority)
* - Returns 0 for no match
*
* Higher scores will appear first in search results.
*/
function blockScoreForQuery(block: _Block, query: string): number {
if (!query) return 1;
const normalizedQuery = query.toLowerCase().trim();
const queryWords = normalizedQuery.split(/\s+/);
// Use cached values for performance
const { blockName, beautifiedName, description } = block._cached!;
// 1. Exact match in name (highest priority)
if (
blockName.includes(normalizedQuery) ||
beautifiedName.includes(normalizedQuery)
) {
return 3;
}
// 2. All query words in name (regardless of order)
const allWordsInName = queryWords.every(
(word) => blockName.includes(word) || beautifiedName.includes(word),
);
if (allWordsInName) return 2;
// 3. Similarity with name (Jaro-Winkler)
const similarityThreshold = 0.65;
const nameSimilarity = jaro(blockName, normalizedQuery);
const beautifiedSimilarity = jaro(beautifiedName, normalizedQuery);
const maxSimilarity = Math.max(nameSimilarity, beautifiedSimilarity);
if (maxSimilarity > similarityThreshold) {
return 1 + maxSimilarity; // Score between 1 and 2
}
// 4. All query words in description (lower priority)
const allWordsInDescription = queryWords.every((word) =>
description.includes(word),
);
if (allWordsInDescription) return 0.5;
return 0;
}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect } from "react";
import React, { useEffect } from "react";
import {
Popover,
PopoverContent,
@@ -22,7 +22,7 @@ interface SaveControlProps {
agentName: string;
agentDescription: string;
canSave: boolean;
onSave: () => void;
onSave: () => Promise<void>;
onNameChange: (name: string) => void;
onDescriptionChange: (description: string) => void;
pinSavePopover: boolean;
@@ -56,17 +56,13 @@ export const SaveControl = ({
* We should migrate this to be handled with form controls and a form library.
*/
const handleSave = useCallback(() => {
onSave();
}, [onSave]);
const { toast } = useToast();
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const handleKeyDown = async (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
event.preventDefault(); // Stop the browser default action
handleSave(); // Call your save function
await onSave(); // Call your save function
toast({
duration: 2000,
title: "All changes saved successfully!",
@@ -79,7 +75,7 @@ export const SaveControl = ({
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleSave, toast]);
}, [onSave, toast]);
return (
<Popover open={pinSavePopover ? true : undefined}>
@@ -154,7 +150,7 @@ export const SaveControl = ({
<CardFooter className="flex flex-col items-stretch gap-2">
<Button
className="w-full dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleSave}
onClick={onSave}
data-id="save-control-save-agent"
data-testid="save-control-save-agent-button"
disabled={!canSave}

View File

@@ -1,9 +1,7 @@
import React, { useEffect, useState, useCallback } from "react";
import React, { useEffect, useState } from "react";
import {
GraphExecutionMeta,
Graph,
BlockUIType,
BlockIORootSchema,
GraphExecutionMeta,
LibraryAgent,
} from "@/lib/autogpt-server-api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -25,7 +23,7 @@ import {
TrashIcon,
} from "@radix-ui/react-icons";
import Link from "next/link";
import { exportAsJSONFile, filterBlocksByType } from "@/lib/utils";
import { exportAsJSONFile } from "@/lib/utils";
import { FlowRunsStats } from "@/components/monitor/index";
import {
Dialog,
@@ -35,9 +33,9 @@ import {
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import RunnerInputUI from "@/components/runner-ui/RunnerInputUI";
import useAgentGraph from "@/hooks/useAgentGraph";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { RunnerInputDialog } from "@/components/runner-ui/RunnerInputUI";
export const FlowInfo: React.FC<
React.HTMLAttributes<HTMLDivElement> & {
@@ -47,8 +45,12 @@ export const FlowInfo: React.FC<
refresh: () => void;
}
> = ({ flow, executions, flowVersion, refresh, ...props }) => {
const { requestSaveAndRun, requestStopRun, isRunning, nodes, setNodes } =
useAgentGraph(flow.graph_id, flow.graph_version, undefined, false);
const { savedAgent, saveAndRun, stopRun, isRunning } = useAgentGraph(
flow.graph_id,
flow.graph_version,
undefined,
false,
);
const api = useBackendAPI();
@@ -62,90 +64,30 @@ export const FlowInfo: React.FC<
(selectedVersion == "all" ? flow.graph_version : selectedVersion),
);
const hasInputs = Object.keys(flow.input_schema.properties).length > 0;
const hasCredentialsInputs =
Object.keys(flow.credentials_input_schema.properties).length > 0;
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isRunnerInputOpen, setIsRunnerInputOpen] = useState(false);
const [isRunDialogOpen, setIsRunDialogOpen] = useState(false);
const isDisabled = !selectedFlowVersion;
const getBlockInputsAndOutputs = useCallback(() => {
const inputBlocks = filterBlocksByType(
nodes,
(node) => node.data.uiType === BlockUIType.INPUT,
);
const outputBlocks = filterBlocksByType(
nodes,
(node) => node.data.uiType === BlockUIType.OUTPUT,
);
const inputs = inputBlocks.map((node) => ({
id: node.id,
type: "input" as const,
inputSchema: node.data.inputSchema as BlockIORootSchema,
hardcodedValues: {
name: (node.data.hardcodedValues as any).name || "",
description: (node.data.hardcodedValues as any).description || "",
value: (node.data.hardcodedValues as any).value,
placeholder_values:
(node.data.hardcodedValues as any).placeholder_values || [],
},
}));
const outputs = outputBlocks.map((node) => ({
id: node.id,
type: "output" as const,
hardcodedValues: {
name: (node.data.hardcodedValues as any).name || "Output",
description:
(node.data.hardcodedValues as any).description ||
"Output from the agent",
value: (node.data.hardcodedValues as any).value,
},
result: (node.data.executionResults as any)?.at(-1)?.data?.output,
}));
return { inputs, outputs };
}, [nodes]);
useEffect(() => {
api
.getGraphAllVersions(flow.graph_id)
.then((result) => setFlowVersions(result));
}, [flow.graph_id, api]);
const openRunnerInput = () => setIsRunnerInputOpen(true);
const openRunDialog = () => setIsRunDialogOpen(true);
const runOrOpenInput = () => {
const { inputs } = getBlockInputsAndOutputs();
if (inputs.length > 0) {
openRunnerInput();
if (hasInputs || hasCredentialsInputs) {
openRunDialog();
} else {
requestSaveAndRun();
saveAndRun({}, {});
}
};
const handleInputChange = useCallback(
(nodeId: string, field: string, value: any) => {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
hardcodedValues: {
...(node.data.hardcodedValues as any),
[field]: value,
},
},
};
}
return node;
}),
);
},
[setNodes],
);
return (
<Card {...props}>
<CardHeader className="">
@@ -222,7 +164,7 @@ export const FlowInfo: React.FC<
<Button
variant="secondary"
className="bg-purple-500 text-white hover:bg-purple-700"
onClick={isRunning ? requestStopRun : runOrOpenInput}
onClick={!isRunning ? runOrOpenInput : stopRun}
disabled={isDisabled}
title={!isRunning ? "Run Agent" : "Stop Agent"}
>
@@ -282,20 +224,14 @@ export const FlowInfo: React.FC<
</DialogFooter>
</DialogContent>
</Dialog>
<RunnerInputUI
isOpen={isRunnerInputOpen}
onClose={() => setIsRunnerInputOpen(false)}
blockInputs={getBlockInputsAndOutputs().inputs}
onInputChange={handleInputChange}
onRun={() => {
setIsRunnerInputOpen(false);
requestSaveAndRun();
}}
isRunning={isRunning}
scheduledInput={false}
isScheduling={false}
onSchedule={async () => {}} // Fixed type error by making async
/>
{savedAgent && (
<RunnerInputDialog
isOpen={isRunDialogOpen}
doClose={() => setIsRunDialogOpen(false)}
graph={savedAgent}
doRun={saveAndRun}
/>
)}
</Card>
);
};

View File

@@ -7,7 +7,7 @@ import { IconSquare } from "@/components/ui/icons";
import { ExitIcon, Pencil2Icon } from "@radix-ui/react-icons";
import moment from "moment/moment";
import { FlowRunStatusBadge } from "@/components/monitor/FlowRunStatusBadge";
import RunnerOutputUI, { BlockOutput } from "../runner-ui/RunnerOutputUI";
import RunnerOutputUI, { OutputNodeInfo } from "../runner-ui/RunnerOutputUI";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
export const FlowRunInfo: React.FC<
@@ -17,7 +17,7 @@ export const FlowRunInfo: React.FC<
}
> = ({ agent, execution, ...props }) => {
const [isOutputOpen, setIsOutputOpen] = useState(false);
const [blockOutputs, setBlockOutputs] = useState<BlockOutput[]>([]);
const [blockOutputs, setBlockOutputs] = useState<OutputNodeInfo[]>([]);
const api = useBackendAPI();
const fetchBlockResults = useCallback(async () => {
@@ -40,7 +40,7 @@ export const FlowRunInfo: React.FC<
"Output from the agent",
},
result: value,
}) satisfies BlockOutput,
}) satisfies OutputNodeInfo,
),
),
);
@@ -121,8 +121,8 @@ export const FlowRunInfo: React.FC<
</Card>
<RunnerOutputUI
isOpen={isOutputOpen}
onClose={() => setIsOutputOpen(false)}
blockOutputs={blockOutputs}
doClose={() => setIsOutputOpen(false)}
outputs={blockOutputs}
/>
</>
);

View File

@@ -1,49 +0,0 @@
import React from "react";
import {
BlockIOStringSubSchema,
BlockIOSubSchema,
} from "@/lib/autogpt-server-api";
import { TypeBasedInput } from "@/components/type-based-input";
import SchemaTooltip from "../SchemaTooltip";
interface InputBlockProps {
id: string;
name: string;
schema: BlockIOSubSchema;
description?: string;
value: string;
placeholder_values?: any[];
onInputChange: (id: string, field: string, value: string) => void;
}
export function InputBlock({
id,
name,
schema,
description,
value,
placeholder_values,
onInputChange,
}: InputBlockProps) {
if (placeholder_values && placeholder_values.length > 0) {
schema = { ...schema, enum: placeholder_values } as BlockIOStringSubSchema;
}
return (
<div className="space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
{name || "Unnamed Input"}
<SchemaTooltip description={description} />
</label>
<TypeBasedInput
id={`${id}-Value`}
data-testid={`run-dialog-input-${name}`}
schema={schema}
value={value}
placeholder={description}
onChange={(value) => onInputChange(id, "value", value)}
/>
</div>
);
}

View File

@@ -1,34 +0,0 @@
import React from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { InputBlock } from "./RunnerInputBlock";
import { BlockInput } from "./RunnerInputUI";
interface InputListProps {
blockInputs: BlockInput[];
onInputChange: (nodeId: string, field: string, value: any) => void;
}
export function InputList({ blockInputs, onInputChange }: InputListProps) {
return (
<ScrollArea className="max-h-[60vh] overflow-auto">
<div className="space-y-4">
{blockInputs && blockInputs.length > 0 ? (
blockInputs.map((block) => (
<InputBlock
key={block.id}
id={block.id}
schema={block.inputSchema}
name={block.hardcodedValues.name}
description={block.hardcodedValues.description}
value={block.hardcodedValues.value ?? ""}
placeholder_values={block.hardcodedValues.placeholder_values}
onInputChange={onInputChange}
/>
))
) : (
<p>No input blocks available.</p>
)}
</div>
</ScrollArea>
);
}

View File

@@ -1,87 +1,90 @@
import React from "react";
import React, { useCallback } from "react";
import type {
CredentialsMetaInput,
GraphMeta,
} from "@/lib/autogpt-server-api/types";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { BlockIORootSchema } from "@/lib/autogpt-server-api/types";
import { InputList } from "./RunnerInputList";
import AgentRunDraftView from "@/components/agents/agent-run-draft-view";
export interface BlockInput {
id: string;
inputSchema: BlockIORootSchema;
hardcodedValues: {
name: string;
description: string;
value: any;
placeholder_values?: any[];
};
}
interface RunSettingsUiProps {
interface RunInputDialogProps {
isOpen: boolean;
onClose: () => void;
blockInputs: BlockInput[];
onInputChange: (nodeId: string, field: string, value: any) => void;
onRun: () => void;
onSchedule: () => Promise<void>;
scheduledInput: boolean;
isScheduling: boolean;
isRunning: boolean;
doClose: () => void;
graph: GraphMeta;
doRun?: (
inputs: Record<string, any>,
credentialsInputs: Record<string, CredentialsMetaInput>,
) => Promise<void> | void;
doCreateSchedule?: (
cronExpression: string,
scheduleName: string,
inputs: Record<string, any>,
credentialsInputs: Record<string, CredentialsMetaInput>,
) => Promise<void> | void;
}
export function RunnerInputUI({
export function RunnerInputDialog({
isOpen,
onClose,
blockInputs,
isScheduling,
onInputChange,
onRun,
onSchedule,
scheduledInput,
isRunning,
}: RunSettingsUiProps) {
const handleRun = () => {
onRun();
onClose();
};
doClose,
graph,
doRun,
doCreateSchedule,
}: RunInputDialogProps) {
const handleRun = useCallback(
doRun
? async (
inputs: Record<string, any>,
credentials_inputs: Record<string, CredentialsMetaInput>,
) => {
await doRun(inputs, credentials_inputs);
doClose();
}
: async () => {},
[doRun, doClose],
);
const handleSchedule = async () => {
onClose();
await onSchedule();
};
const handleSchedule = useCallback(
doCreateSchedule
? async (
cronExpression: string,
scheduleName: string,
inputs: Record<string, any>,
credentialsInputs: Record<string, CredentialsMetaInput>,
) => {
await doCreateSchedule(
cronExpression,
scheduleName,
inputs,
credentialsInputs,
);
doClose();
}
: async () => {},
[doCreateSchedule, doClose],
);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="flex flex-col px-10 py-8">
<Dialog open={isOpen} onOpenChange={doClose}>
<DialogContent className="flex w-[90vw] max-w-4xl flex-col p-10">
<DialogHeader>
<DialogTitle className="text-2xl">
{scheduledInput ? "Schedule Settings" : "Run Settings"}
</DialogTitle>
<DialogDescription className="mt-2 text-sm">
Configure settings for running your agent.
</DialogDescription>
<DialogTitle className="text-2xl">Run your agent</DialogTitle>
<DialogDescription className="mt-2">{graph.name}</DialogDescription>
</DialogHeader>
<div className="flex-grow overflow-y-auto">
<InputList blockInputs={blockInputs} onInputChange={onInputChange} />
</div>
<DialogFooter>
<Button
data-testid="run-dialog-run-button"
onClick={scheduledInput ? handleSchedule : handleRun}
className="text-lg"
disabled={scheduledInput ? isScheduling : isRunning}
>
{scheduledInput ? "Schedule" : isRunning ? "Running..." : "Run"}
</Button>
</DialogFooter>
<AgentRunDraftView
className="p-0"
graph={graph}
doRun={doRun ? handleRun : undefined}
onRun={doRun ? undefined : doClose}
doCreateSchedule={doCreateSchedule ? handleSchedule : undefined}
onCreateSchedule={doCreateSchedule ? undefined : doClose}
/>
</DialogContent>
</Dialog>
);
}
export default RunnerInputUI;

View File

@@ -13,7 +13,7 @@ import { Button } from "@/components/ui/button";
import { Clipboard } from "lucide-react";
import { useToast } from "@/components/molecules/Toast/use-toast";
export type BlockOutput = {
export type OutputNodeInfo = {
metadata: {
name: string;
description: string;
@@ -23,8 +23,8 @@ export type BlockOutput = {
interface OutputModalProps {
isOpen: boolean;
onClose: () => void;
blockOutputs: BlockOutput[];
doClose: () => void;
outputs: OutputNodeInfo[];
}
const formatOutput = (output: any): string => {
@@ -47,11 +47,7 @@ const formatOutput = (output: any): string => {
return String(output);
};
export function RunnerOutputUI({
isOpen,
onClose,
blockOutputs,
}: OutputModalProps) {
export function RunnerOutputUI({ isOpen, doClose, outputs }: OutputModalProps) {
const { toast } = useToast();
const copyOutput = (name: string, output: any) => {
@@ -70,7 +66,7 @@ export function RunnerOutputUI({
};
return (
<Sheet open={isOpen} onOpenChange={onClose}>
<Sheet open={isOpen} onOpenChange={doClose}>
<SheetContent
side="right"
className="flex h-full w-full flex-col overflow-hidden sm:max-w-[600px]"
@@ -84,16 +80,16 @@ export function RunnerOutputUI({
<div className="flex-grow overflow-y-auto px-2 py-2">
<ScrollArea className="h-full overflow-auto pr-4">
<div className="space-y-4">
{blockOutputs && blockOutputs.length > 0 ? (
blockOutputs.map((block, i) => (
{outputs && outputs.length > 0 ? (
outputs.map((output, i) => (
<div key={i} className="space-y-1">
<Label className="text-base font-semibold">
{block.metadata.name || "Unnamed Output"}
{output.metadata.name || "Unnamed Output"}
</Label>
{block.metadata.description && (
{output.metadata.description && (
<Label className="block text-sm text-gray-600">
{block.metadata.description}
{output.metadata.description}
</Label>
)}
@@ -104,8 +100,8 @@ export function RunnerOutputUI({
size="icon"
onClick={() =>
copyOutput(
block.metadata.name || "Unnamed Output",
block.result,
output.metadata.name || "Unnamed Output",
output.result,
)
}
title="Copy Output"
@@ -114,7 +110,7 @@ export function RunnerOutputUI({
</Button>
<Textarea
readOnly
value={formatOutput(block.result ?? "No output yet")}
value={formatOutput(output.result ?? "No output yet")}
className="w-full resize-none whitespace-pre-wrap break-words border-none bg-transparent text-sm"
style={{
height: "auto",

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { useCallback } from "react";
import { Node, Edge, useReactFlow, useViewport } from "@xyflow/react";
import { Node, Edge, useReactFlow } from "@xyflow/react";
interface CopyableData {
nodes: Node[];
@@ -7,8 +7,9 @@ interface CopyableData {
}
export function useCopyPaste(getNextNodeId: () => string) {
const { setNodes, addEdges, getNodes, getEdges } = useReactFlow();
const { x, y, zoom } = useViewport();
const { setNodes, addEdges, getNodes, getEdges, getViewport } =
useReactFlow();
const { x, y, zoom } = getViewport();
const handleCopyPaste = useCallback(
(event: KeyboardEvent) => {

View File

@@ -471,7 +471,7 @@ export default class BackendAPI {
);
}
getAgentMetaByStoreListingVersionId(
getGraphMetaByStoreListingVersionID(
storeListingVersionID: string,
): Promise<GraphMeta> {
return this._get(`/store/graph/${storeListingVersionID}`);
@@ -672,21 +672,16 @@ export default class BackendAPI {
return this._request("POST", `/library/agents/${libraryAgentId}/fork`);
}
async setupAgentTrigger(
libraryAgentID: LibraryAgentID,
params: {
name: string;
description?: string;
trigger_config: Record<string, any>;
agent_credentials: Record<string, CredentialsMetaInput>;
},
): Promise<LibraryAgentPreset> {
async setupAgentTrigger(params: {
name: string;
description?: string;
graph_id: GraphID;
graph_version: number;
trigger_config: Record<string, any>;
agent_credentials: Record<string, CredentialsMetaInput>;
}): Promise<LibraryAgentPreset> {
return parseLibraryAgentPresetTimestamp(
await this._request(
"POST",
`/library/agents/${libraryAgentID}/setup-trigger`,
params,
),
await this._request("POST", `/library/presets/setup-trigger`, params),
);
}

View File

@@ -24,7 +24,7 @@ export enum BlockCostType {
export type BlockCost = {
cost_amount: number;
cost_type: BlockCostType;
cost_filter: { [key: string]: any };
cost_filter: Record<string, any>;
};
/* Mirror of backend/data/block.py:Block */
@@ -37,14 +37,12 @@ export type Block = {
outputSchema: BlockIORootSchema;
staticOutput: boolean;
uiType: BlockUIType;
uiKey?: string;
costs: BlockCost[];
hardcodedValues: { [key: string]: any } | null;
};
export type BlockIORootSchema = {
type: "object";
properties: { [key: string]: BlockIOSubSchema };
properties: Record<string, BlockIOSubSchema>;
required?: (keyof BlockIORootSchema["properties"])[];
additionalProperties?: { type: string };
};
@@ -93,9 +91,9 @@ export type BlockIOSubSchemaMeta = {
export type BlockIOObjectSubSchema = BlockIOSubSchemaMeta & {
type: "object";
properties: { [key: string]: BlockIOSubSchema };
const?: { [key: keyof BlockIOObjectSubSchema["properties"]]: any };
default?: { [key: keyof BlockIOObjectSubSchema["properties"]]: any };
properties: Record<string, BlockIOSubSchema>;
const?: Record<keyof BlockIOObjectSubSchema["properties"], any>;
default?: Record<keyof BlockIOObjectSubSchema["properties"], any>;
required?: (keyof BlockIOObjectSubSchema["properties"])[];
secret?: boolean;
};
@@ -103,8 +101,8 @@ export type BlockIOObjectSubSchema = BlockIOSubSchemaMeta & {
export type BlockIOKVSubSchema = BlockIOSubSchemaMeta & {
type: "object";
additionalProperties?: { type: "string" | "number" | "integer" };
const?: { [key: string]: string | number };
default?: { [key: string]: string | number };
const?: Record<string, string | number>;
default?: Record<string, string | number>;
secret?: boolean;
};
@@ -169,7 +167,7 @@ export type BlockIOCredentialsSubSchema = BlockIOObjectSubSchema & {
credentials_scopes?: string[];
credentials_types: Array<CredentialsType>;
discriminator?: string;
discriminator_mapping?: { [key: string]: CredentialsProviderName };
discriminator_mapping?: Record<string, CredentialsProviderName>;
discriminator_values?: any[];
secret?: boolean;
};
@@ -215,17 +213,20 @@ export type BlockIODiscriminatedOneOfSubSchema = {
secret?: boolean;
};
/* Mirror of backend/data/graph.py:Node */
export type Node = {
export type NodeCreatable = {
id: string;
block_id: string;
input_default: { [key: string]: any };
input_nodes: Array<{ name: string; node_id: string }>;
output_nodes: Array<{ name: string; node_id: string }>;
input_default: Record<string, any>;
metadata: {
position: { x: number; y: number };
[key: string]: any;
};
};
/* Mirror of backend/data/graph.py:Node */
export type Node = NodeCreatable & {
input_links: Link[];
output_links: Link[];
webhook?: Webhook;
};
@@ -279,6 +280,7 @@ export type GraphExecution = GraphExecutionMeta & {
node_executions?: NodeExecutionResult[];
};
/* Mirror of backend/data/graph.py:GraphMeta */
export type GraphMeta = {
id: GraphID;
user_id: UserID;
@@ -290,11 +292,8 @@ export type GraphMeta = {
forked_from_version?: number | null;
input_schema: GraphIOSchema;
output_schema: GraphIOSchema;
credentials_input_schema: {
type: "object";
properties: { [key: string]: BlockIOCredentialsSubSchema };
required: (keyof GraphMeta["credentials_input_schema"]["properties"])[];
};
has_external_trigger: boolean;
credentials_input_schema: CredentialsInputSchema;
};
export type GraphID = Brand<string, "GraphID">;
@@ -302,7 +301,7 @@ export type GraphID = Brand<string, "GraphID">;
/* Derived from backend/data/graph.py:Graph._generate_schema() */
export type GraphIOSchema = {
type: "object";
properties: { [key: string]: GraphIOSubSchema };
properties: Record<string, GraphIOSubSchema>;
required: (keyof BlockIORootSchema["properties"])[];
};
export type GraphIOSubSchema = Omit<
@@ -315,11 +314,16 @@ export type GraphIOSubSchema = Omit<
metadata?: any;
};
export type CredentialsInputSchema = {
type: "object";
properties: Record<string, BlockIOCredentialsSubSchema>;
required: (keyof CredentialsInputSchema["properties"])[];
};
/* Mirror of backend/data/graph.py:Graph */
export type Graph = GraphMeta & {
nodes: Array<Node>;
links: Array<Link>;
has_webhook_trigger: boolean;
};
export type GraphUpdateable = Omit<
@@ -327,14 +331,16 @@ export type GraphUpdateable = Omit<
| "user_id"
| "version"
| "is_active"
| "nodes"
| "links"
| "input_schema"
| "output_schema"
| "credentials_input_schema"
| "has_webhook_trigger"
| "has_external_trigger"
> & {
version?: number;
is_active?: boolean;
nodes: Array<NodeCreatable>;
links: Array<LinkCreatable>;
input_schema?: GraphIOSchema;
output_schema?: GraphIOSchema;
@@ -357,8 +363,8 @@ export type NodeExecutionResult = {
| "COMPLETED"
| "TERMINATED"
| "FAILED";
input_data: { [key: string]: any };
output_data: { [key: string]: Array<any> };
input_data: Record<string, any>;
output_data: Record<string, Array<any>>;
add_time: Date;
queue_time?: Date;
start_time?: Date;
@@ -380,31 +386,29 @@ export type LibraryAgent = {
name: string;
description: string;
input_schema: GraphIOSchema;
credentials_input_schema: {
type: "object";
properties: { [key: string]: BlockIOCredentialsSubSchema };
required: (keyof LibraryAgent["credentials_input_schema"]["properties"])[];
};
credentials_input_schema: CredentialsInputSchema;
new_output: boolean;
can_access_graph: boolean;
is_latest_version: boolean;
} & (
| {
has_external_trigger: true;
trigger_setup_info: {
provider: CredentialsProviderName;
config_schema: BlockIORootSchema;
credentials_input_name?: string;
};
trigger_setup_info: LibraryAgentTriggerInfo;
}
| {
has_external_trigger: false;
trigger_setup_info?: null;
trigger_setup_info?: undefined;
}
);
export type LibraryAgentID = Brand<string, "LibraryAgentID">;
export type LibraryAgentTriggerInfo = {
provider: CredentialsProviderName;
config_schema: BlockIORootSchema;
credentials_input_name?: string;
};
export enum AgentStatus {
COMPLETED = "COMPLETED",
HEALTHY = "HEALTHY",
@@ -427,7 +431,7 @@ export type LibraryAgentPreset = {
updated_at: Date;
graph_id: GraphID;
graph_version: number;
inputs: { [key: string]: any };
inputs: Record<string, any>;
credentials: Record<string, CredentialsMetaInput>;
name: string;
description: string;
@@ -616,7 +620,7 @@ export type AnalyticsMetrics = {
export type AnalyticsDetails = {
type: string;
data: { [key: string]: any };
data: Record<string, any>;
index: string;
};
@@ -887,7 +891,7 @@ export interface UserOnboarding {
integrations: string[];
otherIntegrations: string | null;
selectedStoreListingVersionId: string | null;
agentInput: { [key: string]: string | number } | null;
agentInput: Record<string, string | number> | null;
onboardingAgentExecutionId: GraphExecutionID | null;
agentRuns: number;
}

View File

@@ -1,4 +1,5 @@
import { type ClassValue, clsx } from "clsx";
import { isEmpty as _isEmpty } from "lodash";
import { twMerge } from "tailwind-merge";
import { Category } from "@/lib/autogpt-server-api/types";
@@ -227,13 +228,6 @@ export function getPrimaryCategoryColor(categories: Category[]): string {
);
}
export function filterBlocksByType<T>(
blocks: T[],
predicate: (block: T) => boolean,
): T[] {
return blocks.filter(predicate);
}
export enum BehaveAs {
CLOUD = "CLOUD",
LOCAL = "LOCAL",
@@ -397,6 +391,14 @@ export function isEmptyOrWhitespace(str: string | undefined | null): boolean {
return !str || str.trim().length === 0;
}
export function isEmpty(value: any): boolean {
return (
value === undefined ||
value === "" ||
(typeof value === "object" && _isEmpty(value))
);
}
/** Check if a value is an object or not */
export function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);

View File

@@ -21,17 +21,13 @@ test.describe("Build", () => { //(1)!
await loginPage.login(testUser.email, testUser.password);
await test.expect(page).toHaveURL("/marketplace"); //(5)!
await buildPage.navbar.clickBuildLink();
await test.expect(page).toHaveURL("/build");
await buildPage.waitForPageLoad();
});
// Reason Ignore: admonishment is in the wrong place visually with correct prettier rules
// prettier-ignore
test("user can add a block", async ({ page }) => { //(6)!
// workaround for #8788
await buildPage.navbar.clickBuildLink();
await test.expect(page).toHaveURL(new RegExp("/build"));
await buildPage.waitForPageLoad();
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy(); //(7)!
test("user can add a block", async () => { //(6)!
await buildPage.closeTutorial(); //(9)!
await buildPage.openBlocksPanel(); //(10)!
const block = await buildPage.getDictionaryBlockDetails();
@@ -43,24 +39,24 @@ test.describe("Build", () => { //(1)!
// --8<-- [end:BuildPageExample]
test.skip("user can add all blocks a-l", async ({ page }, testInfo) => {
// this test is slow af so we 10x the timeout (sorry future me)
await test.setTimeout(testInfo.timeout * 100);
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
await test.expect(page).toHaveURL(new RegExp("/.*build"));
// this test is slow af so we 100x the timeout (sorry future me)
test.setTimeout(testInfo.timeout * 100);
await buildPage.closeTutorial();
await buildPage.openBlocksPanel();
const blocks = await buildPage.getBlocks();
const blockIdsToSkip = await buildPage.getBlocksToSkip();
const blockTypesToSkip = ["Input", "Output", "Agent", "AI"];
console.log("⚠️ Skipping blocks:", blockIdsToSkip);
console.log("⚠️ Skipping block types:", blockTypesToSkip);
// add all the blocks in order except for the agent executor block
for (const block of blocks) {
if (block.name[0].toLowerCase() >= "m") {
continue;
}
if (!blockIdsToSkip.some((b) => b === block.id) && !blockTypesToSkip.some((b) => block.type === b)) {
console.log("Adding block:", block.name, block.id, block.type, " skipping types:", blockTypesToSkip);
if (!blockIdsToSkip.includes(block.id) && !blockTypesToSkip.includes(block.type)) {
await buildPage.addBlock(block);
}
}
@@ -70,8 +66,7 @@ test.describe("Build", () => { //(1)!
if (block.name[0].toLowerCase() >= "m") {
continue;
}
if (!blockIdsToSkip.some((b) => b === block.id) && !blockTypesToSkip.some((b) => block.type === b)) {
console.log("Checking block:", block.name, block.id, block.type, " skipping types:", blockTypesToSkip);
if (!blockIdsToSkip.includes(block.id) && !blockTypesToSkip.includes(block.type)) {
await test.expect(buildPage.hasBlock(block)).resolves.toBeTruthy();
}
}
@@ -79,28 +74,28 @@ test.describe("Build", () => { //(1)!
// check that we can save the agent with all the blocks
await buildPage.saveAgent("all blocks test", "all blocks test");
// page should have a url like http://localhost:3000/build?flowID=f4f3a1da-cfb3-430f-a074-a455b047e340
await test.expect(page).toHaveURL(new RegExp("/.*build\\?flowID=.+"));
await test.expect(page).toHaveURL(({ searchParams }) => !!searchParams.get("flowID"));
});
test.skip("user can add all blocks m-z", async ({ page }, testInfo) => {
// this test is slow af so we 10x the timeout (sorry future me)
await test.setTimeout(testInfo.timeout * 100);
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
await test.expect(page).toHaveURL(new RegExp("/.*build"));
// this test is slow af so we 100x the timeout (sorry future me)
test.setTimeout(testInfo.timeout * 100);
await buildPage.closeTutorial();
await buildPage.openBlocksPanel();
const blocks = await buildPage.getBlocks();
const blockIdsToSkip = await buildPage.getBlocksToSkip();
const blockTypesToSkip = ["Input", "Output", "Agent", "AI"];
console.log("⚠️ Skipping blocks:", blockIdsToSkip);
console.log("⚠️ Skipping block types:", blockTypesToSkip);
// add all the blocks in order except for the agent executor block
for (const block of blocks) {
if (block.name[0].toLowerCase() < "m") {
continue;
}
if (!blockIdsToSkip.some((b) => b === block.id) && !blockTypesToSkip.some((b) => block.type === b)) {
console.log("Adding block:", block.name, block.id, block.type, " skipping types:", blockTypesToSkip);
if (!blockIdsToSkip.includes(block.id) && !blockTypesToSkip.includes(block.type)) {
await buildPage.addBlock(block);
}
}
@@ -110,8 +105,7 @@ test.describe("Build", () => { //(1)!
if (block.name[0].toLowerCase() < "m") {
continue;
}
if (!blockIdsToSkip.some((b) => b === block.id) && !blockTypesToSkip.some((b) => block.type === b)) {
console.log("Checking block:", block.name, block.id, block.type, " skipping types:", blockTypesToSkip);
if (!blockIdsToSkip.includes(block.id) && !blockTypesToSkip.includes(block.type)) {
await test.expect(buildPage.hasBlock(block)).resolves.toBeTruthy();
}
}
@@ -119,25 +113,26 @@ test.describe("Build", () => { //(1)!
// check that we can save the agent with all the blocks
await buildPage.saveAgent("all blocks test", "all blocks test");
// page should have a url like http://localhost:3000/build?flowID=f4f3a1da-cfb3-430f-a074-a455b047e340
await test.expect(page).toHaveURL(new RegExp("/.*build\\?flowID=.+"));
await test.expect(page).toHaveURL(({ searchParams }) => !!searchParams.get("flowID"));
});
test("build navigation is accessible from navbar", async ({ page }) => {
// Navigate somewhere else first
await page.goto("/marketplace"); //(4)!
// Check that navigation to the Builder is available on the page
await buildPage.navbar.clickBuildLink();
await test.expect(page).toHaveURL(new RegExp("/build"));
// workaround for #8788
await page.reload();
await page.reload();
await buildPage.waitForPageLoad();
await test.expect(page).toHaveURL("/build");
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
});
test("user can add two blocks and connect them", async ({
page,
}, testInfo) => {
await test.setTimeout(testInfo.timeout * 10);
test.setTimeout(testInfo.timeout * 10);
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
await test.expect(page).toHaveURL(new RegExp("/.*build"));
await buildPage.closeTutorial();
await buildPage.openBlocksPanel();
@@ -179,7 +174,7 @@ test.describe("Build", () => { //(1)!
"Connected Blocks Test",
"Testing block connections",
);
await test.expect(page).toHaveURL(new RegExp("/.*build\\?flowID=.+"));
await test.expect(page).toHaveURL(({ searchParams }) => !!searchParams.get("flowID"));
// Wait for the save button to be enabled again
await buildPage.waitForSaveButton();
@@ -204,9 +199,7 @@ test.describe("Build", () => { //(1)!
}) => {
// simple calculator to double input and output it
// load the pages and prep
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
await test.expect(page).toHaveURL(new RegExp("/.*build"));
// prep
await buildPage.closeTutorial();
await buildPage.openBlocksPanel();
@@ -286,7 +279,7 @@ test.describe("Build", () => { //(1)!
"Input and Output Blocks Test",
"Testing input and output blocks",
);
await test.expect(page).toHaveURL(new RegExp("/.*build\\?flowID=.+"));
await test.expect(page).toHaveURL(({ searchParams }) => !!searchParams.get("flowID"));
// Wait for save to complete
await page.waitForTimeout(1000);

View File

@@ -45,7 +45,7 @@ export class BuildPage extends BasePage {
name: string = "Test Agent",
description: string = "",
): Promise<void> {
console.log(`saving agent ${name} with description ${description}`);
console.log(`💾 Saving agent '${name}' with description '${description}'`);
await this.page.getByTestId("blocks-control-save-button").click();
await this.page.getByTestId("save-control-name-input").fill(name);
await this.page
@@ -55,9 +55,11 @@ export class BuildPage extends BasePage {
}
async getBlocks(): Promise<Block[]> {
console.log(`getting blocks in sidebar panel`);
console.log(`Getting available blocks from sidebar panel`);
try {
const blocks = await this.page.locator('[data-id^="block-card-"]').all();
const blockFinder = this.page.locator('[data-id^="block-card-"]');
await blockFinder.first().waitFor();
const blocks = await blockFinder.all();
console.log(`found ${blocks.length} blocks`);
@@ -97,7 +99,7 @@ export class BuildPage extends BasePage {
}
async addBlock(block: Block): Promise<void> {
console.log(`adding block ${block.id} ${block.name} to agent`);
console.log(`Adding block ${block.name} (${block.id}) to agent`);
await this.page.getByTestId(`block-name-${block.id}`).click();
}
@@ -108,13 +110,11 @@ export class BuildPage extends BasePage {
async hasBlock(block: Block): Promise<boolean> {
console.log(
`checking if block ${block.id} ${block.name} is visible on page`,
`Checking if block ${block.name} (${block.id}) is visible on page`,
);
try {
// Use both ID and name for most precise matching
const node = await this.page
.locator(`[data-blockid="${block.id}"]`)
.first();
const node = this.page.locator(`[data-blockid="${block.id}"]`).first();
return await node.isVisible();
} catch (error) {
console.error("Error checking for block:", error);
@@ -123,11 +123,9 @@ export class BuildPage extends BasePage {
}
async getBlockInputs(blockId: string): Promise<string[]> {
console.log(`getting block ${blockId} inputs`);
console.log(`Getting block ${blockId} inputs`);
try {
const node = await this.page
.locator(`[data-blockid="${blockId}"]`)
.first();
const node = this.page.locator(`[data-blockid="${blockId}"]`).first();
const inputsData = await node.getAttribute("data-inputs");
return inputsData ? JSON.parse(inputsData) : [];
} catch (error) {
@@ -159,9 +157,7 @@ export class BuildPage extends BasePage {
async getBlockById(blockId: string, dataId?: string): Promise<Locator> {
console.log(`getting block ${blockId} with dataId ${dataId}`);
return await this.page.locator(
await this._buildBlockSelector(blockId, dataId),
);
return this.page.locator(await this._buildBlockSelector(blockId, dataId));
}
// dataId is optional, if provided, it will start the search with that container, otherwise it will start with the blockId
@@ -177,7 +173,7 @@ export class BuildPage extends BasePage {
`filling block input ${placeholder} with value ${value} of block ${blockId}`,
);
const block = await this.getBlockById(blockId, dataId);
const input = await block.getByPlaceholder(placeholder);
const input = block.getByPlaceholder(placeholder);
await input.fill(value);
}
@@ -222,7 +218,7 @@ export class BuildPage extends BasePage {
): Promise<void> {
console.log(`filling block input ${label} with value ${value}`);
const block = await this.getBlockById(blockId);
const input = await block.getByLabel(label);
const input = block.getByLabel(label);
await input.fill(value);
}
@@ -235,13 +231,9 @@ export class BuildPage extends BasePage {
);
try {
// Locate the output element
const outputElement = await this.page.locator(
`[data-id="${blockOutputId}"]`,
);
const outputElement = this.page.locator(`[data-id="${blockOutputId}"]`);
// Locate the input element
const inputElement = await this.page.locator(
`[data-id="${blockInputId}"]`,
);
const inputElement = this.page.locator(`[data-id="${blockInputId}"]`);
await outputElement.dragTo(inputElement);
} catch (error) {
@@ -303,12 +295,12 @@ export class BuildPage extends BasePage {
async fillRunDialog(inputs: Record<string, string>): Promise<void> {
console.log(`filling run dialog`);
for (const [key, value] of Object.entries(inputs)) {
await this.page.getByTestId(`run-dialog-input-${key}`).fill(value);
await this.page.getByTestId(`agent-input-${key}`).fill(value);
}
}
async clickRunDialogRunButton(): Promise<void> {
console.log(`clicking run button`);
await this.page.getByTestId("run-dialog-run-button").click();
await this.page.getByTestId("agent-run-button").click();
}
async waitForCompletionBadge(): Promise<void> {