890 lines
28 KiB
TypeScript
890 lines
28 KiB
TypeScript
import React, {
|
|
useState,
|
|
useEffect,
|
|
useCallback,
|
|
useRef,
|
|
useContext,
|
|
useMemo,
|
|
} from "react";
|
|
import { NodeProps, useReactFlow, Node as XYNode, Edge } from "@xyflow/react";
|
|
import "@xyflow/react/dist/style.css";
|
|
import "./customnode.css";
|
|
import InputModalComponent from "./InputModalComponent";
|
|
import OutputModalComponent from "./OutputModalComponent";
|
|
import {
|
|
BlockIORootSchema,
|
|
BlockIOSubSchema,
|
|
BlockIOStringSubSchema,
|
|
Category,
|
|
Node,
|
|
NodeExecutionResult,
|
|
BlockUIType,
|
|
BlockCost,
|
|
} from "@/lib/autogpt-server-api";
|
|
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
|
import {
|
|
beautifyString,
|
|
cn,
|
|
getValue,
|
|
hasNonNullNonObjectValue,
|
|
parseKeys,
|
|
setNestedProperty,
|
|
} from "@/lib/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { TextRenderer } from "@/components/ui/render";
|
|
import { history } from "./history";
|
|
import NodeHandle from "./NodeHandle";
|
|
import {
|
|
NodeGenericInputField,
|
|
NodeTextBoxInput,
|
|
} from "./node-input-components";
|
|
import { getPrimaryCategoryColor } from "@/lib/utils";
|
|
import { FlowContext } 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 {
|
|
DotsVerticalIcon,
|
|
TrashIcon,
|
|
CopyIcon,
|
|
ExitIcon,
|
|
} from "@radix-ui/react-icons";
|
|
|
|
export type ConnectionData = Array<{
|
|
edge_id: string;
|
|
source: string;
|
|
sourceHandle: string;
|
|
target: string;
|
|
targetHandle: string;
|
|
}>;
|
|
|
|
export type CustomNodeData = {
|
|
blockType: string;
|
|
blockCosts: BlockCost[];
|
|
title: string;
|
|
description: string;
|
|
categories: Category[];
|
|
inputSchema: BlockIORootSchema;
|
|
outputSchema: BlockIORootSchema;
|
|
hardcodedValues: { [key: string]: any };
|
|
connections: ConnectionData;
|
|
webhook?: Node["webhook"];
|
|
isOutputOpen: boolean;
|
|
status?: NodeExecutionResult["status"];
|
|
/** executionResults contains outputs across multiple executions
|
|
* with the last element being the most recent output */
|
|
executionResults?: {
|
|
execId: string;
|
|
data: NodeExecutionResult["output_data"];
|
|
}[];
|
|
block_id: string;
|
|
backend_id?: string;
|
|
errors?: { [key: string]: string };
|
|
isOutputStatic?: boolean;
|
|
uiType: BlockUIType;
|
|
};
|
|
|
|
export type CustomNode = XYNode<CustomNodeData, "custom">;
|
|
|
|
export function CustomNode({
|
|
data,
|
|
id,
|
|
width,
|
|
height,
|
|
selected,
|
|
}: NodeProps<CustomNode>) {
|
|
const [isOutputOpen, setIsOutputOpen] = useState(data.isOutputOpen || false);
|
|
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [activeKey, setActiveKey] = useState<string | null>(null);
|
|
const [inputModalValue, setInputModalValue] = useState<string>("");
|
|
const [isOutputModalOpen, setIsOutputModalOpen] = useState(false);
|
|
const { updateNodeData, deleteElements, addNodes, getNode } = useReactFlow<
|
|
CustomNode,
|
|
Edge
|
|
>();
|
|
const isInitialSetup = useRef(true);
|
|
const flowContext = useContext(FlowContext);
|
|
const api = useBackendAPI();
|
|
let nodeFlowId = "";
|
|
|
|
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;
|
|
}
|
|
|
|
if (!flowContext) {
|
|
throw new Error("FlowContext consumer must be inside FlowEditor component");
|
|
}
|
|
|
|
const { setIsAnyModalOpen, getNextNodeId } = flowContext;
|
|
|
|
useEffect(() => {
|
|
if (data.executionResults || data.status) {
|
|
setIsOutputOpen(true);
|
|
}
|
|
}, [data.executionResults, data.status]);
|
|
|
|
useEffect(() => {
|
|
setIsOutputOpen(data.isOutputOpen);
|
|
}, [data.isOutputOpen]);
|
|
|
|
useEffect(() => {
|
|
setIsAnyModalOpen?.(isModalOpen || isOutputModalOpen);
|
|
}, [isModalOpen, isOutputModalOpen, data, setIsAnyModalOpen]);
|
|
|
|
useEffect(() => {
|
|
isInitialSetup.current = false;
|
|
}, []);
|
|
|
|
const setHardcodedValues = (values: any) => {
|
|
updateNodeData(id, { hardcodedValues: values });
|
|
};
|
|
|
|
const setErrors = (errors: { [key: string]: string }) => {
|
|
updateNodeData(id, { errors });
|
|
};
|
|
|
|
const toggleOutput = (checked: boolean) => {
|
|
setIsOutputOpen(checked);
|
|
};
|
|
|
|
const toggleAdvancedSettings = (checked: boolean) => {
|
|
setIsAdvancedOpen(checked);
|
|
};
|
|
|
|
const generateOutputHandles = (
|
|
schema: BlockIORootSchema,
|
|
nodeType: BlockUIType,
|
|
) => {
|
|
if (
|
|
!schema?.properties ||
|
|
nodeType === BlockUIType.OUTPUT ||
|
|
nodeType === BlockUIType.NOTE
|
|
)
|
|
return null;
|
|
|
|
const renderHandles = (
|
|
propSchema: { [key: string]: BlockIOSubSchema },
|
|
keyPrefix = "",
|
|
titlePrefix = "",
|
|
) => {
|
|
return Object.keys(propSchema).map((propKey) => {
|
|
const fieldSchema = propSchema[propKey];
|
|
const fieldTitle =
|
|
titlePrefix + (fieldSchema.title || beautifyString(propKey));
|
|
|
|
return (
|
|
<div key={propKey}>
|
|
<NodeHandle
|
|
title={fieldTitle}
|
|
keyName={`${keyPrefix}${propKey}`}
|
|
isConnected={isOutputHandleConnected(propKey)}
|
|
schema={fieldSchema}
|
|
side="right"
|
|
/>
|
|
{"properties" in fieldSchema &&
|
|
renderHandles(
|
|
fieldSchema.properties,
|
|
`${keyPrefix}${propKey}_#_`,
|
|
`${fieldTitle}.`,
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
};
|
|
|
|
return renderHandles(schema.properties);
|
|
};
|
|
|
|
const generateInputHandles = (
|
|
schema: BlockIORootSchema,
|
|
nodeType: BlockUIType,
|
|
) => {
|
|
if (!schema?.properties) return null;
|
|
let keys = Object.entries(schema.properties);
|
|
switch (nodeType) {
|
|
case BlockUIType.NOTE:
|
|
// For NOTE blocks, don't render any input handles
|
|
const [noteKey, noteSchema] = keys[0];
|
|
return (
|
|
<div key={noteKey}>
|
|
<NodeTextBoxInput
|
|
className=""
|
|
selfKey={noteKey}
|
|
schema={noteSchema as BlockIOStringSubSchema}
|
|
value={getValue(noteKey, data.hardcodedValues)}
|
|
handleInputChange={handleInputChange}
|
|
handleInputClick={handleInputClick}
|
|
error={data.errors?.[noteKey] ?? ""}
|
|
displayName={noteSchema.title || beautifyString(noteKey)}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
default:
|
|
const getInputPropKey = (key: string) =>
|
|
nodeType == BlockUIType.AGENT ? `data.${key}` : key;
|
|
|
|
return keys.map(([propKey, propSchema]) => {
|
|
const isRequired = data.inputSchema.required?.includes(propKey);
|
|
const isAdvanced = propSchema.advanced;
|
|
const isHidden = propSchema.hidden;
|
|
const isConnectable =
|
|
// No input connection handles on INPUT and WEBHOOK blocks
|
|
![
|
|
BlockUIType.INPUT,
|
|
BlockUIType.WEBHOOK,
|
|
BlockUIType.WEBHOOK_MANUAL,
|
|
].includes(nodeType) &&
|
|
// No input connection handles for credentials
|
|
propKey !== "credentials" &&
|
|
// For OUTPUT blocks, only show the 'value' (hides 'name') input connection handle
|
|
!(nodeType == BlockUIType.OUTPUT && propKey == "name");
|
|
const isConnected = isInputHandleConnected(propKey);
|
|
return (
|
|
!isHidden &&
|
|
(isRequired || isAdvancedOpen || isConnected || !isAdvanced) && (
|
|
<div key={propKey} data-id={`input-handle-${propKey}`}>
|
|
{isConnectable ? (
|
|
<NodeHandle
|
|
keyName={propKey}
|
|
isConnected={isConnected}
|
|
isRequired={isRequired}
|
|
schema={propSchema}
|
|
side="left"
|
|
/>
|
|
) : (
|
|
propKey != "credentials" && (
|
|
<div className="flex gap-1">
|
|
<span className="text-m green mb-0 text-gray-900 dark:text-gray-100">
|
|
{propSchema.title || beautifyString(propKey)}
|
|
</span>
|
|
<SchemaTooltip description={propSchema.description} />
|
|
</div>
|
|
)
|
|
)}
|
|
{isConnected || (
|
|
<NodeGenericInputField
|
|
nodeId={id}
|
|
propKey={getInputPropKey(propKey)}
|
|
propSchema={propSchema}
|
|
currentValue={getValue(
|
|
getInputPropKey(propKey),
|
|
data.hardcodedValues,
|
|
)}
|
|
connections={data.connections}
|
|
handleInputChange={handleInputChange}
|
|
handleInputClick={handleInputClick}
|
|
errors={data.errors ?? {}}
|
|
displayName={propSchema.title || beautifyString(propKey)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
);
|
|
});
|
|
}
|
|
};
|
|
const handleInputChange = (path: string, value: any) => {
|
|
const keys = parseKeys(path);
|
|
const newValues = JSON.parse(JSON.stringify(data.hardcodedValues));
|
|
let current = newValues;
|
|
|
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
const { key: currentKey, index } = keys[i];
|
|
if (index !== undefined) {
|
|
if (!current[currentKey]) current[currentKey] = [];
|
|
if (!current[currentKey][index]) current[currentKey][index] = {};
|
|
current = current[currentKey][index];
|
|
} else {
|
|
if (!current[currentKey]) current[currentKey] = {};
|
|
current = current[currentKey];
|
|
}
|
|
}
|
|
|
|
const lastKey = keys[keys.length - 1];
|
|
if (lastKey.index !== undefined) {
|
|
if (!current[lastKey.key]) current[lastKey.key] = [];
|
|
current[lastKey.key][lastKey.index] = value;
|
|
} else {
|
|
current[lastKey.key] = value;
|
|
}
|
|
|
|
if (!isInitialSetup.current) {
|
|
history.push({
|
|
type: "UPDATE_INPUT",
|
|
payload: { nodeId: id, oldValues: data.hardcodedValues, newValues },
|
|
undo: () => setHardcodedValues(data.hardcodedValues),
|
|
redo: () => setHardcodedValues(newValues),
|
|
});
|
|
}
|
|
|
|
setHardcodedValues(newValues);
|
|
const errors = data.errors || {};
|
|
// Remove error with the same key
|
|
setNestedProperty(errors, path, null);
|
|
setErrors({ ...errors });
|
|
};
|
|
|
|
const isInputHandleConnected = (key: string) => {
|
|
return (
|
|
data.connections &&
|
|
data.connections.some((conn: any) => {
|
|
if (typeof conn === "string") {
|
|
const [_source, target] = conn.split(" -> ");
|
|
return target.includes(key) && target.includes(data.title);
|
|
}
|
|
return conn.target === id && conn.targetHandle === key;
|
|
})
|
|
);
|
|
};
|
|
|
|
const isOutputHandleConnected = (key: string) => {
|
|
return (
|
|
data.connections &&
|
|
data.connections.some((conn: any) => {
|
|
if (typeof conn === "string") {
|
|
const [source, _target] = conn.split(" -> ");
|
|
return source.includes(key) && source.includes(data.title);
|
|
}
|
|
return conn.source === id && conn.sourceHandle === key;
|
|
})
|
|
);
|
|
};
|
|
|
|
const handleInputClick = (key: string) => {
|
|
console.debug(`Opening modal for key: ${key}`);
|
|
setActiveKey(key);
|
|
const value = getValue(key, data.hardcodedValues);
|
|
setInputModalValue(
|
|
typeof value === "object" ? JSON.stringify(value, null, 2) : value,
|
|
);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const handleModalSave = (value: string) => {
|
|
if (activeKey) {
|
|
try {
|
|
const parsedValue = JSON.parse(value);
|
|
handleInputChange(activeKey, parsedValue);
|
|
} catch (error) {
|
|
handleInputChange(activeKey, value);
|
|
}
|
|
}
|
|
setIsModalOpen(false);
|
|
setActiveKey(null);
|
|
};
|
|
|
|
const handleOutputClick = () => {
|
|
setIsOutputModalOpen(true);
|
|
};
|
|
|
|
const deleteNode = useCallback(() => {
|
|
console.debug("Deleting node:", id);
|
|
|
|
// Remove the node
|
|
deleteElements({ nodes: [{ id }] });
|
|
}, [id, deleteElements]);
|
|
|
|
const copyNode = useCallback(() => {
|
|
const newId = getNextNodeId();
|
|
const currentNode = getNode(id);
|
|
|
|
if (!currentNode) {
|
|
console.error("Cannot copy node: current node not found");
|
|
return;
|
|
}
|
|
|
|
const verticalOffset = height ?? 100;
|
|
|
|
const newNode: CustomNode = {
|
|
id: newId,
|
|
type: currentNode.type,
|
|
position: {
|
|
x: currentNode.position.x,
|
|
y: currentNode.position.y - verticalOffset - 20,
|
|
},
|
|
data: {
|
|
...data,
|
|
title: `${data.title} (Copy)`,
|
|
block_id: data.block_id,
|
|
connections: [],
|
|
isOutputOpen: false,
|
|
},
|
|
};
|
|
|
|
addNodes(newNode);
|
|
|
|
history.push({
|
|
type: "ADD_NODE",
|
|
payload: { node: { ...newNode, ...newNode.data } as CustomNodeData },
|
|
undo: () => deleteElements({ nodes: [{ id: newId }] }),
|
|
redo: () => addNodes(newNode),
|
|
});
|
|
}, [id, data, height, addNodes, deleteElements, getNode, getNextNodeId]);
|
|
|
|
const hasConfigErrors = data.errors && hasNonNullNonObjectValue(data.errors);
|
|
const outputData = data.executionResults?.at(-1)?.data;
|
|
const hasOutputError =
|
|
typeof outputData === "object" &&
|
|
outputData !== null &&
|
|
"error" in outputData;
|
|
|
|
useEffect(() => {
|
|
if (hasConfigErrors) {
|
|
const filteredErrors = Object.fromEntries(
|
|
Object.entries(data.errors || {}).filter(([, value]) =>
|
|
hasNonNullNonObjectValue(value),
|
|
),
|
|
);
|
|
console.error(
|
|
"Block configuration errors for",
|
|
data.title,
|
|
":",
|
|
filteredErrors,
|
|
);
|
|
}
|
|
if (hasOutputError) {
|
|
console.error(
|
|
"Block output contains error for",
|
|
data.title,
|
|
":",
|
|
outputData.error,
|
|
);
|
|
}
|
|
}, [hasConfigErrors, hasOutputError, data.errors, outputData, data.title]);
|
|
|
|
const blockClasses = [
|
|
"custom-node",
|
|
"dark-theme",
|
|
"rounded-xl",
|
|
"bg-white/[.9] dark:bg-gray-800/[.9]",
|
|
"border border-gray-300 dark:border-gray-600",
|
|
data.uiType === BlockUIType.NOTE ? "w-[300px]" : "w-[500px]",
|
|
data.uiType === BlockUIType.NOTE
|
|
? "bg-yellow-100 dark:bg-yellow-900"
|
|
: "bg-white dark:bg-gray-800",
|
|
selected ? "shadow-2xl" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
|
|
const errorClass =
|
|
hasConfigErrors || hasOutputError
|
|
? "border-red-200 dark:border-red-800 border-2"
|
|
: "";
|
|
|
|
const statusClass = (() => {
|
|
if (hasConfigErrors || hasOutputError)
|
|
return "border-red-200 dark:border-red-800 border-4";
|
|
switch (data.status?.toLowerCase()) {
|
|
case "completed":
|
|
return "border-green-200 dark:border-green-800 border-4";
|
|
case "running":
|
|
return "border-yellow-200 dark:border-yellow-800 border-4";
|
|
case "failed":
|
|
return "border-red-200 dark:border-red-800 border-4";
|
|
case "incomplete":
|
|
return "border-purple-200 dark:border-purple-800 border-4";
|
|
case "queued":
|
|
return "border-cyan-200 dark:border-cyan-800 border-4";
|
|
default:
|
|
return "";
|
|
}
|
|
})();
|
|
|
|
const statusBackgroundClass = (() => {
|
|
if (hasConfigErrors || hasOutputError) return "bg-red-200 dark:bg-red-800";
|
|
switch (data.status?.toLowerCase()) {
|
|
case "completed":
|
|
return "bg-green-200 dark:bg-green-800";
|
|
case "running":
|
|
return "bg-yellow-200 dark:bg-yellow-800";
|
|
case "failed":
|
|
return "bg-red-200 dark:bg-red-800";
|
|
case "incomplete":
|
|
return "bg-purple-200 dark:bg-purple-800";
|
|
case "queued":
|
|
return "bg-cyan-200 dark:bg-cyan-800";
|
|
default:
|
|
return "";
|
|
}
|
|
})();
|
|
|
|
const hasAdvancedFields =
|
|
data.inputSchema &&
|
|
Object.entries(data.inputSchema.properties).some(([key, value]) => {
|
|
return (
|
|
value.advanced === true && !data.inputSchema.required?.includes(key)
|
|
);
|
|
});
|
|
|
|
const inputValues = data.hardcodedValues;
|
|
|
|
const isCostFilterMatch = (costFilter: any, inputValues: any): boolean => {
|
|
/*
|
|
Filter rules:
|
|
- If costFilter is an object, then check if costFilter is the subset of inputValues
|
|
- Otherwise, check if costFilter is equal to inputValues.
|
|
- Undefined, null, and empty string are considered as equal.
|
|
*/
|
|
return typeof costFilter === "object" && typeof inputValues === "object"
|
|
? Object.entries(costFilter).every(
|
|
([k, v]) =>
|
|
(!v && !inputValues[k]) || isCostFilterMatch(v, inputValues[k]),
|
|
)
|
|
: costFilter === inputValues;
|
|
};
|
|
|
|
const blockCost =
|
|
data.blockCosts &&
|
|
data.blockCosts.find((cost) =>
|
|
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>
|
|
</div>
|
|
);
|
|
|
|
const ContextMenuContent = () => (
|
|
<ContextMenu.Content className="z-10 rounded-xl border bg-white p-1 shadow-md dark:bg-gray-800">
|
|
<ContextMenu.Item
|
|
onSelect={copyNode}
|
|
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
>
|
|
<CopyIcon className="mr-2 h-5 w-5 dark:text-gray-100" />
|
|
<span className="dark:text-gray-100">Copy</span>
|
|
</ContextMenu.Item>
|
|
{nodeFlowId && (
|
|
<ContextMenu.Item
|
|
onSelect={() => window.open(`/build?flowID=${nodeFlowId}`)}
|
|
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" />
|
|
<span className="dark:text-gray-100">Open agent</span>
|
|
</ContextMenu.Item>
|
|
)}
|
|
<ContextMenu.Separator className="my-1 h-px bg-gray-300 dark:bg-gray-600" />
|
|
<ContextMenu.Item
|
|
onSelect={deleteNode}
|
|
className="flex cursor-pointer items-center rounded-md px-3 py-2 text-red-500 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
>
|
|
<TrashIcon className="mr-2 h-5 w-5 text-red-500 dark:text-red-400" />
|
|
<span className="dark:text-red-400">Delete</span>
|
|
</ContextMenu.Item>
|
|
</ContextMenu.Content>
|
|
);
|
|
|
|
const onContextButtonTrigger = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const event = new MouseEvent("contextmenu", {
|
|
bubbles: true,
|
|
clientX: rect.left + rect.width / 2,
|
|
clientY: rect.top + rect.height / 2,
|
|
});
|
|
e.currentTarget.dispatchEvent(event);
|
|
};
|
|
|
|
const stripeColor = getPrimaryCategoryColor(data.categories);
|
|
|
|
const nodeContent = () => (
|
|
<div
|
|
className={`${blockClasses} ${errorClass} ${statusClass}`}
|
|
data-id={`custom-node-${id}`}
|
|
z-index={1}
|
|
data-blockid={data.block_id}
|
|
data-blockname={data.title}
|
|
data-blocktype={data.blockType}
|
|
data-nodetype={data.uiType}
|
|
data-category={data.categories[0]?.category.toLowerCase() || ""}
|
|
data-inputs={JSON.stringify(
|
|
Object.keys(data.inputSchema?.properties || {}),
|
|
)}
|
|
data-outputs={JSON.stringify(
|
|
Object.keys(data.outputSchema?.properties || {}),
|
|
)}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
className={`flex h-24 border-b border-gray-300 ${data.uiType === BlockUIType.NOTE ? "bg-yellow-100" : "bg-white"} space-x-1 rounded-t-xl`}
|
|
>
|
|
{/* Color Stripe */}
|
|
<div className={`-ml-px h-full w-3 rounded-tl-xl ${stripeColor}`}></div>
|
|
|
|
<div className="flex w-full flex-col justify-start space-y-2.5 px-4 pt-4">
|
|
<div className="flex flex-row items-center space-x-2 font-semibold">
|
|
<h3 className="font-roboto text-lg">
|
|
<TextRenderer
|
|
value={beautifyString(
|
|
data.blockType?.replace(/Block$/, "") || data.title,
|
|
)}
|
|
truncateLengthLimit={80}
|
|
/>
|
|
</h3>
|
|
<span className="text-xs text-gray-500">#{id.split("-")[0]}</span>
|
|
|
|
<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"
|
|
onClick={onContextButtonTrigger}
|
|
>
|
|
<DotsVerticalIcon className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
{blockCost && (
|
|
<div className="mr-3 text-base font-light">
|
|
<span className="ml-auto flex items-center">
|
|
<IconCoin />{" "}
|
|
<span className="mx-1 font-medium">
|
|
{blockCost.cost_amount}
|
|
</span>{" "}
|
|
credits/{blockCost.cost_type}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{data.categories.map((category) => (
|
|
<Badge
|
|
key={category.category}
|
|
variant="outline"
|
|
className={`${getPrimaryCategoryColor([category])} h-6 whitespace-nowrap rounded-full border border-gray-300 opacity-50`}
|
|
>
|
|
{beautifyString(category.category.toLowerCase())}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<ContextMenuContent />
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="mx-5 my-6 rounded-b-xl">
|
|
{/* Input Handles */}
|
|
{data.uiType !== BlockUIType.NOTE ? (
|
|
<div data-id="input-handles">
|
|
<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.inputSchema &&
|
|
generateInputHandles(data.inputSchema, data.uiType)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{data.inputSchema &&
|
|
generateInputHandles(data.inputSchema, data.uiType)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Advanced Settings */}
|
|
{data.uiType !== BlockUIType.NOTE && hasAdvancedFields && (
|
|
<>
|
|
<LineSeparator />
|
|
<div className="flex items-center justify-between pt-6">
|
|
Advanced
|
|
<Switch
|
|
onCheckedChange={toggleAdvancedSettings}
|
|
checked={isAdvancedOpen}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
{/* Output Handles */}
|
|
{data.uiType !== BlockUIType.NOTE && (
|
|
<>
|
|
<LineSeparator />
|
|
<div className="flex items-start justify-end rounded-b-xl pt-6">
|
|
<div className="flex-none">
|
|
{data.outputSchema &&
|
|
generateOutputHandles(data.outputSchema, data.uiType)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
{/* End Body */}
|
|
{/* Footer */}
|
|
<div className="flex rounded-b-xl">
|
|
{/* Display Outputs */}
|
|
{isOutputOpen && data.uiType !== BlockUIType.NOTE && (
|
|
<div
|
|
data-id="latest-output"
|
|
className={cn(
|
|
"nodrag w-full overflow-hidden break-words",
|
|
statusBackgroundClass,
|
|
)}
|
|
>
|
|
{(data.executionResults?.length ?? 0) > 0 ? (
|
|
<div className="mt-0 rounded-b-xl bg-gray-50">
|
|
<LineSeparator />
|
|
<NodeOutputs
|
|
title="Latest Output"
|
|
truncateLongData
|
|
data={data.executionResults!.at(-1)?.data || {}}
|
|
/>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={handleOutputClick}
|
|
className="border border-gray-300"
|
|
>
|
|
View More
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="mt-0 min-h-4 rounded-b-xl bg-white"></div>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
"flex min-h-12 items-center justify-end",
|
|
statusBackgroundClass,
|
|
)}
|
|
>
|
|
<Badge
|
|
variant="default"
|
|
data-id={`badge-${id}-${data.status}`}
|
|
className={cn(
|
|
"mr-4 flex min-w-[114px] items-center justify-center rounded-3xl text-center text-xs font-semibold",
|
|
hasConfigErrors || hasOutputError
|
|
? "border-red-600 bg-red-600 text-white"
|
|
: {
|
|
"border-green-600 bg-green-600 text-white":
|
|
data.status === "COMPLETED",
|
|
"border-yellow-600 bg-yellow-600 text-white":
|
|
data.status === "RUNNING",
|
|
"border-red-600 bg-red-600 text-white":
|
|
data.status === "FAILED",
|
|
"border-blue-600 bg-blue-600 text-white":
|
|
data.status === "QUEUED",
|
|
"border-gray-600 bg-gray-600 font-black":
|
|
data.status === "INCOMPLETE",
|
|
},
|
|
)}
|
|
>
|
|
{hasConfigErrors || hasOutputError
|
|
? "Error"
|
|
: data.status
|
|
? beautifyString(data.status)
|
|
: "Not Run"}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<InputModalComponent
|
|
title={activeKey ? `Enter ${beautifyString(activeKey)}` : undefined}
|
|
isOpen={isModalOpen}
|
|
onClose={() => setIsModalOpen(false)}
|
|
onSave={handleModalSave}
|
|
defaultValue={inputModalValue}
|
|
key={activeKey}
|
|
/>
|
|
<OutputModalComponent
|
|
isOpen={isOutputModalOpen}
|
|
onClose={() => setIsOutputModalOpen(false)}
|
|
executionResults={data.executionResults?.toReversed() || []}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<ContextMenu.Root>
|
|
<ContextMenu.Trigger>{nodeContent()}</ContextMenu.Trigger>
|
|
</ContextMenu.Root>
|
|
);
|
|
}
|