feat(platform): Disable Trigger Setup through Builder (#10418)

We want users to set up triggers through the Library rather than the
Builder.

- Resolves #10413


https://github.com/user-attachments/assets/515ed80d-6569-4e26-862f-2a663115218c

### Changes 🏗️

- Update node UI to push users to Library for trigger set-up and
management
  - Add note redirecting to Library for trigger set-up
  - Remove webhook status indicator and webhook URL section
- Add `libraryAgent: LibraryAgent` to `BuilderContext` for access inside
`CustomNode`
  - Move library agent loader from `FlowEditor` to `useAgentGraph`

- Implement `migrate_legacy_triggered_graphs` migrator function
- Remove `on_node_activate` hook (which previously handled webhook
setup)
- Propagate `created_at` from DB to `GraphModel` and
`LibraryAgentPreset` models

### 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:
- [x] Existing node triggers are converted to triggered presets (visible
in the Library)
    - [x] Converted triggered presets work
  - [x] Trigger node inputs are disabled and handles are hidden
- [x] Trigger node message links to the correct Library Agent when saved
This commit is contained in:
Reinier van der Leer
2025-09-17 00:52:51 +02:00
committed by GitHub
parent c2f11dbcfa
commit 7d2ab61546
15 changed files with 185 additions and 198 deletions

View File

@@ -1,6 +1,7 @@
import logging
import uuid
from collections import defaultdict
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Literal, Optional, cast
from prisma.enums import SubmissionStatus
@@ -381,6 +382,8 @@ class GraphModel(Graph):
user_id: str
nodes: list[NodeModel] = [] # type: ignore
created_at: datetime
@property
def starting_nodes(self) -> list[NodeModel]:
outbound_nodes = {link.sink_id for link in self.links}
@@ -393,6 +396,10 @@ class GraphModel(Graph):
if node.id not in outbound_nodes or node.id in input_nodes
]
@property
def webhook_input_node(self) -> NodeModel | None: # type: ignore
return cast(NodeModel, super().webhook_input_node)
def meta(self) -> "GraphMeta":
"""
Returns a GraphMeta object with metadata about the graph.
@@ -694,6 +701,7 @@ class GraphModel(Graph):
version=graph.version,
forked_from_id=graph.forkedFromId,
forked_from_version=graph.forkedFromVersion,
created_at=graph.createdAt,
is_active=graph.isActive,
name=graph.name or "",
description=graph.description or "",
@@ -1144,6 +1152,7 @@ def make_graph_model(creatable_graph: Graph, user_id: str) -> GraphModel:
return GraphModel(
**creatable_graph.model_dump(exclude={"nodes"}),
user_id=user_id,
created_at=datetime.now(tz=timezone.utc),
nodes=[
NodeModel(
**creatable_node.model_dump(),

View File

@@ -7,10 +7,9 @@ from backend.data.graph import set_node_webhook
from backend.integrations.creds_manager import IntegrationCredentialsManager
from . import get_webhook_manager, supports_webhooks
from .utils import setup_webhook_for_block
if TYPE_CHECKING:
from backend.data.graph import BaseGraph, GraphModel, Node, NodeModel
from backend.data.graph import BaseGraph, GraphModel, NodeModel
from backend.data.model import Credentials
from ._base import BaseWebhooksManager
@@ -43,32 +42,19 @@ async def _on_graph_activate(graph: "BaseGraph", user_id: str) -> "BaseGraph": .
async def _on_graph_activate(graph: "BaseGraph | GraphModel", user_id: str):
get_credentials = credentials_manager.cached_getter(user_id)
updated_nodes = []
for new_node in graph.nodes:
block_input_schema = cast(BlockSchema, new_node.block.input_schema)
node_credentials = None
if (
# Webhook-triggered blocks are only allowed to have 1 credentials input
(
creds_field_name := next(
iter(block_input_schema.get_credentials_fields()), None
for creds_field_name in block_input_schema.get_credentials_fields().keys():
# Prevent saving graph with non-existent credentials
if (
creds_meta := new_node.input_default.get(creds_field_name)
) and not await get_credentials(creds_meta["id"]):
raise ValueError(
f"Node #{new_node.id} input '{creds_field_name}' updated with "
f"non-existent credentials #{creds_meta['id']}"
)
)
and (creds_meta := new_node.input_default.get(creds_field_name))
and not (node_credentials := await get_credentials(creds_meta["id"]))
):
raise ValueError(
f"Node #{new_node.id} input '{creds_field_name}' updated with "
f"non-existent credentials #{creds_meta['id']}"
)
updated_node = await on_node_activate(
user_id, graph.id, new_node, credentials=node_credentials
)
updated_nodes.append(updated_node)
graph.nodes = updated_nodes
return graph
@@ -85,20 +71,14 @@ async def on_graph_deactivate(graph: "GraphModel", user_id: str):
block_input_schema = cast(BlockSchema, node.block.input_schema)
node_credentials = None
if (
# Webhook-triggered blocks are only allowed to have 1 credentials input
(
creds_field_name := next(
iter(block_input_schema.get_credentials_fields()), None
for creds_field_name in block_input_schema.get_credentials_fields().keys():
if (creds_meta := node.input_default.get(creds_field_name)) and not (
node_credentials := await get_credentials(creds_meta["id"])
):
logger.warning(
f"Node #{node.id} input '{creds_field_name}' referenced "
f"non-existent credentials #{creds_meta['id']}"
)
)
and (creds_meta := node.input_default.get(creds_field_name))
and not (node_credentials := await get_credentials(creds_meta["id"]))
):
logger.error(
f"Node #{node.id} input '{creds_field_name}' referenced non-existent "
f"credentials #{creds_meta['id']}"
)
updated_node = await on_node_deactivate(
user_id, node, credentials=node_credentials
@@ -109,32 +89,6 @@ async def on_graph_deactivate(graph: "GraphModel", user_id: str):
return graph
async def on_node_activate(
user_id: str,
graph_id: str,
node: "Node",
*,
credentials: Optional["Credentials"] = None,
) -> "Node":
"""Hook to be called when the node is activated/created"""
if node.block.webhook_config:
new_webhook, feedback = await setup_webhook_for_block(
user_id=user_id,
trigger_block=node.block,
trigger_config=node.input_default,
for_graph_id=graph_id,
)
if new_webhook:
node = await set_node_webhook(node.id, new_webhook.id)
else:
logger.debug(
f"Node #{node.id} does not have everything for a webhook: {feedback}"
)
return node
async def on_node_deactivate(
user_id: str,
node: "NodeModel",

View File

@@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, Optional, cast
from pydantic import JsonValue
from backend.integrations.creds_manager import IntegrationCredentialsManager
from backend.integrations.providers import ProviderName
from backend.util.settings import Config
from . import get_webhook_manager, supports_webhooks
@@ -13,6 +12,7 @@ if TYPE_CHECKING:
from backend.data.block import Block, BlockSchema
from backend.data.integrations import Webhook
from backend.data.model import Credentials
from backend.integrations.providers import ProviderName
logger = logging.getLogger(__name__)
app_config = Config()
@@ -20,7 +20,7 @@ credentials_manager = IntegrationCredentialsManager()
# TODO: add test to assert this matches the actual API route
def webhook_ingress_url(provider_name: ProviderName, webhook_id: str) -> str:
def webhook_ingress_url(provider_name: "ProviderName", webhook_id: str) -> str:
return (
f"{app_config.platform_base_url}/api/integrations/{provider_name.value}"
f"/webhooks/{webhook_id}/ingress"
@@ -144,3 +144,62 @@ async def setup_webhook_for_block(
)
logger.debug(f"Acquired webhook: {webhook}")
return webhook, None
async def migrate_legacy_triggered_graphs():
from prisma.models import AgentGraph
from backend.data.graph import AGENT_GRAPH_INCLUDE, GraphModel, set_node_webhook
from backend.data.model import is_credentials_field_name
from backend.server.v2.library.db import create_preset
from backend.server.v2.library.model import LibraryAgentPresetCreatable
triggered_graphs = [
GraphModel.from_db(_graph)
for _graph in await AgentGraph.prisma().find_many(
where={
"isActive": True,
"Nodes": {"some": {"NOT": [{"webhookId": None}]}},
},
include=AGENT_GRAPH_INCLUDE,
)
]
n_migrated_webhooks = 0
for graph in triggered_graphs:
if not ((trigger_node := graph.webhook_input_node) and trigger_node.webhook_id):
continue
# Use trigger node's inputs for the preset
preset_credentials = {
field_name: creds_meta
for field_name, creds_meta in trigger_node.input_default.items()
if is_credentials_field_name(field_name)
}
preset_inputs = {
field_name: value
for field_name, value in trigger_node.input_default.items()
if not is_credentials_field_name(field_name)
}
# Create a triggered preset for the graph
await create_preset(
graph.user_id,
LibraryAgentPresetCreatable(
graph_id=graph.id,
graph_version=graph.version,
inputs=preset_inputs,
credentials=preset_credentials,
name=graph.name,
description=graph.description,
webhook_id=trigger_node.webhook_id,
is_active=True,
),
)
# Detach webhook from the graph node
await set_node_webhook(trigger_node.id, None)
n_migrated_webhooks += 1
logger.info(f"Migrated {n_migrated_webhooks} node triggers to triggered presets")

View File

@@ -18,6 +18,7 @@ import backend.data.block
import backend.data.db
import backend.data.graph
import backend.data.user
import backend.integrations.webhooks.utils
import backend.server.routers.postmark.postmark
import backend.server.routers.v1
import backend.server.v2.admin.credit_admin_routes
@@ -79,6 +80,8 @@ async def lifespan_context(app: fastapi.FastAPI):
await backend.data.user.migrate_and_encrypt_user_integrations()
await backend.data.graph.fix_llm_provider_credentials()
await backend.data.graph.migrate_llm_models(LlmModel.GPT4O)
await backend.integrations.webhooks.utils.migrate_legacy_triggered_graphs()
with launch_darkly_context():
yield

View File

@@ -1,4 +1,5 @@
import json
from datetime import datetime
from io import BytesIO
from unittest.mock import AsyncMock, Mock, patch
@@ -265,6 +266,7 @@ def test_get_graphs(
name="Test Graph",
description="A test graph",
user_id=test_user_id,
created_at=datetime(2025, 9, 4, 13, 37),
)
mocker.patch(
@@ -299,6 +301,7 @@ def test_get_graph(
name="Test Graph",
description="A test graph",
user_id=test_user_id,
created_at=datetime(2025, 9, 4, 13, 37),
)
mocker.patch(
@@ -348,6 +351,7 @@ def test_delete_graph(
name="Test Graph",
description="A test graph",
user_id=test_user_id,
created_at=datetime(2025, 9, 4, 13, 37),
)
mocker.patch(

View File

@@ -795,10 +795,7 @@ async def create_preset(
)
for name, data in {
**preset.inputs,
**{
key: creds_meta.model_dump(exclude_none=True)
for key, creds_meta in preset.credentials.items()
},
**preset.credentials,
}.items()
]
},

View File

@@ -261,6 +261,7 @@ class LibraryAgentPreset(LibraryAgentPresetCreatable):
id: str
user_id: str
created_at: datetime.datetime
updated_at: datetime.datetime
webhook: "Webhook | None"
@@ -290,6 +291,7 @@ class LibraryAgentPreset(LibraryAgentPresetCreatable):
return cls(
id=preset.id,
user_id=preset.userId,
created_at=preset.createdAt,
updated_at=preset.updatedAt,
graph_id=preset.agentGraphId,
graph_version=preset.agentGraphVersion,

View File

@@ -1,4 +1,5 @@
{
"created_at": "2025-09-04T13:37:00",
"credentials_input_schema": {
"properties": {},
"title": "TestGraphCredentialsInputSchema",

View File

@@ -5743,6 +5743,11 @@
"default": []
},
"user_id": { "type": "string", "title": "User Id" },
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
},
"input_schema": {
"additionalProperties": true,
"type": "object",
@@ -5779,6 +5784,7 @@
"name",
"description",
"user_id",
"created_at",
"input_schema",
"output_schema",
"has_external_trigger",
@@ -6003,6 +6009,11 @@
},
"id": { "type": "string", "title": "Id" },
"user_id": { "type": "string", "title": "User Id" },
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
},
"updated_at": {
"type": "string",
"format": "date-time",
@@ -6025,6 +6036,7 @@
"description",
"id",
"user_id",
"created_at",
"updated_at",
"webhook"
],

View File

@@ -17,7 +17,7 @@ import {
import "./customedge.css";
import { X } from "lucide-react";
import { useBezierPath } from "@/hooks/useBezierPath";
import { FlowContext } from "./Flow";
import { BuilderContext } from "./Flow";
import { NodeExecutionResult } from "@/lib/autogpt-server-api";
export type CustomEdgeData = {
@@ -60,7 +60,7 @@ export function CustomEdge({
targetY - 5,
);
const { deleteElements } = useReactFlow<Node, CustomEdge>();
const { visualizeBeads } = useContext(FlowContext) ?? {
const { visualizeBeads } = useContext(BuilderContext) ?? {
visualizeBeads: "no",
};

View File

@@ -4,8 +4,8 @@ import React, {
useCallback,
useRef,
useContext,
useMemo,
} from "react";
import Link from "next/link";
import { NodeProps, useReactFlow, Node as XYNode, Edge } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import "./customnode.css";
@@ -16,12 +16,10 @@ import {
BlockIOSubSchema,
BlockIOStringSubSchema,
Category,
Node,
NodeExecutionResult,
BlockUIType,
BlockCost,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import {
beautifyString,
cn,
@@ -40,13 +38,14 @@ import {
NodeTextBoxInput,
} from "./node-input-components";
import { getPrimaryCategoryColor } from "@/lib/utils";
import { FlowContext } from "./Flow";
import { BuilderContext } from "./Flow";
import { Badge } from "./ui/badge";
import NodeOutputs from "./NodeOutputs";
import SchemaTooltip from "./SchemaTooltip";
import { IconCoin } from "./ui/icons";
import * as Separator from "@radix-ui/react-separator";
import * as ContextMenu from "@radix-ui/react-context-menu";
import { Alert, AlertDescription } from "./ui/alert";
import {
DotsVerticalIcon,
TrashIcon,
@@ -54,7 +53,7 @@ import {
ExitIcon,
Pencil1Icon,
} from "@radix-ui/react-icons";
import { Key } from "@phosphor-icons/react";
import { InfoIcon, Key } from "@phosphor-icons/react";
import useCredits from "@/hooks/useCredits";
import { getV1GetAyrshareSsoUrl } from "@/app/api/__generated__/endpoints/integrations/integrations";
import { toast } from "@/components/molecules/Toast/use-toast";
@@ -85,7 +84,6 @@ export type CustomNodeData = {
outputSchema: BlockIORootSchema;
hardcodedValues: { [key: string]: any };
connections: ConnectionData;
webhook?: Node["webhook"];
isOutputOpen: boolean;
status?: NodeExecutionResult["status"];
/** executionResults contains outputs across multiple executions
@@ -126,27 +124,26 @@ export const CustomNode = React.memo(
Edge
>();
const isInitialSetup = useRef(true);
const flowContext = useContext(FlowContext);
const api = useBackendAPI();
const builderContext = useContext(BuilderContext);
const { formatCredits } = useCredits();
const [isLoading, setIsLoading] = useState(false);
let nodeFlowId = "";
let subGraphID = "";
if (data.uiType === BlockUIType.AGENT) {
// Display the graph's schema instead AgentExecutorBlock's schema.
data.inputSchema = data.hardcodedValues?.input_schema || {};
data.outputSchema = data.hardcodedValues?.output_schema || {};
nodeFlowId = data.hardcodedValues?.graph_id || nodeFlowId;
subGraphID = data.hardcodedValues?.graph_id || subGraphID;
}
if (!flowContext) {
if (!builderContext) {
throw new Error(
"FlowContext consumer must be inside FlowEditor component",
"BuilderContext consumer must be inside FlowEditor component",
);
}
const { setIsAnyModalOpen, getNextNodeId } = flowContext;
const { libraryAgent, setIsAnyModalOpen, getNextNodeId } = builderContext;
useEffect(() => {
if (data.executionResults || data.status) {
@@ -366,7 +363,6 @@ export const CustomNode = React.memo(
return (
<div key={noteKey}>
<NodeTextBoxInput
className=""
selfKey={noteKey}
schema={noteSchema as BlockIOStringSubSchema}
value={getValue(noteKey, data.hardcodedValues)}
@@ -402,7 +398,11 @@ export const CustomNode = React.memo(
return (
!isHidden &&
(isRequired || isAdvancedOpen || isConnected || !isAdvanced) && (
<div key={propKey} data-id={`input-handle-${propKey}`}>
<div
key={propKey}
data-id={`input-handle-${propKey}`}
className="mb-4"
>
{isConnectable &&
!(
"oneOf" in propSchema &&
@@ -729,60 +729,6 @@ export const CustomNode = React.memo(
isCostFilterMatch(cost.cost_filter, inputValues),
);
const [webhookStatus, setWebhookStatus] = useState<
"works" | "exists" | "broken" | "none" | "pending" | null
>(null);
useEffect(() => {
if (
![BlockUIType.WEBHOOK, BlockUIType.WEBHOOK_MANUAL].includes(data.uiType)
)
return;
if (!data.webhook) {
setWebhookStatus("none");
return;
}
setWebhookStatus("pending");
api
.pingWebhook(data.webhook.id)
.then((pinged) => setWebhookStatus(pinged ? "works" : "exists"))
.catch((error: Error) =>
error.message.includes("ping timed out")
? setWebhookStatus("broken")
: setWebhookStatus("none"),
);
}, [data.uiType, data.webhook, api, setWebhookStatus]);
const webhookStatusDot = useMemo(
() =>
webhookStatus && (
<div
className={cn(
"size-4 rounded-full border-2",
{
pending: "animate-pulse border-gray-300 bg-gray-400",
works: "border-green-300 bg-green-400",
exists: "border-green-200 bg-green-300",
broken: "border-red-400 bg-red-500",
none: "border-gray-300 bg-gray-400",
}[webhookStatus],
)}
title={
{
pending: "Checking connection status...",
works: "Connected",
exists:
"Connected (but we could not verify the real-time status)",
broken: "The connected webhook is not working",
none: "Not connected. Fill out all the required block inputs and save the agent to connect.",
}[webhookStatus]
}
/>
),
[webhookStatus],
);
const LineSeparator = () => (
<div className="bg-white pt-6 dark:bg-gray-800">
<Separator.Root className="h-[1px] w-full bg-gray-300 dark:bg-gray-600"></Separator.Root>
@@ -798,9 +744,9 @@ export const CustomNode = React.memo(
<CopyIcon className="mr-2 h-5 w-5 dark:text-gray-100" />
<span className="dark:text-gray-100">Copy</span>
</ContextMenu.Item>
{nodeFlowId && (
{subGraphID && (
<ContextMenu.Item
onSelect={() => window.open(`/build?flowID=${nodeFlowId}`)}
onSelect={() => window.open(`/build?flowID=${subGraphID}`)}
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<ExitIcon className="mr-2 h-5 w-5 dark:text-gray-100" />
@@ -917,7 +863,6 @@ export const CustomNode = React.memo(
<div className="w-auto grow" />
{webhookStatusDot}
<button
aria-label="Options"
className="cursor-pointer rounded-full border-none bg-transparent p-1 hover:bg-gray-100"
@@ -958,35 +903,8 @@ export const CustomNode = React.memo(
<div className="mx-5 my-6 rounded-b-xl">
{/* Input Handles */}
{data.uiType !== BlockUIType.NOTE ? (
<div data-id="input-handles">
<div data-id="input-handles" className="mb-4">
<div>
{data.uiType === BlockUIType.WEBHOOK_MANUAL &&
(data.webhook ? (
<div className="nodrag mr-5 flex flex-col gap-1">
Webhook URL:
<div className="flex gap-2 rounded-md bg-gray-50 p-2">
<code className="select-all text-sm">
{data.webhook.url}
</code>
<Button
variant="outline"
size="icon"
className="size-7 flex-none"
onClick={() =>
data.webhook &&
navigator.clipboard.writeText(data.webhook.url)
}
title="Copy webhook URL"
>
<CopyIcon className="size-4" />
</Button>
</div>
</div>
) : (
<p className="italic text-gray-500">
(A Webhook URL will be generated when you save the agent)
</p>
))}
{data.uiType === BlockUIType.AYRSHARE ? (
<>
{generateAyrshareSSOHandles()}
@@ -995,6 +913,33 @@ export const CustomNode = React.memo(
BlockUIType.STANDARD,
)}
</>
) : [BlockUIType.WEBHOOK, BlockUIType.WEBHOOK_MANUAL].includes(
data.uiType,
) ? (
<>
<Alert className="mb-3 select-none">
<AlertDescription className="flex items-center">
<InfoIcon className="mr-2 size-6" />
<span>
You can set up and manage this trigger in your{" "}
<Link
href={
libraryAgent
? `/library/agents/${libraryAgent.id}`
: "/library"
}
className="underline"
>
Agent Library
</Link>
{!data.backend_id && " (after saving the graph)"}.
</span>
</AlertDescription>
</Alert>
<div className="pointer-events-none opacity-50">
{generateInputHandles(data.inputSchema, data.uiType)}
</div>
</>
) : (
data.inputSchema &&
generateInputHandles(data.inputSchema, data.uiType)

View File

@@ -35,7 +35,6 @@ import {
GraphID,
LibraryAgent,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { Key, storage } from "@/services/storage/local-storage";
import {
getTypeColor,
@@ -69,7 +68,8 @@ import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
// It helps to prevent spamming the history with small movements especially when pressing on a input in a block
const MINIMUM_MOVE_BEFORE_LOG = 50;
type FlowContextType = {
type BuilderContextType = {
libraryAgent: LibraryAgent | null;
visualizeBeads: "no" | "static" | "animate";
setIsAnyModalOpen: (isOpen: boolean) => void;
getNextNodeId: () => string;
@@ -84,7 +84,7 @@ export type NodeDimension = {
};
};
export const FlowContext = createContext<FlowContextType | null>(null);
export const BuilderContext = createContext<BuilderContextType | null>(null);
const FlowEditor: React.FC<{
flowID?: GraphID;
@@ -120,6 +120,7 @@ const FlowEditor: React.FC<{
agentRecommendedScheduleCron,
setAgentRecommendedScheduleCron,
savedAgent,
libraryAgent,
availableBlocks,
availableFlows,
getOutputType,
@@ -142,20 +143,6 @@ const FlowEditor: React.FC<{
flowExecutionID,
visualizeBeads !== "no",
);
const api = useBackendAPI();
const [libraryAgent, setLibraryAgent] = useState<LibraryAgent | null>(null);
useEffect(() => {
if (!flowID) return;
api
.getLibraryAgentByGraphID(flowID, flowVersion)
.then((libraryAgent) => setLibraryAgent(libraryAgent))
.catch((error) => {
console.warn(
`Failed to fetch LibraryAgent for graph #${flowID} v${flowVersion}`,
error,
);
});
}, [api, flowID, flowVersion]);
const router = useRouter();
const pathname = usePathname();
@@ -835,8 +822,8 @@ const FlowEditor: React.FC<{
);
return (
<FlowContext.Provider
value={{ visualizeBeads, setIsAnyModalOpen, getNextNodeId }}
<BuilderContext.Provider
value={{ libraryAgent, visualizeBeads, setIsAnyModalOpen, getNextNodeId }}
>
<div className={className}>
<ReactFlow
@@ -931,7 +918,7 @@ const FlowEditor: React.FC<{
: "will listen"}{" "}
for its trigger and will run when the time is right.
<br />
You can view its activity in your
You can view its activity in your{" "}
<Link
href={
libraryAgent
@@ -964,7 +951,7 @@ const FlowEditor: React.FC<{
className="fixed bottom-4 right-4 z-20"
/>
</Suspense>
</FlowContext.Provider>
</BuilderContext.Provider>
);
};

View File

@@ -4,11 +4,6 @@
transition: border-color 0.3s ease-in-out;
}
.custom-node [data-id="input-handles"],
.custom-node [data-id="input-handles"] > div > div {
margin-bottom: 1rem;
}
.custom-node .custom-switch {
padding: 0.5rem 1.25rem;
display: flex;

View File

@@ -14,6 +14,7 @@ import {
GraphExecutionID,
GraphID,
GraphMeta,
LibraryAgent,
LinkCreatable,
NodeCreatable,
NodeExecutionResult,
@@ -48,6 +49,7 @@ export default function useAgentGraph(
const [savedAgent, setSavedAgent] = useState<Graph | null>(null);
const [agentDescription, setAgentDescription] = useState<string>("");
const [agentName, setAgentName] = useState<string>("");
const [libraryAgent, setLibraryAgent] = useState<LibraryAgent | null>(null);
const [agentRecommendedScheduleCron, setAgentRecommendedScheduleCron] =
useState<string>("");
const [allBlocks, setAllBlocks] = useState<Block[]>([]);
@@ -197,7 +199,6 @@ export default function useAgentGraph(
inputSchema: block.inputSchema,
outputSchema: block.outputSchema,
hardcodedValues: node.input_default,
webhook: node.webhook,
uiType: block.uiType,
metadata: node.metadata,
connections: graph.links
@@ -435,6 +436,20 @@ export default function useAgentGraph(
});
}, [flowID, flowVersion, availableBlocks, api]);
// Load library agent
useEffect(() => {
if (!flowID) return;
api
.getLibraryAgentByGraphID(flowID, flowVersion)
.then((libraryAgent) => setLibraryAgent(libraryAgent))
.catch((error) => {
console.warn(
`Failed to fetch LibraryAgent for graph #${flowID} v${flowVersion}`,
error,
);
});
}, [api, flowID, flowVersion]);
// Check if local graph state is in sync with backend
const nodesSyncedWithSavedAgent = useMemo(() => {
if (!savedAgent || xyNodes.length === 0) return false;
@@ -940,6 +955,7 @@ export default function useAgentGraph(
agentRecommendedScheduleCron,
setAgentRecommendedScheduleCron,
savedAgent,
libraryAgent,
availableBlocks,
availableFlows,
getOutputType,

View File

@@ -348,6 +348,7 @@ export type GraphTriggerInfo = {
/* Mirror of backend/data/graph.py:Graph */
export type Graph = GraphMeta & {
created_at: Date;
nodes: Node[];
links: Link[];
sub_graphs: Omit<Graph, "sub_graphs">[]; // Flattened sub-graphs
@@ -357,6 +358,7 @@ export type GraphUpdateable = Omit<
Graph,
| "user_id"
| "version"
| "created_at"
| "is_active"
| "nodes"
| "links"
@@ -461,6 +463,7 @@ export type LibraryAgentResponse = {
export type LibraryAgentPreset = {
id: LibraryAgentPresetID;
created_at: Date;
updated_at: Date;
graph_id: GraphID;
graph_version: number;
@@ -489,7 +492,7 @@ export type LibraryAgentPresetResponse = {
export type LibraryAgentPresetCreatable = Omit<
LibraryAgentPreset,
"id" | "updated_at" | "is_active"
"id" | "created_at" | "updated_at" | "is_active"
> & {
is_active?: boolean;
};