mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-12 07:45:14 -05:00
Compare commits
6 Commits
refactor/r
...
abhimanyuy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6147f2994 | ||
|
|
209ca7dcb6 | ||
|
|
9d19ab4139 | ||
|
|
20b0963f9d | ||
|
|
2d97de8185 | ||
|
|
4c1612c969 |
@@ -4,7 +4,7 @@ import {
|
||||
} from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
|
||||
import { GraphExecutionMeta } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/use-agent-runs";
|
||||
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -1,100 +1,30 @@
|
||||
// import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import React, { memo } from "react";
|
||||
import { BlockMenu } from "./NewBlockMenu/BlockMenu/BlockMenu";
|
||||
import { useNewControlPanel } from "./useNewControlPanel";
|
||||
// import { NewSaveControl } from "../SaveControl/NewSaveControl";
|
||||
import { GraphExecutionID } from "@/lib/autogpt-server-api";
|
||||
// import { ControlPanelButton } from "../ControlPanelButton";
|
||||
// import { GraphSearchMenu } from "../GraphMenu/GraphMenu";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { CustomNode } from "../FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import { NewSaveControl } from "./NewSaveControl/NewSaveControl";
|
||||
import { UndoRedoButtons } from "./UndoRedoButtons";
|
||||
|
||||
export type Control = {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
export const NewControlPanel = memo(() => {
|
||||
useNewControlPanel({});
|
||||
|
||||
export type NewControlPanelProps = {
|
||||
flowExecutionID?: GraphExecutionID | undefined;
|
||||
visualizeBeads?: "no" | "static" | "animate";
|
||||
pinSavePopover?: boolean;
|
||||
pinBlocksPopover?: boolean;
|
||||
nodes?: CustomNode[];
|
||||
onNodeSelect?: (nodeId: string) => void;
|
||||
onNodeHover?: (nodeId: string) => void;
|
||||
};
|
||||
export const NewControlPanel = memo(
|
||||
({
|
||||
flowExecutionID: _flowExecutionID,
|
||||
visualizeBeads: _visualizeBeads,
|
||||
pinSavePopover: _pinSavePopover,
|
||||
pinBlocksPopover: _pinBlocksPopover,
|
||||
nodes: _nodes,
|
||||
onNodeSelect: _onNodeSelect,
|
||||
onNodeHover: _onNodeHover,
|
||||
}: NewControlPanelProps) => {
|
||||
const _isGraphSearchEnabled = useGetFlag(Flag.GRAPH_SEARCH);
|
||||
|
||||
const {
|
||||
// agentDescription,
|
||||
// setAgentDescription,
|
||||
// saveAgent,
|
||||
// agentName,
|
||||
// setAgentName,
|
||||
// savedAgent,
|
||||
// isSaving,
|
||||
// isRunning,
|
||||
// isStopping,
|
||||
} = useNewControlPanel({});
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"absolute left-4 top-10 z-10 overflow-hidden rounded-[1rem] border-none bg-white p-0 shadow-[0_1px_5px_0_rgba(0,0,0,0.1)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center rounded-[1rem] p-0">
|
||||
<BlockMenu />
|
||||
{/* <Separator className="text-[#E1E1E1]" />
|
||||
{isGraphSearchEnabled && (
|
||||
<>
|
||||
<GraphSearchMenu
|
||||
nodes={nodes}
|
||||
blockMenuSelected={blockMenuSelected}
|
||||
setBlockMenuSelected={setBlockMenuSelected}
|
||||
onNodeSelect={onNodeSelect}
|
||||
onNodeHover={onNodeHover}
|
||||
/>
|
||||
<Separator className="text-[#E1E1E1]" />
|
||||
</>
|
||||
)}
|
||||
{controls.map((control, index) => (
|
||||
<ControlPanelButton
|
||||
key={index}
|
||||
onClick={() => control.onClick()}
|
||||
data-id={`control-button-${index}`}
|
||||
data-testid={`blocks-control-${control.label.toLowerCase()}-button`}
|
||||
disabled={control.disabled || false}
|
||||
className="rounded-none"
|
||||
>
|
||||
{control.icon}
|
||||
</ControlPanelButton>
|
||||
))} */}
|
||||
<Separator className="text-[#E1E1E1]" />
|
||||
<NewSaveControl />
|
||||
<Separator className="text-[#E1E1E1]" />
|
||||
<UndoRedoButtons />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
},
|
||||
);
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"absolute left-4 top-10 z-10 overflow-hidden rounded-[1rem] border-none bg-white p-0 shadow-[0_1px_5px_0_rgba(0,0,0,0.1)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center rounded-[1rem] p-0">
|
||||
<BlockMenu />
|
||||
<Separator className="text-[#E1E1E1]" />
|
||||
<NewSaveControl />
|
||||
<Separator className="text-[#E1E1E1]" />
|
||||
<UndoRedoButtons />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
|
||||
export default NewControlPanel;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import { CustomNode } from "../../../FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useGraphSearch } from "../GraphMenuSearchBar/useGraphMenuSearchBar";
|
||||
import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import { CustomNode } from "../../../FlowEditor/nodes/CustomNode/CustomNode";
|
||||
|
||||
interface UseGraphMenuProps {
|
||||
nodes: CustomNode[];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
|
||||
import { beautifyString, getPrimaryCategoryColor } from "@/lib/utils";
|
||||
import { beautifyString, categoryColorMap } from "@/lib/utils";
|
||||
import { SearchableNode } from "../GraphMenuSearchBar/useGraphMenuSearchBar";
|
||||
import { TextRenderer } from "@/components/__legacy__/ui/render";
|
||||
import {
|
||||
@@ -73,14 +73,12 @@ export const GraphSearchContent: React.FC<GraphSearchContentProps> = ({
|
||||
}
|
||||
|
||||
const nodeTitle =
|
||||
node.data?.metadata?.customized_name ||
|
||||
beautifyString(node.data?.blockType || "").replace(
|
||||
/ Block$/,
|
||||
"",
|
||||
);
|
||||
const nodeType = beautifyString(
|
||||
node.data?.blockType || "",
|
||||
).replace(/ Block$/, "");
|
||||
(node.data?.metadata?.customized_name as string) ||
|
||||
beautifyString(node.data?.title || "").replace(/ Block$/, "");
|
||||
const nodeType = beautifyString(node.data?.title || "").replace(
|
||||
/ Block$/,
|
||||
"",
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider key={node.id}>
|
||||
@@ -100,7 +98,13 @@ export const GraphSearchContent: React.FC<GraphSearchContentProps> = ({
|
||||
onMouseLeave={() => onNodeHover?.(null)}
|
||||
>
|
||||
<div
|
||||
className={`h-full w-3 rounded-l-[7px] ${getPrimaryCategoryColor(node.data?.categories)}`}
|
||||
className={`h-full w-3 rounded-l-[7px] ${
|
||||
(node.data?.categories?.[0]?.category &&
|
||||
categoryColorMap[
|
||||
node.data.categories[0].category
|
||||
]) ||
|
||||
"bg-gray-300 dark:bg-slate-700"
|
||||
}`}
|
||||
/>
|
||||
<div className="mx-3 flex flex-1 items-center justify-between">
|
||||
<div className="mr-2 min-w-0">
|
||||
@@ -129,9 +133,10 @@ export const GraphSearchContent: React.FC<GraphSearchContentProps> = ({
|
||||
<div className="font-semibold">
|
||||
Node Type: {nodeType}
|
||||
</div>
|
||||
{node.data?.metadata?.customized_name && (
|
||||
{!!node.data?.metadata?.customized_name && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Custom Name: {node.data.metadata.customized_name}
|
||||
Custom Name:{" "}
|
||||
{String(node.data.metadata.customized_name)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo, useDeferredValue } from "react";
|
||||
import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import { CustomNode } from "../../../FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
import jaro from "jaro-winkler";
|
||||
|
||||
@@ -67,10 +67,10 @@ function calculateNodeScore(
|
||||
const nodeTitle = (node.data?.title || "").toLowerCase(); // This includes the ID
|
||||
const nodeId = (node.id || "").toLowerCase();
|
||||
const nodeDescription = (node.data?.description || "").toLowerCase();
|
||||
const blockType = (node.data?.blockType || "").toLowerCase();
|
||||
const blockType = (node.data?.title || "").toLowerCase();
|
||||
const beautifiedBlockType = beautifyString(blockType).toLowerCase();
|
||||
const customizedName = (
|
||||
node.data?.metadata?.customized_name || ""
|
||||
const customizedName = String(
|
||||
node.data?.metadata?.customized_name || "",
|
||||
).toLowerCase();
|
||||
|
||||
// Get input and output names with defensive checks
|
||||
|
||||
@@ -1,54 +1,18 @@
|
||||
import { GraphID } from "@/lib/autogpt-server-api";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface NewControlPanelProps {
|
||||
// flowExecutionID: GraphExecutionID | undefined;
|
||||
visualizeBeads?: "no" | "static" | "animate";
|
||||
}
|
||||
|
||||
export const useNewControlPanel = ({
|
||||
// flowExecutionID,
|
||||
visualizeBeads: _visualizeBeads,
|
||||
}: NewControlPanelProps) => {
|
||||
const [blockMenuSelected, setBlockMenuSelected] = useState<
|
||||
"save" | "block" | "search" | ""
|
||||
>("");
|
||||
const query = useSearchParams();
|
||||
const _graphVersion = query.get("flowVersion");
|
||||
const _graphVersionParsed = _graphVersion
|
||||
? parseInt(_graphVersion)
|
||||
: undefined;
|
||||
|
||||
const _flowID = (query.get("flowID") as GraphID | null) ?? undefined;
|
||||
// const {
|
||||
// agentDescription,
|
||||
// setAgentDescription,
|
||||
// saveAgent,
|
||||
// agentName,
|
||||
// setAgentName,
|
||||
// savedAgent,
|
||||
// isSaving,
|
||||
// isRunning,
|
||||
// isStopping,
|
||||
// } = useAgentGraph(
|
||||
// flowID,
|
||||
// graphVersion,
|
||||
// flowExecutionID,
|
||||
// visualizeBeads !== "no",
|
||||
// );
|
||||
|
||||
return {
|
||||
blockMenuSelected,
|
||||
setBlockMenuSelected,
|
||||
// agentDescription,
|
||||
// setAgentDescription,
|
||||
// saveAgent,
|
||||
// agentName,
|
||||
// setAgentName,
|
||||
// savedAgent,
|
||||
// isSaving,
|
||||
// isRunning,
|
||||
// isStopping,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,443 +0,0 @@
|
||||
import React, { useCallback, useMemo, useState, useDeferredValue } from "react";
|
||||
import { Card, CardContent, CardHeader } from "@/components/__legacy__/ui/card";
|
||||
import { Label } from "@/components/__legacy__/ui/label";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import { TextRenderer } from "@/components/__legacy__/ui/render";
|
||||
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
|
||||
import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import {
|
||||
Block,
|
||||
BlockIORootSchema,
|
||||
BlockUIType,
|
||||
GraphInputSchema,
|
||||
GraphOutputSchema,
|
||||
SpecialBlockID,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { MagnifyingGlassIcon, PlusIcon } from "@radix-ui/react-icons";
|
||||
import { IconToyBrick } from "@/components/__legacy__/ui/icons";
|
||||
import { getPrimaryCategoryColor } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { GraphMeta } from "@/lib/autogpt-server-api";
|
||||
import jaro from "jaro-winkler";
|
||||
import { getV1GetSpecificGraph } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
|
||||
type _Block = Omit<Block, "inputSchema" | "outputSchema"> & {
|
||||
uiKey?: string;
|
||||
inputSchema: BlockIORootSchema | GraphInputSchema;
|
||||
outputSchema: BlockIORootSchema | GraphOutputSchema;
|
||||
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[];
|
||||
addBlock: (
|
||||
id: string,
|
||||
name: string,
|
||||
hardcodedValues: Record<string, any>,
|
||||
) => void;
|
||||
pinBlocksPopover: boolean;
|
||||
flows: GraphMeta[];
|
||||
nodes: CustomNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A React functional component that displays a control for managing blocks.
|
||||
*
|
||||
* @component
|
||||
* @param {Object} BlocksControlProps - The properties for the BlocksControl component.
|
||||
* @param {Block[]} BlocksControlProps.blocks - An array of blocks to be displayed and filtered.
|
||||
* @param {(id: string, name: string) => void} BlocksControlProps.addBlock - A function to call when a block is added.
|
||||
* @returns The rendered BlocksControl component.
|
||||
*/
|
||||
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),
|
||||
);
|
||||
const graphHasInputNodes = nodes.some(
|
||||
(n) => n.data.uiType == BlockUIType.INPUT,
|
||||
);
|
||||
|
||||
const filteredAvailableBlocks = useMemo(() => {
|
||||
const blockList = blocks
|
||||
.filter((b) => b.uiType !== BlockUIType.AGENT)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Agent blocks are created from GraphMeta which doesn't include schemas.
|
||||
// Schemas will be fetched on-demand when the block is actually added.
|
||||
const agentBlockList = flows
|
||||
.map((flow): _Block => {
|
||||
return {
|
||||
id: SpecialBlockID.AGENT,
|
||||
name: flow.name,
|
||||
description:
|
||||
`Ver.${flow.version}` +
|
||||
(flow.description ? ` | ${flow.description}` : ""),
|
||||
categories: [{ category: "AGENT", description: "" }],
|
||||
// Empty schemas - will be populated when block is added
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
outputSchema: { type: "object", properties: {} },
|
||||
staticOutput: false,
|
||||
uiType: BlockUIType.AGENT,
|
||||
costs: [],
|
||||
uiKey: flow.id,
|
||||
hardcodedValues: {
|
||||
graph_id: flow.id,
|
||||
graph_version: flow.version,
|
||||
// Schemas will be fetched on-demand when block is added
|
||||
},
|
||||
};
|
||||
})
|
||||
.map(
|
||||
(agentBlock): _Block => ({
|
||||
...agentBlock,
|
||||
_cached: {
|
||||
blockName: agentBlock.name.toLowerCase(),
|
||||
beautifiedName: beautifyString(agentBlock.name).toLowerCase(),
|
||||
description: agentBlock.description.toLowerCase(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return blockList
|
||||
.concat(agentBlockList)
|
||||
.map((block) => ({
|
||||
block,
|
||||
score: blockScoreForQuery(block, deferredSearchQuery),
|
||||
}))
|
||||
.filter(
|
||||
({ block, score }) =>
|
||||
score > 0 &&
|
||||
(!selectedCategory ||
|
||||
block.categories.some((cat) => cat.category === selectedCategory)),
|
||||
)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(({ block }) => ({
|
||||
...block,
|
||||
notAvailable:
|
||||
(block.uiType == BlockUIType.WEBHOOK &&
|
||||
graphHasWebhookNodes &&
|
||||
"Agents can only have one webhook-triggered block") ||
|
||||
(block.uiType == BlockUIType.WEBHOOK &&
|
||||
graphHasInputNodes &&
|
||||
"Webhook-triggered blocks can't be used together with input blocks") ||
|
||||
(block.uiType == BlockUIType.INPUT &&
|
||||
graphHasWebhookNodes &&
|
||||
"Input blocks can't be used together with a webhook-triggered block") ||
|
||||
null,
|
||||
}));
|
||||
}, [
|
||||
blocks,
|
||||
flows,
|
||||
selectedCategory,
|
||||
deferredSearchQuery,
|
||||
graphHasInputNodes,
|
||||
graphHasWebhookNodes,
|
||||
]);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setSearchQuery("");
|
||||
setSelectedCategory(null);
|
||||
}, []);
|
||||
|
||||
// Handler to add a block, fetching graph data on-demand for agent blocks
|
||||
const handleAddBlock = useCallback(
|
||||
async (block: _Block & { notAvailable: string | null }) => {
|
||||
if (block.notAvailable) return;
|
||||
|
||||
// For agent blocks, fetch the full graph to get schemas
|
||||
if (block.uiType === BlockUIType.AGENT && block.hardcodedValues) {
|
||||
const graphID = block.hardcodedValues.graph_id as string;
|
||||
const graphVersion = block.hardcodedValues.graph_version as number;
|
||||
const graphData = okData(
|
||||
await getV1GetSpecificGraph(graphID, { version: graphVersion }),
|
||||
);
|
||||
|
||||
if (graphData) {
|
||||
addBlock(block.id, block.name, {
|
||||
...block.hardcodedValues,
|
||||
input_schema: graphData.input_schema,
|
||||
output_schema: graphData.output_schema,
|
||||
});
|
||||
} else {
|
||||
// Fallback: add without schemas (will be incomplete)
|
||||
console.error("Failed to fetch graph data for agent block");
|
||||
addBlock(block.id, block.name, block.hardcodedValues || {});
|
||||
}
|
||||
} else {
|
||||
addBlock(block.id, block.name, block.hardcodedValues || {});
|
||||
}
|
||||
},
|
||||
[addBlock],
|
||||
);
|
||||
|
||||
// Extract unique categories from blocks
|
||||
const categories = useMemo(() => {
|
||||
return Array.from(
|
||||
new Set([
|
||||
null,
|
||||
...blocks
|
||||
.flatMap((block) => block.categories.map((cat) => cat.category))
|
||||
.sort(),
|
||||
]),
|
||||
);
|
||||
}, [blocks]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={pinBlocksPopover ? true : undefined}
|
||||
onOpenChange={(open) => open || resetFilters()}
|
||||
>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-id="blocks-control-popover-trigger"
|
||||
data-testid="blocks-control-blocks-button"
|
||||
name="Blocks"
|
||||
className="dark:hover:bg-slate-800"
|
||||
>
|
||||
<IconToyBrick />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Blocks</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
sideOffset={22}
|
||||
align="start"
|
||||
className="absolute -top-3 w-[17rem] rounded-xl border-none p-0 shadow-none md:w-[30rem]"
|
||||
data-id="blocks-control-popover-content"
|
||||
>
|
||||
<Card className="p-3 pb-0 dark:bg-slate-900">
|
||||
<CardHeader className="flex flex-col gap-x-8 gap-y-1 p-3 px-2">
|
||||
<div className="items-center justify-between">
|
||||
<Label
|
||||
htmlFor="search-blocks"
|
||||
className="whitespace-nowrap text-base font-bold text-black dark:text-white 2xl:text-xl"
|
||||
data-id="blocks-control-label"
|
||||
data-testid="blocks-control-blocks-label"
|
||||
>
|
||||
Blocks
|
||||
</Label>
|
||||
</div>
|
||||
<div className="relative flex items-center">
|
||||
<MagnifyingGlassIcon className="absolute m-2 h-5 w-5 text-gray-500 dark:text-gray-400" />
|
||||
<Input
|
||||
id="search-blocks"
|
||||
type="text"
|
||||
placeholder="Search blocks"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="rounded-lg px-8 py-5 dark:bg-slate-800 dark:text-white"
|
||||
data-id="blocks-control-search-input"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 flex flex-wrap gap-2"
|
||||
data-testid="blocks-categories-list"
|
||||
>
|
||||
{categories.map((category) => {
|
||||
const color = getPrimaryCategoryColor([
|
||||
{ category: category || "All", description: "" },
|
||||
]);
|
||||
const colorClass =
|
||||
selectedCategory === category ? `${color}` : "";
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
data-testid="blocks-category"
|
||||
role="button"
|
||||
className={`cursor-pointer rounded-xl border px-2 py-2 text-xs font-medium dark:border-slate-700 dark:text-white ${colorClass}`}
|
||||
onClick={() =>
|
||||
setSelectedCategory(
|
||||
selectedCategory === category ? null : category,
|
||||
)
|
||||
}
|
||||
>
|
||||
{beautifyString((category || "All").toLowerCase())}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-scroll border-t border-t-gray-200 p-0 dark:border-t-slate-700">
|
||||
<ScrollArea
|
||||
className="h-[60vh] w-full"
|
||||
data-id="blocks-control-scroll-area"
|
||||
>
|
||||
{filteredAvailableBlocks.map((block) => (
|
||||
<Card
|
||||
key={block.uiKey || block.id}
|
||||
className={`m-2 my-4 flex h-20 shadow-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700 ${
|
||||
block.notAvailable
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "cursor-move hover:shadow-lg"
|
||||
}`}
|
||||
data-id={`block-card-${block.id}`}
|
||||
draggable={!block.notAvailable}
|
||||
onDragStart={(e) => {
|
||||
if (block.notAvailable) return;
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData(
|
||||
"application/reactflow",
|
||||
JSON.stringify({
|
||||
blockId: block.id,
|
||||
blockName: block.name,
|
||||
hardcodedValues: block?.hardcodedValues || {},
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onClick={() => handleAddBlock(block)}
|
||||
title={block.notAvailable ?? undefined}
|
||||
>
|
||||
<div
|
||||
className={`-ml-px h-full w-3 rounded-l-xl ${getPrimaryCategoryColor(block.categories)}`}
|
||||
></div>
|
||||
|
||||
<div className="mx-3 flex flex-1 items-center justify-between">
|
||||
<div className="mr-2 min-w-0">
|
||||
<span
|
||||
className="block truncate pb-1 text-sm font-semibold dark:text-white"
|
||||
data-id={`block-name-${block.id}`}
|
||||
data-type={block.uiType}
|
||||
data-testid={`block-name-${block.id}`}
|
||||
>
|
||||
<TextRenderer
|
||||
value={beautifyString(block.name).replace(
|
||||
/ Block$/,
|
||||
"",
|
||||
)}
|
||||
truncateLengthLimit={45}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="block break-all text-xs font-normal text-gray-500 dark:text-gray-400"
|
||||
data-testid={`block-description-${block.id}`}
|
||||
>
|
||||
<TextRenderer
|
||||
value={block.description}
|
||||
truncateLengthLimit={165}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-shrink-0 items-center gap-1"
|
||||
data-id={`block-tooltip-${block.id}`}
|
||||
data-testid={`block-add`}
|
||||
>
|
||||
<PlusIcon className="h-6 w-6 rounded-lg bg-gray-200 stroke-black stroke-[0.5px] p-1 dark:bg-gray-700 dark:stroke-white" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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;
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { ClockIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { IconPlay, IconSquare } from "@/components/__legacy__/ui/icons";
|
||||
|
||||
interface Props {
|
||||
onClickAgentOutputs?: () => void;
|
||||
onClickRunAgent?: () => void;
|
||||
onClickStopRun: () => void;
|
||||
onClickScheduleButton?: () => void;
|
||||
isRunning: boolean;
|
||||
isDisabled: boolean;
|
||||
className?: string;
|
||||
resolutionModeActive?: boolean;
|
||||
}
|
||||
|
||||
export const BuildActionBar: React.FC<Props> = ({
|
||||
onClickAgentOutputs,
|
||||
onClickRunAgent,
|
||||
onClickStopRun,
|
||||
onClickScheduleButton,
|
||||
isRunning,
|
||||
isDisabled,
|
||||
className,
|
||||
resolutionModeActive = false,
|
||||
}) => {
|
||||
const buttonClasses =
|
||||
"flex items-center gap-2 text-sm font-medium md:text-lg";
|
||||
|
||||
// Show resolution mode message instead of action buttons
|
||||
if (resolutionModeActive) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-fit select-none items-center justify-center p-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 dark:border-amber-700 dark:bg-amber-900/30">
|
||||
<WarningIcon className="size-5 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
Remove incompatible connections to continue
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-fit select-none items-center justify-center p-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-1 md:gap-4">
|
||||
{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
|
||||
className={cn(
|
||||
buttonClasses,
|
||||
onClickRunAgent && isDisabled
|
||||
? "cursor-default opacity-50 hover:bg-accent"
|
||||
: "",
|
||||
)}
|
||||
variant="accent"
|
||||
size="primary"
|
||||
onClick={onClickRunAgent}
|
||||
disabled={!onClickRunAgent}
|
||||
title="Run the agent"
|
||||
aria-label="Run the agent"
|
||||
data-testid="primary-action-run-agent"
|
||||
data-tutorial-id="primary-action-run-agent"
|
||||
>
|
||||
<IconPlay /> Run
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className={buttonClasses}
|
||||
variant="destructive"
|
||||
size="primary"
|
||||
onClick={onClickStopRun}
|
||||
title="Stop the agent"
|
||||
data-id="primary-action-stop-agent"
|
||||
>
|
||||
<IconSquare /> Stop
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onClickScheduleButton && (
|
||||
<Button
|
||||
className={buttonClasses}
|
||||
variant="outline"
|
||||
size="primary"
|
||||
onClick={onClickScheduleButton}
|
||||
title="Set up a run schedule for the agent"
|
||||
data-id="primary-action-schedule-agent"
|
||||
>
|
||||
<ClockIcon className="hidden h-5 w-5 md:flex" />
|
||||
Schedule Run
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import {
|
||||
BaseEdge,
|
||||
ConnectionLineComponentProps,
|
||||
Node,
|
||||
getBezierPath,
|
||||
Position,
|
||||
} from "@xyflow/react";
|
||||
|
||||
export default function ConnectionLine<NodeType extends Node>({
|
||||
fromPosition,
|
||||
fromHandle,
|
||||
fromX,
|
||||
fromY,
|
||||
toPosition,
|
||||
toX,
|
||||
toY,
|
||||
}: ConnectionLineComponentProps<NodeType>) {
|
||||
const sourceX =
|
||||
fromPosition === Position.Right
|
||||
? fromX + ((fromHandle?.width ?? 0) / 2 - 5)
|
||||
: fromX - ((fromHandle?.width ?? 0) / 2 - 5);
|
||||
|
||||
const [path] = getBezierPath({
|
||||
sourceX: sourceX,
|
||||
sourceY: fromY,
|
||||
sourcePosition: fromPosition,
|
||||
targetX: toX,
|
||||
targetY: toY,
|
||||
targetPosition: toPosition,
|
||||
});
|
||||
|
||||
return <BaseEdge path={path} style={{ strokeWidth: 2, stroke: "#555" }} />;
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Card, CardContent } from "@/components/__legacy__/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Represents a control element for the ControlPanel Component.
|
||||
* @type {Object} Control
|
||||
* @property {React.ReactNode} icon - The icon of the control from lucide-react https://lucide.dev/icons/
|
||||
* @property {string} label - The label of the control, to be leveraged by ToolTip.
|
||||
* @property {onclick} onClick - The function to be executed when the control is clicked.
|
||||
*/
|
||||
export type Control = {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
interface ControlPanelProps {
|
||||
controls: Control[];
|
||||
topChildren?: React.ReactNode;
|
||||
botChildren?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ControlPanel component displays a panel with controls as icons.tsx with the ability to take in children.
|
||||
* @param {Object} ControlPanelProps - The properties of the control panel component.
|
||||
* @param {Array} ControlPanelProps.controls - An array of control objects representing actions to be preformed.
|
||||
* @param {Array} ControlPanelProps.children - The child components of the control panel.
|
||||
* @param {string} ControlPanelProps.className - Additional CSS class names for the control panel.
|
||||
* @returns The rendered control panel component.
|
||||
*/
|
||||
export const ControlPanel = ({
|
||||
controls,
|
||||
topChildren,
|
||||
botChildren,
|
||||
className,
|
||||
}: ControlPanelProps) => {
|
||||
return (
|
||||
<Card className={cn("m-4 mt-24 w-14 dark:bg-slate-900", className)}>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl py-3">
|
||||
{topChildren}
|
||||
<Separator className="dark:bg-slate-700" />
|
||||
{controls.map((control, index) => (
|
||||
<Tooltip key={index} delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => control.onClick()}
|
||||
data-id={`control-button-${index}`}
|
||||
data-testid={`blocks-control-${control.label.toLowerCase()}-button`}
|
||||
disabled={control.disabled || false}
|
||||
className="dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
||||
>
|
||||
{control.icon}
|
||||
<span className="sr-only">{control.label}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="dark:bg-slate-800 dark:text-slate-100"
|
||||
>
|
||||
{control.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<Separator className="dark:bg-slate-700" />
|
||||
{botChildren}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
export default ControlPanel;
|
||||
@@ -1,240 +0,0 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
} from "react";
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
EdgeProps,
|
||||
useReactFlow,
|
||||
XYPosition,
|
||||
Edge,
|
||||
Node,
|
||||
} from "@xyflow/react";
|
||||
import "./customedge.css";
|
||||
import { X } from "lucide-react";
|
||||
import { BuilderContext } from "../Flow/Flow";
|
||||
import { NodeExecutionResult } from "@/lib/autogpt-server-api";
|
||||
import { useCustomEdge } from "./useCustomEdge";
|
||||
|
||||
export type CustomEdgeData = {
|
||||
edgeColor: string;
|
||||
sourcePos?: XYPosition;
|
||||
isStatic?: boolean;
|
||||
beadUp: number;
|
||||
beadDown: number;
|
||||
beadData?: Map<string, NodeExecutionResult["status"]>;
|
||||
};
|
||||
|
||||
type Bead = {
|
||||
t: number;
|
||||
targetT: number;
|
||||
startTime: number;
|
||||
};
|
||||
|
||||
export type CustomEdge = Edge<CustomEdgeData, "custom">;
|
||||
|
||||
export function CustomEdge({
|
||||
id,
|
||||
data,
|
||||
selected,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
markerEnd,
|
||||
}: EdgeProps<CustomEdge>) {
|
||||
const [beads, setBeads] = useState<{
|
||||
beads: Bead[];
|
||||
created: number;
|
||||
destroyed: number;
|
||||
}>({ beads: [], created: 0, destroyed: 0 });
|
||||
const beadsRef = useRef(beads);
|
||||
const { svgPath, length, getPointForT, getTForDistance } = useCustomEdge(
|
||||
sourceX - 5,
|
||||
sourceY - 5,
|
||||
targetX + 3,
|
||||
targetY - 5,
|
||||
);
|
||||
const { deleteElements } = useReactFlow<Node, CustomEdge>();
|
||||
const builderContext = useContext(BuilderContext);
|
||||
const { visualizeBeads } = builderContext ?? {
|
||||
visualizeBeads: "no",
|
||||
};
|
||||
|
||||
// Check if this edge is broken (during resolution mode)
|
||||
const isBroken =
|
||||
builderContext?.resolutionMode?.active &&
|
||||
builderContext?.resolutionMode?.brokenEdgeIds?.includes(id);
|
||||
|
||||
const onEdgeRemoveClick = () => {
|
||||
deleteElements({ edges: [{ id }] });
|
||||
};
|
||||
|
||||
const animationDuration = 500; // Duration in milliseconds for bead to travel the curve
|
||||
const beadDiameter = 12;
|
||||
const deltaTime = 16;
|
||||
|
||||
const setTargetPositions = useCallback(
|
||||
(beads: Bead[]) => {
|
||||
const distanceBetween = Math.min(
|
||||
(length - beadDiameter) / (beads.length + 1),
|
||||
beadDiameter,
|
||||
);
|
||||
|
||||
return beads.map((bead, index) => {
|
||||
const distanceFromEnd = beadDiameter * 1.35;
|
||||
const targetPosition = distanceBetween * index + distanceFromEnd;
|
||||
const t = getTForDistance(-targetPosition);
|
||||
|
||||
return {
|
||||
...bead,
|
||||
t: visualizeBeads === "animate" ? bead.t : t,
|
||||
targetT: t,
|
||||
} as Bead;
|
||||
});
|
||||
},
|
||||
[getTForDistance, length, visualizeBeads],
|
||||
);
|
||||
|
||||
beadsRef.current = beads;
|
||||
useEffect(() => {
|
||||
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;
|
||||
}
|
||||
|
||||
// Add beads
|
||||
if (beadUp > beads.created) {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
// Animate and remove beads
|
||||
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;
|
||||
}
|
||||
|
||||
setBeads(({ beads, created, destroyed }) => {
|
||||
let destroyedCount = 0;
|
||||
|
||||
const newBeads = beads
|
||||
.map((bead) => {
|
||||
const progressIncrement = deltaTime / animationDuration;
|
||||
const t = Math.min(
|
||||
bead.t + bead.targetT * progressIncrement,
|
||||
bead.targetT,
|
||||
);
|
||||
|
||||
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?.beadUp, data?.beadDown, setTargetPositions, visualizeBeads]);
|
||||
|
||||
const middle = getPointForT(0.5);
|
||||
|
||||
// Determine edge color - red for broken edges
|
||||
const baseColor = data?.edgeColor ?? "#555555";
|
||||
const edgeColor = isBroken ? "#ef4444" : baseColor;
|
||||
// Add opacity to hex color (99 = 60% opacity, 80 = 50% opacity)
|
||||
const strokeColor = isBroken
|
||||
? `${edgeColor}99`
|
||||
: selected
|
||||
? edgeColor
|
||||
: `${edgeColor}80`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
path={svgPath}
|
||||
markerEnd={markerEnd}
|
||||
style={{
|
||||
stroke: strokeColor,
|
||||
strokeWidth: data?.isStatic ? 2.5 : 2,
|
||||
strokeDasharray: data?.isStatic ? "5 3" : undefined,
|
||||
}}
|
||||
className="data-sentry-unmask transition-all duration-200"
|
||||
/>
|
||||
<path
|
||||
d={svgPath}
|
||||
fill="none"
|
||||
strokeOpacity={0}
|
||||
strokeWidth={20}
|
||||
className="data-sentry-unmask react-flow__edge-interaction"
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
transform: `translate(-50%, -50%) translate(${middle.x}px,${middle.y}px)`,
|
||||
pointerEvents: "all",
|
||||
}}
|
||||
className="edge-label-renderer"
|
||||
>
|
||||
<button
|
||||
className="edge-label-button opacity-0 transition-opacity duration-200 hover:opacity-100"
|
||||
onClick={onEdgeRemoveClick}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
{beads.beads.map((bead, index) => {
|
||||
const pos = getPointForT(bead.t);
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r={beadDiameter / 2} // Bead radius
|
||||
fill={data?.edgeColor ?? "#555555"}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
.edge-label-renderer {
|
||||
position: absolute;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.edge-label-button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #eee;
|
||||
border: 1px solid #fff;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
color: #555;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.2s ease-in-out,
|
||||
background-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.edge-label-button.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.edge-label-button:hover {
|
||||
box-shadow: 0 0 6px 2px rgba(0, 0, 0, 0.08);
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.edge-label-button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.react-flow__edge-interaction {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.react-flow__edges > svg:has(> g.selected) {
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
.react-flow__edgelabel-renderer {
|
||||
z-index: 11 !important;
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
type XYPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type BezierPath = {
|
||||
sourcePosition: XYPosition;
|
||||
control1: XYPosition;
|
||||
control2: XYPosition;
|
||||
targetPosition: XYPosition;
|
||||
};
|
||||
|
||||
export function useCustomEdge(
|
||||
sourceX: number,
|
||||
sourceY: number,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
) {
|
||||
const path: BezierPath = useMemo(() => {
|
||||
const xDifference = Math.abs(sourceX - targetX);
|
||||
const yDifference = Math.abs(sourceY - targetY);
|
||||
const xControlDistance =
|
||||
sourceX < targetX ? 64 : Math.max(xDifference / 2, 64);
|
||||
const yControlDistance = yDifference < 128 && sourceX > targetX ? -64 : 0;
|
||||
|
||||
return {
|
||||
sourcePosition: { x: sourceX, y: sourceY },
|
||||
control1: {
|
||||
x: sourceX + xControlDistance,
|
||||
y: sourceY + yControlDistance,
|
||||
},
|
||||
control2: {
|
||||
x: targetX - xControlDistance,
|
||||
y: targetY + yControlDistance,
|
||||
},
|
||||
targetPosition: { x: targetX, y: targetY },
|
||||
};
|
||||
}, [sourceX, sourceY, targetX, targetY]);
|
||||
|
||||
const svgPath = useMemo(
|
||||
() =>
|
||||
`M ${path.sourcePosition.x} ${path.sourcePosition.y} ` +
|
||||
`C ${path.control1.x} ${path.control1.y} ${path.control2.x} ${path.control2.y} ` +
|
||||
`${path.targetPosition.x}, ${path.targetPosition.y}`,
|
||||
[path],
|
||||
);
|
||||
|
||||
const getPointForT = useCallback(
|
||||
(t: number) => {
|
||||
// Bezier formula: (1-t)^3 * p0 + 3*(1-t)^2*t*p1 + 3*(1-t)*t^2*p2 + t^3*p3
|
||||
const x =
|
||||
Math.pow(1 - t, 3) * path.sourcePosition.x +
|
||||
3 * Math.pow(1 - t, 2) * t * path.control1.x +
|
||||
3 * (1 - t) * Math.pow(t, 2) * path.control2.x +
|
||||
Math.pow(t, 3) * path.targetPosition.x;
|
||||
|
||||
const y =
|
||||
Math.pow(1 - t, 3) * path.sourcePosition.y +
|
||||
3 * Math.pow(1 - t, 2) * t * path.control1.y +
|
||||
3 * (1 - t) * Math.pow(t, 2) * path.control2.y +
|
||||
Math.pow(t, 3) * path.targetPosition.y;
|
||||
|
||||
return { x, y };
|
||||
},
|
||||
[path],
|
||||
);
|
||||
|
||||
const getArcLength = useCallback(
|
||||
(t: number, samples: number = 100) => {
|
||||
let length = 0;
|
||||
let prevPoint = getPointForT(0);
|
||||
|
||||
for (let i = 1; i <= samples; i++) {
|
||||
const currT = (i / samples) * t;
|
||||
const currPoint = getPointForT(currT);
|
||||
length += Math.sqrt(
|
||||
Math.pow(currPoint.x - prevPoint.x, 2) +
|
||||
Math.pow(currPoint.y - prevPoint.y, 2),
|
||||
);
|
||||
prevPoint = currPoint;
|
||||
}
|
||||
|
||||
return length;
|
||||
},
|
||||
[getPointForT],
|
||||
);
|
||||
|
||||
const length = useMemo(() => {
|
||||
return getArcLength(1);
|
||||
}, [getArcLength]);
|
||||
|
||||
const getBezierDerivative = useCallback(
|
||||
(t: number) => {
|
||||
const mt = 1 - t;
|
||||
const x =
|
||||
3 *
|
||||
(mt * mt * (path.control1.x - path.sourcePosition.x) +
|
||||
2 * mt * t * (path.control2.x - path.control1.x) +
|
||||
t * t * (path.targetPosition.x - path.control2.x));
|
||||
const y =
|
||||
3 *
|
||||
(mt * mt * (path.control1.y - path.sourcePosition.y) +
|
||||
2 * mt * t * (path.control2.y - path.control1.y) +
|
||||
t * t * (path.targetPosition.y - path.control2.y));
|
||||
return { x, y };
|
||||
},
|
||||
[path],
|
||||
);
|
||||
|
||||
const getTForDistance = useCallback(
|
||||
(distance: number, epsilon: number = 0.0001) => {
|
||||
if (distance < 0) {
|
||||
distance = length + distance; // If distance is negative, calculate from the end of the curve
|
||||
}
|
||||
|
||||
let t = distance / getArcLength(1);
|
||||
let prevT = 0;
|
||||
|
||||
while (Math.abs(t - prevT) > epsilon) {
|
||||
prevT = t;
|
||||
const length = getArcLength(t);
|
||||
const derivative = Math.sqrt(
|
||||
Math.pow(getBezierDerivative(t).x, 2) +
|
||||
Math.pow(getBezierDerivative(t).y, 2),
|
||||
);
|
||||
t -= (length - distance) / derivative;
|
||||
t = Math.max(0, Math.min(1, t)); // Clamp t between 0 and 1
|
||||
}
|
||||
|
||||
return t;
|
||||
},
|
||||
[getArcLength, getBezierDerivative, length],
|
||||
);
|
||||
|
||||
const getPointAtDistance = useCallback(
|
||||
(distance: number) => {
|
||||
if (distance < 0) {
|
||||
distance = length + distance; // If distance is negative, calculate from the end of the curve
|
||||
}
|
||||
|
||||
const t = getTForDistance(distance);
|
||||
return getPointForT(t);
|
||||
},
|
||||
[getTForDistance, getPointForT, length],
|
||||
);
|
||||
|
||||
return {
|
||||
path,
|
||||
svgPath,
|
||||
length,
|
||||
getPointForT,
|
||||
getTForDistance,
|
||||
getPointAtDistance,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,244 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/__legacy__/ui/dialog";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { AlertTriangle, XCircle, PlusCircle } from "lucide-react";
|
||||
import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
|
||||
|
||||
interface IncompatibilityDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
currentVersion: number;
|
||||
latestVersion: number;
|
||||
agentName: string;
|
||||
incompatibilities: IncompatibilityInfo;
|
||||
}
|
||||
|
||||
export const IncompatibilityDialog: React.FC<IncompatibilityDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
agentName,
|
||||
incompatibilities,
|
||||
}) => {
|
||||
const hasMissingInputs = incompatibilities.missingInputs.length > 0;
|
||||
const hasMissingOutputs = incompatibilities.missingOutputs.length > 0;
|
||||
const hasNewInputs = incompatibilities.newInputs.length > 0;
|
||||
const hasNewOutputs = incompatibilities.newOutputs.length > 0;
|
||||
const hasNewRequired = incompatibilities.newRequiredInputs.length > 0;
|
||||
const hasTypeMismatches = incompatibilities.inputTypeMismatches.length > 0;
|
||||
|
||||
const hasInputChanges = hasMissingInputs || hasNewInputs;
|
||||
const hasOutputChanges = hasMissingOutputs || hasNewOutputs;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
Incompatible Update
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Updating <strong>{beautifyString(agentName)}</strong> from v
|
||||
{currentVersion} to v{latestVersion} will break some connections.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Input changes - two column layout */}
|
||||
{hasInputChanges && (
|
||||
<TwoColumnSection
|
||||
title="Input Changes"
|
||||
leftIcon={<XCircle className="h-4 w-4 text-red-500" />}
|
||||
leftTitle="Removed"
|
||||
leftItems={incompatibilities.missingInputs}
|
||||
rightIcon={<PlusCircle className="h-4 w-4 text-green-500" />}
|
||||
rightTitle="Added"
|
||||
rightItems={incompatibilities.newInputs}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Output changes - two column layout */}
|
||||
{hasOutputChanges && (
|
||||
<TwoColumnSection
|
||||
title="Output Changes"
|
||||
leftIcon={<XCircle className="h-4 w-4 text-red-500" />}
|
||||
leftTitle="Removed"
|
||||
leftItems={incompatibilities.missingOutputs}
|
||||
rightIcon={<PlusCircle className="h-4 w-4 text-green-500" />}
|
||||
rightTitle="Added"
|
||||
rightItems={incompatibilities.newOutputs}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasTypeMismatches && (
|
||||
<SingleColumnSection
|
||||
icon={<XCircle className="h-4 w-4 text-red-500" />}
|
||||
title="Type Changed"
|
||||
description="These connected inputs have a different type:"
|
||||
items={incompatibilities.inputTypeMismatches.map(
|
||||
(m) => `${m.name} (${m.oldType} → ${m.newType})`,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasNewRequired && (
|
||||
<SingleColumnSection
|
||||
icon={<PlusCircle className="h-4 w-4 text-amber-500" />}
|
||||
title="New Required Inputs"
|
||||
description="These inputs are now required:"
|
||||
items={incompatibilities.newRequiredInputs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
If you proceed, you'll need to remove the broken connections
|
||||
before you can save or run your agent.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
className="bg-amber-600 hover:bg-amber-700"
|
||||
>
|
||||
Update Anyway
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
interface TwoColumnSectionProps {
|
||||
title: string;
|
||||
leftIcon: React.ReactNode;
|
||||
leftTitle: string;
|
||||
leftItems: string[];
|
||||
rightIcon: React.ReactNode;
|
||||
rightTitle: string;
|
||||
rightItems: string[];
|
||||
}
|
||||
|
||||
const TwoColumnSection: React.FC<TwoColumnSectionProps> = ({
|
||||
title,
|
||||
leftIcon,
|
||||
leftTitle,
|
||||
leftItems,
|
||||
rightIcon,
|
||||
rightTitle,
|
||||
rightItems,
|
||||
}) => (
|
||||
<div className="rounded-md border border-gray-200 p-3 dark:border-gray-700">
|
||||
<span className="font-medium">{title}</span>
|
||||
<div className="mt-2 grid grid-cols-2 items-start gap-4">
|
||||
{/* Left column - Breaking changes */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
|
||||
{leftIcon}
|
||||
<span>{leftTitle}</span>
|
||||
</div>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{leftItems.length > 0 ? (
|
||||
leftItems.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<code className="rounded bg-red-50 px-1 py-0.5 font-mono text-xs text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{item}
|
||||
</code>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="text-sm italic text-gray-400 dark:text-gray-500">
|
||||
None
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Right column - Possible solutions */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
|
||||
{rightIcon}
|
||||
<span>{rightTitle}</span>
|
||||
</div>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{rightItems.length > 0 ? (
|
||||
rightItems.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<code className="rounded bg-green-50 px-1 py-0.5 font-mono text-xs text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||
{item}
|
||||
</code>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="text-sm italic text-gray-400 dark:text-gray-500">
|
||||
None
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface SingleColumnSectionProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
const SingleColumnSection: React.FC<SingleColumnSectionProps> = ({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
}) => (
|
||||
<div className="rounded-md border border-gray-200 p-3 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span className="font-medium">{title}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="ml-4 list-disc text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<code className="rounded bg-gray-100 px-1 py-0.5 font-mono text-xs dark:bg-gray-800">
|
||||
{item}
|
||||
</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default IncompatibilityDialog;
|
||||
@@ -1,130 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { ArrowUp, AlertTriangle, Info } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SubAgentUpdateBarProps {
|
||||
currentVersion: number;
|
||||
latestVersion: number;
|
||||
isCompatible: boolean;
|
||||
incompatibilities: IncompatibilityInfo | null;
|
||||
onUpdate: () => void;
|
||||
isInResolutionMode?: boolean;
|
||||
}
|
||||
|
||||
export const SubAgentUpdateBar: React.FC<SubAgentUpdateBarProps> = ({
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
isCompatible,
|
||||
incompatibilities,
|
||||
onUpdate,
|
||||
isInResolutionMode = false,
|
||||
}) => {
|
||||
if (isInResolutionMode) {
|
||||
return <ResolutionModeBar incompatibilities={incompatibilities} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 rounded-t-lg bg-blue-50 px-3 py-2 dark:bg-blue-900/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUp className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm text-blue-700 dark:text-blue-300">
|
||||
Update available (v{currentVersion} → v{latestVersion})
|
||||
</span>
|
||||
{!isCompatible && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="font-medium">Incompatible changes detected</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Click Update to see details
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isCompatible ? "default" : "outline"}
|
||||
onClick={onUpdate}
|
||||
className={cn(
|
||||
"h-7 text-xs",
|
||||
!isCompatible && "border-amber-500 text-amber-600 hover:bg-amber-50",
|
||||
)}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ResolutionModeBarProps {
|
||||
incompatibilities: IncompatibilityInfo | null;
|
||||
}
|
||||
|
||||
const ResolutionModeBar: React.FC<ResolutionModeBarProps> = ({
|
||||
incompatibilities,
|
||||
}) => {
|
||||
const formatIncompatibilities = () => {
|
||||
if (!incompatibilities) return "No incompatibilities";
|
||||
|
||||
const items: string[] = [];
|
||||
|
||||
if (incompatibilities.missingInputs.length > 0) {
|
||||
items.push(
|
||||
`Missing inputs: ${incompatibilities.missingInputs.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (incompatibilities.missingOutputs.length > 0) {
|
||||
items.push(
|
||||
`Missing outputs: ${incompatibilities.missingOutputs.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (incompatibilities.newRequiredInputs.length > 0) {
|
||||
items.push(
|
||||
`New required inputs: ${incompatibilities.newRequiredInputs.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (incompatibilities.inputTypeMismatches.length > 0) {
|
||||
const mismatches = incompatibilities.inputTypeMismatches
|
||||
.map((m) => `${m.name} (${m.oldType} → ${m.newType})`)
|
||||
.join(", ");
|
||||
items.push(`Type changed: ${mismatches}`);
|
||||
}
|
||||
|
||||
return items.join("\n");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 rounded-t-lg bg-amber-50 px-3 py-2 dark:bg-amber-900/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-sm text-amber-700 dark:text-amber-300">
|
||||
Remove incompatible connections
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-4 w-4 cursor-help text-amber-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm whitespace-pre-line">
|
||||
<p className="font-medium">Incompatible changes:</p>
|
||||
<p className="mt-1 text-xs">{formatIncompatibilities()}</p>
|
||||
<p className="mt-2 text-xs text-gray-400">
|
||||
Delete the red connections to continue
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubAgentUpdateBar;
|
||||
@@ -1,131 +0,0 @@
|
||||
.custom-node {
|
||||
color: #000000;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.custom-node .custom-switch {
|
||||
padding: 0.5rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #d9534f;
|
||||
font-size: 13px;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Existing styles */
|
||||
.handle-container {
|
||||
display: flex;
|
||||
position: relative;
|
||||
margin-bottom: 0px;
|
||||
padding: 5px;
|
||||
min-height: 44px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.react-flow__handle {
|
||||
background: transparent;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border: 0;
|
||||
position: relative;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.border-error {
|
||||
border: 1px solid #d9534f;
|
||||
}
|
||||
|
||||
.select-input {
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #000;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
display: block;
|
||||
margin: 5px 0;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.number-input {
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.array-item-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.array-item-input {
|
||||
flex-grow: 1;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #000;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.array-item-remove {
|
||||
background: #d9534f;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.array-item-add {
|
||||
background: #5bc0de;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #d9534f;
|
||||
font-size: 13px;
|
||||
margin-top: 5px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
/* Styles for node states */
|
||||
.completed {
|
||||
border-color: #27ae60; /* Green border for completed nodes */
|
||||
}
|
||||
|
||||
.running {
|
||||
border-color: #f39c12; /* Orange border for running nodes */
|
||||
}
|
||||
|
||||
.failed {
|
||||
border-color: #c0392b; /* Red border for failed nodes */
|
||||
}
|
||||
|
||||
.incomplete {
|
||||
border-color: #9f14ab; /* Pink border for incomplete nodes */
|
||||
}
|
||||
|
||||
.queued {
|
||||
border-color: #25e6e6; /* Cyan border for queued nodes */
|
||||
}
|
||||
|
||||
.custom-switch {
|
||||
padding-left: 2px;
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
import { Clipboard, Maximize2 } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Button } from "../../../../../components/__legacy__/ui/button";
|
||||
import { ContentRenderer } from "../../../../../components/__legacy__/ui/render";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../../../components/__legacy__/ui/table";
|
||||
import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
|
||||
import {
|
||||
globalRegistry,
|
||||
OutputItem,
|
||||
} from "@/components/contextual/OutputRenderers";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { useToast } from "../../../../../components/molecules/Toast/use-toast";
|
||||
import ExpandableOutputDialog from "./ExpandableOutputDialog";
|
||||
|
||||
type DataTableProps = {
|
||||
title?: string;
|
||||
truncateLongData?: boolean;
|
||||
data: { [key: string]: Array<any> };
|
||||
};
|
||||
|
||||
export default function DataTable({
|
||||
title,
|
||||
truncateLongData,
|
||||
data,
|
||||
}: DataTableProps) {
|
||||
const { toast } = useToast();
|
||||
const enableEnhancedOutputHandling = useGetFlag(
|
||||
Flag.ENABLE_ENHANCED_OUTPUT_HANDLING,
|
||||
);
|
||||
const [expandedDialog, setExpandedDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
execId: string;
|
||||
pinName: string;
|
||||
data: any[];
|
||||
} | null>(null);
|
||||
|
||||
// Prepare renderers for each item when enhanced mode is enabled
|
||||
const getItemRenderer = useMemo(() => {
|
||||
if (!enableEnhancedOutputHandling) return null;
|
||||
return (item: unknown) => {
|
||||
const metadata: OutputMetadata = {};
|
||||
return globalRegistry.getRenderer(item, metadata);
|
||||
};
|
||||
}, [enableEnhancedOutputHandling]);
|
||||
|
||||
const copyData = (pin: string, data: string) => {
|
||||
navigator.clipboard.writeText(data).then(() => {
|
||||
toast({
|
||||
title: `"${pin}" data copied to clipboard!`,
|
||||
duration: 2000,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const openExpandedView = (pinName: string, pinData: any[]) => {
|
||||
setExpandedDialog({
|
||||
isOpen: true,
|
||||
execId: title || "Unknown Execution",
|
||||
pinName,
|
||||
data: pinData,
|
||||
});
|
||||
};
|
||||
|
||||
const closeExpandedView = () => {
|
||||
setExpandedDialog(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{title && <strong className="mt-2 flex justify-center">{title}</strong>}
|
||||
<Table className="cursor-default select-text">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Pin</TableHead>
|
||||
<TableHead>Data</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<TableRow className="group" key={key}>
|
||||
<TableCell className="cursor-text">
|
||||
{beautifyString(key)}
|
||||
</TableCell>
|
||||
<TableCell className="cursor-text">
|
||||
<div className="flex min-h-9 items-center whitespace-pre-wrap">
|
||||
<div className="absolute right-1 top-auto m-1 hidden gap-1 group-hover:flex">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => openExpandedView(key, value)}
|
||||
title="Expand Full View"
|
||||
>
|
||||
<Maximize2 size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
copyData(
|
||||
beautifyString(key),
|
||||
value
|
||||
.map((i) =>
|
||||
typeof i === "object"
|
||||
? JSON.stringify(i, null, 2)
|
||||
: String(i),
|
||||
)
|
||||
.join(", "),
|
||||
)
|
||||
}
|
||||
title="Copy Data"
|
||||
>
|
||||
<Clipboard size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
{value.map((item, index) => {
|
||||
const renderer = getItemRenderer?.(item);
|
||||
if (enableEnhancedOutputHandling && renderer) {
|
||||
const metadata: OutputMetadata = {};
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<OutputItem
|
||||
value={item}
|
||||
metadata={metadata}
|
||||
renderer={renderer}
|
||||
/>
|
||||
{index < value.length - 1 && ", "}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<ContentRenderer
|
||||
value={item}
|
||||
truncateLongData={truncateLongData}
|
||||
/>
|
||||
{index < value.length - 1 && ", "}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{expandedDialog && (
|
||||
<ExpandableOutputDialog
|
||||
isOpen={expandedDialog.isOpen}
|
||||
onClose={closeExpandedView}
|
||||
execId={expandedDialog.execId}
|
||||
pinName={expandedDialog.pinName}
|
||||
data={expandedDialog.data}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
|
||||
import {
|
||||
globalRegistry,
|
||||
OutputActions,
|
||||
OutputItem,
|
||||
} from "@/components/contextual/OutputRenderers";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { Clipboard, Maximize2 } from "lucide-react";
|
||||
import React, { FC, useMemo, useState } from "react";
|
||||
import { Button } from "../../../../../components/__legacy__/ui/button";
|
||||
import { ContentRenderer } from "../../../../../components/__legacy__/ui/render";
|
||||
import { ScrollArea } from "../../../../../components/__legacy__/ui/scroll-area";
|
||||
import { Separator } from "../../../../../components/__legacy__/ui/separator";
|
||||
import { Switch } from "../../../../../components/atoms/Switch/Switch";
|
||||
import { useToast } from "../../../../../components/molecules/Toast/use-toast";
|
||||
|
||||
interface ExpandableOutputDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
execId: string;
|
||||
pinName: string;
|
||||
data: any[];
|
||||
}
|
||||
|
||||
const ExpandableOutputDialog: FC<ExpandableOutputDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
execId,
|
||||
pinName,
|
||||
data,
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
const enableEnhancedOutputHandling = useGetFlag(
|
||||
Flag.ENABLE_ENHANCED_OUTPUT_HANDLING,
|
||||
);
|
||||
const [useEnhancedRenderer, setUseEnhancedRenderer] = useState(false);
|
||||
|
||||
// Prepare items for the enhanced renderer system
|
||||
const outputItems = useMemo(() => {
|
||||
if (!data || !useEnhancedRenderer) return [];
|
||||
|
||||
const items: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
value: unknown;
|
||||
metadata?: OutputMetadata;
|
||||
renderer: any;
|
||||
}> = [];
|
||||
|
||||
data.forEach((value, index) => {
|
||||
const metadata: OutputMetadata = {};
|
||||
|
||||
// Extract metadata from the value if it's an object
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!React.isValidElement(value)
|
||||
) {
|
||||
const objValue = value as any;
|
||||
if (objValue.type) metadata.type = objValue.type;
|
||||
if (objValue.mimeType) metadata.mimeType = objValue.mimeType;
|
||||
if (objValue.filename) metadata.filename = objValue.filename;
|
||||
if (objValue.language) metadata.language = objValue.language;
|
||||
}
|
||||
|
||||
const renderer = globalRegistry.getRenderer(value, metadata);
|
||||
if (renderer) {
|
||||
items.push({
|
||||
key: `item-${index}`,
|
||||
label: index === 0 ? beautifyString(pinName) : "",
|
||||
value,
|
||||
metadata,
|
||||
renderer,
|
||||
});
|
||||
} else {
|
||||
// Fallback to text renderer
|
||||
const textRenderer = globalRegistry
|
||||
.getAllRenderers()
|
||||
.find((r) => r.name === "TextRenderer");
|
||||
if (textRenderer) {
|
||||
items.push({
|
||||
key: `item-${index}`,
|
||||
label: index === 0 ? beautifyString(pinName) : "",
|
||||
value:
|
||||
typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value, null, 2),
|
||||
metadata,
|
||||
renderer: textRenderer,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [data, useEnhancedRenderer, pinName]);
|
||||
|
||||
const copyData = () => {
|
||||
const formattedData = data
|
||||
.map((item) =>
|
||||
typeof item === "object" ? JSON.stringify(item, null, 2) : String(item),
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
navigator.clipboard.writeText(formattedData).then(() => {
|
||||
toast({
|
||||
title: `"${beautifyString(pinName)}" data copied to clipboard!`,
|
||||
duration: 2000,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Maximize2 size={20} />
|
||||
Full Output Preview
|
||||
</div>
|
||||
{enableEnhancedOutputHandling && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label
|
||||
htmlFor="enhanced-rendering-toggle"
|
||||
className="cursor-pointer select-none text-sm font-normal text-gray-600"
|
||||
>
|
||||
Enhanced Rendering
|
||||
</label>
|
||||
<Switch
|
||||
id="enhanced-rendering-toggle"
|
||||
checked={useEnhancedRenderer}
|
||||
onCheckedChange={setUseEnhancedRenderer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
controlled={{
|
||||
isOpen,
|
||||
set: (open) => {
|
||||
if (!open) onClose();
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
styling={{
|
||||
maxWidth: "56rem",
|
||||
width: "90vw",
|
||||
height: "90vh",
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="pb-4">
|
||||
<p className="text-sm text-zinc-600">
|
||||
Execution ID: <span className="font-mono text-xs">{execId}</span>
|
||||
<br />
|
||||
Pin:{" "}
|
||||
<span className="font-semibold">{beautifyString(pinName)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{useEnhancedRenderer && outputItems.length > 0 && (
|
||||
<div className="border-b px-4 py-2">
|
||||
<OutputActions
|
||||
items={outputItems.map((item) => ({
|
||||
value: item.value,
|
||||
metadata: item.metadata,
|
||||
renderer: item.renderer,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
{data.length > 0 ? (
|
||||
useEnhancedRenderer ? (
|
||||
<div className="space-y-4">
|
||||
{outputItems.map((item) => (
|
||||
<OutputItem
|
||||
key={item.key}
|
||||
value={item.value}
|
||||
metadata={item.metadata}
|
||||
renderer={item.renderer}
|
||||
label={item.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{data.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-gray-50 p-4"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
Item {index + 1} of {data.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const itemData =
|
||||
typeof item === "object"
|
||||
? JSON.stringify(item, null, 2)
|
||||
: String(item);
|
||||
navigator.clipboard
|
||||
.writeText(itemData)
|
||||
.then(() => {
|
||||
toast({
|
||||
title: `Item ${index + 1} copied to clipboard!`,
|
||||
duration: 2000,
|
||||
});
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Clipboard size={14} />
|
||||
Copy Item
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="mb-3" />
|
||||
<div className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||
<ContentRenderer
|
||||
value={item}
|
||||
truncateLongData={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer className="flex justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
{data.length} item{data.length !== 1 ? "s" : ""} total
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!useEnhancedRenderer && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={copyData}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Clipboard size={16} />
|
||||
Copy All
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpandableOutputDialog;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,103 +0,0 @@
|
||||
/* flow.css or index.css */
|
||||
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
|
||||
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family:
|
||||
source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
margin-right: -50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #ffffff;
|
||||
padding: 20px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.modal button {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.modal form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal form div {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: -600px;
|
||||
width: 350px;
|
||||
height: calc(100vh - 68px); /* Full height minus top offset */
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
padding: 20px;
|
||||
transition: left 0.3s ease;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
margin-top: 68px; /* Margin to push content below the top fixed area */
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.sidebar h3 {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.sidebar input {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.sidebarNodeRowStyle {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #e2e2e2;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 10px;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.sidebarNodeRowStyle.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.flow-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
|
||||
import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import { GraphSearchContent } from "../NewControlPanel/NewSearchGraph/GraphMenuContent/GraphContent";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { useGraphMenu } from "../NewControlPanel/NewSearchGraph/GraphMenu/useGraphMenu";
|
||||
|
||||
interface GraphSearchControlProps {
|
||||
nodes: CustomNode[];
|
||||
onNodeSelect: (nodeId: string) => void;
|
||||
onNodeHover?: (nodeId: string | null) => void;
|
||||
}
|
||||
|
||||
export function GraphSearchControl({
|
||||
nodes,
|
||||
onNodeSelect,
|
||||
onNodeHover,
|
||||
}: GraphSearchControlProps) {
|
||||
// Use the same hook as GraphSearchMenu for consistency
|
||||
const {
|
||||
open,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filteredNodes,
|
||||
handleNodeSelect,
|
||||
handleOpenChange,
|
||||
} = useGraphMenu({
|
||||
nodes,
|
||||
blockMenuSelected: "", // We don't need to track this in the old control panel
|
||||
setBlockMenuSelected: () => {}, // Not needed in this context
|
||||
onNodeSelect,
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-id="graph-search-control-trigger"
|
||||
data-testid="graph-search-control-button"
|
||||
name="Search"
|
||||
className="dark:hover:bg-slate-800"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Search Graph</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent
|
||||
side="right"
|
||||
sideOffset={22}
|
||||
align="start"
|
||||
alignOffset={-50} // Offset upward to align with control panel top
|
||||
className="absolute -top-3 w-[17rem] rounded-xl border-none p-0 shadow-none md:w-[30rem]"
|
||||
data-id="graph-search-popover-content"
|
||||
>
|
||||
<GraphSearchContent
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
filteredNodes={filteredNodes}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onNodeHover={onNodeHover}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import { Button } from "../../../../../components/__legacy__/ui/button";
|
||||
import { Textarea } from "../../../../../components/__legacy__/ui/textarea";
|
||||
import { Maximize2, Minimize2, Clipboard } from "lucide-react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "../../../../../components/molecules/Toast/use-toast";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (value: string) => void;
|
||||
title?: string;
|
||||
defaultValue: string;
|
||||
}
|
||||
|
||||
const InputModalComponent: FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
title,
|
||||
defaultValue,
|
||||
}) => {
|
||||
const [tempValue, setTempValue] = useState(defaultValue);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTempValue(defaultValue);
|
||||
setIsMaximized(false);
|
||||
}
|
||||
}, [isOpen, defaultValue]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(tempValue);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const toggleSize = () => {
|
||||
setIsMaximized(!isMaximized);
|
||||
};
|
||||
|
||||
const copyValue = () => {
|
||||
navigator.clipboard.writeText(tempValue).then(() => {
|
||||
toast({
|
||||
title: "Input value copied to clipboard!",
|
||||
duration: 2000,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
id="modal-content"
|
||||
className={`fixed rounded-lg border-[1.5px] bg-white p-5 ${
|
||||
isMaximized ? "inset-[128px] flex flex-col" : `w-[90%] max-w-[800px]`
|
||||
}`}
|
||||
>
|
||||
<h2 className="mb-4 text-center text-lg font-semibold">
|
||||
{title || "Enter input text"}
|
||||
</h2>
|
||||
<div className="nowheel relative flex-grow">
|
||||
<Textarea
|
||||
className="h-full min-h-[200px] w-full resize-none"
|
||||
value={tempValue}
|
||||
onChange={(e) => setTempValue(e.target.value)}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 flex space-x-2">
|
||||
<Button onClick={copyValue} size="icon" variant="outline">
|
||||
<Clipboard size={18} />
|
||||
</Button>
|
||||
<Button onClick={toggleSize} size="icon" variant="outline">
|
||||
{isMaximized ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<Button onClick={onClose} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMaximized ? (
|
||||
createPortal(
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-white bg-opacity-60">
|
||||
{modalContent}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
) : (
|
||||
<div className="nodrag fixed inset-0 flex items-center justify-center bg-white bg-opacity-60">
|
||||
{modalContent}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputModalComponent;
|
||||
@@ -1,163 +0,0 @@
|
||||
import { BlockIOSubSchema } from "@/lib/autogpt-server-api/types";
|
||||
import {
|
||||
cn,
|
||||
beautifyString,
|
||||
getTypeBgColor,
|
||||
getTypeTextColor,
|
||||
getEffectiveType,
|
||||
} from "@/lib/utils";
|
||||
import { FC, memo, useCallback } from "react";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
|
||||
|
||||
type HandleProps = {
|
||||
keyName: string;
|
||||
schema: BlockIOSubSchema;
|
||||
isConnected: boolean;
|
||||
isRequired?: boolean;
|
||||
side: "left" | "right";
|
||||
title?: string;
|
||||
className?: string;
|
||||
isBroken?: boolean;
|
||||
};
|
||||
|
||||
// Move the constant out of the component to avoid re-creation on every render.
|
||||
const TYPE_NAME: Record<string, string> = {
|
||||
string: "text",
|
||||
number: "number",
|
||||
integer: "integer",
|
||||
boolean: "true/false",
|
||||
object: "object",
|
||||
array: "list",
|
||||
null: "null",
|
||||
};
|
||||
|
||||
// Extract and memoize the Dot component so that it doesn't re-render unnecessarily.
|
||||
const Dot: FC<{ isConnected: boolean; type?: string; isBroken?: boolean }> =
|
||||
memo(({ isConnected, type, isBroken }) => {
|
||||
const color = isBroken
|
||||
? "border-red-500 bg-red-100 dark:bg-red-900/30"
|
||||
: isConnected
|
||||
? getTypeBgColor(type || "any")
|
||||
: "border-gray-300 dark:border-gray-600";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"m-1 h-4 w-4 rounded-full border-2 bg-white transition-colors duration-100 group-hover:bg-gray-300 dark:bg-slate-800 dark:group-hover:bg-gray-700",
|
||||
color,
|
||||
isBroken && "opacity-50",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Dot.displayName = "Dot";
|
||||
|
||||
const NodeHandle: FC<HandleProps> = ({
|
||||
keyName,
|
||||
schema,
|
||||
isConnected,
|
||||
isRequired,
|
||||
side,
|
||||
title,
|
||||
className,
|
||||
isBroken = false,
|
||||
}) => {
|
||||
// Extract effective type from schema (handles anyOf/oneOf/allOf wrappers)
|
||||
const effectiveType = getEffectiveType(schema);
|
||||
|
||||
const typeClass = `text-sm ${getTypeTextColor(effectiveType || "any")} ${
|
||||
side === "left" ? "text-left" : "text-right"
|
||||
}`;
|
||||
|
||||
const label = (
|
||||
<div className={cn("flex flex-grow flex-row", isBroken && "opacity-50")}>
|
||||
<span
|
||||
className={cn(
|
||||
"data-sentry-unmask text-m green flex items-end pr-2 text-gray-900 dark:text-gray-100",
|
||||
className,
|
||||
isBroken && "text-red-500 line-through",
|
||||
)}
|
||||
>
|
||||
{title || schema.title || beautifyString(keyName.toLowerCase())}
|
||||
{isRequired ? "*" : ""}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
`${typeClass} data-sentry-unmask flex items-end`,
|
||||
isBroken && "text-red-400",
|
||||
)}
|
||||
>
|
||||
({TYPE_NAME[effectiveType as keyof typeof TYPE_NAME] || "any"})
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Use a native HTML onContextMenu handler instead of wrapping a large node with a Radix ContextMenu trigger.
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
// Optionally, you can trigger a custom, lightweight context menu here.
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (side === "left") {
|
||||
return (
|
||||
<div
|
||||
key={keyName}
|
||||
className={cn("handle-container", isBroken && "pointer-events-none")}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
data-testid={`input-handle-${keyName}`}
|
||||
position={Position.Left}
|
||||
id={keyName}
|
||||
className={cn("group -ml-[38px]", isBroken && "cursor-not-allowed")}
|
||||
isConnectable={!isBroken}
|
||||
>
|
||||
<div className="pointer-events-none flex items-center">
|
||||
<Dot
|
||||
isConnected={isConnected}
|
||||
type={effectiveType}
|
||||
isBroken={isBroken}
|
||||
/>
|
||||
{label}
|
||||
</div>
|
||||
</Handle>
|
||||
<InformationTooltip description={schema.description} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
key={keyName}
|
||||
className={cn(
|
||||
"handle-container justify-end",
|
||||
isBroken && "pointer-events-none",
|
||||
)}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<Handle
|
||||
type="source"
|
||||
data-testid={`output-handle-${keyName}`}
|
||||
position={Position.Right}
|
||||
id={keyName}
|
||||
className={cn("group -mr-[38px]", isBroken && "cursor-not-allowed")}
|
||||
isConnectable={!isBroken}
|
||||
>
|
||||
<div className="pointer-events-none flex items-center">
|
||||
{label}
|
||||
<Dot
|
||||
isConnected={isConnected}
|
||||
type={effectiveType}
|
||||
isBroken={isBroken}
|
||||
/>
|
||||
</div>
|
||||
</Handle>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default memo(NodeHandle);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,158 +0,0 @@
|
||||
import React, { useContext, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { Maximize2 } from "lucide-react";
|
||||
import * as Separator from "@radix-ui/react-separator";
|
||||
import { ContentRenderer } from "@/components/__legacy__/ui/render";
|
||||
import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
|
||||
import {
|
||||
globalRegistry,
|
||||
OutputItem,
|
||||
} from "@/components/contextual/OutputRenderers";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
|
||||
import { beautifyString } from "@/lib/utils";
|
||||
|
||||
import { BuilderContext } from "./Flow/Flow";
|
||||
import ExpandableOutputDialog from "./ExpandableOutputDialog";
|
||||
|
||||
type NodeOutputsProps = {
|
||||
title?: string;
|
||||
truncateLongData?: boolean;
|
||||
data: { [key: string]: Array<any> };
|
||||
};
|
||||
|
||||
export default function NodeOutputs({
|
||||
title,
|
||||
truncateLongData,
|
||||
data,
|
||||
}: NodeOutputsProps) {
|
||||
const builderContext = useContext(BuilderContext);
|
||||
const enableEnhancedOutputHandling = useGetFlag(
|
||||
Flag.ENABLE_ENHANCED_OUTPUT_HANDLING,
|
||||
);
|
||||
|
||||
const [expandedDialog, setExpandedDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
execId: string;
|
||||
pinName: string;
|
||||
data: any[];
|
||||
} | null>(null);
|
||||
|
||||
if (!builderContext) {
|
||||
throw new Error(
|
||||
"BuilderContext consumer must be inside FlowEditor component",
|
||||
);
|
||||
}
|
||||
|
||||
const { getNodeTitle } = builderContext;
|
||||
|
||||
// Prepare renderers for each item when enhanced mode is enabled
|
||||
const getItemRenderer = useMemo(() => {
|
||||
if (!enableEnhancedOutputHandling) return null;
|
||||
return (item: unknown) => {
|
||||
const metadata: OutputMetadata = {};
|
||||
return globalRegistry.getRenderer(item, metadata);
|
||||
};
|
||||
}, [enableEnhancedOutputHandling]);
|
||||
|
||||
const getBeautifiedPinName = (pin: string) => {
|
||||
if (!pin.startsWith("tools_^_")) {
|
||||
return beautifyString(pin);
|
||||
}
|
||||
// Special handling for tool pins: replace node ID with node title
|
||||
const toolNodeID = pin.slice(8).split("_~_")[0]; // tools_^_{node_id}_~_{field}
|
||||
const toolNodeTitle = getNodeTitle(toolNodeID);
|
||||
return toolNodeTitle
|
||||
? beautifyString(pin.replace(toolNodeID, toolNodeTitle))
|
||||
: beautifyString(pin);
|
||||
};
|
||||
|
||||
const openExpandedView = (pinName: string, pinData: any[]) => {
|
||||
setExpandedDialog({
|
||||
isOpen: true,
|
||||
execId: title || "Node Output",
|
||||
pinName,
|
||||
data: pinData,
|
||||
});
|
||||
};
|
||||
|
||||
const closeExpandedView = () => {
|
||||
setExpandedDialog(null);
|
||||
};
|
||||
return (
|
||||
<div className="m-4 space-y-4">
|
||||
{title && <strong className="mt-2flex">{title}</strong>}
|
||||
{Object.entries(data).map(([pin, dataArray]) => (
|
||||
<div key={pin} className="group">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<strong className="mr-2">Pin:</strong>
|
||||
<span>{getBeautifiedPinName(pin)}</span>
|
||||
</div>
|
||||
{(truncateLongData || dataArray.length > 10) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openExpandedView(pin, dataArray)}
|
||||
className="hidden items-center gap-1 group-hover:flex"
|
||||
title="Expand Full View"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
Expand
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<strong className="mr-2">Data:</strong>
|
||||
<div className="mt-1">
|
||||
{dataArray.slice(0, 10).map((item, index) => {
|
||||
const renderer = getItemRenderer?.(item);
|
||||
if (enableEnhancedOutputHandling && renderer) {
|
||||
const metadata: OutputMetadata = {};
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<OutputItem
|
||||
value={item}
|
||||
metadata={metadata}
|
||||
renderer={renderer}
|
||||
/>
|
||||
{index < Math.min(dataArray.length, 10) - 1 && ", "}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<ContentRenderer
|
||||
value={item}
|
||||
truncateLongData={truncateLongData}
|
||||
/>
|
||||
{index < Math.min(dataArray.length, 10) - 1 && ", "}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{dataArray.length > 10 && (
|
||||
<span style={{ color: "#888" }}>
|
||||
<br />
|
||||
<b>⋮</b>
|
||||
<br />
|
||||
<span>and {dataArray.length - 10} more</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Separator.Root className="my-4 h-[1px] bg-gray-300" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{expandedDialog && (
|
||||
<ExpandableOutputDialog
|
||||
isOpen={expandedDialog.isOpen}
|
||||
onClose={closeExpandedView}
|
||||
execId={expandedDialog.execId}
|
||||
pinName={expandedDialog.pinName}
|
||||
data={expandedDialog.data}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import { FC, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import NodeHandle from "@/app/(platform)/build/components/legacy-builder/NodeHandle";
|
||||
import type {
|
||||
BlockIOTableSubSchema,
|
||||
TableCellValue,
|
||||
TableRow,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import type { ConnectedEdge } from "./CustomNode/CustomNode";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PlusIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
|
||||
interface NodeTableInputProps {
|
||||
/** Unique identifier for the node in the builder graph */
|
||||
nodeId: string;
|
||||
/** Key identifier for this specific input field within the node */
|
||||
selfKey: string;
|
||||
/** Schema definition for the table structure */
|
||||
schema: BlockIOTableSubSchema;
|
||||
/** Column headers for the table */
|
||||
headers: string[];
|
||||
/** Initial row data for the table */
|
||||
rows?: TableRow[];
|
||||
/** Validation errors mapped by field key */
|
||||
errors: { [key: string]: string | undefined };
|
||||
/** Graph connections between nodes in the builder */
|
||||
connections: ConnectedEdge[];
|
||||
/** Callback when table data changes */
|
||||
handleInputChange: (key: string, value: TableRow[]) => void;
|
||||
/** Callback when input field is clicked (for builder selection) */
|
||||
handleInputClick: (key: string) => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Display name for the input field */
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table input component for the workflow builder interface.
|
||||
*
|
||||
* This component is specifically designed for use in the agent builder where users
|
||||
* design workflows with connected nodes. It includes graph connection capabilities
|
||||
* via NodeHandle and is tightly integrated with the builder's state management.
|
||||
*
|
||||
* @warning Do NOT use this component in runtime/execution contexts (like RunAgentInputs).
|
||||
* For runtime table inputs, use a simpler implementation without builder-specific features.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <NodeTableInput
|
||||
* nodeId="node-123"
|
||||
* selfKey="table_data"
|
||||
* schema={tableSchema}
|
||||
* headers={["Name", "Value"]}
|
||||
* rows={existingData}
|
||||
* connections={graphConnections}
|
||||
* handleInputChange={handleChange}
|
||||
* handleInputClick={handleClick}
|
||||
* errors={{}}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @see Used exclusively in: `/app/(platform)/build/components/legacy-builder/NodeInputs.tsx`
|
||||
*/
|
||||
export const NodeTableInput: FC<NodeTableInputProps> = ({
|
||||
nodeId,
|
||||
selfKey,
|
||||
schema,
|
||||
headers,
|
||||
rows = [],
|
||||
errors,
|
||||
connections,
|
||||
handleInputChange,
|
||||
handleInputClick: _handleInputClick,
|
||||
className,
|
||||
displayName,
|
||||
}) => {
|
||||
const [tableData, setTableData] = useState<TableRow[]>(rows);
|
||||
|
||||
// Sync with parent state when rows change
|
||||
useEffect(() => {
|
||||
setTableData(rows);
|
||||
}, [rows]);
|
||||
|
||||
const isConnected = (key: string) =>
|
||||
connections.some((c) => c.targetHandle === key && c.target === nodeId);
|
||||
|
||||
const updateTableData = useCallback(
|
||||
(newData: TableRow[]) => {
|
||||
setTableData(newData);
|
||||
handleInputChange(selfKey, newData);
|
||||
},
|
||||
[selfKey, handleInputChange],
|
||||
);
|
||||
|
||||
const updateCell = (
|
||||
rowIndex: number,
|
||||
header: string,
|
||||
value: TableCellValue,
|
||||
) => {
|
||||
const newData = [...tableData];
|
||||
if (!newData[rowIndex]) {
|
||||
newData[rowIndex] = {};
|
||||
}
|
||||
newData[rowIndex][header] = value;
|
||||
updateTableData(newData);
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
if (!headers || headers.length === 0) {
|
||||
return;
|
||||
}
|
||||
const newRow: TableRow = {};
|
||||
headers.forEach((header) => {
|
||||
newRow[header] = "";
|
||||
});
|
||||
updateTableData([...tableData, newRow]);
|
||||
};
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
const newData = tableData.filter((_, i) => i !== index);
|
||||
updateTableData(newData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("w-full space-y-2", className)}>
|
||||
<NodeHandle
|
||||
title={displayName || selfKey}
|
||||
keyName={selfKey}
|
||||
schema={schema}
|
||||
isConnected={isConnected(selfKey)}
|
||||
isRequired={false}
|
||||
side="left"
|
||||
/>
|
||||
|
||||
{!isConnected(selfKey) && (
|
||||
<div className="nodrag overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((header, index) => (
|
||||
<th
|
||||
key={index}
|
||||
className="border border-gray-300 bg-gray-100 px-2 py-1 text-left text-sm font-medium dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
<th className="w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableData.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{headers.map((header, colIndex) => (
|
||||
<td
|
||||
key={colIndex}
|
||||
className="border border-gray-300 p-1 dark:border-gray-600"
|
||||
>
|
||||
<Input
|
||||
id={`${selfKey}-${rowIndex}-${header}`}
|
||||
label={header}
|
||||
type="text"
|
||||
value={String(row[header] || "")}
|
||||
onChange={(e) =>
|
||||
updateCell(rowIndex, header, e.target.value)
|
||||
}
|
||||
className="h-8 w-full"
|
||||
placeholder={`Enter ${header}`}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => removeRow(rowIndex)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Button
|
||||
className="mt-2 bg-gray-200 font-normal text-black hover:text-white dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
|
||||
onClick={addRow}
|
||||
size="small"
|
||||
>
|
||||
<PlusIcon className="mr-2" /> Add Row
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors[selfKey] && (
|
||||
<span className="text-sm text-red-500">{errors[selfKey]}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,311 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import type { GraphID } from "@/lib/autogpt-server-api/types";
|
||||
import { askOtto } from "@/app/(platform)/build/actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { environment } from "@/services/environment";
|
||||
|
||||
interface Message {
|
||||
type: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default function OttoChatWidget({
|
||||
graphID,
|
||||
className,
|
||||
}: {
|
||||
graphID?: GraphID;
|
||||
className?: string;
|
||||
}): React.ReactNode {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [includeGraphData, setIncludeGraphData] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Add welcome message when component mounts
|
||||
if (messages.length === 0) {
|
||||
setMessages([
|
||||
{
|
||||
type: "assistant",
|
||||
content: "Hello, I am Otto! Ask me anything about AutoGPT!",
|
||||
},
|
||||
]);
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll to bottom whenever messages change
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inputValue.trim() || isProcessing) return;
|
||||
|
||||
const userMessage = inputValue.trim();
|
||||
setInputValue("");
|
||||
setIsProcessing(true);
|
||||
|
||||
// Add user message to chat
|
||||
setMessages((prev) => [...prev, { type: "user", content: userMessage }]);
|
||||
|
||||
// Add temporary processing message
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ type: "assistant", content: "Processing your question..." },
|
||||
]);
|
||||
|
||||
const conversationHistory = messages.reduce<
|
||||
{ query: string; response: string }[]
|
||||
>((acc, msg, i, arr) => {
|
||||
if (
|
||||
msg.type === "user" &&
|
||||
i + 1 < arr.length &&
|
||||
arr[i + 1].type === "assistant" &&
|
||||
arr[i + 1].content !== "Processing your question..."
|
||||
) {
|
||||
acc.push({
|
||||
query: msg.content,
|
||||
response: arr[i + 1].content,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
try {
|
||||
const data = await askOtto(
|
||||
userMessage,
|
||||
conversationHistory,
|
||||
includeGraphData,
|
||||
graphID,
|
||||
);
|
||||
|
||||
// Check if the response contains an error
|
||||
if ("error" in data && data.error === true) {
|
||||
// Handle different error types
|
||||
let errorMessage =
|
||||
"Sorry, there was an error processing your message. Please try again.";
|
||||
|
||||
if (data.answer === "Authentication required") {
|
||||
errorMessage = "Please sign in to use the chat feature.";
|
||||
} else if (data.answer === "Failed to connect to Otto service") {
|
||||
errorMessage =
|
||||
"Otto service is currently unavailable. Please try again later.";
|
||||
} else if (data.answer.includes("timed out")) {
|
||||
errorMessage = "Request timed out. Please try again later.";
|
||||
}
|
||||
|
||||
// Remove processing message and add error message
|
||||
setMessages((prev) => [
|
||||
...prev.slice(0, -1),
|
||||
{ type: "assistant", content: errorMessage },
|
||||
]);
|
||||
} else {
|
||||
// Remove processing message and add actual response
|
||||
setMessages((prev) => [
|
||||
...prev.slice(0, -1),
|
||||
{ type: "assistant", content: data.answer },
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Unexpected error in chat widget:", error);
|
||||
setMessages((prev) => [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
type: "assistant",
|
||||
content:
|
||||
"An unexpected error occurred. Please refresh the page and try again.",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setIncludeGraphData(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render the chat widget if we're not on the build page or in local mode
|
||||
if (environment.isLocal()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="inline-flex h-14 w-14 items-center justify-center whitespace-nowrap rounded-2xl bg-[rgba(65,65,64,1)] text-neutral-50 shadow transition-colors hover:bg-neutral-900/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90 dark:focus-visible:ring-neutral-300"
|
||||
aria-label="Open chat widget"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-6 w-6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-[600px] w-[600px] flex-col rounded-lg border bg-background shadow-xl",
|
||||
className,
|
||||
"z-40",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<h2 className="font-semibold">Otto Assistant</h2>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Close chat"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex ${message.type === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg p-3 ${
|
||||
message.type === "user"
|
||||
? "ml-4 bg-black text-white"
|
||||
: "mr-4 bg-[#8b5cf6] text-white"
|
||||
}`}
|
||||
>
|
||||
{message.type === "user" ? (
|
||||
message.content
|
||||
) : (
|
||||
<ReactMarkdown
|
||||
className="prose prose-sm dark:prose-invert max-w-none"
|
||||
components={{
|
||||
p: ({ children }) => (
|
||||
<p className="mb-2 last:mb-0">{children}</p>
|
||||
),
|
||||
code(props) {
|
||||
const { children, className, node: _, ...rest } = props;
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return match ? (
|
||||
<pre className="overflow-x-auto rounded-md bg-muted-foreground/20 p-3">
|
||||
<code className="font-mono text-sm" {...rest}>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
) : (
|
||||
<code
|
||||
className="rounded-md bg-muted-foreground/20 px-1 py-0.5 font-mono text-sm"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-2 list-disc pl-4 last:mb-0">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-2 list-decimal pl-4 last:mb-0">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="mb-1 last:mb-0">{children}</li>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<form onSubmit={handleSubmit} className="border-t p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="Type your message..."
|
||||
className="flex-1 rounded-md border bg-background px-3 py-2 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isProcessing}
|
||||
className="rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
{graphID && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIncludeGraphData((prev) => !prev);
|
||||
}}
|
||||
className={`flex items-center gap-2 rounded border px-2 py-1.5 text-sm transition-all duration-200 ${
|
||||
includeGraphData
|
||||
? "border-primary/30 bg-primary/10 text-primary hover:shadow-[0_0_10px_3px_rgba(139,92,246,0.3)]"
|
||||
: "border-transparent bg-muted text-muted-foreground hover:bg-muted/80 hover:shadow-[0_0_10px_3px_rgba(139,92,246,0.15)]"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
{includeGraphData
|
||||
? "Graph data will be included"
|
||||
: "Include graph data"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
import { Button } from "../../../../../components/__legacy__/ui/button";
|
||||
import { NodeExecutionResult } from "@/lib/autogpt-server-api/types";
|
||||
import DataTable from "./DataTable";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
|
||||
interface OutputModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
executionResults: {
|
||||
execId: string;
|
||||
data: NodeExecutionResult["output_data"];
|
||||
}[];
|
||||
}
|
||||
|
||||
const OutputModalComponent: FC<OutputModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
executionResults,
|
||||
}) => {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="nodrag nowheel fixed inset-0 flex items-center justify-center bg-white bg-opacity-60">
|
||||
<div className="w-[500px] max-w-[90%] rounded-lg border-[1.5px] bg-white p-5">
|
||||
<strong>Output Data History</strong>
|
||||
<div className="my-2 max-h-[384px] flex-grow overflow-y-auto rounded-md p-2">
|
||||
{executionResults.map((data, i) => (
|
||||
<>
|
||||
<DataTable
|
||||
key={i}
|
||||
title={data.execId}
|
||||
data={data.data}
|
||||
truncateLongData={true}
|
||||
/>
|
||||
<Separator />
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2.5 flex justify-end gap-2.5">
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutputModalComponent;
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { AgentRunDraftView } from "@/app/(platform)/build/components/legacy-builder/agent-run-draft-view";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import type {
|
||||
CredentialsMetaInput,
|
||||
Graph,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
|
||||
interface RunInputDialogProps {
|
||||
isOpen: boolean;
|
||||
doClose: () => void;
|
||||
graph: Graph;
|
||||
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 RunnerInputDialog({
|
||||
isOpen,
|
||||
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 = 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
|
||||
title="Run your agent"
|
||||
controlled={{
|
||||
isOpen,
|
||||
set: (open) => {
|
||||
if (!open) doClose();
|
||||
},
|
||||
}}
|
||||
onClose={doClose}
|
||||
styling={{
|
||||
maxWidth: "56rem",
|
||||
width: "90vw",
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="flex flex-col p-10">
|
||||
<p className="mt-2 text-sm text-zinc-600">{graph.name}</p>
|
||||
<AgentRunDraftView
|
||||
className="p-0"
|
||||
graph={graph}
|
||||
doRun={doRun ? handleRun : undefined}
|
||||
onRun={doRun ? undefined : doClose}
|
||||
doCreateSchedule={doCreateSchedule ? handleSchedule : undefined}
|
||||
onCreateSchedule={doCreateSchedule ? undefined : doClose}
|
||||
/>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from "@/components/__legacy__/ui/sheet";
|
||||
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
|
||||
import { Label } from "@/components/__legacy__/ui/label";
|
||||
import { Textarea } from "@/components/__legacy__/ui/textarea";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { Clipboard } from "lucide-react";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
|
||||
export type OutputNodeInfo = {
|
||||
metadata: {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
result?: any;
|
||||
};
|
||||
|
||||
interface OutputModalProps {
|
||||
isOpen: boolean;
|
||||
doClose: () => void;
|
||||
outputs: OutputNodeInfo[];
|
||||
graphExecutionError?: string | null;
|
||||
}
|
||||
|
||||
const formatOutput = (output: any): string => {
|
||||
if (typeof output === "object") {
|
||||
try {
|
||||
if (
|
||||
Array.isArray(output) &&
|
||||
output.every((item) => typeof item === "string")
|
||||
) {
|
||||
return output.join("\n").replace(/\\n/g, "\n");
|
||||
}
|
||||
return JSON.stringify(output, null, 2);
|
||||
} catch (error) {
|
||||
return `Error formatting output: ${(error as Error).message}`;
|
||||
}
|
||||
}
|
||||
if (typeof output === "string") {
|
||||
return output.replace(/\\n/g, "\n");
|
||||
}
|
||||
return String(output);
|
||||
};
|
||||
|
||||
export function RunnerOutputUI({
|
||||
isOpen,
|
||||
doClose,
|
||||
outputs,
|
||||
graphExecutionError,
|
||||
}: OutputModalProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const copyOutput = (name: string, output: any) => {
|
||||
const formattedOutput = formatOutput(output);
|
||||
navigator.clipboard.writeText(formattedOutput).then(() => {
|
||||
toast({
|
||||
title: `"${name}" output copied to clipboard!`,
|
||||
duration: 2000,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const adjustTextareaHeight = (textarea: HTMLTextAreaElement) => {
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={doClose}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex h-full w-full flex-col overflow-hidden sm:max-w-[600px]"
|
||||
>
|
||||
<SheetHeader className="px-2 py-2">
|
||||
<SheetTitle className="text-xl">Run Outputs</SheetTitle>
|
||||
<SheetDescription className="mt-1 text-sm">
|
||||
View the outputs from your agent run.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-grow overflow-y-auto px-2 py-2">
|
||||
<ScrollArea className="h-full overflow-auto pr-4">
|
||||
<div className="space-y-4">
|
||||
{graphExecutionError && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
<strong>Error:</strong> {graphExecutionError}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{outputs && outputs.length > 0 ? (
|
||||
outputs.map((output, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<Label className="text-base font-semibold">
|
||||
{output.metadata.name || "Unnamed Output"}
|
||||
</Label>
|
||||
|
||||
{output.metadata.description && (
|
||||
<Label className="block text-sm text-gray-600">
|
||||
{output.metadata.description}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
<div className="group relative rounded-md bg-gray-100 p-2">
|
||||
<Button
|
||||
className="absolute right-1 top-1 z-10 m-1 hidden p-2 group-hover:block"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
copyOutput(
|
||||
output.metadata.name || "Unnamed Output",
|
||||
output.result,
|
||||
)
|
||||
}
|
||||
title="Copy Output"
|
||||
>
|
||||
<Clipboard size={18} />
|
||||
</Button>
|
||||
<Textarea
|
||||
readOnly
|
||||
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",
|
||||
minHeight: "2.5rem",
|
||||
maxHeight: "400px",
|
||||
}}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
adjustTextareaHeight(el);
|
||||
if (el.scrollHeight > 400) {
|
||||
el.style.height = "400px";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p>No output blocks available.</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export default RunnerOutputUI;
|
||||
@@ -1,117 +0,0 @@
|
||||
import React, {
|
||||
useState,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { Node } from "@xyflow/react";
|
||||
import { CustomNodeData } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import {
|
||||
BlockUIType,
|
||||
CredentialsMetaInput,
|
||||
Graph,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import RunnerOutputUI, { OutputNodeInfo } from "./RunnerOutputUI";
|
||||
import { RunnerInputDialog } from "./RunnerInputUI";
|
||||
|
||||
interface RunnerUIWrapperProps {
|
||||
graph: Graph;
|
||||
nodes: Node<CustomNodeData>[];
|
||||
graphExecutionError?: string | null;
|
||||
saveAndRun: (
|
||||
inputs: Record<string, any>,
|
||||
credentialsInputs: Record<string, CredentialsMetaInput>,
|
||||
) => void;
|
||||
createRunSchedule: (
|
||||
cronExpression: string,
|
||||
scheduleName: string,
|
||||
inputs: Record<string, any>,
|
||||
credentialsInputs: Record<string, CredentialsMetaInput>,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface RunnerUIWrapperRef {
|
||||
openRunInputDialog: () => void;
|
||||
openRunnerOutput: () => void;
|
||||
runOrOpenInput: () => void;
|
||||
}
|
||||
|
||||
const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
|
||||
(
|
||||
{ graph, nodes, graphExecutionError, saveAndRun, createRunSchedule },
|
||||
ref,
|
||||
) => {
|
||||
const [isRunInputDialogOpen, setIsRunInputDialogOpen] = useState(false);
|
||||
const [isRunnerOutputOpen, setIsRunnerOutputOpen] = useState(false);
|
||||
|
||||
const graphInputs = graph.input_schema.properties;
|
||||
|
||||
const graphOutputs = useMemo((): OutputNodeInfo[] => {
|
||||
const outputNodes = nodes.filter(
|
||||
(node) => node.data.uiType === BlockUIType.OUTPUT,
|
||||
);
|
||||
|
||||
return outputNodes.map(
|
||||
(node) =>
|
||||
({
|
||||
metadata: {
|
||||
name: node.data.hardcodedValues.name || "Output",
|
||||
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 OutputNodeInfo,
|
||||
);
|
||||
}, [nodes]);
|
||||
|
||||
const openRunInputDialog = () => setIsRunInputDialogOpen(true);
|
||||
const openRunnerOutput = () => setIsRunnerOutputOpen(true);
|
||||
|
||||
const runOrOpenInput = () => {
|
||||
if (
|
||||
Object.keys(graphInputs).length > 0 ||
|
||||
Object.keys(graph.credentials_input_schema.properties).length > 0
|
||||
) {
|
||||
openRunInputDialog();
|
||||
} else {
|
||||
saveAndRun({}, {});
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() =>
|
||||
({
|
||||
openRunInputDialog,
|
||||
openRunnerOutput,
|
||||
runOrOpenInput,
|
||||
}) satisfies RunnerUIWrapperRef,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RunnerInputDialog
|
||||
isOpen={isRunInputDialogOpen}
|
||||
doClose={() => setIsRunInputDialogOpen(false)}
|
||||
graph={graph}
|
||||
doRun={saveAndRun}
|
||||
doCreateSchedule={createRunSchedule}
|
||||
/>
|
||||
<RunnerOutputUI
|
||||
isOpen={isRunnerOutputOpen}
|
||||
doClose={() => setIsRunnerOutputOpen(false)}
|
||||
outputs={graphOutputs}
|
||||
graphExecutionError={graphExecutionError}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RunnerUIWrapper.displayName = "RunnerUIWrapper";
|
||||
|
||||
export default RunnerUIWrapper;
|
||||
@@ -1,217 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import { Card, CardContent, CardFooter } from "@/components/__legacy__/ui/card";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { GraphMeta } from "@/lib/autogpt-server-api";
|
||||
import { Label } from "@/components/__legacy__/ui/label";
|
||||
import { IconSave } from "@/components/__legacy__/ui/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getGetV2ListMySubmissionsQueryKey } from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { CronExpressionDialog } from "@/components/contextual/CronScheduler/cron-scheduler-dialog";
|
||||
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
|
||||
import { CalendarClockIcon } from "lucide-react";
|
||||
|
||||
interface SaveControlProps {
|
||||
agentMeta: GraphMeta | null;
|
||||
agentName: string;
|
||||
agentDescription: string;
|
||||
agentRecommendedScheduleCron: string;
|
||||
canSave: boolean;
|
||||
onSave: () => Promise<void>;
|
||||
onNameChange: (name: string) => void;
|
||||
onDescriptionChange: (description: string) => void;
|
||||
onRecommendedScheduleCronChange: (cron: string) => void;
|
||||
pinSavePopover: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A SaveControl component to be used within the ControlPanel. It allows the user to save the agent.
|
||||
* @param {Object} SaveControlProps - The properties of the SaveControl component.
|
||||
* @param {GraphMeta | null} SaveControlProps.agentMeta - The agent's metadata, or null if creating a new agent.
|
||||
* @param {string} SaveControlProps.agentName - The agent's name.
|
||||
* @param {string} SaveControlProps.agentDescription - The agent's description.
|
||||
* @param {boolean} SaveControlProps.canSave - Whether the button to save the agent should be enabled.
|
||||
* @param {() => void} SaveControlProps.onSave - Function to save the agent.
|
||||
* @param {(name: string) => void} SaveControlProps.onNameChange - Function to handle name changes.
|
||||
* @param {(description: string) => void} SaveControlProps.onDescriptionChange - Function to handle description changes.
|
||||
* @returns The SaveControl component.
|
||||
*/
|
||||
export const SaveControl = ({
|
||||
agentMeta,
|
||||
canSave,
|
||||
onSave,
|
||||
agentName,
|
||||
onNameChange,
|
||||
agentDescription,
|
||||
onDescriptionChange,
|
||||
agentRecommendedScheduleCron,
|
||||
onRecommendedScheduleCronChange,
|
||||
pinSavePopover,
|
||||
}: SaveControlProps) => {
|
||||
/**
|
||||
* Note for improvement:
|
||||
* At the moment we are leveraging onDescriptionChange and onNameChange to handle the changes in the description and name of the agent.
|
||||
* We should migrate this to be handled with form controls and a form library.
|
||||
*/
|
||||
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [cronScheduleDialogOpen, setCronScheduleDialogOpen] = useState(false);
|
||||
|
||||
const handleScheduleChange = (cronExpression: string) => {
|
||||
onRecommendedScheduleCronChange(cronExpression);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
|
||||
event.preventDefault(); // Stop the browser default action
|
||||
await onSave(); // Call your save function
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListMySubmissionsQueryKey(),
|
||||
});
|
||||
toast({
|
||||
duration: 2000,
|
||||
title: "All changes saved successfully!",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onSave, toast]);
|
||||
|
||||
return (
|
||||
<Popover open={pinSavePopover ? true : undefined}>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-id="save-control-popover-trigger"
|
||||
data-testid="blocks-control-save-button"
|
||||
name="Save"
|
||||
>
|
||||
<IconSave className="dark:text-gray-300" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Save</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
sideOffset={15}
|
||||
align="start"
|
||||
data-id="save-control-popover-content"
|
||||
className="w-96 max-w-[400px]"
|
||||
>
|
||||
<Card className="border-none shadow-none dark:bg-slate-900">
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="name" className="dark:text-gray-300">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Enter your agent name"
|
||||
value={agentName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
data-id="save-control-name-input"
|
||||
data-testid="save-control-name-input"
|
||||
maxLength={100}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description" className="dark:text-gray-300">
|
||||
Description
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
placeholder="Your agent description"
|
||||
value={agentDescription}
|
||||
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||
data-id="save-control-description-input"
|
||||
data-testid="save-control-description-input"
|
||||
maxLength={500}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="dark:text-gray-300">
|
||||
Recommended Schedule
|
||||
</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCronScheduleDialogOpen(true)}
|
||||
className="mt-1 w-full min-w-0 justify-start text-sm"
|
||||
data-id="save-control-recommended-schedule-button"
|
||||
data-testid="save-control-recommended-schedule-button"
|
||||
>
|
||||
<CalendarClockIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{agentRecommendedScheduleCron
|
||||
? humanizeCronExpression(agentRecommendedScheduleCron)
|
||||
: "Set schedule"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{agentMeta?.version && (
|
||||
<div>
|
||||
<Label htmlFor="version" className="dark:text-gray-300">
|
||||
Version
|
||||
</Label>
|
||||
<Input
|
||||
id="version"
|
||||
placeholder="Version"
|
||||
value={agentMeta?.version || "-"}
|
||||
disabled
|
||||
data-testid="save-control-version-output"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<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={onSave}
|
||||
data-id="save-control-save-agent"
|
||||
data-testid="save-control-save-agent-button"
|
||||
disabled={!canSave}
|
||||
>
|
||||
Save Agent
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</PopoverContent>
|
||||
<CronExpressionDialog
|
||||
open={cronScheduleDialogOpen}
|
||||
setOpen={setCronScheduleDialogOpen}
|
||||
onSubmit={handleScheduleChange}
|
||||
defaultCronExpression={agentRecommendedScheduleCron}
|
||||
title="Recommended Schedule"
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
import { CustomNodeData } from "./CustomNode/CustomNode";
|
||||
import { CustomEdgeData } from "./CustomEdge/CustomEdge";
|
||||
import { Edge } from "@xyflow/react";
|
||||
|
||||
type ActionType =
|
||||
| "ADD_NODE"
|
||||
| "DELETE_NODE"
|
||||
| "ADD_EDGE"
|
||||
| "DELETE_EDGE"
|
||||
| "UPDATE_NODE"
|
||||
| "MOVE_NODE"
|
||||
| "UPDATE_INPUT"
|
||||
| "UPDATE_NODE_POSITION";
|
||||
|
||||
type AddNodePayload = { node: CustomNodeData };
|
||||
type DeleteNodePayload = { nodeId: string };
|
||||
type AddEdgePayload = { edge: Edge<CustomEdgeData> };
|
||||
type DeleteEdgePayload = { edgeId: string };
|
||||
type UpdateNodePayload = { nodeId: string; newData: Partial<CustomNodeData> };
|
||||
type MoveNodePayload = { nodeId: string; position: { x: number; y: number } };
|
||||
type UpdateInputPayload = {
|
||||
nodeId: string;
|
||||
oldValues: { [key: string]: any };
|
||||
newValues: { [key: string]: any };
|
||||
};
|
||||
type UpdateNodePositionPayload = {
|
||||
nodeId: string;
|
||||
oldPosition: { x: number; y: number };
|
||||
newPosition: { x: number; y: number };
|
||||
};
|
||||
|
||||
type ActionPayload =
|
||||
| AddNodePayload
|
||||
| DeleteNodePayload
|
||||
| AddEdgePayload
|
||||
| DeleteEdgePayload
|
||||
| UpdateNodePayload
|
||||
| MoveNodePayload
|
||||
| UpdateInputPayload
|
||||
| UpdateNodePositionPayload;
|
||||
|
||||
type Action = {
|
||||
type: ActionType;
|
||||
payload: ActionPayload;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
};
|
||||
|
||||
class History {
|
||||
private past: Action[] = [];
|
||||
private future: Action[] = [];
|
||||
|
||||
push(action: Action) {
|
||||
this.past.push(action);
|
||||
this.future = [];
|
||||
}
|
||||
|
||||
undo() {
|
||||
const action = this.past.pop();
|
||||
if (action) {
|
||||
action.undo();
|
||||
this.future.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
redo() {
|
||||
const action = this.future.pop();
|
||||
if (action) {
|
||||
action.redo();
|
||||
this.past.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
canUndo(): boolean {
|
||||
return this.past.length > 0;
|
||||
}
|
||||
|
||||
canRedo(): boolean {
|
||||
return this.future.length > 0;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.past = [];
|
||||
this.future = [];
|
||||
}
|
||||
|
||||
getHistoryState() {
|
||||
return {
|
||||
past: [...this.past],
|
||||
future: [...this.future],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const history = new History();
|
||||
@@ -1,569 +0,0 @@
|
||||
import Shepherd from "shepherd.js";
|
||||
import "shepherd.js/dist/css/shepherd.css";
|
||||
import { Key, storage } from "@/services/storage/local-storage";
|
||||
import { analytics } from "@/services/analytics";
|
||||
|
||||
export const startTutorial = (
|
||||
emptyNodeList: (forceEmpty: boolean) => boolean,
|
||||
setPinBlocksPopover: (value: boolean) => void,
|
||||
setPinSavePopover: (value: boolean) => void,
|
||||
) => {
|
||||
const tour = new Shepherd.Tour({
|
||||
useModalOverlay: true,
|
||||
defaultStepOptions: {
|
||||
cancelIcon: { enabled: true },
|
||||
scrollTo: { behavior: "smooth", block: "center" },
|
||||
},
|
||||
});
|
||||
|
||||
// CSS classes for disabling and highlighting blocks
|
||||
const disableClass = "disable-blocks";
|
||||
const highlightClass = "highlight-block";
|
||||
let isConnecting = false;
|
||||
|
||||
// Helper function to disable all blocks except the target block
|
||||
const disableOtherBlocks = (targetBlockSelector: string) => {
|
||||
document.querySelectorAll('[data-id^="block-card-"]').forEach((block) => {
|
||||
block.classList.toggle(disableClass, !block.matches(targetBlockSelector));
|
||||
block.classList.toggle(
|
||||
highlightClass,
|
||||
block.matches(targetBlockSelector),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to enable all blocks
|
||||
const enableAllBlocks = () => {
|
||||
document.querySelectorAll('[data-id^="block-card-"]').forEach((block) => {
|
||||
block.classList.remove(disableClass, highlightClass);
|
||||
});
|
||||
};
|
||||
|
||||
// Inject CSS for disabling and highlighting blocks
|
||||
const injectStyles = () => {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
.${disableClass} {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.${highlightClass} {
|
||||
background-color: #ffeb3b;
|
||||
border: 2px solid #fbc02d;
|
||||
transition: background-color 0.3s, border-color 0.3s;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
// Helper function to check if an element is present in the DOM
|
||||
const waitForElement = (selector: string): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
const checkElement = () => {
|
||||
if (document.querySelector(selector)) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkElement, 10);
|
||||
}
|
||||
};
|
||||
checkElement();
|
||||
});
|
||||
};
|
||||
|
||||
// Function to detect the correct connection and advance the tour
|
||||
const detectConnection = () => {
|
||||
const checkForConnection = () => {
|
||||
const correctConnection = document.querySelector(
|
||||
'[data-testid^="rf__edge-"]',
|
||||
);
|
||||
if (correctConnection) {
|
||||
tour.show("press-run-again");
|
||||
} else {
|
||||
setTimeout(checkForConnection, 100);
|
||||
}
|
||||
};
|
||||
|
||||
checkForConnection();
|
||||
};
|
||||
|
||||
// Define state management functions to handle connection state
|
||||
function startConnecting() {
|
||||
isConnecting = true;
|
||||
}
|
||||
|
||||
function stopConnecting() {
|
||||
isConnecting = false;
|
||||
}
|
||||
|
||||
// Reset connection state when revisiting the step
|
||||
function resetConnectionState() {
|
||||
stopConnecting();
|
||||
}
|
||||
|
||||
// Event handlers for mouse down and up to manage connection state
|
||||
function handleMouseDown() {
|
||||
startConnecting();
|
||||
setTimeout(() => {
|
||||
if (isConnecting) {
|
||||
tour.next();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
// Event handler for mouse up to check if the connection was successful
|
||||
function handleMouseUp(event: { target: any }) {
|
||||
const target = event.target;
|
||||
const validConnectionPoint = document.querySelector(
|
||||
'[data-testid^="rf__node-"]:nth-child(2) [data-id$="-a-target"]',
|
||||
);
|
||||
|
||||
if (validConnectionPoint && !validConnectionPoint.contains(target)) {
|
||||
setTimeout(() => {
|
||||
if (!document.querySelector('[data-testid^="rf__edge-"]')) {
|
||||
stopConnecting();
|
||||
tour.show("connect-blocks-output");
|
||||
}
|
||||
}, 200);
|
||||
} else {
|
||||
stopConnecting();
|
||||
}
|
||||
}
|
||||
|
||||
// Define the fitViewToScreen function
|
||||
const fitViewToScreen = () => {
|
||||
const fitViewButton = document.querySelector(
|
||||
".react-flow__controls-fitview",
|
||||
) as HTMLButtonElement;
|
||||
if (fitViewButton) {
|
||||
fitViewButton.click();
|
||||
}
|
||||
};
|
||||
|
||||
injectStyles();
|
||||
|
||||
const warningText = emptyNodeList(false)
|
||||
? ""
|
||||
: "<br/><br/><b>Caution: Clicking next will start a tutorial and will clear the current flow.</b>";
|
||||
|
||||
tour.addStep({
|
||||
id: "starting-step",
|
||||
title: "Welcome to the Tutorial",
|
||||
text: `This is the AutoGPT builder! ${warningText}`,
|
||||
buttons: [
|
||||
{
|
||||
text: "Skip Tutorial",
|
||||
action: () => {
|
||||
tour.cancel(); // Ends the tour
|
||||
storage.set(Key.SHEPHERD_TOUR, "skipped"); // Set the tutorial as skipped in local storage
|
||||
},
|
||||
classes: "shepherd-button-secondary", // Optionally add a class for styling the skip button differently
|
||||
},
|
||||
{
|
||||
text: "Next",
|
||||
action: () => {
|
||||
emptyNodeList(true);
|
||||
tour.next();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "open-block-step",
|
||||
title: "Open Blocks Menu",
|
||||
text: "Please click the block button to open the blocks menu.",
|
||||
attachTo: {
|
||||
element: '[data-id="blocks-control-popover-trigger"]',
|
||||
on: "right",
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '[data-id="blocks-control-popover-trigger"]',
|
||||
event: "click",
|
||||
},
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "scroll-block-menu",
|
||||
title: "Scroll Down or Search",
|
||||
text: 'Scroll down or search in the blocks menu for the "Calculator Block" and press the block to add it.',
|
||||
attachTo: {
|
||||
element: '[data-id="blocks-control-popover-content"]',
|
||||
on: "right",
|
||||
},
|
||||
buttons: [],
|
||||
beforeShowPromise: () =>
|
||||
waitForElement('[data-id="blocks-control-popover-content"]').then(() => {
|
||||
disableOtherBlocks(
|
||||
'[data-id="block-card-b1ab9b19-67a6-406d-abf5-2dba76d00c79"]',
|
||||
);
|
||||
}),
|
||||
advanceOn: {
|
||||
selector: '[data-id="block-card-b1ab9b19-67a6-406d-abf5-2dba76d00c79"]',
|
||||
event: "click",
|
||||
},
|
||||
when: {
|
||||
show: () => setPinBlocksPopover(true),
|
||||
hide: enableAllBlocks,
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "focus-new-block",
|
||||
title: "New Block",
|
||||
text: "This is the Calculator Block! Let's go over how it works.",
|
||||
attachTo: { element: `[data-id="custom-node-1"]`, on: "left" },
|
||||
beforeShowPromise: () => waitForElement('[data-id="custom-node-1"]'),
|
||||
buttons: [
|
||||
{
|
||||
text: "Next",
|
||||
action: tour.next,
|
||||
},
|
||||
],
|
||||
when: {
|
||||
show: () => {
|
||||
setPinBlocksPopover(false);
|
||||
setTimeout(() => {
|
||||
fitViewToScreen();
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "input-to-block",
|
||||
title: "Input to the Block",
|
||||
text: "This is the input pin for the block. You can input the output of other blocks here; this block takes numbers as input.",
|
||||
attachTo: { element: '[data-nodeid="1"]', on: "left" },
|
||||
buttons: [
|
||||
{
|
||||
text: "Back",
|
||||
action: tour.back,
|
||||
},
|
||||
{
|
||||
text: "Next",
|
||||
action: tour.next,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "output-from-block",
|
||||
title: "Output from the Block",
|
||||
text: "This is the output pin for the block. You can connect this to another block to pass the output along.",
|
||||
attachTo: { element: '[data-handlepos="right"]', on: "right" },
|
||||
buttons: [
|
||||
{
|
||||
text: "Back",
|
||||
action: tour.back,
|
||||
},
|
||||
{
|
||||
text: "Next",
|
||||
action: tour.next,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "select-operation-and-input",
|
||||
title: "Select Operation and Input Numbers",
|
||||
text: "Select any mathematical operation you'd like to perform, and enter numbers in both input fields.",
|
||||
attachTo: { element: '[data-id="input-handles"]', on: "right" },
|
||||
buttons: [
|
||||
{
|
||||
text: "Back",
|
||||
action: tour.back,
|
||||
},
|
||||
{
|
||||
text: "Next",
|
||||
action: tour.next,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "press-initial-save-button",
|
||||
title: "Press Save",
|
||||
text: "First we need to save the flow before we can run it!",
|
||||
attachTo: {
|
||||
element: '[data-id="save-control-popover-trigger"]',
|
||||
on: "left",
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '[data-id="save-control-popover-trigger"]',
|
||||
event: "click",
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
text: "Back",
|
||||
action: tour.back,
|
||||
},
|
||||
],
|
||||
when: {
|
||||
hide: () => setPinSavePopover(true),
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "save-agent-details",
|
||||
title: "Save the Agent",
|
||||
text: "Enter a name for your agent, add an optional description, and then click 'Save agent' to save your flow.",
|
||||
attachTo: {
|
||||
element: '[data-id="save-control-popover-content"]',
|
||||
on: "top",
|
||||
},
|
||||
buttons: [],
|
||||
beforeShowPromise: () =>
|
||||
waitForElement('[data-id="save-control-popover-content"]'),
|
||||
advanceOn: {
|
||||
selector: '[data-id="save-control-save-agent"]',
|
||||
event: "click",
|
||||
},
|
||||
when: {
|
||||
hide: () => setPinSavePopover(false),
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "press-run",
|
||||
title: "Press Run",
|
||||
text: "Start your first flow by pressing the Run button!",
|
||||
attachTo: {
|
||||
element: '[data-tutorial-id="primary-action-run-agent"]',
|
||||
on: "top",
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '[data-tutorial-id="primary-action-run-agent"]',
|
||||
event: "click",
|
||||
},
|
||||
buttons: [],
|
||||
beforeShowPromise: () =>
|
||||
waitForElement('[data-tutorial-id="primary-action-run-agent"]'),
|
||||
when: {
|
||||
hide: () => {
|
||||
setTimeout(() => {
|
||||
fitViewToScreen();
|
||||
}, 500);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "wait-for-processing",
|
||||
title: "Processing",
|
||||
text: "Let's wait for the block to finish being processed...",
|
||||
attachTo: {
|
||||
element: '[data-id^="badge-"][data-id$="-QUEUED"]',
|
||||
on: "bottom",
|
||||
},
|
||||
buttons: [],
|
||||
beforeShowPromise: () =>
|
||||
waitForElement('[data-id^="badge-"][data-id$="-QUEUED"]').then(
|
||||
fitViewToScreen,
|
||||
),
|
||||
when: {
|
||||
show: () => {
|
||||
waitForElement('[data-id^="badge-"][data-id$="-COMPLETED"]').then(
|
||||
() => {
|
||||
tour.next();
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "check-output",
|
||||
title: "Check the Output",
|
||||
text: "Check here to see the output of the block after running the flow.",
|
||||
attachTo: { element: '[data-id="latest-output"]', on: "top" },
|
||||
beforeShowPromise: () =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
waitForElement('[data-id="latest-output"]').then(resolve);
|
||||
}, 100);
|
||||
}),
|
||||
buttons: [
|
||||
{
|
||||
text: "Next",
|
||||
action: tour.next,
|
||||
},
|
||||
],
|
||||
when: {
|
||||
show: () => {
|
||||
fitViewToScreen();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "copy-paste-block",
|
||||
title: "Copy and Paste the Block",
|
||||
text: "Let’s duplicate this block. Click and hold the block with your mouse, then press Ctrl+C (Cmd+C on Mac) to copy and Ctrl+V (Cmd+V on Mac) to paste.",
|
||||
attachTo: { element: '[data-testid^="rf__node-"]', on: "top" },
|
||||
buttons: [
|
||||
{
|
||||
text: "Back",
|
||||
action: tour.back,
|
||||
},
|
||||
],
|
||||
when: {
|
||||
show: () => {
|
||||
fitViewToScreen();
|
||||
waitForElement('[data-testid^="rf__node-"]:nth-child(2)').then(() => {
|
||||
tour.next();
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "focus-second-block",
|
||||
title: "Focus on the New Block",
|
||||
text: "This is your copied Calculator Block. Now, let’s move it to the side of the first block.",
|
||||
attachTo: { element: '[data-testid^="rf__node-"]:nth-child(2)', on: "top" },
|
||||
beforeShowPromise: () =>
|
||||
waitForElement('[data-testid^="rf__node-"]:nth-child(2)'),
|
||||
buttons: [
|
||||
{
|
||||
text: "Next",
|
||||
action: tour.next,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "connect-blocks-output",
|
||||
title: "Connect the Blocks: Output",
|
||||
text: "Now, let's connect the output of the first Calculator Block to the input of the second Calculator Block. Drag from the output pin of the first block to the input pin (A) of the second block.",
|
||||
attachTo: {
|
||||
element:
|
||||
'[data-testid^="rf__node-"]:first-child [data-id$="-result-source"]',
|
||||
on: "bottom",
|
||||
},
|
||||
|
||||
buttons: [
|
||||
{
|
||||
text: "Back",
|
||||
action: tour.back,
|
||||
},
|
||||
],
|
||||
beforeShowPromise: () => {
|
||||
return waitForElement(
|
||||
'[data-testid^="rf__node-"]:first-child [data-id$="-result-source"]',
|
||||
);
|
||||
},
|
||||
when: {
|
||||
show: () => {
|
||||
fitViewToScreen();
|
||||
resetConnectionState(); // Reset state when revisiting this step
|
||||
tour.modal.show();
|
||||
const outputPin = document.querySelector(
|
||||
'[data-testid^="rf__node-"]:first-child [data-id$="-result-source"]',
|
||||
);
|
||||
if (outputPin) {
|
||||
outputPin.addEventListener("mousedown", handleMouseDown);
|
||||
}
|
||||
},
|
||||
hide: () => {
|
||||
const outputPin = document.querySelector(
|
||||
'[data-testid^="rf__node-"]:first-child [data-id$="-result-source"]',
|
||||
);
|
||||
if (outputPin) {
|
||||
outputPin.removeEventListener("mousedown", handleMouseDown);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "connect-blocks-input",
|
||||
title: "Connect the Blocks: Input",
|
||||
text: "Now, connect the output to the input pin of the second block (A).",
|
||||
attachTo: {
|
||||
element: '[data-testid^="rf__node-"]:nth-child(2) [data-id$="-a-target"]',
|
||||
on: "top",
|
||||
},
|
||||
buttons: [],
|
||||
beforeShowPromise: () => {
|
||||
return waitForElement(
|
||||
'[data-testid^="rf__node-"]:nth-child(2) [data-id$="-a-target"]',
|
||||
).then(() => {
|
||||
detectConnection();
|
||||
});
|
||||
},
|
||||
when: {
|
||||
show: () => {
|
||||
tour.modal.show();
|
||||
document.addEventListener("mouseup", handleMouseUp, true);
|
||||
},
|
||||
hide: () => {
|
||||
tour.modal.hide();
|
||||
document.removeEventListener("mouseup", handleMouseUp, true);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "press-run-again",
|
||||
title: "Press Run Again",
|
||||
text: "Now, press the Run button again to execute the flow with the new Calculator Block added!",
|
||||
attachTo: {
|
||||
element: '[data-tutorial-id="primary-action-run-agent"]',
|
||||
on: "top",
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '[data-tutorial-id="primary-action-run-agent"]',
|
||||
event: "click",
|
||||
},
|
||||
buttons: [],
|
||||
beforeShowPromise: () =>
|
||||
waitForElement('[data-tutorial-id="primary-action-run-agent"]'),
|
||||
when: {
|
||||
hide: () => {
|
||||
setTimeout(() => {
|
||||
fitViewToScreen();
|
||||
}, 500);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
tour.addStep({
|
||||
id: "congratulations",
|
||||
title: "Congratulations!",
|
||||
text: "You have successfully created your first flow. Watch for the outputs in the blocks!",
|
||||
beforeShowPromise: () => waitForElement('[data-id="latest-output"]'),
|
||||
when: {
|
||||
show: () => tour.modal.hide(),
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
text: "Finish",
|
||||
action: tour.complete,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Unpin blocks and save menu when the tour is completed or canceled
|
||||
tour.on("complete", () => {
|
||||
setPinBlocksPopover(false);
|
||||
setPinSavePopover(false);
|
||||
storage.set(Key.SHEPHERD_TOUR, "completed"); // Optionally mark the tutorial as completed
|
||||
});
|
||||
|
||||
for (const step of tour.steps) {
|
||||
step.on("show", () => {
|
||||
"use client";
|
||||
console.debug("sendTutorialStep");
|
||||
|
||||
analytics.sendGAEvent("event", "tutorial_step_shown", { value: step.id });
|
||||
});
|
||||
}
|
||||
|
||||
tour.on("cancel", () => {
|
||||
setPinBlocksPopover(false);
|
||||
setPinSavePopover(false);
|
||||
storage.set(Key.SHEPHERD_TOUR, "canceled"); // Optionally mark the tutorial as canceled
|
||||
});
|
||||
|
||||
tour.start();
|
||||
};
|
||||
@@ -1,142 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import { Node, Edge, useReactFlow } from "@xyflow/react";
|
||||
import { Key, storage } from "@/services/storage/local-storage";
|
||||
import { ConnectedEdge } from "./CustomNode/CustomNode";
|
||||
|
||||
interface CopyableData {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
}
|
||||
|
||||
export function useCopyPaste(getNextNodeId: () => string) {
|
||||
const { setNodes, addEdges, getNodes, getEdges, getViewport } =
|
||||
useReactFlow();
|
||||
|
||||
const handleCopyPaste = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.key === "c" || event.key === "C") {
|
||||
const selectedNodes = getNodes().filter((node) => node.selected);
|
||||
const selectedNodeIds = new Set(selectedNodes.map((node) => node.id));
|
||||
|
||||
// Only copy edges where both source and target nodes are selected
|
||||
const selectedEdges = getEdges().filter(
|
||||
(edge) =>
|
||||
edge.selected &&
|
||||
selectedNodeIds.has(edge.source) &&
|
||||
selectedNodeIds.has(edge.target),
|
||||
);
|
||||
|
||||
const copiedData: CopyableData = {
|
||||
nodes: selectedNodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
connections: node.data.connections || [], // Preserve connections
|
||||
},
|
||||
})),
|
||||
edges: selectedEdges,
|
||||
};
|
||||
|
||||
storage.set(Key.COPIED_FLOW_DATA, JSON.stringify(copiedData));
|
||||
}
|
||||
if (event.key === "v" || event.key === "V") {
|
||||
const copiedDataString = storage.get(Key.COPIED_FLOW_DATA);
|
||||
if (copiedDataString) {
|
||||
const copiedData = JSON.parse(copiedDataString) as CopyableData;
|
||||
const oldToNewIdMap: Record<string, string> = {};
|
||||
|
||||
// Get fresh viewport values at paste time to ensure correct positioning
|
||||
const { x, y, zoom } = getViewport();
|
||||
const viewportCenter = {
|
||||
x: (window.innerWidth / 2 - x) / zoom,
|
||||
y: (window.innerHeight / 2 - y) / zoom,
|
||||
};
|
||||
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
copiedData.nodes.forEach((node: Node) => {
|
||||
minX = Math.min(minX, node.position.x);
|
||||
minY = Math.min(minY, node.position.y);
|
||||
maxX = Math.max(maxX, node.position.x);
|
||||
maxY = Math.max(maxY, node.position.y);
|
||||
});
|
||||
|
||||
const offsetX = viewportCenter.x - (minX + maxX) / 2;
|
||||
const offsetY = viewportCenter.y - (minY + maxY) / 2;
|
||||
|
||||
const pastedNodes = copiedData.nodes.map((node: Node) => {
|
||||
const newNodeId = getNextNodeId();
|
||||
oldToNewIdMap[node.id] = newNodeId;
|
||||
return {
|
||||
...node,
|
||||
id: newNodeId, // Generate unique ID for the pasted node
|
||||
selected: true, // Select the pasted nodes so they're visible
|
||||
position: {
|
||||
x: node.position.x + offsetX,
|
||||
y: node.position.y + offsetY,
|
||||
},
|
||||
data: {
|
||||
...node.data,
|
||||
backend_id: undefined, // Clear backend_id so the new node.id is used when saving
|
||||
connections: node.data.connections || [], // Preserve connections
|
||||
status: undefined,
|
||||
executionResults: undefined,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const pastedEdges = copiedData.edges.map((edge) => {
|
||||
const newSourceId = oldToNewIdMap[edge.source] ?? edge.source;
|
||||
const newTargetId = oldToNewIdMap[edge.target] ?? edge.target;
|
||||
return {
|
||||
...edge,
|
||||
id: `${newSourceId}_${edge.sourceHandle}_${newTargetId}_${edge.targetHandle}_${Date.now()}`,
|
||||
source: newSourceId,
|
||||
target: newTargetId,
|
||||
};
|
||||
});
|
||||
|
||||
setNodes((existingNodes) => [
|
||||
...existingNodes.map((node) => ({ ...node, selected: false })),
|
||||
...pastedNodes,
|
||||
]);
|
||||
addEdges(pastedEdges);
|
||||
|
||||
setNodes((nodes) => {
|
||||
return nodes.map((node) => {
|
||||
const nodeConnections = getEdges()
|
||||
.filter(
|
||||
(edge: Edge) =>
|
||||
edge.source === node.id || edge.target === node.id,
|
||||
)
|
||||
.map(
|
||||
(edge: Edge): ConnectedEdge => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle!,
|
||||
targetHandle: edge.targetHandle!,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
connections: nodeConnections,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[setNodes, addEdges, getNodes, getEdges, getNextNodeId, getViewport],
|
||||
);
|
||||
|
||||
return handleCopyPaste;
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
"use client";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useQueryState } from "nuqs";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
Graph,
|
||||
GraphExecution,
|
||||
GraphExecutionID,
|
||||
GraphExecutionMeta,
|
||||
GraphID,
|
||||
LibraryAgent,
|
||||
LibraryAgentID,
|
||||
LibraryAgentPreset,
|
||||
LibraryAgentPresetID,
|
||||
Schedule,
|
||||
ScheduleID,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { exportAsJSONFile } from "@/lib/utils";
|
||||
|
||||
import DeleteConfirmDialog from "@/components/__legacy__/delete-confirm-dialog";
|
||||
import type { ButtonAction } from "@/components/__legacy__/types";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/__legacy__/ui/dialog";
|
||||
import LoadingBox, { LoadingSpinner } from "@/components/__legacy__/ui/loading";
|
||||
import {
|
||||
useToast,
|
||||
useToastOnFail,
|
||||
} from "@/components/molecules/Toast/use-toast";
|
||||
import { AgentRunDetailsView } from "./components/agent-run-details-view";
|
||||
import { AgentRunDraftView } from "./components/agent-run-draft-view";
|
||||
import { CreatePresetDialog } from "./components/create-preset-dialog";
|
||||
import { useAgentRunsInfinite } from "./use-agent-runs";
|
||||
import { AgentRunsSelectorList } from "./components/agent-runs-selector-list";
|
||||
import { AgentScheduleDetailsView } from "./components/agent-schedule-details-view";
|
||||
|
||||
export function OldAgentLibraryView() {
|
||||
const { id: agentID }: { id: LibraryAgentID } = useParams();
|
||||
const [executionId, setExecutionId] = useQueryState("executionId");
|
||||
const toastOnFail = useToastOnFail();
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const api = useBackendAPI();
|
||||
|
||||
// ============================ STATE =============================
|
||||
|
||||
const [graph, setGraph] = useState<Graph | null>(null); // Graph version corresponding to LibraryAgent
|
||||
const [agent, setAgent] = useState<LibraryAgent | null>(null);
|
||||
const agentRunsQuery = useAgentRunsInfinite(graph?.id); // only runs once graph.id is known
|
||||
const agentRuns = agentRunsQuery.agentRuns;
|
||||
const [agentPresets, setAgentPresets] = useState<LibraryAgentPreset[]>([]);
|
||||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||
const [selectedView, selectView] = useState<
|
||||
| { type: "run"; id?: GraphExecutionID }
|
||||
| { type: "preset"; id: LibraryAgentPresetID }
|
||||
| { type: "schedule"; id: ScheduleID }
|
||||
>({ type: "run" });
|
||||
const [selectedRun, setSelectedRun] = useState<
|
||||
GraphExecution | GraphExecutionMeta | null
|
||||
>(null);
|
||||
const selectedSchedule =
|
||||
selectedView.type == "schedule"
|
||||
? schedules.find((s) => s.id == selectedView.id)
|
||||
: null;
|
||||
const [isFirstLoad, setIsFirstLoad] = useState<boolean>(true);
|
||||
const [agentDeleteDialogOpen, setAgentDeleteDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
const [confirmingDeleteAgentRun, setConfirmingDeleteAgentRun] =
|
||||
useState<GraphExecutionMeta | null>(null);
|
||||
const [confirmingDeleteAgentPreset, setConfirmingDeleteAgentPreset] =
|
||||
useState<LibraryAgentPresetID | null>(null);
|
||||
const [copyAgentDialogOpen, setCopyAgentDialogOpen] = useState(false);
|
||||
const [creatingPresetFromExecutionID, setCreatingPresetFromExecutionID] =
|
||||
useState<GraphExecutionID | null>(null);
|
||||
|
||||
// Set page title with agent name
|
||||
useEffect(() => {
|
||||
if (agent) {
|
||||
document.title = `${agent.name} - Library - AutoGPT Platform`;
|
||||
}
|
||||
}, [agent]);
|
||||
|
||||
const openRunDraftView = useCallback(() => {
|
||||
selectView({ type: "run" });
|
||||
}, []);
|
||||
|
||||
const selectRun = useCallback((id: GraphExecutionID) => {
|
||||
selectView({ type: "run", id });
|
||||
}, []);
|
||||
|
||||
const selectPreset = useCallback((id: LibraryAgentPresetID) => {
|
||||
selectView({ type: "preset", id });
|
||||
}, []);
|
||||
|
||||
const selectSchedule = useCallback((id: ScheduleID) => {
|
||||
selectView({ type: "schedule", id });
|
||||
}, []);
|
||||
|
||||
const graphVersions = useRef<Record<number, Graph>>({});
|
||||
const loadingGraphVersions = useRef<Record<number, Promise<Graph>>>({});
|
||||
const getGraphVersion = useCallback(
|
||||
async (graphID: GraphID, version: number) => {
|
||||
if (version in graphVersions.current)
|
||||
return graphVersions.current[version];
|
||||
if (version in loadingGraphVersions.current)
|
||||
return loadingGraphVersions.current[version];
|
||||
|
||||
const pendingGraph = api.getGraph(graphID, version).then((graph) => {
|
||||
graphVersions.current[version] = graph;
|
||||
return graph;
|
||||
});
|
||||
// Cache promise as well to avoid duplicate requests
|
||||
loadingGraphVersions.current[version] = pendingGraph;
|
||||
return pendingGraph;
|
||||
},
|
||||
[api, graphVersions, loadingGraphVersions],
|
||||
);
|
||||
|
||||
const lastRefresh = useRef<number>(0);
|
||||
const refreshPageData = useCallback(() => {
|
||||
if (Date.now() - lastRefresh.current < 2e3) return; // 2 second debounce
|
||||
lastRefresh.current = Date.now();
|
||||
|
||||
api.getLibraryAgent(agentID).then((agent) => {
|
||||
setAgent(agent);
|
||||
|
||||
getGraphVersion(agent.graph_id, agent.graph_version).then(
|
||||
(_graph) =>
|
||||
(graph && graph.version == _graph.version) || setGraph(_graph),
|
||||
);
|
||||
Promise.all([
|
||||
agentRunsQuery.refetchRuns(),
|
||||
api.listLibraryAgentPresets({
|
||||
graph_id: agent.graph_id,
|
||||
page_size: 100,
|
||||
}),
|
||||
]).then(([runsQueryResult, presets]) => {
|
||||
setAgentPresets(presets.presets);
|
||||
|
||||
const newestAgentRunsResponse = runsQueryResult.data?.pages[0];
|
||||
if (!newestAgentRunsResponse || newestAgentRunsResponse.status != 200)
|
||||
return;
|
||||
const newestAgentRuns = newestAgentRunsResponse.data.executions;
|
||||
// Preload the corresponding graph versions for the latest 10 runs
|
||||
new Set(
|
||||
newestAgentRuns.slice(0, 10).map((run) => run.graph_version),
|
||||
).forEach((version) => getGraphVersion(agent.graph_id, version));
|
||||
});
|
||||
});
|
||||
}, [api, agentID, getGraphVersion, graph]);
|
||||
|
||||
// On first load: select the latest run
|
||||
useEffect(() => {
|
||||
// Only for first load or first execution
|
||||
if (selectedView.id || !isFirstLoad) return;
|
||||
if (agentRuns.length == 0 && agentPresets.length == 0) return;
|
||||
|
||||
setIsFirstLoad(false);
|
||||
if (agentRuns.length > 0) {
|
||||
// select latest run
|
||||
const latestRun = agentRuns.reduce((latest, current) => {
|
||||
if (!latest.started_at && !current.started_at) return latest;
|
||||
if (!latest.started_at) return current;
|
||||
if (!current.started_at) return latest;
|
||||
return latest.started_at > current.started_at ? latest : current;
|
||||
}, agentRuns[0]);
|
||||
selectRun(latestRun.id as GraphExecutionID);
|
||||
} else {
|
||||
// select top preset
|
||||
const latestPreset = agentPresets.toSorted(
|
||||
(a, b) => b.updated_at.getTime() - a.updated_at.getTime(),
|
||||
)[0];
|
||||
selectPreset(latestPreset.id);
|
||||
}
|
||||
}, [
|
||||
isFirstLoad,
|
||||
selectedView.id,
|
||||
agentRuns,
|
||||
agentPresets,
|
||||
selectRun,
|
||||
selectPreset,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (executionId) {
|
||||
selectRun(executionId as GraphExecutionID);
|
||||
setExecutionId(null);
|
||||
}
|
||||
}, [executionId, selectRun, setExecutionId]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
refreshPageData();
|
||||
|
||||
// Show a toast when the WebSocket connection disconnects
|
||||
let connectionToast: ReturnType<typeof toast> | null = null;
|
||||
const cancelDisconnectHandler = api.onWebSocketDisconnect(() => {
|
||||
connectionToast ??= toast({
|
||||
title: "Connection to server was lost",
|
||||
variant: "destructive",
|
||||
description: (
|
||||
<div className="flex items-center">
|
||||
Trying to reconnect...
|
||||
<LoadingSpinner className="ml-1.5 size-3.5" />
|
||||
</div>
|
||||
),
|
||||
duration: Infinity,
|
||||
dismissable: true,
|
||||
});
|
||||
});
|
||||
const cancelConnectHandler = api.onWebSocketConnect(() => {
|
||||
if (connectionToast)
|
||||
connectionToast.update({
|
||||
id: connectionToast.id,
|
||||
title: "✅ Connection re-established",
|
||||
variant: "default",
|
||||
description: (
|
||||
<div className="flex items-center">
|
||||
Refreshing data...
|
||||
<LoadingSpinner className="ml-1.5 size-3.5" />
|
||||
</div>
|
||||
),
|
||||
duration: 2000,
|
||||
dismissable: true,
|
||||
});
|
||||
connectionToast = null;
|
||||
});
|
||||
return () => {
|
||||
cancelDisconnectHandler();
|
||||
cancelConnectHandler();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Subscribe to WebSocket updates for agent runs
|
||||
useEffect(() => {
|
||||
if (!agent?.graph_id) return;
|
||||
|
||||
return api.onWebSocketConnect(() => {
|
||||
refreshPageData(); // Sync up on (re)connect
|
||||
|
||||
// Subscribe to all executions for this agent
|
||||
api.subscribeToGraphExecutions(agent.graph_id);
|
||||
});
|
||||
}, [api, agent?.graph_id, refreshPageData]);
|
||||
|
||||
// Handle execution updates
|
||||
useEffect(() => {
|
||||
const detachExecUpdateHandler = api.onWebSocketMessage(
|
||||
"graph_execution_event",
|
||||
(data) => {
|
||||
if (data.graph_id != agent?.graph_id) return;
|
||||
|
||||
agentRunsQuery.upsertAgentRun(data);
|
||||
if (data.id === selectedView.id) {
|
||||
// Update currently viewed run
|
||||
setSelectedRun(data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
detachExecUpdateHandler();
|
||||
};
|
||||
}, [api, agent?.graph_id, selectedView.id]);
|
||||
|
||||
// Pre-load selectedRun based on selectedView
|
||||
useEffect(() => {
|
||||
if (selectedView.type != "run" || !selectedView.id) return;
|
||||
|
||||
const newSelectedRun = agentRuns.find((run) => run.id == selectedView.id);
|
||||
if (selectedView.id !== selectedRun?.id) {
|
||||
// Pull partial data from "cache" while waiting for the rest to load
|
||||
setSelectedRun((newSelectedRun as GraphExecutionMeta) ?? null);
|
||||
}
|
||||
}, [api, selectedView, agentRuns, selectedRun?.id]);
|
||||
|
||||
// Load selectedRun based on selectedView; refresh on agent refresh
|
||||
useEffect(() => {
|
||||
if (selectedView.type != "run" || !selectedView.id || !agent) return;
|
||||
|
||||
api
|
||||
.getGraphExecutionInfo(agent.graph_id, selectedView.id)
|
||||
.then(async (run) => {
|
||||
// Ensure corresponding graph version is available before rendering I/O
|
||||
await getGraphVersion(run.graph_id, run.graph_version);
|
||||
setSelectedRun(run);
|
||||
});
|
||||
}, [api, selectedView, agent, getGraphVersion]);
|
||||
|
||||
const fetchSchedules = useCallback(async () => {
|
||||
if (!agent) return;
|
||||
|
||||
setSchedules(await api.listGraphExecutionSchedules(agent.graph_id));
|
||||
}, [api, agent?.graph_id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSchedules();
|
||||
}, [fetchSchedules]);
|
||||
|
||||
// =========================== ACTIONS ============================
|
||||
|
||||
const deleteRun = useCallback(
|
||||
async (run: GraphExecutionMeta) => {
|
||||
if (run.status == "RUNNING" || run.status == "QUEUED") {
|
||||
await api.stopGraphExecution(run.graph_id, run.id);
|
||||
}
|
||||
await api.deleteGraphExecution(run.id);
|
||||
|
||||
setConfirmingDeleteAgentRun(null);
|
||||
if (selectedView.type == "run" && selectedView.id == run.id) {
|
||||
openRunDraftView();
|
||||
}
|
||||
agentRunsQuery.removeAgentRun(run.id);
|
||||
},
|
||||
[api, selectedView, openRunDraftView],
|
||||
);
|
||||
|
||||
const deletePreset = useCallback(
|
||||
async (presetID: LibraryAgentPresetID) => {
|
||||
await api.deleteLibraryAgentPreset(presetID);
|
||||
|
||||
setConfirmingDeleteAgentPreset(null);
|
||||
if (selectedView.type == "preset" && selectedView.id == presetID) {
|
||||
openRunDraftView();
|
||||
}
|
||||
setAgentPresets((presets) => presets.filter((p) => p.id !== presetID));
|
||||
},
|
||||
[api, selectedView, openRunDraftView],
|
||||
);
|
||||
|
||||
const deleteSchedule = useCallback(
|
||||
async (scheduleID: ScheduleID) => {
|
||||
const removedSchedule =
|
||||
await api.deleteGraphExecutionSchedule(scheduleID);
|
||||
|
||||
setSchedules((schedules) => {
|
||||
const newSchedules = schedules.filter(
|
||||
(s) => s.id !== removedSchedule.id,
|
||||
);
|
||||
if (
|
||||
selectedView.type == "schedule" &&
|
||||
selectedView.id == removedSchedule.id
|
||||
) {
|
||||
if (newSchedules.length > 0) {
|
||||
// Select next schedule if available
|
||||
selectSchedule(newSchedules[0].id);
|
||||
} else {
|
||||
// Reset to draft view if current schedule was deleted
|
||||
openRunDraftView();
|
||||
}
|
||||
}
|
||||
return newSchedules;
|
||||
});
|
||||
openRunDraftView();
|
||||
},
|
||||
[schedules, api],
|
||||
);
|
||||
|
||||
const handleCreatePresetFromRun = useCallback(
|
||||
async (name: string, description: string) => {
|
||||
if (!creatingPresetFromExecutionID) return;
|
||||
|
||||
await api
|
||||
.createLibraryAgentPreset({
|
||||
name,
|
||||
description,
|
||||
graph_execution_id: creatingPresetFromExecutionID,
|
||||
})
|
||||
.then((preset) => {
|
||||
setAgentPresets((prev) => [...prev, preset]);
|
||||
selectPreset(preset.id);
|
||||
setCreatingPresetFromExecutionID(null);
|
||||
})
|
||||
.catch(toastOnFail("create a preset"));
|
||||
},
|
||||
[api, creatingPresetFromExecutionID, selectPreset, toast],
|
||||
);
|
||||
|
||||
const downloadGraph = useCallback(
|
||||
async () =>
|
||||
agent &&
|
||||
// Export sanitized graph from backend
|
||||
api
|
||||
.getGraph(agent.graph_id, agent.graph_version, true)
|
||||
.then((graph) =>
|
||||
exportAsJSONFile(graph, `${graph.name}_v${graph.version}.json`),
|
||||
),
|
||||
[api, agent],
|
||||
);
|
||||
|
||||
const copyAgent = useCallback(async () => {
|
||||
setCopyAgentDialogOpen(false);
|
||||
api
|
||||
.forkLibraryAgent(agentID)
|
||||
.then((newAgent) => {
|
||||
router.push(`/library/agents/${newAgent.id}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error copying agent:", error);
|
||||
toast({
|
||||
title: "Error copying agent",
|
||||
description: `An error occurred while copying the agent: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
});
|
||||
}, [agentID, api, router, toast]);
|
||||
|
||||
const agentActions: ButtonAction[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: "Customize agent",
|
||||
href: `/build?flowID=${agent?.graph_id}&flowVersion=${agent?.graph_version}`,
|
||||
disabled: !agent?.can_access_graph,
|
||||
},
|
||||
{ label: "Export agent to file", callback: downloadGraph },
|
||||
...(!agent?.can_access_graph
|
||||
? [
|
||||
{
|
||||
label: "Edit a copy",
|
||||
callback: () => setCopyAgentDialogOpen(true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Delete agent",
|
||||
callback: () => setAgentDeleteDialogOpen(true),
|
||||
},
|
||||
],
|
||||
[agent, downloadGraph],
|
||||
);
|
||||
|
||||
const runGraph =
|
||||
graphVersions.current[selectedRun?.graph_version ?? 0] ?? graph;
|
||||
|
||||
const onCreateSchedule = useCallback(
|
||||
(schedule: Schedule) => {
|
||||
setSchedules((prev) => [...prev, schedule]);
|
||||
selectSchedule(schedule.id);
|
||||
},
|
||||
[selectView],
|
||||
);
|
||||
|
||||
const onCreatePreset = useCallback(
|
||||
(preset: LibraryAgentPreset) => {
|
||||
setAgentPresets((prev) => [...prev, preset]);
|
||||
selectPreset(preset.id);
|
||||
},
|
||||
[selectPreset],
|
||||
);
|
||||
|
||||
const onUpdatePreset = useCallback(
|
||||
(updated: LibraryAgentPreset) => {
|
||||
setAgentPresets((prev) =>
|
||||
prev.map((p) => (p.id === updated.id ? updated : p)),
|
||||
);
|
||||
selectPreset(updated.id);
|
||||
},
|
||||
[selectPreset],
|
||||
);
|
||||
|
||||
if (!agent || !graph) {
|
||||
return <LoadingBox className="h-[90vh]" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container justify-stretch p-0 pt-16 lg:flex">
|
||||
{/* Sidebar w/ list of runs */}
|
||||
{/* TODO: render this below header in sm and md layouts */}
|
||||
<AgentRunsSelectorList
|
||||
className="agpt-div w-full border-b pb-2 lg:w-auto lg:border-b-0 lg:border-r lg:pb-0"
|
||||
agent={agent}
|
||||
agentRunsQuery={agentRunsQuery}
|
||||
agentPresets={agentPresets}
|
||||
schedules={schedules}
|
||||
selectedView={selectedView}
|
||||
onSelectRun={selectRun}
|
||||
onSelectPreset={selectPreset}
|
||||
onSelectSchedule={selectSchedule}
|
||||
onSelectDraftNewRun={openRunDraftView}
|
||||
doDeleteRun={setConfirmingDeleteAgentRun}
|
||||
doDeletePreset={setConfirmingDeleteAgentPreset}
|
||||
doDeleteSchedule={deleteSchedule}
|
||||
doCreatePresetFromRun={setCreatingPresetFromExecutionID}
|
||||
/>
|
||||
|
||||
<div className="flex-1">
|
||||
{/* Header */}
|
||||
<div className="agpt-div w-full border-b">
|
||||
<h1
|
||||
data-testid="agent-title"
|
||||
className="font-poppins text-3xl font-medium"
|
||||
>
|
||||
{
|
||||
agent.name /* TODO: use dynamic/custom run title - https://github.com/Significant-Gravitas/AutoGPT/issues/9184 */
|
||||
}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Run / Schedule views */}
|
||||
{(selectedView.type == "run" && selectedView.id ? (
|
||||
selectedRun && runGraph ? (
|
||||
<AgentRunDetailsView
|
||||
agent={agent}
|
||||
graph={runGraph}
|
||||
run={selectedRun}
|
||||
agentActions={agentActions}
|
||||
onRun={selectRun}
|
||||
doDeleteRun={() => setConfirmingDeleteAgentRun(selectedRun)}
|
||||
doCreatePresetFromRun={() =>
|
||||
setCreatingPresetFromExecutionID(selectedRun.id)
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
) : selectedView.type == "run" ? (
|
||||
/* Draft new runs / Create new presets */
|
||||
<AgentRunDraftView
|
||||
graph={graph}
|
||||
onRun={selectRun}
|
||||
onCreateSchedule={onCreateSchedule}
|
||||
onCreatePreset={onCreatePreset}
|
||||
agentActions={agentActions}
|
||||
recommendedScheduleCron={agent?.recommended_schedule_cron || null}
|
||||
/>
|
||||
) : selectedView.type == "preset" ? (
|
||||
/* Edit & update presets */
|
||||
<AgentRunDraftView
|
||||
graph={graph}
|
||||
agentPreset={
|
||||
agentPresets.find((preset) => preset.id == selectedView.id)!
|
||||
}
|
||||
onRun={selectRun}
|
||||
recommendedScheduleCron={agent?.recommended_schedule_cron || null}
|
||||
onCreateSchedule={onCreateSchedule}
|
||||
onUpdatePreset={onUpdatePreset}
|
||||
doDeletePreset={setConfirmingDeleteAgentPreset}
|
||||
agentActions={agentActions}
|
||||
/>
|
||||
) : selectedView.type == "schedule" ? (
|
||||
selectedSchedule &&
|
||||
graph && (
|
||||
<AgentScheduleDetailsView
|
||||
graph={graph}
|
||||
schedule={selectedSchedule}
|
||||
// agent={agent}
|
||||
agentActions={agentActions}
|
||||
onForcedRun={selectRun}
|
||||
doDeleteSchedule={deleteSchedule}
|
||||
/>
|
||||
)
|
||||
) : null) || <LoadingBox className="h-[70vh]" />}
|
||||
|
||||
<DeleteConfirmDialog
|
||||
entityType="agent"
|
||||
open={agentDeleteDialogOpen}
|
||||
onOpenChange={setAgentDeleteDialogOpen}
|
||||
onDoDelete={() =>
|
||||
agent &&
|
||||
api.deleteLibraryAgent(agent.id).then(() => router.push("/library"))
|
||||
}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
entityType="agent run"
|
||||
open={!!confirmingDeleteAgentRun}
|
||||
onOpenChange={(open) => !open && setConfirmingDeleteAgentRun(null)}
|
||||
onDoDelete={() =>
|
||||
confirmingDeleteAgentRun && deleteRun(confirmingDeleteAgentRun)
|
||||
}
|
||||
/>
|
||||
<DeleteConfirmDialog
|
||||
entityType={agent.has_external_trigger ? "trigger" : "agent preset"}
|
||||
open={!!confirmingDeleteAgentPreset}
|
||||
onOpenChange={(open) => !open && setConfirmingDeleteAgentPreset(null)}
|
||||
onDoDelete={() =>
|
||||
confirmingDeleteAgentPreset &&
|
||||
deletePreset(confirmingDeleteAgentPreset)
|
||||
}
|
||||
/>
|
||||
{/* Copy agent confirmation dialog */}
|
||||
<Dialog
|
||||
onOpenChange={setCopyAgentDialogOpen}
|
||||
open={copyAgentDialogOpen}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>You're making an editable copy</DialogTitle>
|
||||
<DialogDescription className="pt-2">
|
||||
The original Marketplace agent stays the same and cannot be
|
||||
edited. We'll save a new version of this agent to your
|
||||
Library. From there, you can customize it however you'd
|
||||
like by clicking "Customize agent" — this will open
|
||||
the builder where you can see and modify the inner workings.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCopyAgentDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={copyAgent}>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CreatePresetDialog
|
||||
open={!!creatingPresetFromExecutionID}
|
||||
onOpenChange={() => setCreatingPresetFromExecutionID(null)}
|
||||
onConfirm={handleCreatePresetFromRun}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
"use client";
|
||||
import { format, formatDistanceToNow, formatDistanceStrict } from "date-fns";
|
||||
import React, { useCallback, useMemo, useEffect } from "react";
|
||||
|
||||
import {
|
||||
Graph,
|
||||
GraphExecution,
|
||||
GraphExecutionID,
|
||||
GraphExecutionMeta,
|
||||
LibraryAgent,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
|
||||
import ActionButtonGroup from "@/components/__legacy__/action-button-group";
|
||||
import type { ButtonAction } from "@/components/__legacy__/types";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/__legacy__/ui/card";
|
||||
import {
|
||||
IconRefresh,
|
||||
IconSquare,
|
||||
IconCircleAlert,
|
||||
} from "@/components/__legacy__/ui/icons";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import LoadingBox from "@/components/__legacy__/ui/loading";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
|
||||
|
||||
import { AgentRunStatus, agentRunStatusMap } from "./agent-run-status-chip";
|
||||
import useCredits from "@/hooks/useCredits";
|
||||
import { AgentRunOutputView } from "./agent-run-output-view";
|
||||
import { analytics } from "@/services/analytics";
|
||||
import { PendingReviewsList } from "@/components/organisms/PendingReviewsList/PendingReviewsList";
|
||||
import { usePendingReviewsForExecution } from "@/hooks/usePendingReviews";
|
||||
|
||||
export function AgentRunDetailsView({
|
||||
agent,
|
||||
graph,
|
||||
run,
|
||||
agentActions,
|
||||
onRun,
|
||||
doDeleteRun,
|
||||
doCreatePresetFromRun,
|
||||
}: {
|
||||
agent: LibraryAgent;
|
||||
graph: Graph;
|
||||
run: GraphExecution | GraphExecutionMeta;
|
||||
agentActions: ButtonAction[];
|
||||
onRun: (runID: GraphExecutionID) => void;
|
||||
doDeleteRun: () => void;
|
||||
doCreatePresetFromRun: () => void;
|
||||
}): React.ReactNode {
|
||||
const api = useBackendAPI();
|
||||
const { formatCredits } = useCredits();
|
||||
|
||||
const runStatus: AgentRunStatus = useMemo(
|
||||
() => agentRunStatusMap[run.status],
|
||||
[run],
|
||||
);
|
||||
|
||||
const {
|
||||
pendingReviews,
|
||||
isLoading: reviewsLoading,
|
||||
refetch: refetchReviews,
|
||||
} = usePendingReviewsForExecution(run.id);
|
||||
|
||||
const toastOnFail = useToastOnFail();
|
||||
|
||||
// Refetch pending reviews when execution status changes to REVIEW
|
||||
useEffect(() => {
|
||||
if (runStatus === "review" && run.id) {
|
||||
refetchReviews();
|
||||
}
|
||||
}, [runStatus, run.id, refetchReviews]);
|
||||
|
||||
const infoStats: { label: string; value: React.ReactNode }[] = useMemo(() => {
|
||||
if (!run) return [];
|
||||
return [
|
||||
{
|
||||
label: "Status",
|
||||
value: runStatus.charAt(0).toUpperCase() + runStatus.slice(1),
|
||||
},
|
||||
{
|
||||
label: "Started",
|
||||
value: run.started_at
|
||||
? `${formatDistanceToNow(run.started_at, { addSuffix: true })}, ${format(run.started_at, "HH:mm")}`
|
||||
: "—",
|
||||
},
|
||||
...(run.stats
|
||||
? [
|
||||
{
|
||||
label: "Duration",
|
||||
value: formatDistanceStrict(0, run.stats.duration * 1000),
|
||||
},
|
||||
{ label: "Steps", value: run.stats.node_exec_count },
|
||||
{ label: "Cost", value: formatCredits(run.stats.cost) },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}, [run, runStatus, formatCredits]);
|
||||
|
||||
const agentRunInputs:
|
||||
| Record<
|
||||
string,
|
||||
{
|
||||
title?: string;
|
||||
/* type: BlockIOSubType; */
|
||||
value: string | number | undefined;
|
||||
}
|
||||
>
|
||||
| undefined = useMemo(() => {
|
||||
if (!run.inputs) return undefined;
|
||||
// TODO: show (link to) preset - https://github.com/Significant-Gravitas/AutoGPT/issues/9168
|
||||
|
||||
// Add type info from agent input schema
|
||||
return Object.fromEntries(
|
||||
Object.entries(run.inputs).map(([k, v]) => [
|
||||
k,
|
||||
{
|
||||
title: graph.input_schema.properties[k]?.title,
|
||||
// type: graph.input_schema.properties[k].type, // TODO: implement typed graph inputs
|
||||
value: typeof v == "object" ? JSON.stringify(v, undefined, 2) : v,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}, [graph, run]);
|
||||
|
||||
const runAgain = useCallback(() => {
|
||||
if (
|
||||
!run.inputs ||
|
||||
!(graph.credentials_input_schema?.required ?? []).every(
|
||||
(k) => k in (run.credential_inputs ?? {}),
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
if (run.preset_id) {
|
||||
return api
|
||||
.executeLibraryAgentPreset(
|
||||
run.preset_id,
|
||||
run.inputs!,
|
||||
run.credential_inputs!,
|
||||
)
|
||||
.then(({ id }) => {
|
||||
analytics.sendDatafastEvent("run_agent", {
|
||||
name: graph.name,
|
||||
id: graph.id,
|
||||
});
|
||||
onRun(id);
|
||||
})
|
||||
.catch(toastOnFail("execute agent preset"));
|
||||
}
|
||||
|
||||
return api
|
||||
.executeGraph(
|
||||
graph.id,
|
||||
graph.version,
|
||||
run.inputs!,
|
||||
run.credential_inputs!,
|
||||
"library",
|
||||
)
|
||||
.then(({ id }) => {
|
||||
analytics.sendDatafastEvent("run_agent", {
|
||||
name: graph.name,
|
||||
id: graph.id,
|
||||
});
|
||||
onRun(id);
|
||||
})
|
||||
.catch(toastOnFail("execute agent"));
|
||||
}, [api, graph, run, onRun, toastOnFail]);
|
||||
|
||||
const stopRun = useCallback(
|
||||
() => api.stopGraphExecution(graph.id, run.id),
|
||||
[api, graph.id, run.id],
|
||||
);
|
||||
|
||||
const agentRunOutputs:
|
||||
| Record<
|
||||
string,
|
||||
{
|
||||
title?: string;
|
||||
/* type: BlockIOSubType; */
|
||||
values: Array<React.ReactNode>;
|
||||
}
|
||||
>
|
||||
| null
|
||||
| undefined = useMemo(() => {
|
||||
if (!("outputs" in run)) return undefined;
|
||||
if (!["running", "success", "failed", "stopped"].includes(runStatus))
|
||||
return null;
|
||||
|
||||
// Add type info from agent input schema
|
||||
return Object.fromEntries(
|
||||
Object.entries(run.outputs).map(([k, vv]) => [
|
||||
k,
|
||||
{
|
||||
title: graph.output_schema.properties[k].title,
|
||||
/* type: agent.output_schema.properties[k].type */
|
||||
values: vv.map((v) =>
|
||||
typeof v == "object" ? JSON.stringify(v, undefined, 2) : v,
|
||||
),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}, [graph, run, runStatus]);
|
||||
|
||||
const runActions: ButtonAction[] = useMemo(
|
||||
() => [
|
||||
...(["running", "queued"].includes(runStatus)
|
||||
? ([
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
<IconSquare className="mr-2 size-4" />
|
||||
Stop run
|
||||
</>
|
||||
),
|
||||
variant: "secondary",
|
||||
callback: stopRun,
|
||||
},
|
||||
] satisfies ButtonAction[])
|
||||
: []),
|
||||
...(["success", "failed", "stopped"].includes(runStatus) &&
|
||||
!graph.has_external_trigger &&
|
||||
(graph.credentials_input_schema?.required ?? []).every(
|
||||
(k) => k in (run.credential_inputs ?? {}),
|
||||
)
|
||||
? [
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
<IconRefresh className="mr-2 size-4" />
|
||||
Run again
|
||||
</>
|
||||
),
|
||||
callback: runAgain,
|
||||
dataTestId: "run-again-button",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(agent.can_access_graph
|
||||
? [
|
||||
{
|
||||
label: "Open run in builder",
|
||||
href: `/build?flowID=${run.graph_id}&flowVersion=${run.graph_version}&flowExecutionID=${run.id}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ label: "Create preset from run", callback: doCreatePresetFromRun },
|
||||
{ label: "Delete run", variant: "secondary", callback: doDeleteRun },
|
||||
],
|
||||
[
|
||||
runStatus,
|
||||
runAgain,
|
||||
stopRun,
|
||||
doDeleteRun,
|
||||
doCreatePresetFromRun,
|
||||
graph.has_external_trigger,
|
||||
graph.credentials_input_schema?.required,
|
||||
agent.can_access_graph,
|
||||
run.graph_id,
|
||||
run.graph_version,
|
||||
run.id,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="agpt-div flex gap-6">
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<Card className="agpt-box">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-poppins text-lg">Info</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="flex justify-stretch gap-4">
|
||||
{infoStats.map(({ label, value }) => (
|
||||
<div key={label} className="flex-1">
|
||||
<p className="text-sm font-medium text-black">{label}</p>
|
||||
<p className="text-sm text-neutral-600">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{run.status === "FAILED" && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
<strong>Error:</strong>{" "}
|
||||
{run.stats?.error ||
|
||||
"The execution failed due to an internal error. You can re-run the agent to retry."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Smart Agent Execution Summary */}
|
||||
{run.stats?.activity_status && (
|
||||
<Card className="agpt-box">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 font-poppins text-lg">
|
||||
Task Summary
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconCircleAlert className="size-4 cursor-help text-neutral-500 hover:text-neutral-700" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs">
|
||||
This AI-generated summary describes how the agent
|
||||
handled your task. It’s an experimental feature and may
|
||||
occasionally be inaccurate.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm leading-relaxed text-neutral-700">
|
||||
{run.stats.activity_status}
|
||||
</p>
|
||||
|
||||
{/* Correctness Score */}
|
||||
{typeof run.stats.correctness_score === "number" && (
|
||||
<div className="flex items-center gap-3 rounded-lg bg-neutral-50 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-neutral-600">
|
||||
Success Estimate:
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative h-2 w-16 overflow-hidden rounded-full bg-neutral-200">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
run.stats.correctness_score >= 0.8
|
||||
? "bg-green-500"
|
||||
: run.stats.correctness_score >= 0.6
|
||||
? "bg-yellow-500"
|
||||
: run.stats.correctness_score >= 0.4
|
||||
? "bg-orange-500"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.round(run.stats.correctness_score * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{Math.round(run.stats.correctness_score * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconCircleAlert className="size-4 cursor-help text-neutral-400 hover:text-neutral-600" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs">
|
||||
AI-generated estimate of how well this execution
|
||||
achieved its intended purpose. This score indicates
|
||||
{run.stats.correctness_score >= 0.8
|
||||
? " the agent was highly successful."
|
||||
: run.stats.correctness_score >= 0.6
|
||||
? " the agent was mostly successful with minor issues."
|
||||
: run.stats.correctness_score >= 0.4
|
||||
? " the agent was partially successful with some gaps."
|
||||
: " the agent had limited success with significant issues."}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{agentRunOutputs !== null && (
|
||||
<AgentRunOutputView agentRunOutputs={agentRunOutputs} />
|
||||
)}
|
||||
|
||||
{/* Pending Reviews Section */}
|
||||
{runStatus === "review" && (
|
||||
<Card className="agpt-box">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-poppins text-lg">
|
||||
Pending Reviews ({pendingReviews.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{reviewsLoading ? (
|
||||
<LoadingBox spinnerSize={12} className="h-24" />
|
||||
) : pendingReviews.length > 0 ? (
|
||||
<PendingReviewsList
|
||||
reviews={pendingReviews}
|
||||
onReviewComplete={refetchReviews}
|
||||
emptyMessage="No pending reviews for this execution"
|
||||
/>
|
||||
) : (
|
||||
<div className="py-4 text-neutral-600">
|
||||
No pending reviews for this execution
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="agpt-box">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-poppins text-lg">Input</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{agentRunInputs !== undefined ? (
|
||||
Object.entries(agentRunInputs).map(([key, { title, value }]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">{title || key}</label>
|
||||
<Input value={value} className="rounded-full" disabled />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<LoadingBox spinnerSize={12} className="h-24" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Run / Agent Actions */}
|
||||
<aside className="w-48 xl:w-56">
|
||||
<div className="flex flex-col gap-8">
|
||||
<ActionButtonGroup title="Run actions" actions={runActions} />
|
||||
|
||||
<ActionButtonGroup title="Agent actions" actions={agentActions} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
|
||||
import { RunAgentInputs } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentInputs/RunAgentInputs";
|
||||
import { ScheduleTaskDialog } from "@/components/contextual/CronScheduler/cron-scheduler-dialog";
|
||||
import { ScheduleTaskDialog } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/cron-scheduler-dialog";
|
||||
import ActionButtonGroup from "@/components/__legacy__/action-button-group";
|
||||
import type { ButtonAction } from "@/components/__legacy__/types";
|
||||
import {
|
||||
@@ -53,10 +53,7 @@ import { ClockIcon, CopyIcon, InfoIcon } from "@phosphor-icons/react";
|
||||
import { CalendarClockIcon, Trash2Icon } from "lucide-react";
|
||||
|
||||
import { analytics } from "@/services/analytics";
|
||||
import {
|
||||
AgentStatus,
|
||||
AgentStatusChip,
|
||||
} from "@/app/(platform)/build/components/legacy-builder/agent-status-chip";
|
||||
import { AgentStatus, AgentStatusChip } from "./agent-status-chip";
|
||||
|
||||
export function AgentRunDraftView({
|
||||
graph,
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/__legacy__/ui/card";
|
||||
|
||||
import LoadingBox from "@/components/__legacy__/ui/loading";
|
||||
import type { OutputMetadata } from "../../../../../../../../components/contextual/OutputRenderers";
|
||||
import {
|
||||
globalRegistry,
|
||||
OutputActions,
|
||||
OutputItem,
|
||||
} from "../../../../../../../../components/contextual/OutputRenderers";
|
||||
|
||||
export function AgentRunOutputView({
|
||||
agentRunOutputs,
|
||||
}: {
|
||||
agentRunOutputs:
|
||||
| Record<
|
||||
string,
|
||||
{
|
||||
title?: string;
|
||||
/* type: BlockIOSubType; */
|
||||
values: Array<React.ReactNode>;
|
||||
}
|
||||
>
|
||||
| undefined;
|
||||
}) {
|
||||
const enableEnhancedOutputHandling = useGetFlag(
|
||||
Flag.ENABLE_ENHANCED_OUTPUT_HANDLING,
|
||||
);
|
||||
|
||||
// Prepare items for the renderer system
|
||||
const outputItems = useMemo(() => {
|
||||
if (!agentRunOutputs) return [];
|
||||
|
||||
const items: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
value: unknown;
|
||||
metadata?: OutputMetadata;
|
||||
renderer: any;
|
||||
}> = [];
|
||||
|
||||
Object.entries(agentRunOutputs).forEach(([key, { title, values }]) => {
|
||||
values.forEach((value, index) => {
|
||||
// Enhanced metadata extraction
|
||||
const metadata: OutputMetadata = {};
|
||||
|
||||
// Type guard to safely access properties
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!React.isValidElement(value)
|
||||
) {
|
||||
const objValue = value as any;
|
||||
if (objValue.type) metadata.type = objValue.type;
|
||||
if (objValue.mimeType) metadata.mimeType = objValue.mimeType;
|
||||
if (objValue.filename) metadata.filename = objValue.filename;
|
||||
}
|
||||
|
||||
const renderer = globalRegistry.getRenderer(value, metadata);
|
||||
if (renderer) {
|
||||
items.push({
|
||||
key: `${key}-${index}`,
|
||||
label: index === 0 ? title || key : "",
|
||||
value,
|
||||
metadata,
|
||||
renderer,
|
||||
});
|
||||
} else {
|
||||
const textRenderer = globalRegistry
|
||||
.getAllRenderers()
|
||||
.find((r) => r.name === "TextRenderer");
|
||||
if (textRenderer) {
|
||||
items.push({
|
||||
key: `${key}-${index}`,
|
||||
label: index === 0 ? title || key : "",
|
||||
value: JSON.stringify(value, null, 2),
|
||||
metadata,
|
||||
renderer: textRenderer,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [agentRunOutputs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{enableEnhancedOutputHandling ? (
|
||||
<Card className="agpt-box" style={{ maxWidth: "950px" }}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="font-poppins text-lg">Output</CardTitle>
|
||||
{outputItems.length > 0 && (
|
||||
<OutputActions
|
||||
items={outputItems.map((item) => ({
|
||||
value: item.value,
|
||||
metadata: item.metadata,
|
||||
renderer: item.renderer,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent
|
||||
className="flex flex-col gap-4"
|
||||
style={{ maxWidth: "660px" }}
|
||||
>
|
||||
{agentRunOutputs !== undefined ? (
|
||||
outputItems.length > 0 ? (
|
||||
outputItems.map((item) => (
|
||||
<OutputItem
|
||||
key={item.key}
|
||||
value={item.value}
|
||||
metadata={item.metadata}
|
||||
renderer={item.renderer}
|
||||
label={item.label}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No outputs to display
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<LoadingBox spinnerSize={12} className="h-24" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="agpt-box" style={{ maxWidth: "950px" }}>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-poppins text-lg">Output</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent
|
||||
className="flex flex-col gap-4"
|
||||
style={{ maxWidth: "660px" }}
|
||||
>
|
||||
{agentRunOutputs !== undefined ? (
|
||||
Object.entries(agentRunOutputs).map(
|
||||
([key, { title, values }]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{title || key}
|
||||
</label>
|
||||
{values.map((value, i) => (
|
||||
<p
|
||||
className="resize-none overflow-x-auto whitespace-pre-wrap break-words border-none text-sm text-neutral-700 disabled:cursor-not-allowed"
|
||||
key={i}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
))}
|
||||
{/* TODO: pretty type-dependent rendering */}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<LoadingBox spinnerSize={12} className="h-24" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
|
||||
import { Badge } from "@/components/__legacy__/ui/badge";
|
||||
|
||||
import { GraphExecutionMeta } from "@/lib/autogpt-server-api/types";
|
||||
|
||||
export type AgentRunStatus =
|
||||
| "success"
|
||||
| "failed"
|
||||
| "queued"
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "scheduled"
|
||||
| "draft"
|
||||
| "review";
|
||||
|
||||
export const agentRunStatusMap: Record<
|
||||
GraphExecutionMeta["status"],
|
||||
AgentRunStatus
|
||||
> = {
|
||||
INCOMPLETE: "draft",
|
||||
COMPLETED: "success",
|
||||
FAILED: "failed",
|
||||
QUEUED: "queued",
|
||||
RUNNING: "running",
|
||||
TERMINATED: "stopped",
|
||||
REVIEW: "review",
|
||||
};
|
||||
|
||||
const statusData: Record<
|
||||
AgentRunStatus,
|
||||
{ label: string; variant: keyof typeof statusStyles }
|
||||
> = {
|
||||
success: { label: "Success", variant: "success" },
|
||||
running: { label: "Running", variant: "info" },
|
||||
failed: { label: "Failed", variant: "destructive" },
|
||||
queued: { label: "Queued", variant: "warning" },
|
||||
draft: { label: "Draft", variant: "secondary" },
|
||||
stopped: { label: "Stopped", variant: "secondary" },
|
||||
scheduled: { label: "Scheduled", variant: "secondary" },
|
||||
review: { label: "In Review", variant: "warning" },
|
||||
};
|
||||
|
||||
const statusStyles = {
|
||||
success:
|
||||
"bg-green-100 text-green-800 hover:bg-green-100 hover:text-green-800",
|
||||
destructive: "bg-red-100 text-red-800 hover:bg-red-100 hover:text-red-800",
|
||||
warning:
|
||||
"bg-yellow-100 text-yellow-800 hover:bg-yellow-100 hover:text-yellow-800",
|
||||
info: "bg-blue-100 text-blue-800 hover:bg-blue-100 hover:text-blue-800",
|
||||
secondary:
|
||||
"bg-slate-100 text-slate-800 hover:bg-slate-100 hover:text-slate-800",
|
||||
};
|
||||
|
||||
export function AgentRunStatusChip({
|
||||
status,
|
||||
}: {
|
||||
status: AgentRunStatus;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`text-xs font-medium ${statusStyles[statusData[status]?.variant]} rounded-[45px] px-[9px] py-[3px]`}
|
||||
>
|
||||
{statusData[status]?.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from "react";
|
||||
import { formatDistanceToNow, isPast } from "date-fns";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Link2Icon, Link2OffIcon, MoreVertical } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/__legacy__/ui/card";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/__legacy__/ui/dropdown-menu";
|
||||
|
||||
import { AgentStatus, AgentStatusChip } from "./agent-status-chip";
|
||||
import { AgentRunStatus, AgentRunStatusChip } from "./agent-run-status-chip";
|
||||
import { PushPinSimpleIcon } from "@phosphor-icons/react";
|
||||
|
||||
export type AgentRunSummaryProps = (
|
||||
| {
|
||||
type: "run";
|
||||
status: AgentRunStatus;
|
||||
}
|
||||
| {
|
||||
type: "preset";
|
||||
status?: undefined;
|
||||
}
|
||||
| {
|
||||
type: "preset.triggered";
|
||||
status: AgentStatus;
|
||||
}
|
||||
| {
|
||||
type: "schedule";
|
||||
status: "scheduled";
|
||||
}
|
||||
) & {
|
||||
title: string;
|
||||
timestamp?: number | Date;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
// onRename: () => void;
|
||||
onDelete: () => void;
|
||||
onPinAsPreset?: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AgentRunSummaryCard({
|
||||
type,
|
||||
status,
|
||||
title,
|
||||
timestamp,
|
||||
selected = false,
|
||||
onClick,
|
||||
// onRename,
|
||||
onDelete,
|
||||
onPinAsPreset,
|
||||
className,
|
||||
}: AgentRunSummaryProps): React.ReactElement {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"agpt-rounded-card cursor-pointer border-zinc-300",
|
||||
selected ? "agpt-card-selected" : "",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent className="relative p-2.5 lg:p-4">
|
||||
{(type == "run" || type == "schedule") && (
|
||||
<AgentRunStatusChip status={status} />
|
||||
)}
|
||||
{type == "preset" && (
|
||||
<div className="flex items-center text-sm font-medium text-neutral-700">
|
||||
<PushPinSimpleIcon className="mr-1 size-4 text-foreground" /> Preset
|
||||
</div>
|
||||
)}
|
||||
{type == "preset.triggered" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<AgentStatusChip status={status} />
|
||||
|
||||
<div className="flex items-center text-sm font-medium text-neutral-700">
|
||||
{status == "inactive" ? (
|
||||
<Link2OffIcon className="mr-1 size-4 text-foreground" />
|
||||
) : (
|
||||
<Link2Icon className="mr-1 size-4 text-foreground" />
|
||||
)}{" "}
|
||||
Trigger
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<h3 className="truncate pr-2 text-base font-medium text-neutral-900">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-5 w-5 p-0">
|
||||
<MoreVertical className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{onPinAsPreset && (
|
||||
<DropdownMenuItem onClick={onPinAsPreset}>
|
||||
Pin as a preset
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* <DropdownMenuItem onClick={onRename}>Rename</DropdownMenuItem> */}
|
||||
|
||||
<DropdownMenuItem onClick={onDelete}>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{timestamp && (
|
||||
<p
|
||||
className="mt-1 text-sm font-normal text-neutral-500"
|
||||
title={new Date(timestamp).toString()}
|
||||
>
|
||||
{isPast(timestamp) ? "Ran" : "Runs in"}{" "}
|
||||
{formatDistanceToNow(timestamp, { addSuffix: true })}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
import { Plus } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
GraphExecutionID,
|
||||
GraphExecutionMeta,
|
||||
LibraryAgent,
|
||||
LibraryAgentPreset,
|
||||
LibraryAgentPresetID,
|
||||
Schedule,
|
||||
ScheduleID,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Badge } from "@/components/__legacy__/ui/badge";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import LoadingBox, { LoadingSpinner } from "@/components/__legacy__/ui/loading";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
import { AgentRunsQuery } from "../use-agent-runs";
|
||||
import { agentRunStatusMap } from "./agent-run-status-chip";
|
||||
import { AgentRunSummaryCard } from "./agent-run-summary-card";
|
||||
|
||||
interface AgentRunsSelectorListProps {
|
||||
agent: LibraryAgent;
|
||||
agentRunsQuery: AgentRunsQuery;
|
||||
agentPresets: LibraryAgentPreset[];
|
||||
schedules: Schedule[];
|
||||
selectedView: { type: "run" | "preset" | "schedule"; id?: string };
|
||||
allowDraftNewRun?: boolean;
|
||||
onSelectRun: (id: GraphExecutionID) => void;
|
||||
onSelectPreset: (preset: LibraryAgentPresetID) => void;
|
||||
onSelectSchedule: (id: ScheduleID) => void;
|
||||
onSelectDraftNewRun: () => void;
|
||||
doDeleteRun: (id: GraphExecutionMeta) => void;
|
||||
doDeletePreset: (id: LibraryAgentPresetID) => void;
|
||||
doDeleteSchedule: (id: ScheduleID) => void;
|
||||
doCreatePresetFromRun?: (id: GraphExecutionID) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AgentRunsSelectorList({
|
||||
agent,
|
||||
agentRunsQuery: {
|
||||
agentRuns,
|
||||
agentRunCount,
|
||||
agentRunsLoading,
|
||||
hasMoreRuns,
|
||||
fetchMoreRuns,
|
||||
isFetchingMoreRuns,
|
||||
},
|
||||
agentPresets,
|
||||
schedules,
|
||||
selectedView,
|
||||
allowDraftNewRun = true,
|
||||
onSelectRun,
|
||||
onSelectPreset,
|
||||
onSelectSchedule,
|
||||
onSelectDraftNewRun,
|
||||
doDeleteRun,
|
||||
doDeletePreset,
|
||||
doDeleteSchedule,
|
||||
doCreatePresetFromRun,
|
||||
className,
|
||||
}: AgentRunsSelectorListProps): React.ReactElement {
|
||||
const [activeListTab, setActiveListTab] = useState<"runs" | "scheduled">(
|
||||
"runs",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedView.type === "schedule") {
|
||||
setActiveListTab("scheduled");
|
||||
} else {
|
||||
setActiveListTab("runs");
|
||||
}
|
||||
}, [selectedView]);
|
||||
|
||||
const listItemClasses = "h-28 w-72 lg:w-full lg:h-32";
|
||||
|
||||
return (
|
||||
<aside className={cn("flex flex-col gap-4", className)}>
|
||||
{allowDraftNewRun ? (
|
||||
<Button
|
||||
className={"mb-4 hidden lg:flex"}
|
||||
onClick={onSelectDraftNewRun}
|
||||
leftIcon={<Plus className="h-6 w-6" />}
|
||||
>
|
||||
New {agent.has_external_trigger ? "trigger" : "run"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Badge
|
||||
variant={activeListTab === "runs" ? "secondary" : "outline"}
|
||||
className="cursor-pointer gap-2 rounded-full text-base"
|
||||
onClick={() => setActiveListTab("runs")}
|
||||
>
|
||||
<span>Runs</span>
|
||||
<span className="text-neutral-600">
|
||||
{agentRunCount ?? <LoadingSpinner className="size-4" />}
|
||||
</span>
|
||||
</Badge>
|
||||
|
||||
<Badge
|
||||
variant={activeListTab === "scheduled" ? "secondary" : "outline"}
|
||||
className="cursor-pointer gap-2 rounded-full text-base"
|
||||
onClick={() => setActiveListTab("scheduled")}
|
||||
>
|
||||
<span>Scheduled</span>
|
||||
<span className="text-neutral-600">{schedules.length}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Runs / Schedules list */}
|
||||
{agentRunsLoading && activeListTab === "runs" ? (
|
||||
<LoadingBox className="h-28 w-full lg:h-[calc(100vh-300px)] lg:w-72 xl:w-80" />
|
||||
) : (
|
||||
<ScrollArea
|
||||
className="w-full lg:h-[calc(100vh-300px)] lg:w-72 xl:w-80"
|
||||
orientation={window.innerWidth >= 1024 ? "vertical" : "horizontal"}
|
||||
>
|
||||
<InfiniteScroll
|
||||
direction={window.innerWidth >= 1024 ? "vertical" : "horizontal"}
|
||||
hasNextPage={hasMoreRuns}
|
||||
fetchNextPage={fetchMoreRuns}
|
||||
isFetchingNextPage={isFetchingMoreRuns}
|
||||
>
|
||||
<div className="flex items-center gap-2 lg:flex-col">
|
||||
{/* New Run button - only in small layouts */}
|
||||
{allowDraftNewRun && (
|
||||
<Button
|
||||
size="large"
|
||||
className={
|
||||
"flex h-12 w-40 items-center gap-2 py-6 lg:hidden " +
|
||||
(selectedView.type == "run" && !selectedView.id
|
||||
? "agpt-card-selected text-accent"
|
||||
: "")
|
||||
}
|
||||
onClick={onSelectDraftNewRun}
|
||||
leftIcon={<Plus className="h-6 w-6" />}
|
||||
>
|
||||
New {agent.has_external_trigger ? "trigger" : "run"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{activeListTab === "runs" ? (
|
||||
<>
|
||||
{agentPresets
|
||||
.filter((preset) => preset.webhook) // Triggers
|
||||
.toSorted(
|
||||
(a, b) => b.updated_at.getTime() - a.updated_at.getTime(),
|
||||
)
|
||||
.map((preset) => (
|
||||
<AgentRunSummaryCard
|
||||
className={cn(listItemClasses, "lg:h-auto")}
|
||||
key={preset.id}
|
||||
type="preset.triggered"
|
||||
status={preset.is_active ? "active" : "inactive"}
|
||||
title={preset.name}
|
||||
// timestamp={preset.last_run_time} // TODO: implement this
|
||||
selected={selectedView.id === preset.id}
|
||||
onClick={() => onSelectPreset(preset.id)}
|
||||
onDelete={() => doDeletePreset(preset.id)}
|
||||
/>
|
||||
))}
|
||||
{agentPresets
|
||||
.filter((preset) => !preset.webhook) // Presets
|
||||
.toSorted(
|
||||
(a, b) => b.updated_at.getTime() - a.updated_at.getTime(),
|
||||
)
|
||||
.map((preset) => (
|
||||
<AgentRunSummaryCard
|
||||
className={cn(listItemClasses, "lg:h-auto")}
|
||||
key={preset.id}
|
||||
type="preset"
|
||||
title={preset.name}
|
||||
// timestamp={preset.last_run_time} // TODO: implement this
|
||||
selected={selectedView.id === preset.id}
|
||||
onClick={() => onSelectPreset(preset.id)}
|
||||
onDelete={() => doDeletePreset(preset.id)}
|
||||
/>
|
||||
))}
|
||||
{agentPresets.length > 0 && <Separator className="my-1" />}
|
||||
{agentRuns
|
||||
.toSorted((a, b) => {
|
||||
const aTime = a.started_at?.getTime() ?? 0;
|
||||
const bTime = b.started_at?.getTime() ?? 0;
|
||||
return bTime - aTime;
|
||||
})
|
||||
.map((run) => (
|
||||
<AgentRunSummaryCard
|
||||
className={listItemClasses}
|
||||
key={run.id}
|
||||
type="run"
|
||||
status={agentRunStatusMap[run.status]}
|
||||
title={
|
||||
(run.preset_id
|
||||
? agentPresets.find((p) => p.id == run.preset_id)
|
||||
?.name
|
||||
: null) ?? agent.name
|
||||
}
|
||||
timestamp={run.started_at ?? undefined}
|
||||
selected={selectedView.id === run.id}
|
||||
onClick={() => onSelectRun(run.id)}
|
||||
onDelete={() => doDeleteRun(run as GraphExecutionMeta)}
|
||||
onPinAsPreset={
|
||||
doCreatePresetFromRun
|
||||
? () => doCreatePresetFromRun(run.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
schedules.map((schedule) => (
|
||||
<AgentRunSummaryCard
|
||||
className={listItemClasses}
|
||||
key={schedule.id}
|
||||
type="schedule"
|
||||
status="scheduled" // TODO: implement active/inactive status for schedules
|
||||
title={schedule.name}
|
||||
timestamp={schedule.next_run_time}
|
||||
selected={selectedView.id === schedule.id}
|
||||
onClick={() => onSelectSchedule(schedule.id)}
|
||||
onDelete={() => doDeleteSchedule(schedule.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
|
||||
import {
|
||||
Graph,
|
||||
GraphExecutionID,
|
||||
Schedule,
|
||||
ScheduleID,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
|
||||
import ActionButtonGroup from "@/components/__legacy__/action-button-group";
|
||||
import type { ButtonAction } from "@/components/__legacy__/types";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/__legacy__/ui/card";
|
||||
import { IconCross } from "@/components/__legacy__/ui/icons";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import LoadingBox from "@/components/__legacy__/ui/loading";
|
||||
import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
|
||||
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
|
||||
import { formatScheduleTime } from "@/lib/timezone-utils";
|
||||
import { useUserTimezone } from "@/lib/hooks/useUserTimezone";
|
||||
import { PlayIcon } from "lucide-react";
|
||||
|
||||
import { AgentRunStatus } from "./agent-run-status-chip";
|
||||
|
||||
export function AgentScheduleDetailsView({
|
||||
graph,
|
||||
schedule,
|
||||
agentActions,
|
||||
onForcedRun,
|
||||
doDeleteSchedule,
|
||||
}: {
|
||||
graph: Graph;
|
||||
schedule: Schedule;
|
||||
agentActions: ButtonAction[];
|
||||
onForcedRun: (runID: GraphExecutionID) => void;
|
||||
doDeleteSchedule: (scheduleID: ScheduleID) => void;
|
||||
}): React.ReactNode {
|
||||
const api = useBackendAPI();
|
||||
|
||||
const selectedRunStatus: AgentRunStatus = "scheduled";
|
||||
|
||||
const toastOnFail = useToastOnFail();
|
||||
|
||||
// Get user's timezone for displaying schedule times
|
||||
const userTimezone = useUserTimezone();
|
||||
|
||||
const infoStats: { label: string; value: React.ReactNode }[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: "Status",
|
||||
value:
|
||||
selectedRunStatus.charAt(0).toUpperCase() +
|
||||
selectedRunStatus.slice(1),
|
||||
},
|
||||
{
|
||||
label: "Schedule",
|
||||
value: humanizeCronExpression(schedule.cron),
|
||||
},
|
||||
{
|
||||
label: "Next run",
|
||||
value: formatScheduleTime(schedule.next_run_time, userTimezone),
|
||||
},
|
||||
];
|
||||
}, [schedule, selectedRunStatus, userTimezone]);
|
||||
|
||||
const agentRunInputs: Record<
|
||||
string,
|
||||
{ title?: string; /* type: BlockIOSubType; */ value: any }
|
||||
> = useMemo(() => {
|
||||
// TODO: show (link to) preset - https://github.com/Significant-Gravitas/AutoGPT/issues/9168
|
||||
|
||||
// Add type info from agent input schema
|
||||
return Object.fromEntries(
|
||||
Object.entries(schedule.input_data).map(([k, v]) => [
|
||||
k,
|
||||
{
|
||||
title: graph.input_schema.properties[k].title,
|
||||
/* TODO: type: agent.input_schema.properties[k].type */
|
||||
value: v,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}, [graph, schedule]);
|
||||
|
||||
const runNow = useCallback(
|
||||
() =>
|
||||
api
|
||||
.executeGraph(
|
||||
graph.id,
|
||||
graph.version,
|
||||
schedule.input_data,
|
||||
schedule.input_credentials,
|
||||
"library",
|
||||
)
|
||||
.then((run) => onForcedRun(run.id))
|
||||
.catch(toastOnFail("execute agent")),
|
||||
[api, graph, schedule, onForcedRun, toastOnFail],
|
||||
);
|
||||
|
||||
const runActions: ButtonAction[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
<PlayIcon className="mr-2 size-4" />
|
||||
Run now
|
||||
</>
|
||||
),
|
||||
callback: runNow,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
<IconCross className="mr-2 size-4 px-0.5" />
|
||||
Delete schedule
|
||||
</>
|
||||
),
|
||||
callback: () => doDeleteSchedule(schedule.id),
|
||||
variant: "destructive",
|
||||
},
|
||||
],
|
||||
[runNow],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="agpt-div flex gap-6">
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<Card className="agpt-box">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-poppins text-lg">Info</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="flex justify-stretch gap-4">
|
||||
{infoStats.map(({ label, value }) => (
|
||||
<div key={label} className="flex-1">
|
||||
<p className="text-sm font-medium text-black">{label}</p>
|
||||
<p className="text-sm text-neutral-600">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="agpt-box">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-poppins text-lg">Input</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{agentRunInputs !== undefined ? (
|
||||
Object.entries(agentRunInputs).map(([key, { title, value }]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">{title || key}</label>
|
||||
<Input value={value} className="rounded-full" disabled />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<LoadingBox spinnerSize={12} className="h-24" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Run / Agent Actions */}
|
||||
<aside className="w-48 xl:w-56">
|
||||
<div className="flex flex-col gap-8">
|
||||
<ActionButtonGroup title="Run actions" actions={runActions} />
|
||||
|
||||
<ActionButtonGroup title="Agent actions" actions={agentActions} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/__legacy__/ui/dialog";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import { Textarea } from "@/components/__legacy__/ui/textarea";
|
||||
|
||||
interface CreatePresetDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (name: string, description: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function CreatePresetDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: CreatePresetDialogProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (name.trim()) {
|
||||
await onConfirm(name.trim(), description.trim());
|
||||
setName("");
|
||||
setDescription("");
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Preset</DialogTitle>
|
||||
<DialogDescription>
|
||||
Give your preset a name and description to help identify it later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="preset-name" className="text-sm font-medium">
|
||||
Name *
|
||||
</label>
|
||||
<Input
|
||||
id="preset-name"
|
||||
placeholder="Enter preset name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="preset-description" className="text-sm font-medium">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
id="preset-description"
|
||||
placeholder="Optional description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||
Create Preset
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { CronScheduler } from "@/components/contextual/CronScheduler/cron-scheduler";
|
||||
import { CronScheduler } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/cron-scheduler";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { getTimezoneDisplayName } from "@/lib/timezone-utils";
|
||||
import { useUserTimezone } from "@/lib/hooks/useUserTimezone";
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
GraphExecutionMeta as LegacyGraphExecutionMeta,
|
||||
GraphID,
|
||||
GraphExecutionID,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import {
|
||||
getPaginatedTotalCount,
|
||||
getPaginationNextPageNumber,
|
||||
unpaginate,
|
||||
} from "@/app/api/helpers";
|
||||
import {
|
||||
getV1ListGraphExecutionsResponse,
|
||||
getV1ListGraphExecutionsResponse200,
|
||||
useGetV1ListGraphExecutionsInfinite,
|
||||
} from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { GraphExecutionsPaginated } from "@/app/api/__generated__/models/graphExecutionsPaginated";
|
||||
import { GraphExecutionMeta as RawGraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
|
||||
|
||||
export type GraphExecutionMeta = Omit<
|
||||
RawGraphExecutionMeta,
|
||||
"id" | "user_id" | "graph_id" | "preset_id" | "stats"
|
||||
> &
|
||||
Pick<
|
||||
LegacyGraphExecutionMeta,
|
||||
"id" | "user_id" | "graph_id" | "preset_id" | "stats"
|
||||
>;
|
||||
|
||||
/** Hook to fetch runs for a specific graph, with support for infinite scroll.
|
||||
*
|
||||
* @param graphID - The ID of the graph to fetch agent runs for. This parameter is
|
||||
* optional in the sense that the hook doesn't run unless it is passed.
|
||||
* This way, it can be used in components where the graph ID is not
|
||||
* immediately available.
|
||||
*/
|
||||
export const useAgentRunsInfinite = (graphID?: GraphID) => {
|
||||
const queryClient = getQueryClient();
|
||||
const {
|
||||
data: queryResults,
|
||||
refetch: refetchRuns,
|
||||
isPending: agentRunsLoading,
|
||||
isRefetching: agentRunsReloading,
|
||||
hasNextPage: hasMoreRuns,
|
||||
fetchNextPage: fetchMoreRuns,
|
||||
isFetchingNextPage: isFetchingMoreRuns,
|
||||
queryKey,
|
||||
} = useGetV1ListGraphExecutionsInfinite(
|
||||
graphID!,
|
||||
{ page: 1, page_size: 20 },
|
||||
{
|
||||
query: {
|
||||
getNextPageParam: getPaginationNextPageNumber,
|
||||
|
||||
// Prevent query from running if graphID is not available (yet)
|
||||
...(!graphID
|
||||
? {
|
||||
enabled: false,
|
||||
queryFn: () =>
|
||||
// Fake empty response if graphID is not available (yet)
|
||||
Promise.resolve({
|
||||
status: 200,
|
||||
data: {
|
||||
executions: [],
|
||||
pagination: {
|
||||
current_page: 1,
|
||||
page_size: 20,
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
},
|
||||
},
|
||||
headers: new Headers(),
|
||||
} satisfies getV1ListGraphExecutionsResponse),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const agentRuns = queryResults ? unpaginate(queryResults, "executions") : [];
|
||||
const agentRunCount = getPaginatedTotalCount(queryResults);
|
||||
|
||||
const upsertAgentRun = (newAgentRun: GraphExecutionMeta) => {
|
||||
queryClient.setQueryData(
|
||||
queryKey,
|
||||
(currentQueryData: typeof queryResults) => {
|
||||
if (!currentQueryData?.pages || agentRunCount === undefined)
|
||||
return currentQueryData;
|
||||
|
||||
const exists = currentQueryData.pages.some((page) => {
|
||||
if (page.status !== 200) return false;
|
||||
|
||||
const response = page.data;
|
||||
return response.executions.some((run) => run.id === newAgentRun.id);
|
||||
});
|
||||
if (exists) {
|
||||
// If the run already exists, we update it
|
||||
return {
|
||||
...currentQueryData,
|
||||
pages: currentQueryData.pages.map((page) => {
|
||||
if (page.status !== 200) return page;
|
||||
const response = page.data;
|
||||
const executions = response.executions;
|
||||
|
||||
const index = executions.findIndex(
|
||||
(run) => run.id === newAgentRun.id,
|
||||
);
|
||||
if (index === -1) return page;
|
||||
|
||||
const newExecutions = [...executions];
|
||||
newExecutions[index] = newAgentRun;
|
||||
|
||||
return {
|
||||
...page,
|
||||
data: {
|
||||
...response,
|
||||
executions: newExecutions,
|
||||
},
|
||||
} satisfies getV1ListGraphExecutionsResponse;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// If the run does not exist, we add it to the first page
|
||||
const page = currentQueryData
|
||||
.pages[0] as getV1ListGraphExecutionsResponse200 & {
|
||||
headers: Headers;
|
||||
};
|
||||
const updatedExecutions = [newAgentRun, ...page.data.executions];
|
||||
const updatedPage = {
|
||||
...page,
|
||||
data: {
|
||||
...page.data,
|
||||
executions: updatedExecutions,
|
||||
},
|
||||
} satisfies getV1ListGraphExecutionsResponse;
|
||||
const updatedPages = [updatedPage, ...currentQueryData.pages.slice(1)];
|
||||
return {
|
||||
...currentQueryData,
|
||||
pages: updatedPages.map(
|
||||
// Increment the total runs count in the pagination info of all pages
|
||||
(page) =>
|
||||
page.status === 200
|
||||
? {
|
||||
...page,
|
||||
data: {
|
||||
...page.data,
|
||||
pagination: {
|
||||
...page.data.pagination,
|
||||
total_items: agentRunCount + 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
: page,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const removeAgentRun = (runID: GraphExecutionID) => {
|
||||
queryClient.setQueryData(
|
||||
[queryKey, { page: 1, page_size: 20 }],
|
||||
(currentQueryData: typeof queryResults) => {
|
||||
if (!currentQueryData?.pages) return currentQueryData;
|
||||
|
||||
let found = false;
|
||||
return {
|
||||
...currentQueryData,
|
||||
pages: currentQueryData.pages.map((page) => {
|
||||
const response = page.data as GraphExecutionsPaginated;
|
||||
const filteredExecutions = response.executions.filter(
|
||||
(run) => run.id !== runID,
|
||||
);
|
||||
if (filteredExecutions.length < response.executions.length) {
|
||||
found = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...page,
|
||||
data: {
|
||||
...response,
|
||||
executions: filteredExecutions,
|
||||
pagination: {
|
||||
...response.pagination,
|
||||
total_items:
|
||||
response.pagination.total_items - (found ? 1 : 0),
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
agentRuns: agentRuns as GraphExecutionMeta[],
|
||||
refetchRuns,
|
||||
agentRunCount,
|
||||
agentRunsLoading: agentRunsLoading || agentRunsReloading,
|
||||
hasMoreRuns,
|
||||
fetchMoreRuns,
|
||||
isFetchingMoreRuns,
|
||||
upsertAgentRun,
|
||||
removeAgentRun,
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentRunsQuery = ReturnType<typeof useAgentRunsInfinite>;
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { OldAgentLibraryView } from "../../agents/[id]/components/OldAgentLibraryView/OldAgentLibraryView";
|
||||
|
||||
export default function OldAgentLibraryPage() {
|
||||
return <OldAgentLibraryView />;
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { GraphExecutionMeta, LibraryAgent } from "@/lib/autogpt-server-api";
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/__legacy__/ui/card";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { TextRenderer } from "@/components/__legacy__/ui/render";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "@/components/__legacy__/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/__legacy__/ui/dropdown-menu";
|
||||
import { ChevronDownIcon, EnterIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/__legacy__/ui/table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { DialogTitle } from "@/components/__legacy__/ui/dialog";
|
||||
import { AgentImportForm } from "./AgentImportForm";
|
||||
|
||||
export const AgentFlowList = ({
|
||||
flows,
|
||||
executions,
|
||||
selectedFlow,
|
||||
onSelectFlow,
|
||||
className,
|
||||
}: {
|
||||
flows: LibraryAgent[];
|
||||
executions?: GraphExecutionMeta[];
|
||||
selectedFlow: LibraryAgent | null;
|
||||
onSelectFlow: (f: LibraryAgent) => void;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="flex-row items-center justify-between space-x-3 space-y-0">
|
||||
<CardTitle>Agents</CardTitle>
|
||||
|
||||
<div className="flex items-center">
|
||||
{/* Split "Create" button */}
|
||||
<Button variant="outline" className="rounded-r-none">
|
||||
<Link href="/build">Create</Link>
|
||||
</Button>
|
||||
<Dialog>
|
||||
{/* https://ui.shadcn.com/docs/components/dialog#notes */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={"rounded-l-none border-l-0 px-2"}
|
||||
data-testid="create-agent-dropdown"
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem data-testid="import-agent-from-file">
|
||||
<EnterIcon className="mr-2" /> Import from file
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="sr-only">Import Agent</DialogTitle>
|
||||
<h2 className="text-lg font-semibold">
|
||||
Import an Agent from a file
|
||||
</h2>
|
||||
</DialogHeader>
|
||||
<AgentImportForm />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
{/* <TableHead>Status</TableHead> */}
|
||||
{/* <TableHead>Last updated</TableHead> */}
|
||||
{executions && (
|
||||
<TableHead className="md:hidden lg:table-cell">
|
||||
# of runs
|
||||
</TableHead>
|
||||
)}
|
||||
{executions && <TableHead>Last run</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody data-testid="agent-flow-list-body">
|
||||
{flows
|
||||
.map((flow) => {
|
||||
let runCount = 0,
|
||||
lastRun: GraphExecutionMeta | null = null;
|
||||
if (executions) {
|
||||
const _flowRuns = executions.filter(
|
||||
(r) => r.graph_id == flow.graph_id,
|
||||
);
|
||||
runCount = _flowRuns.length;
|
||||
lastRun =
|
||||
runCount == 0
|
||||
? null
|
||||
: _flowRuns.reduce((a, c) => {
|
||||
const aTime = a.started_at?.getTime() ?? 0;
|
||||
const cTime = c.started_at?.getTime() ?? 0;
|
||||
return aTime > cTime ? a : c;
|
||||
});
|
||||
}
|
||||
return { flow, runCount, lastRun };
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (!a.lastRun && !b.lastRun) return 0;
|
||||
if (!a.lastRun) return 1;
|
||||
if (!b.lastRun) return -1;
|
||||
const bTime = b.lastRun.started_at?.getTime() ?? 0;
|
||||
const aTime = a.lastRun.started_at?.getTime() ?? 0;
|
||||
return bTime - aTime;
|
||||
})
|
||||
.map(({ flow, runCount, lastRun }) => (
|
||||
<TableRow
|
||||
key={flow.id}
|
||||
data-testid={flow.id}
|
||||
data-name={flow.name}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSelectFlow(flow)}
|
||||
data-state={selectedFlow?.id == flow.id ? "selected" : null}
|
||||
>
|
||||
<TableCell>
|
||||
<TextRenderer value={flow.name} truncateLengthLimit={30} />
|
||||
</TableCell>
|
||||
{/* <TableCell><FlowStatusBadge status={flow.status ?? "active"} /></TableCell> */}
|
||||
{/* <TableCell>
|
||||
{flow.updatedAt ?? "???"}
|
||||
</TableCell> */}
|
||||
{executions && (
|
||||
<TableCell className="md:hidden lg:table-cell">
|
||||
{runCount}
|
||||
</TableCell>
|
||||
)}
|
||||
{executions &&
|
||||
(!lastRun ? (
|
||||
<TableCell />
|
||||
) : (
|
||||
<TableCell title={lastRun.started_at?.toString() ?? ""}>
|
||||
{lastRun.started_at
|
||||
? formatDistanceToNow(lastRun.started_at, {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "—"}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
export default AgentFlowList;
|
||||
@@ -1,175 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/__legacy__/ui/form";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { Textarea } from "@/components/__legacy__/ui/textarea";
|
||||
import { EnterIcon } from "@radix-ui/react-icons";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import {
|
||||
Graph,
|
||||
GraphCreatable,
|
||||
sanitizeImportedGraph,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
|
||||
// Add this custom schema for File type
|
||||
const fileSchema = z.custom<File>((val) => val instanceof File, {
|
||||
message: "Must be a File object",
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
agentFile: fileSchema,
|
||||
agentName: z.string().min(1, "Agent name is required"),
|
||||
agentDescription: z.string(),
|
||||
importAsTemplate: z.boolean(),
|
||||
});
|
||||
|
||||
export const AgentImportForm: React.FC<
|
||||
React.FormHTMLAttributes<HTMLFormElement>
|
||||
> = ({ className, ...props }) => {
|
||||
const [agentObject, setAgentObject] = useState<GraphCreatable | null>(null);
|
||||
const api = useBackendAPI();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
agentName: "",
|
||||
agentDescription: "",
|
||||
importAsTemplate: false,
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
if (!agentObject) {
|
||||
form.setError("root", { message: "No Agent object to save" });
|
||||
return;
|
||||
}
|
||||
const payload: GraphCreatable = {
|
||||
...agentObject,
|
||||
name: values.agentName,
|
||||
description: values.agentDescription,
|
||||
is_active: !values.importAsTemplate,
|
||||
};
|
||||
|
||||
api
|
||||
.createGraph(payload, "upload")
|
||||
.then((response) => {
|
||||
const qID = "flowID";
|
||||
window.location.href = `/build?${qID}=${response.id}`;
|
||||
})
|
||||
.catch((error) => {
|
||||
const entity_type = "agent";
|
||||
form.setError("root", {
|
||||
message: `Could not create ${entity_type}: ${error}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn("space-y-4", className)}
|
||||
{...props}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="agentFile"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Agent file</FormLabel>
|
||||
<FormControl className="cursor-pointer">
|
||||
<Input
|
||||
type="file"
|
||||
accept="application/json"
|
||||
data-testid="import-agent-file-input"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
field.onChange(file);
|
||||
const reader = new FileReader();
|
||||
// Attach parser to file reader
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const obj = JSON.parse(
|
||||
event.target?.result as string,
|
||||
);
|
||||
if (
|
||||
!["name", "description", "nodes", "links"].every(
|
||||
(key) => key in obj && obj[key] != null,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Invalid agent object in file: " +
|
||||
JSON.stringify(obj, null, 2),
|
||||
);
|
||||
}
|
||||
const graph = obj as Graph;
|
||||
sanitizeImportedGraph(graph);
|
||||
setAgentObject(graph);
|
||||
form.setValue("agentName", graph.name);
|
||||
form.setValue("agentDescription", graph.description);
|
||||
} catch (error) {
|
||||
console.error("Error loading agent file:", error);
|
||||
}
|
||||
};
|
||||
// Load file
|
||||
reader.readAsText(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="agentName"
|
||||
disabled={!agentObject}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Agent name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} data-testid="agent-name-input" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="agentDescription"
|
||||
disabled={!agentObject}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Agent description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} data-testid="agent-description-input" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!agentObject}
|
||||
data-testid="import-agent-submit"
|
||||
>
|
||||
<EnterIcon className="mr-2" /> Import & Edit
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,243 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Graph,
|
||||
GraphExecutionMeta,
|
||||
LibraryAgent,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/__legacy__/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/__legacy__/ui/dropdown-menu";
|
||||
import { Button, buttonVariants } from "@/components/__legacy__/ui/button";
|
||||
import {
|
||||
ClockIcon,
|
||||
ExitIcon,
|
||||
Pencil2Icon,
|
||||
PlayIcon,
|
||||
TrashIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
import { exportAsJSONFile } from "@/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/__legacy__/ui/dialog";
|
||||
import useAgentGraph from "@/hooks/useAgentGraph";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { FlowRunsStatus } from "./FlowRunsStatus";
|
||||
import { RunnerInputDialog } from "../../build/components/legacy-builder/RunnerInputUI";
|
||||
|
||||
export const FlowInfo: React.FC<
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
flow: LibraryAgent;
|
||||
executions: GraphExecutionMeta[];
|
||||
flowVersion?: number | "all";
|
||||
refresh: () => void;
|
||||
}
|
||||
> = ({ flow, executions, flowVersion, refresh, ...props }) => {
|
||||
const { savedAgent, saveAndRun, stopRun, isRunning } = useAgentGraph(
|
||||
flow.graph_id,
|
||||
flow.graph_version,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
const api = useBackendAPI();
|
||||
|
||||
const [flowVersions, setFlowVersions] = useState<Graph[] | null>(null);
|
||||
const [selectedVersion, setSelectedFlowVersion] = useState(
|
||||
flowVersion ?? "all",
|
||||
);
|
||||
const selectedFlowVersion: Graph | undefined = flowVersions?.find(
|
||||
(v) =>
|
||||
v.version ==
|
||||
(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 [isRunDialogOpen, setIsRunDialogOpen] = useState(false);
|
||||
const isDisabled = !selectedFlowVersion;
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getGraphAllVersions(flow.graph_id)
|
||||
.then((result) => setFlowVersions(result));
|
||||
}, [flow.graph_id, api]);
|
||||
|
||||
const openRunDialog = () => setIsRunDialogOpen(true);
|
||||
|
||||
const runOrOpenInput = () => {
|
||||
if (hasInputs || hasCredentialsInputs) {
|
||||
openRunDialog();
|
||||
} else {
|
||||
saveAndRun({}, {});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card {...props}>
|
||||
<CardHeader className="">
|
||||
<CardTitle>
|
||||
{flow.name} <span className="font-light">v{flow.graph_version}</span>
|
||||
</CardTitle>
|
||||
<div className="flex flex-col space-y-2 py-6">
|
||||
{(flowVersions?.length ?? 0) > 1 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<ClockIcon className="mr-2" />
|
||||
{selectedVersion == "all"
|
||||
? "All versions"
|
||||
: `Version ${selectedVersion}`}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuLabel>Choose a version</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={String(selectedVersion)}
|
||||
onValueChange={(choice: string) =>
|
||||
setSelectedFlowVersion(
|
||||
choice == "all" ? choice : Number(choice),
|
||||
)
|
||||
}
|
||||
>
|
||||
<DropdownMenuRadioItem value="all">
|
||||
All versions
|
||||
</DropdownMenuRadioItem>
|
||||
{flowVersions?.map((v) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={v.version}
|
||||
value={v.version.toString()}
|
||||
>
|
||||
Version {v.version}
|
||||
{v.is_active ? " (active)" : ""}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{flow.can_access_graph && (
|
||||
<Link
|
||||
className={buttonVariants({ variant: "default" })}
|
||||
href={`/build?flowID=${flow.graph_id}&flowVersion=${flow.graph_version}`}
|
||||
>
|
||||
<Pencil2Icon className="mr-2" />
|
||||
Open in Builder
|
||||
</Link>
|
||||
)}
|
||||
{flow.can_access_graph && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-2.5"
|
||||
title="Export to a JSON-file"
|
||||
data-testid="export-button"
|
||||
onClick={() =>
|
||||
api
|
||||
.getGraph(flow.graph_id, selectedFlowVersion!.version, true)
|
||||
.then((graph) =>
|
||||
exportAsJSONFile(
|
||||
graph,
|
||||
`${flow.name}_v${selectedFlowVersion!.version}.json`,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ExitIcon className="mr-2" /> Export
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="bg-purple-500 text-white hover:bg-purple-700"
|
||||
onClick={!isRunning ? runOrOpenInput : stopRun}
|
||||
disabled={isDisabled}
|
||||
title={!isRunning ? "Run Agent" : "Stop Agent"}
|
||||
>
|
||||
<PlayIcon className="mr-2" />
|
||||
{isRunning ? "Stop Agent" : "Run Agent"}
|
||||
</Button>
|
||||
{flow.can_access_graph && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
data-testid="delete-button"
|
||||
>
|
||||
<TrashIcon className="mr-2" />
|
||||
Delete Agent
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FlowRunsStatus
|
||||
flows={[flow]}
|
||||
executions={executions.filter(
|
||||
(execution) =>
|
||||
execution.graph_id == flow.graph_id &&
|
||||
(selectedVersion == "all" ||
|
||||
execution.graph_version == selectedVersion),
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Agent</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this agent? <br />
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsDeleteModalOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
api.deleteLibraryAgent(flow.id).then(() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
refresh();
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{savedAgent && (
|
||||
<RunnerInputDialog
|
||||
isOpen={isRunDialogOpen}
|
||||
doClose={() => setIsRunDialogOpen(false)}
|
||||
graph={savedAgent}
|
||||
doRun={saveAndRun}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
export default FlowInfo;
|
||||
@@ -1,142 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { GraphExecutionMeta, LibraryAgent } from "@/lib/autogpt-server-api";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/__legacy__/ui/card";
|
||||
import Link from "next/link";
|
||||
import { Button, buttonVariants } from "@/components/__legacy__/ui/button";
|
||||
import { IconSquare } from "@/components/__legacy__/ui/icons";
|
||||
import { ExitIcon, Pencil2Icon } from "@radix-ui/react-icons";
|
||||
import { format } from "date-fns";
|
||||
import { FlowRunStatusBadge } from "@/app/(platform)/monitoring/components/FlowRunStatusBadge";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import RunnerOutputUI, {
|
||||
OutputNodeInfo,
|
||||
} from "../../build/components/legacy-builder/RunnerOutputUI";
|
||||
|
||||
export const FlowRunInfo: React.FC<
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
agent: LibraryAgent;
|
||||
execution: GraphExecutionMeta;
|
||||
}
|
||||
> = ({ agent, execution, ...props }) => {
|
||||
const [isOutputOpen, setIsOutputOpen] = useState(false);
|
||||
const [blockOutputs, setBlockOutputs] = useState<OutputNodeInfo[]>([]);
|
||||
const api = useBackendAPI();
|
||||
|
||||
const fetchBlockResults = useCallback(async () => {
|
||||
const graph = await api.getGraph(agent.graph_id, agent.graph_version);
|
||||
const graphExecution = await api.getGraphExecutionInfo(
|
||||
agent.graph_id,
|
||||
execution.id,
|
||||
);
|
||||
|
||||
// Transform results to BlockOutput format
|
||||
setBlockOutputs(
|
||||
Object.entries(graphExecution.outputs).flatMap(([key, values]) =>
|
||||
values.map(
|
||||
(value) =>
|
||||
({
|
||||
metadata: {
|
||||
name: graph.output_schema.properties[key].title || "Output",
|
||||
description:
|
||||
graph.output_schema.properties[key].description ||
|
||||
"Output from the agent",
|
||||
},
|
||||
result: value,
|
||||
}) satisfies OutputNodeInfo,
|
||||
),
|
||||
),
|
||||
);
|
||||
}, [api, agent.graph_id, agent.graph_version, execution.id]);
|
||||
|
||||
// Fetch graph and execution data
|
||||
useEffect(() => {
|
||||
if (!isOutputOpen) return;
|
||||
fetchBlockResults();
|
||||
}, [isOutputOpen, fetchBlockResults]);
|
||||
|
||||
if (execution.graph_id != agent.graph_id) {
|
||||
throw new Error(
|
||||
`FlowRunInfo can't be used with non-matching execution.graph_id and flow.id`,
|
||||
);
|
||||
}
|
||||
|
||||
const handleStopRun = useCallback(() => {
|
||||
api.stopGraphExecution(agent.graph_id, execution.id);
|
||||
}, [api, agent.graph_id, execution.id]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card {...props}>
|
||||
<CardHeader className="flex-row items-center justify-between space-x-3 space-y-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{agent.name}{" "}
|
||||
<span className="font-light">v{execution.graph_version}</span>
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
{execution.status === "RUNNING" && (
|
||||
<Button onClick={handleStopRun} variant="destructive">
|
||||
<IconSquare className="mr-2" /> Stop Run
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setIsOutputOpen(true)} variant="outline">
|
||||
<ExitIcon className="mr-2" /> View Outputs
|
||||
</Button>
|
||||
{agent.can_access_graph && (
|
||||
<Link
|
||||
className={buttonVariants({ variant: "default" })}
|
||||
href={`/build?flowID=${execution.graph_id}&flowVersion=${execution.graph_version}&flowExecutionID=${execution.id}`}
|
||||
>
|
||||
<Pencil2Icon className="mr-2" /> Open in Builder
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="hidden">
|
||||
<strong>Agent ID:</strong> <code>{agent.graph_id}</code>
|
||||
</p>
|
||||
<p className="hidden">
|
||||
<strong>Run ID:</strong> <code>{execution.id}</code>
|
||||
</p>
|
||||
<div>
|
||||
<strong>Status:</strong>{" "}
|
||||
<FlowRunStatusBadge status={execution.status} />
|
||||
</div>
|
||||
<p>
|
||||
<strong>Started:</strong>{" "}
|
||||
{execution.started_at
|
||||
? format(execution.started_at, "yyyy-MM-dd HH:mm:ss")
|
||||
: "—"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Finished:</strong>{" "}
|
||||
{execution.ended_at
|
||||
? format(execution.ended_at, "yyyy-MM-dd HH:mm:ss")
|
||||
: "—"}
|
||||
</p>
|
||||
{execution.stats && (
|
||||
<p>
|
||||
<strong>Duration (run time):</strong>{" "}
|
||||
{execution.stats.duration.toFixed(1)} (
|
||||
{execution.stats.node_exec_time.toFixed(1)}) seconds
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<RunnerOutputUI
|
||||
isOpen={isOutputOpen}
|
||||
doClose={() => setIsOutputOpen(false)}
|
||||
outputs={blockOutputs}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlowRunInfo;
|
||||
@@ -1,25 +0,0 @@
|
||||
import React from "react";
|
||||
import { Badge } from "@/components/__legacy__/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { GraphExecutionMeta } from "@/lib/autogpt-server-api";
|
||||
|
||||
export const FlowRunStatusBadge: React.FC<{
|
||||
status: GraphExecutionMeta["status"];
|
||||
className?: string;
|
||||
}> = ({ status, className }) => (
|
||||
<Badge
|
||||
variant="default"
|
||||
className={cn(
|
||||
status === "RUNNING"
|
||||
? "bg-blue-500 dark:bg-blue-700"
|
||||
: status === "QUEUED"
|
||||
? "bg-yellow-500 dark:bg-yellow-600"
|
||||
: status === "COMPLETED"
|
||||
? "bg-green-500 dark:bg-green-600"
|
||||
: "bg-red-500 dark:bg-red-700",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
@@ -1,92 +0,0 @@
|
||||
import React from "react";
|
||||
import { GraphExecutionMeta, LibraryAgent } from "@/lib/autogpt-server-api";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/__legacy__/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/__legacy__/ui/table";
|
||||
import { format } from "date-fns";
|
||||
import { FlowRunStatusBadge } from "@/app/(platform)/monitoring/components/FlowRunStatusBadge";
|
||||
import { TextRenderer } from "../../../../components/__legacy__/ui/render";
|
||||
|
||||
export const FlowRunsList: React.FC<{
|
||||
flows: LibraryAgent[];
|
||||
executions: GraphExecutionMeta[];
|
||||
className?: string;
|
||||
selectedRun?: GraphExecutionMeta | null;
|
||||
onSelectRun: (r: GraphExecutionMeta) => void;
|
||||
}> = ({ flows, executions, selectedRun, onSelectRun, className }) => (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Runs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Agent</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody data-testid="flow-runs-list-body">
|
||||
{executions.map((execution) => (
|
||||
<TableRow
|
||||
key={execution.id}
|
||||
data-testid={`flow-run-${execution.id}-graph-${execution.graph_id}`}
|
||||
data-runid={execution.id}
|
||||
data-graphid={execution.graph_id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSelectRun(execution)}
|
||||
data-state={selectedRun?.id == execution.id ? "selected" : null}
|
||||
>
|
||||
<TableCell>
|
||||
<TextRenderer
|
||||
value={
|
||||
flows.find((f) => f.graph_id == execution.graph_id)?.name
|
||||
}
|
||||
truncateLengthLimit={30}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{execution.started_at
|
||||
? format(execution.started_at, "HH:mm")
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FlowRunStatusBadge
|
||||
status={execution.status}
|
||||
className="w-full justify-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{execution.stats
|
||||
? formatDuration(execution.stats.duration)
|
||||
: ""}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
return (
|
||||
(seconds < 100 ? seconds.toPrecision(2) : Math.round(seconds)).toString() +
|
||||
"s"
|
||||
);
|
||||
}
|
||||
|
||||
export default FlowRunsList;
|
||||
@@ -1,131 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { GraphExecutionMeta, LibraryAgent } from "@/lib/autogpt-server-api";
|
||||
import { CardTitle } from "@/components/__legacy__/ui/card";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import { Calendar } from "@/components/__legacy__/ui/calendar";
|
||||
import { FlowRunsTimeline } from "@/app/(platform)/monitoring/components/FlowRunsTimeline";
|
||||
|
||||
export const FlowRunsStatus: React.FC<{
|
||||
flows: LibraryAgent[];
|
||||
executions: GraphExecutionMeta[];
|
||||
title?: string;
|
||||
className?: string;
|
||||
}> = ({ flows, executions: executions, title, className }) => {
|
||||
/* "dateMin": since the first flow in the dataset
|
||||
* number > 0: custom date (unix timestamp)
|
||||
* number < 0: offset relative to Date.now() (in seconds) */
|
||||
const [selected, setSelected] = useState<Date>();
|
||||
const [statsSince, setStatsSince] = useState<number | "dataMin">(-24 * 3600);
|
||||
const statsSinceTimestamp = // unix timestamp or null
|
||||
typeof statsSince == "string"
|
||||
? null
|
||||
: statsSince < 0
|
||||
? Date.now() + statsSince * 1000
|
||||
: statsSince;
|
||||
const filteredFlowRuns =
|
||||
statsSinceTimestamp != null
|
||||
? executions.filter(
|
||||
(fr) =>
|
||||
fr.started_at && fr.started_at.getTime() > statsSinceTimestamp,
|
||||
)
|
||||
: executions;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<CardTitle>{title || "Stats"}</CardTitle>
|
||||
<div className="flex flex-wrap space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStatsSince(-2 * 3600)}
|
||||
>
|
||||
2h
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStatsSince(-8 * 3600)}
|
||||
>
|
||||
8h
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStatsSince(-24 * 3600)}
|
||||
>
|
||||
24h
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStatsSince(-7 * 24 * 3600)}
|
||||
>
|
||||
7d
|
||||
</Button>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={"outline"} size="sm">
|
||||
Custom
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={(_, selectedDay) => {
|
||||
setSelected(selectedDay);
|
||||
setStatsSince(selectedDay.getTime());
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStatsSince("dataMin")}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<FlowRunsTimeline
|
||||
flows={flows}
|
||||
executions={executions}
|
||||
dataMin={statsSince}
|
||||
className="mt-3"
|
||||
/>
|
||||
<hr className="my-4" />
|
||||
<div>
|
||||
<p>
|
||||
<strong>Total runs:</strong> {filteredFlowRuns.length}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Total run time:</strong>{" "}
|
||||
{filteredFlowRuns.reduce(
|
||||
(total, run) => total + (run.stats?.node_exec_time ?? 0),
|
||||
0,
|
||||
)}{" "}
|
||||
seconds
|
||||
</p>
|
||||
{filteredFlowRuns.some((r) => r.stats) && (
|
||||
<p>
|
||||
<strong>Total cost:</strong> $
|
||||
{(
|
||||
filteredFlowRuns.reduce(
|
||||
(total, run) => total + (run.stats?.cost ?? 0),
|
||||
0,
|
||||
) / 100
|
||||
).toFixed(2)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default FlowRunsStatus;
|
||||
@@ -1,189 +0,0 @@
|
||||
import { GraphExecutionMeta, LibraryAgent } from "@/lib/autogpt-server-api";
|
||||
import {
|
||||
ComposedChart,
|
||||
DefaultLegendContentProps,
|
||||
Legend,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Scatter,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { differenceInHours, format } from "date-fns";
|
||||
import { Card } from "@/components/__legacy__/ui/card";
|
||||
import { cn, hashString } from "@/lib/utils";
|
||||
import React from "react";
|
||||
import { FlowRunStatusBadge } from "@/app/(platform)/monitoring/components/FlowRunStatusBadge";
|
||||
|
||||
export const FlowRunsTimeline = ({
|
||||
flows,
|
||||
executions,
|
||||
dataMin,
|
||||
className,
|
||||
}: {
|
||||
flows: LibraryAgent[];
|
||||
executions: GraphExecutionMeta[];
|
||||
dataMin: "dataMin" | number;
|
||||
className?: string;
|
||||
}) => (
|
||||
/* TODO: make logarithmic? */
|
||||
<ResponsiveContainer width="100%" height={120} className={className}>
|
||||
<ComposedChart>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
type="number"
|
||||
domain={[
|
||||
typeof dataMin == "string"
|
||||
? dataMin
|
||||
: dataMin < 0
|
||||
? Date.now() + dataMin * 1000
|
||||
: dataMin,
|
||||
Date.now(),
|
||||
]}
|
||||
allowDataOverflow={true}
|
||||
tickFormatter={(unixTime) => {
|
||||
const now = new Date();
|
||||
const time = new Date(unixTime);
|
||||
return differenceInHours(now, time) < 24
|
||||
? format(time, "HH:mm")
|
||||
: format(time, "yyyy-MM-dd HH:mm");
|
||||
}}
|
||||
name="Time"
|
||||
scale="time"
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="_duration"
|
||||
name="Duration (s)"
|
||||
tickFormatter={(s) => (s > 90 ? `${Math.round(s / 60)}m` : `${s}s`)}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ payload }) => {
|
||||
if (payload && payload.length) {
|
||||
const data: GraphExecutionMeta & {
|
||||
time: number;
|
||||
_duration: number;
|
||||
} = payload[0].payload;
|
||||
const flow = flows.find((f) => f.graph_id === data.graph_id);
|
||||
return (
|
||||
<Card className="p-2 text-xs leading-normal">
|
||||
<p>
|
||||
<strong>Agent:</strong> {flow ? flow.name : "Unknown"}
|
||||
</p>
|
||||
<div>
|
||||
<strong>Status:</strong>
|
||||
<FlowRunStatusBadge
|
||||
status={data.status}
|
||||
className="px-1.5 py-0"
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
<strong>Started:</strong>{" "}
|
||||
{data.started_at
|
||||
? format(data.started_at, "yyyy-MM-dd HH:mm:ss")
|
||||
: "—"}
|
||||
</p>
|
||||
{data.stats && (
|
||||
<p>
|
||||
<strong>Duration / run time:</strong>{" "}
|
||||
{formatDuration(data.stats.duration)} /{" "}
|
||||
{formatDuration(data.stats.node_exec_time)}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
{flows.map((flow) => (
|
||||
<Scatter
|
||||
key={flow.id}
|
||||
data={executions
|
||||
.filter((e) => e.graph_id == flow.graph_id && e.started_at)
|
||||
.map((e) => ({
|
||||
...e,
|
||||
time:
|
||||
(e.started_at?.getTime() ?? 0) +
|
||||
(e.stats?.node_exec_time ?? 0) * 1000,
|
||||
_duration: e.stats?.node_exec_time ?? 0,
|
||||
}))}
|
||||
name={flow.name}
|
||||
fill={`hsl(${(hashString(flow.id) * 137.5) % 360}, 70%, 50%)`}
|
||||
/>
|
||||
))}
|
||||
{executions
|
||||
.filter((e) => e.started_at && e.ended_at)
|
||||
.map((execution) => (
|
||||
<Line
|
||||
key={execution.id}
|
||||
type="linear"
|
||||
dataKey="_duration"
|
||||
data={[
|
||||
{
|
||||
...execution,
|
||||
time: execution.started_at!.getTime(),
|
||||
_duration: 0,
|
||||
},
|
||||
{
|
||||
...execution,
|
||||
time: execution.ended_at!.getTime(),
|
||||
_duration: execution.stats?.node_exec_time ?? 0,
|
||||
},
|
||||
]}
|
||||
stroke={`hsl(${(hashString(execution.graph_id) * 137.5) % 360}, 70%, 50%)`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
legendType="none"
|
||||
/>
|
||||
))}
|
||||
<Legend
|
||||
content={<ScrollableLegend />}
|
||||
wrapperStyle={{
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
export default FlowRunsTimeline;
|
||||
|
||||
const ScrollableLegend: React.FC<
|
||||
DefaultLegendContentProps & { className?: string }
|
||||
> = ({ payload, className }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"space-x-3 overflow-x-auto whitespace-nowrap px-4 text-sm",
|
||||
className,
|
||||
)}
|
||||
style={{ scrollbarWidth: "none" }}
|
||||
>
|
||||
{payload?.map((entry, index) => {
|
||||
if (entry.type == "none") return;
|
||||
return (
|
||||
<span key={`item-${index}`} className="inline-flex items-center">
|
||||
<span
|
||||
className="mr-1 inline-block size-2.5 rounded-full"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span>{entry.value}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
return (
|
||||
(seconds < 100 ? seconds.toPrecision(2) : Math.round(seconds)).toString() +
|
||||
"s"
|
||||
);
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
import { LibraryAgent } from "@/lib/autogpt-server-api";
|
||||
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { Card } from "@/components/__legacy__/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/__legacy__/ui/table";
|
||||
import { Badge } from "@/components/__legacy__/ui/badge";
|
||||
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
|
||||
import { ClockIcon, Loader2 } from "lucide-react";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
|
||||
import { useUserTimezone } from "@/lib/hooks/useUserTimezone";
|
||||
import {
|
||||
formatScheduleTime,
|
||||
getTimezoneAbbreviation,
|
||||
} from "@/lib/timezone-utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/__legacy__/ui/select";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/__legacy__/ui/dialog";
|
||||
import { TextRenderer } from "../../../../components/__legacy__/ui/render";
|
||||
import { Input } from "../../../../components/__legacy__/ui/input";
|
||||
import { Label } from "../../../../components/__legacy__/ui/label";
|
||||
|
||||
interface SchedulesTableProps {
|
||||
schedules: GraphExecutionJobInfo[];
|
||||
agents: LibraryAgent[];
|
||||
onRemoveSchedule: (scheduleId: string, enabled: boolean) => void;
|
||||
sortColumn: keyof GraphExecutionJobInfo;
|
||||
sortDirection: "asc" | "desc";
|
||||
onSort: (column: keyof GraphExecutionJobInfo) => void;
|
||||
}
|
||||
|
||||
export const SchedulesTable = ({
|
||||
schedules,
|
||||
agents,
|
||||
onRemoveSchedule,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
onSort,
|
||||
}: SchedulesTableProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const [selectedAgent, setSelectedAgent] = useState<string>(""); // Library Agent ID
|
||||
const [selectedVersion, setSelectedVersion] = useState<number>(0); // Graph version
|
||||
const [maxVersion, setMaxVersion] = useState<number>(0);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedFilter, setSelectedFilter] = useState<string>(""); // Graph ID
|
||||
|
||||
// Get user's timezone for displaying schedule times
|
||||
const userTimezone = useUserTimezone() ?? "UTC";
|
||||
|
||||
const filteredAndSortedSchedules = [...schedules]
|
||||
.filter(
|
||||
(schedule) => !selectedFilter || schedule.graph_id === selectedFilter,
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aValue = a[sortColumn];
|
||||
const bValue = b[sortColumn];
|
||||
if (sortDirection === "asc") {
|
||||
return String(aValue).localeCompare(String(bValue));
|
||||
}
|
||||
return String(bValue).localeCompare(String(aValue));
|
||||
});
|
||||
|
||||
const handleToggleSchedule = (scheduleId: string, enabled: boolean) => {
|
||||
onRemoveSchedule(scheduleId, enabled);
|
||||
if (!enabled) {
|
||||
toast({
|
||||
title: "Schedule Disabled",
|
||||
description: "The schedule has been successfully disabled.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewSchedule = () => {
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleAgentSelect = (agentId: string) => {
|
||||
setSelectedAgent(agentId);
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
setMaxVersion(agent!.graph_version);
|
||||
setSelectedVersion(agent!.graph_version);
|
||||
};
|
||||
|
||||
const handleVersionSelect = (version: string) => {
|
||||
setSelectedVersion(parseInt(version));
|
||||
};
|
||||
|
||||
const handleSchedule = async () => {
|
||||
if (!selectedAgent || !selectedVersion) {
|
||||
toast({
|
||||
title: "Invalid Input",
|
||||
description: "Please select an agent and a version.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (selectedVersion < 1 || selectedVersion > maxVersion) {
|
||||
toast({
|
||||
title: "Invalid Version",
|
||||
description: `Please select a version between 1 and ${maxVersion}.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
const agent = agents.find((a) => a.id == selectedAgent)!;
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
router.push(
|
||||
`/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}&open_scheduling=true`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Navigation error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="h-fit p-4">
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select Agent for New Schedule</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Select onValueChange={handleAgentSelect}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select an agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agents.map((agent, i) => (
|
||||
<SelectItem key={agent.id + i} value={agent.id}>
|
||||
<TextRenderer value={agent.name} truncateLengthLimit={30} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label className="mt-4">
|
||||
Select version between 1 and {maxVersion}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={selectedAgent ? maxVersion : 0}
|
||||
value={selectedVersion}
|
||||
onChange={(e) => handleVersionSelect(e.target.value)}
|
||||
placeholder="Select version"
|
||||
className="w-full"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSchedule}
|
||||
disabled={isLoading || !selectedAgent}
|
||||
className="mt-4"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
"Schedule"
|
||||
)}
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Schedules</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select onValueChange={setSelectedFilter}>
|
||||
<SelectTrigger className="h-8 w-[180px] rounded-md px-3 text-xs">
|
||||
<SelectValue placeholder="Filter by graph" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="text-xs">
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.graph_id}>
|
||||
{agent.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" onClick={handleNewSchedule}>
|
||||
<ClockIcon className="mr-2 h-4 w-4" />
|
||||
New Schedule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="max-h-[400px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead
|
||||
onClick={() => onSort("graph_id")}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Graph Name
|
||||
</TableHead>
|
||||
<TableHead className="cursor-pointer">Graph Version</TableHead>
|
||||
<TableHead
|
||||
onClick={() => onSort("next_run_time")}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Next Execution
|
||||
</TableHead>
|
||||
<TableHead
|
||||
onClick={() => onSort("cron")}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Schedule
|
||||
</TableHead>
|
||||
<TableHead>Timezone</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAndSortedSchedules.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="py-8 text-center text-lg text-gray-400"
|
||||
>
|
||||
No schedules are available
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredAndSortedSchedules.map((schedule) => (
|
||||
<TableRow key={schedule.id}>
|
||||
<TableCell className="font-medium">
|
||||
{agents.find((a) => a.graph_id === schedule.graph_id)
|
||||
?.name || schedule.graph_id}
|
||||
</TableCell>
|
||||
<TableCell>{schedule.graph_version}</TableCell>
|
||||
<TableCell>
|
||||
{formatScheduleTime(schedule.next_run_time, userTimezone)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{humanizeCronExpression(schedule.cron)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{schedule.timezone
|
||||
? getTimezoneAbbreviation(schedule.timezone)
|
||||
: userTimezone && getTimezoneAbbreviation(userTimezone)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={() => handleToggleSchedule(schedule.id, false)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
export default function AgentsFlowListSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Agents</h1>
|
||||
<div className="h-10 w-24 animate-pulse rounded bg-gray-200"></div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white p-4 shadow">
|
||||
<div className="mb-4 grid grid-cols-3 gap-4 font-medium text-gray-500">
|
||||
<div>Name</div>
|
||||
<div># of runs</div>
|
||||
<div>Last run</div>
|
||||
</div>
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div key={index} className="mb-4 grid grid-cols-3 gap-4">
|
||||
<div className="h-6 animate-pulse rounded bg-gray-200"></div>
|
||||
<div className="h-6 animate-pulse rounded bg-gray-200"></div>
|
||||
<div className="h-6 animate-pulse rounded bg-gray-200"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
export default function FlowRunsListSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl p-4">
|
||||
<div className="rounded-lg bg-white p-4 shadow">
|
||||
<h2 className="mb-4 text-xl font-semibold">Runs</h2>
|
||||
<div className="mb-4 grid grid-cols-4 gap-4 text-sm font-medium text-gray-500">
|
||||
<div>Agent</div>
|
||||
<div>Started</div>
|
||||
<div>Status</div>
|
||||
<div>Duration</div>
|
||||
</div>
|
||||
{[...Array(4)].map((_, index) => (
|
||||
<div key={index} className="mb-4 grid grid-cols-4 gap-4">
|
||||
<div className="h-5 animate-pulse rounded bg-gray-200"></div>
|
||||
<div className="h-5 animate-pulse rounded bg-gray-200"></div>
|
||||
<div className="h-5 animate-pulse rounded bg-gray-200"></div>
|
||||
<div className="h-5 animate-pulse rounded bg-gray-200"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export default function FlowRunsStatusSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl p-4">
|
||||
<div className="rounded-lg bg-white p-4 shadow">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Stats</h2>
|
||||
<div className="flex space-x-2">
|
||||
{["2h", "8h", "24h", "7d", "Custom", "All"].map((btn) => (
|
||||
<div
|
||||
key={btn}
|
||||
className="h-8 w-16 animate-pulse rounded bg-gray-200"
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder for the line chart */}
|
||||
<div className="mb-6 h-64 w-full animate-pulse rounded bg-gray-200"></div>
|
||||
|
||||
{/* Placeholders for total runs and total run time */}
|
||||
<div className="space-y-2">
|
||||
<div className="h-6 w-1/3 animate-pulse rounded bg-gray-200"></div>
|
||||
<div className="h-6 w-1/2 animate-pulse rounded bg-gray-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import AgentFlowListSkeleton from "@/app/(platform)/monitoring/components/skeletons/AgentFlowListSkeleton";
|
||||
import React from "react";
|
||||
import FlowRunsListSkeleton from "@/app/(platform)/monitoring/components/skeletons/FlowRunsListSkeleton";
|
||||
import FlowRunsStatusSkeleton from "@/app/(platform)/monitoring/components/skeletons/FlowRunsStatusSkeleton";
|
||||
|
||||
export default function MonitorLoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{/* Agents Section */}
|
||||
<AgentFlowListSkeleton />
|
||||
|
||||
{/* Runs Section */}
|
||||
<FlowRunsListSkeleton />
|
||||
|
||||
{/* Stats Section */}
|
||||
<FlowRunsStatusSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
"use client";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { GraphExecutionMeta, LibraryAgent } from "@/lib/autogpt-server-api";
|
||||
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
||||
import {
|
||||
useGetV1ListExecutionSchedulesForAUser,
|
||||
useDeleteV1DeleteExecutionSchedule,
|
||||
} from "@/app/api/__generated__/endpoints/schedules/schedules";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
|
||||
import { Card } from "@/components/__legacy__/ui/card";
|
||||
import { SchedulesTable } from "@/app/(platform)/monitoring/components/SchedulesTable";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import AgentFlowList from "./components/AgentFlowList";
|
||||
import FlowRunsList from "./components/FlowRunsList";
|
||||
import FlowRunInfo from "./components/FlowRunInfo";
|
||||
import FlowInfo from "./components/FlowInfo";
|
||||
import FlowRunsStatus from "./components/FlowRunsStatus";
|
||||
|
||||
const Monitor = () => {
|
||||
const [flows, setFlows] = useState<LibraryAgent[]>([]);
|
||||
const [executions, setExecutions] = useState<GraphExecutionMeta[]>([]);
|
||||
const [selectedFlow, setSelectedFlow] = useState<LibraryAgent | null>(null);
|
||||
const [selectedRun, setSelectedRun] = useState<GraphExecutionMeta | null>(
|
||||
null,
|
||||
);
|
||||
const [sortColumn, setSortColumn] =
|
||||
useState<keyof GraphExecutionJobInfo>("id");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
const api = useBackendAPI();
|
||||
|
||||
// Use generated API hooks for schedules
|
||||
const { data: schedulesResponse, refetch: refetchSchedules } =
|
||||
useGetV1ListExecutionSchedulesForAUser();
|
||||
const deleteScheduleMutation = useDeleteV1DeleteExecutionSchedule();
|
||||
|
||||
const schedules = okData(schedulesResponse) ?? [];
|
||||
|
||||
const removeSchedule = useCallback(
|
||||
async (scheduleId: string) => {
|
||||
await deleteScheduleMutation.mutateAsync({ scheduleId });
|
||||
refetchSchedules();
|
||||
},
|
||||
[deleteScheduleMutation, refetchSchedules],
|
||||
);
|
||||
|
||||
const fetchAgents = useCallback(() => {
|
||||
api.listLibraryAgents().then((response) => {
|
||||
setFlows(response.agents);
|
||||
});
|
||||
api.getExecutions().then((executions) => {
|
||||
setExecutions(executions);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAgents();
|
||||
}, [fetchAgents]);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => fetchAgents(), 5000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [fetchAgents, flows]);
|
||||
|
||||
const column1 = "md:col-span-2 xl:col-span-3 xxl:col-span-2";
|
||||
const column2 = "md:col-span-3 lg:col-span-2 xl:col-span-3";
|
||||
const column3 = "col-span-full xl:col-span-4 xxl:col-span-5";
|
||||
|
||||
const handleSort = (column: keyof GraphExecutionJobInfo) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-1 gap-4 p-4 md:grid-cols-5 lg:grid-cols-4 xl:grid-cols-10"
|
||||
data-testid="monitor-page"
|
||||
>
|
||||
<AgentFlowList
|
||||
className={column1}
|
||||
flows={flows}
|
||||
executions={executions}
|
||||
selectedFlow={selectedFlow}
|
||||
onSelectFlow={(f) => {
|
||||
setSelectedRun(null);
|
||||
setSelectedFlow(f.id == selectedFlow?.id ? null : f);
|
||||
}}
|
||||
/>
|
||||
<FlowRunsList
|
||||
className={column2}
|
||||
flows={flows}
|
||||
executions={[
|
||||
...(selectedFlow
|
||||
? executions.filter((v) => v.graph_id == selectedFlow.graph_id)
|
||||
: executions),
|
||||
].sort((a, b) => {
|
||||
const aTime = a.started_at?.getTime() ?? 0;
|
||||
const bTime = b.started_at?.getTime() ?? 0;
|
||||
return bTime - aTime;
|
||||
})}
|
||||
selectedRun={selectedRun}
|
||||
onSelectRun={(r) => setSelectedRun(r.id == selectedRun?.id ? null : r)}
|
||||
/>
|
||||
{(selectedRun && (
|
||||
<FlowRunInfo
|
||||
agent={
|
||||
selectedFlow ||
|
||||
flows.find((f) => f.graph_id == selectedRun.graph_id)!
|
||||
}
|
||||
execution={selectedRun}
|
||||
className={column3}
|
||||
/>
|
||||
)) ||
|
||||
(selectedFlow && (
|
||||
<FlowInfo
|
||||
flow={selectedFlow}
|
||||
executions={executions.filter(
|
||||
(e) => e.graph_id == selectedFlow.graph_id,
|
||||
)}
|
||||
className={column3}
|
||||
refresh={() => {
|
||||
fetchAgents();
|
||||
setSelectedFlow(null);
|
||||
setSelectedRun(null);
|
||||
}}
|
||||
/>
|
||||
)) || (
|
||||
<Card className={`p-6 ${column3}`}>
|
||||
<FlowRunsStatus flows={flows} executions={executions} />
|
||||
</Card>
|
||||
)}
|
||||
<div className="col-span-full xl:col-span-6">
|
||||
<SchedulesTable
|
||||
schedules={schedules} // all schedules
|
||||
agents={flows} // for filtering purpose
|
||||
onRemoveSchedule={removeSchedule}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Monitor;
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CronExpressionDialog } from "@/components/contextual/CronScheduler/cron-scheduler-dialog";
|
||||
import { CronExpressionDialog } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/cron-scheduler-dialog";
|
||||
import { Form, FormField } from "@/components/__legacy__/ui/form";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,6 @@ export const PROTECTED_PAGES = [
|
||||
"/onboarding",
|
||||
"/profile",
|
||||
"/library",
|
||||
"/monitoring",
|
||||
] as const;
|
||||
|
||||
export const ADMIN_PAGES = ["/admin"] as const;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { type ClassValue, clsx } from "clsx";
|
||||
import _isEmpty from "lodash/isEmpty";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { NodeDimension } from "@/app/(platform)/build/components/legacy-builder/Flow/Flow";
|
||||
import {
|
||||
BlockIOObjectSubSchema,
|
||||
BlockIORootSchema,
|
||||
@@ -332,81 +331,6 @@ export function getPrimaryCategoryColor(categories: Category[]): string {
|
||||
);
|
||||
}
|
||||
|
||||
function rectanglesOverlap(
|
||||
rect1: { x: number; y: number; width: number; height?: number },
|
||||
rect2: { x: number; y: number; width: number; height?: number },
|
||||
): boolean {
|
||||
const x1 = rect1.x,
|
||||
y1 = rect1.y,
|
||||
w1 = rect1.width,
|
||||
h1 = rect1.height ?? 100;
|
||||
const x2 = rect2.x,
|
||||
y2 = rect2.y,
|
||||
w2 = rect2.width,
|
||||
h2 = rect2.height ?? 100;
|
||||
|
||||
// Check if the rectangles do not overlap
|
||||
return !(x1 + w1 <= x2 || x1 >= x2 + w2 || y1 + h1 <= y2 || y1 >= y2 + h2);
|
||||
}
|
||||
|
||||
export function findNewlyAddedBlockCoordinates(
|
||||
nodeDimensions: NodeDimension,
|
||||
newWidth: number,
|
||||
margin: number,
|
||||
zoom: number,
|
||||
) {
|
||||
const nodeDimensionArray = Object.values(nodeDimensions);
|
||||
|
||||
for (let i = nodeDimensionArray.length - 1; i >= 0; i--) {
|
||||
const lastNode = nodeDimensionArray[i];
|
||||
const lastNodeHeight = lastNode.height ?? 100;
|
||||
|
||||
// Right of the last node
|
||||
let newX = lastNode.x + lastNode.width + margin;
|
||||
let newY = lastNode.y;
|
||||
let newRect = { x: newX, y: newY, width: newWidth, height: 100 / zoom };
|
||||
|
||||
const collisionRight = nodeDimensionArray.some((node) =>
|
||||
rectanglesOverlap(newRect, node),
|
||||
);
|
||||
|
||||
if (!collisionRight) {
|
||||
return { x: newX, y: newY };
|
||||
}
|
||||
|
||||
// Left of the last node
|
||||
newX = lastNode.x - newWidth - margin;
|
||||
newRect = { x: newX, y: newY, width: newWidth, height: 100 / zoom };
|
||||
|
||||
const collisionLeft = nodeDimensionArray.some((node) =>
|
||||
rectanglesOverlap(newRect, node),
|
||||
);
|
||||
|
||||
if (!collisionLeft) {
|
||||
return { x: newX, y: newY };
|
||||
}
|
||||
|
||||
// Below the last node
|
||||
newX = lastNode.x;
|
||||
newY = lastNode.y + lastNodeHeight + margin;
|
||||
newRect = { x: newX, y: newY, width: newWidth, height: 100 / zoom };
|
||||
|
||||
const collisionBelow = nodeDimensionArray.some((node) =>
|
||||
rectanglesOverlap(newRect, node),
|
||||
);
|
||||
|
||||
if (!collisionBelow) {
|
||||
return { x: newX, y: newY };
|
||||
}
|
||||
}
|
||||
|
||||
// Default position if no space is found
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasNonNullNonObjectValue(obj: any): boolean {
|
||||
if (obj !== null && typeof obj === "object") {
|
||||
return Object.values(obj).some((value) => hasNonNullNonObjectValue(value));
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useFlags } from "launchdarkly-react-client-sdk";
|
||||
export enum Flag {
|
||||
BETA_BLOCKS = "beta-blocks",
|
||||
NEW_BLOCK_MENU = "new-block-menu",
|
||||
NEW_AGENT_RUNS = "new-agent-runs",
|
||||
GRAPH_SEARCH = "graph-search",
|
||||
ENABLE_ENHANCED_OUTPUT_HANDLING = "enable-enhanced-output-handling",
|
||||
SHARE_EXECUTION_RESULTS = "share-execution-results",
|
||||
@@ -21,6 +22,7 @@ const isPwMockEnabled = process.env.NEXT_PUBLIC_PW_TEST === "true";
|
||||
const defaultFlags = {
|
||||
[Flag.BETA_BLOCKS]: [],
|
||||
[Flag.NEW_BLOCK_MENU]: false,
|
||||
[Flag.NEW_AGENT_RUNS]: false,
|
||||
[Flag.GRAPH_SEARCH]: false,
|
||||
[Flag.ENABLE_ENHANCED_OUTPUT_HANDLING]: false,
|
||||
[Flag.SHARE_EXECUTION_RESULTS]: false,
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import test, { expect, TestInfo } from "@playwright/test";
|
||||
|
||||
import { BuildPage } from "./pages/build.page";
|
||||
import { MonitorPage } from "./pages/monitor.page";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import * as fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import { getTestUser } from "./utils/auth";
|
||||
import { hasUrl } from "./utils/assertion";
|
||||
import {
|
||||
navigateToLibrary,
|
||||
clickFirstAgent,
|
||||
runAgent,
|
||||
waitForAgentPageLoad,
|
||||
} from "./pages/library.page";
|
||||
|
||||
test.describe.configure({
|
||||
mode: "parallel",
|
||||
timeout: 30000,
|
||||
});
|
||||
// --8<-- [start:AttachAgentId]
|
||||
test.beforeEach(async ({ page }, testInfo: TestInfo) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
const testUser = await getTestUser();
|
||||
const monitorPage = new MonitorPage(page);
|
||||
|
||||
// Start each test with login using worker auth
|
||||
await page.goto("/login");
|
||||
await loginPage.login(testUser.email, testUser.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
// Navigate to library and run the first agent
|
||||
await navigateToLibrary(page);
|
||||
await clickFirstAgent(page);
|
||||
await waitForAgentPageLoad(page);
|
||||
await runAgent(page);
|
||||
|
||||
// Navigate to monitoring page
|
||||
await page.goto("/monitoring");
|
||||
await test.expect(monitorPage.isLoaded()).resolves.toBeTruthy();
|
||||
|
||||
// Generate a test ID for tracking
|
||||
const id = uuidv4();
|
||||
testInfo.attach("agent-id", { body: id });
|
||||
});
|
||||
// --8<-- [end:AttachAgentId]
|
||||
|
||||
test.afterAll(async () => {
|
||||
// clear out the downloads folder
|
||||
const downloadsFolder = process.cwd() + "/downloads";
|
||||
console.log(`clearing out the downloads folder ${downloadsFolder}/monitor`);
|
||||
|
||||
await fs.rm(`${downloadsFolder}/monitor`, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
test.skip("user can export and import agents", async ({
|
||||
page,
|
||||
}, testInfo: TestInfo) => {
|
||||
const monitorPage = new MonitorPage(page);
|
||||
const buildPage = new BuildPage(page);
|
||||
|
||||
// --8<-- [start:ReadAgentId]
|
||||
if (testInfo.attachments.length === 0 || !testInfo.attachments[0].body) {
|
||||
throw new Error("No agent id attached to the test");
|
||||
}
|
||||
|
||||
const testAttachName = testInfo.attachments[0].body.toString();
|
||||
// --8<-- [end:ReadAgentId]
|
||||
const agents = await monitorPage.listAgents();
|
||||
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
|
||||
const agent = agents.find(
|
||||
(a: any) => a.name === `test-agent-${testAttachName}`,
|
||||
);
|
||||
|
||||
if (!agent) throw new Error(`Agent ${testAttachName} not found`);
|
||||
|
||||
await monitorPage.exportToFile(agent);
|
||||
const download = await downloadPromise;
|
||||
|
||||
// Wait for the download process to complete and save the downloaded file somewhere.
|
||||
await download.saveAs(
|
||||
`${monitorPage.downloadsFolder}/monitor/${download.suggestedFilename()}`,
|
||||
);
|
||||
|
||||
console.log(`downloaded file to ${download.suggestedFilename()}`);
|
||||
|
||||
expect(download.suggestedFilename()).toBeDefined();
|
||||
expect(download.suggestedFilename()).toContain("test-agent-");
|
||||
expect(download.suggestedFilename()).toContain("v1.json");
|
||||
|
||||
// import the agent
|
||||
const preImportAgents = await monitorPage.listAgents();
|
||||
|
||||
const filesInFolder = await fs.readdir(
|
||||
`${monitorPage.downloadsFolder}/monitor`,
|
||||
);
|
||||
|
||||
const importFile = filesInFolder.find((f) => f.includes(testAttachName));
|
||||
if (!importFile) {
|
||||
throw new Error(`No import file found for agent ${testAttachName}`);
|
||||
}
|
||||
|
||||
const baseName = importFile.split(".")[0];
|
||||
|
||||
await monitorPage.importFromFile(
|
||||
path.resolve(monitorPage.downloadsFolder, "monitor"),
|
||||
importFile,
|
||||
baseName + "-imported",
|
||||
);
|
||||
|
||||
// You'll be dropped at the build page, so hit run and then go back to monitor
|
||||
await buildPage.runAgent();
|
||||
await monitorPage.navbar.clickMonitorLink();
|
||||
|
||||
const postImportAgents = await monitorPage.listAgents();
|
||||
|
||||
expect(postImportAgents.length).toBeGreaterThan(preImportAgents.length);
|
||||
|
||||
console.log(`postImportAgents: ${JSON.stringify(postImportAgents)}`);
|
||||
|
||||
const importedAgent = postImportAgents.find(
|
||||
(a: any) => a.name === `${baseName}-imported`,
|
||||
);
|
||||
|
||||
expect(importedAgent).toBeDefined();
|
||||
});
|
||||
|
||||
test.skip("user can view runs and agents", async ({ page }) => {
|
||||
const monitorPage = new MonitorPage(page);
|
||||
// const runs = await monitorPage.listRuns();
|
||||
const agents = await monitorPage.listAgents();
|
||||
|
||||
expect(agents.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -252,21 +252,6 @@ export class LibraryPage extends BasePage {
|
||||
]);
|
||||
}
|
||||
|
||||
async clickMonitoringLink(): Promise<void> {
|
||||
console.log(`clicking monitoring link in alert`);
|
||||
await this.page.getByRole("link", { name: "here" }).click();
|
||||
}
|
||||
|
||||
async isMonitoringAlertVisible(): Promise<boolean> {
|
||||
console.log(`checking if monitoring alert is visible`);
|
||||
try {
|
||||
const alertText = this.page.locator("text=/Prefer the old experience/");
|
||||
return await alertText.isVisible();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getSearchValue(): Promise<string> {
|
||||
console.log(`getting search input value`);
|
||||
try {
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
import { Page } from "@playwright/test";
|
||||
import { BasePage } from "./base.page";
|
||||
import path from "path";
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
runCount: number;
|
||||
lastRun: string;
|
||||
}
|
||||
|
||||
interface Run {
|
||||
id: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
started: string;
|
||||
duration: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
graphName: string;
|
||||
nextExecution: string;
|
||||
schedule: string;
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
enum ImportType {
|
||||
AGENT = "agent",
|
||||
TEMPLATE = "template",
|
||||
}
|
||||
|
||||
export class MonitorPage extends BasePage {
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
async isLoaded(): Promise<boolean> {
|
||||
console.log(`checking if monitor page is loaded`);
|
||||
try {
|
||||
// Wait for the monitor page
|
||||
await this.page.getByTestId("monitor-page").waitFor({
|
||||
state: "visible",
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Wait for table headers to be visible (indicates table structure is ready)
|
||||
await this.page.locator("thead th").first().waitFor({
|
||||
state: "visible",
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Wait for either a table row or an empty tbody to be present
|
||||
await Promise.race([
|
||||
// Wait for at least one row
|
||||
this.page.locator("tbody tr[data-testid]").first().waitFor({
|
||||
state: "visible",
|
||||
timeout: 15_000,
|
||||
}),
|
||||
// OR wait for an empty tbody (indicating no agents but table is loaded)
|
||||
this.page
|
||||
.locator("tbody[data-testid='agent-flow-list-body']:empty")
|
||||
.waitFor({
|
||||
state: "visible",
|
||||
timeout: 15_000,
|
||||
}),
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async listAgents(): Promise<Agent[]> {
|
||||
console.log(`listing agents`);
|
||||
// Wait for table rows to be available
|
||||
const rows = await this.page.locator("tbody tr[data-testid]").all();
|
||||
|
||||
const agents: Agent[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
// Get the id from data-testid attribute
|
||||
const id = (await row.getAttribute("data-testid")) || "";
|
||||
|
||||
// Get columns - there are 3 cells per row (name, run count, last run)
|
||||
const cells = await row.locator("td").all();
|
||||
|
||||
// Extract name from first cell
|
||||
const name = (await row.getAttribute("data-name")) || "";
|
||||
|
||||
// Extract run count from second cell
|
||||
const runCountText = (await cells[1].textContent()) || "0";
|
||||
const runCount = parseInt(runCountText, 10);
|
||||
|
||||
// Extract last run from third cell's title attribute (contains full timestamp)
|
||||
// If no title, the cell will be empty indicating no last run
|
||||
const lastRunCell = cells[2];
|
||||
const lastRun = (await lastRunCell.getAttribute("title")) || "";
|
||||
|
||||
agents.push({
|
||||
id,
|
||||
name,
|
||||
runCount,
|
||||
lastRun,
|
||||
});
|
||||
}
|
||||
|
||||
agents.reduce((acc, agent) => {
|
||||
if (!agent.id.includes("flow-run")) {
|
||||
acc.push(agent);
|
||||
}
|
||||
return acc;
|
||||
}, [] as Agent[]);
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
async listRuns(filter?: Agent): Promise<Run[]> {
|
||||
console.log(`listing runs`);
|
||||
// Wait for the runs table to be loaded - look for table header "Agent"
|
||||
await this.page.locator("[data-testid='flow-runs-list-body']").waitFor({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Get all run rows
|
||||
const rows = await this.page
|
||||
.locator('tbody tr[data-testid^="flow-run-"]')
|
||||
.all();
|
||||
|
||||
const runs: Run[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const runId = (await row.getAttribute("data-runid")) || "";
|
||||
const agentId = (await row.getAttribute("data-graphid")) || "";
|
||||
|
||||
// Get columns
|
||||
const cells = await row.locator("td").all();
|
||||
|
||||
// Parse data from cells
|
||||
const agentName = (await cells[0].textContent()) || "";
|
||||
const started = (await cells[1].textContent()) || "";
|
||||
const status = (await cells[2].locator("div").textContent()) || "";
|
||||
const duration = (await cells[3].textContent()) || "";
|
||||
|
||||
// Only add if no filter or if matches filter
|
||||
if (!filter || filter.id === agentId) {
|
||||
runs.push({
|
||||
id: runId,
|
||||
agentId: agentId,
|
||||
agentName: agentName.trim(),
|
||||
started: started.trim(),
|
||||
duration: parseFloat(duration.replace("s", "")),
|
||||
status: status.toLowerCase().trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return runs;
|
||||
}
|
||||
async listSchedules(): Promise<Schedule[]> {
|
||||
console.log(`listing schedules`);
|
||||
return [];
|
||||
}
|
||||
|
||||
async clickAgent(id: string) {
|
||||
console.log(`selecting agent ${id}`);
|
||||
await this.page.getByTestId(id).click();
|
||||
}
|
||||
|
||||
async clickCreateAgent(): Promise<void> {
|
||||
console.log(`clicking create agent`);
|
||||
await this.page.getByRole("link", { name: "Create" }).click();
|
||||
}
|
||||
|
||||
async importFromFile(
|
||||
directory: string,
|
||||
file: string,
|
||||
name?: string,
|
||||
description?: string,
|
||||
importType: ImportType = ImportType.AGENT,
|
||||
) {
|
||||
console.log(
|
||||
`importing from directory: ${directory} file: ${file} name: ${name} description: ${description} importType: ${importType}`,
|
||||
);
|
||||
await this.page.getByTestId("create-agent-dropdown").click();
|
||||
await this.page.getByTestId("import-agent-from-file").click();
|
||||
|
||||
await this.page
|
||||
.getByTestId("import-agent-file-input")
|
||||
.setInputFiles(path.join(directory, file));
|
||||
if (name) {
|
||||
console.log(`filling agent name: ${name}`);
|
||||
await this.page.getByTestId("agent-name-input").fill(name);
|
||||
}
|
||||
if (description) {
|
||||
console.log(`filling agent description: ${description}`);
|
||||
await this.page.getByTestId("agent-description-input").fill(description);
|
||||
}
|
||||
if (importType === ImportType.TEMPLATE) {
|
||||
console.log(`clicking import as template switch`);
|
||||
await this.page.getByTestId("import-as-template-switch").click();
|
||||
}
|
||||
console.log(`clicking import agent submit`);
|
||||
await this.page.getByTestId("import-agent-submit").click();
|
||||
}
|
||||
|
||||
async deleteAgent(agent: Agent) {
|
||||
console.log(`deleting agent ${agent.id} ${agent.name}`);
|
||||
}
|
||||
|
||||
async clickAllVersions(agent: Agent) {
|
||||
console.log(`clicking all versions for agent ${agent.id} ${agent.name}`);
|
||||
}
|
||||
|
||||
async openInBuilder(agent: Agent) {
|
||||
console.log(`opening agent ${agent.id} ${agent.name} in builder`);
|
||||
}
|
||||
|
||||
async exportToFile(agent: Agent) {
|
||||
await this.clickAgent(agent.id);
|
||||
|
||||
console.log(`exporting agent id: ${agent.id} name: ${agent.name} to file`);
|
||||
await this.page.getByTestId("export-button").click();
|
||||
}
|
||||
|
||||
async selectRun(agent: Agent, run: Run) {
|
||||
console.log(`selecting run ${run.id} for agent ${agent.id} ${agent.name}`);
|
||||
}
|
||||
|
||||
async openOutputs(agent: Agent, run: Run) {
|
||||
console.log(
|
||||
`opening outputs for run ${run.id} of agent ${agent.id} ${agent.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,6 @@ export class NavBar {
|
||||
await this.page.getByRole("link", { name: "Edit profile" }).click();
|
||||
}
|
||||
|
||||
async clickMonitorLink() {
|
||||
await this.page.getByTestId("navbar-link-library").click();
|
||||
}
|
||||
|
||||
async clickBuildLink() {
|
||||
const link = this.page.getByTestId("navbar-link-build");
|
||||
await link.waitFor({ state: "visible", timeout: 15000 });
|
||||
|
||||
Reference in New Issue
Block a user