diff --git a/autogpt_platform/backend/backend/data/graph.py b/autogpt_platform/backend/backend/data/graph.py index 7bdf29707b70..ce3b484350da 100644 --- a/autogpt_platform/backend/backend/data/graph.py +++ b/autogpt_platform/backend/backend/data/graph.py @@ -292,7 +292,6 @@ def has_value(name): # Validate dependencies between fields for field_name, field_info in input_schema.items(): - # Apply input dependency validation only on run & field with depends_on json_schema_extra = field_info.json_schema_extra or {} dependencies = json_schema_extra.get("depends_on", []) @@ -475,7 +474,7 @@ async def get_graphs( return graph_models -async def get_executions(user_id: str) -> list[GraphExecution]: +async def get_graphs_executions(user_id: str) -> list[GraphExecution]: executions = await AgentGraphExecution.prisma().find_many( where={"userId": user_id}, order={"createdAt": "desc"}, @@ -483,6 +482,14 @@ async def get_executions(user_id: str) -> list[GraphExecution]: return [GraphExecution.from_db(execution) for execution in executions] +async def get_graph_executions(graph_id: str, user_id: str) -> list[GraphExecution]: + executions = await AgentGraphExecution.prisma().find_many( + where={"agentGraphId": graph_id, "userId": user_id}, + order={"createdAt": "desc"}, + ) + return [GraphExecution.from_db(execution) for execution in executions] + + async def get_execution(user_id: str, execution_id: str) -> GraphExecution | None: execution = await AgentGraphExecution.prisma().find_first( where={"id": execution_id, "userId": user_id} diff --git a/autogpt_platform/backend/backend/server/routers/v1.py b/autogpt_platform/backend/backend/server/routers/v1.py index 9c5f3522a7f9..0ddc22f03522 100644 --- a/autogpt_platform/backend/backend/server/routers/v1.py +++ b/autogpt_platform/backend/backend/server/routers/v1.py @@ -405,10 +405,22 @@ async def stop_graph_run( tags=["graphs"], dependencies=[Depends(auth_middleware)], ) -async def get_executions( +async def get_graphs_executions( user_id: Annotated[str, Depends(get_user_id)], ) -> list[graph_db.GraphExecution]: - return await graph_db.get_executions(user_id=user_id) + return await graph_db.get_graphs_executions(user_id=user_id) + + +@v1_router.get( + path="/graphs/{graph_id}/executions", + tags=["graphs"], + dependencies=[Depends(auth_middleware)], +) +async def get_graph_executions( + graph_id: str, + user_id: Annotated[str, Depends(get_user_id)], +) -> list[graph_db.GraphExecution]: + return await graph_db.get_graph_executions(graph_id=graph_id, user_id=user_id) @v1_router.get( diff --git a/autogpt_platform/frontend/package.json b/autogpt_platform/frontend/package.json index 1d3545133a7e..440420b7ae80 100644 --- a/autogpt_platform/frontend/package.json +++ b/autogpt_platform/frontend/package.json @@ -35,6 +35,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-navigation-menu": "^1.2.3", "@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-scroll-area": "^1.2.1", @@ -42,6 +43,7 @@ "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-tooltip": "^1.1.6", "@sentry/nextjs": "^8", diff --git a/autogpt_platform/frontend/src/app/agents/[id]/page.tsx b/autogpt_platform/frontend/src/app/agents/[id]/page.tsx new file mode 100644 index 000000000000..997e2823e28c --- /dev/null +++ b/autogpt_platform/frontend/src/app/agents/[id]/page.tsx @@ -0,0 +1,316 @@ +"use client"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { Plus } from "lucide-react"; + +import { useBackendAPI } from "@/lib/autogpt-server-api/context"; +import { + GraphExecution, + Schedule, + GraphMeta, + BlockIOSubType, +} from "@/lib/autogpt-server-api"; + +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +import { Button } from "@/components/agptui/Button"; +import { AgentRunStatus } from "@/components/agptui/AgentRunStatusChip"; +import AgentRunSummaryCard from "@/components/agptui/AgentRunSummaryCard"; +import moment from "moment"; + +const agentRunStatusMap: Record = { + COMPLETED: "success", + FAILED: "failed", + QUEUED: "queued", + RUNNING: "running", + // TODO: implement "draft" + // TODO: implement "stopped" +}; + +export default function AgentRunsPage(): React.ReactElement { + const { id: agentID }: { id: string } = useParams(); + const router = useRouter(); + const api = useBackendAPI(); + + const [agent, setAgent] = useState(null); + const [agentRuns, setAgentRuns] = useState([]); + const [schedules, setSchedules] = useState([]); + const [selectedRun, setSelectedRun] = useState< + GraphExecution | Schedule | null + >(null); + const [activeListTab, setActiveListTab] = useState<"runs" | "scheduled">( + "runs", + ); + + const fetchAgents = useCallback(() => { + api.getGraph(agentID).then(setAgent); + api.getGraphExecutions(agentID).then((agentRuns) => { + setAgentRuns(agentRuns.toSorted((a, b) => b.started_at - a.started_at)); + + if (!selectedRun) { + setSelectedRun(agentRuns[0]); + } + }); + }, [api, agentID, selectedRun]); + + useEffect(() => { + fetchAgents(); + }, [fetchAgents]); + + const fetchSchedules = useCallback(async () => { + // TODO: filter in backend + setSchedules( + (await api.listSchedules()).filter((s) => s.graph_id == agentID), + ); + }, [api, agentID]); + + useEffect(() => { + fetchSchedules(); + }, [fetchSchedules]); + + const removeSchedule = useCallback( + async (scheduleId: string) => { + const removedSchedule = await api.deleteSchedule(scheduleId); + setSchedules(schedules.filter((s) => s.id !== removedSchedule.id)); + }, + [schedules, api], + ); + + /* TODO: use websockets instead of polling */ + useEffect(() => { + const intervalId = setInterval(() => fetchAgents(), 5000); + return () => clearInterval(intervalId); + }, [fetchAgents, agent]); + + const selectedRunStatus: AgentRunStatus = useMemo( + () => + !selectedRun + ? "draft" + : "status" in selectedRun + ? agentRunStatusMap[selectedRun.status] + : "scheduled", + [selectedRun], + ); + + const infoStats: { label: string; value: React.ReactNode }[] = useMemo(() => { + if (!selectedRun) return []; + return [ + { + label: "Status", + value: + selectedRunStatus.charAt(0).toUpperCase() + + selectedRunStatus.slice(1), + }, + ...("started_at" in selectedRun + ? [ + { + label: "Started", + value: `${moment(selectedRun.started_at).fromNow()}, ${moment(selectedRun.started_at).format("HH:mm")}`, + }, + { + label: "Duration", + value: `${moment.duration(selectedRun.duration, "seconds").humanize()}`, + }, + // { label: "Cost", value: selectedRun.cost }, // TODO: implement cost + ] + : [{ label: "Scheduled for", value: selectedRun.next_run_time }]), + ]; + }, [selectedRun, selectedRunStatus]); + + const agentRunInputs: Record = + useMemo(() => { + if (!selectedRun) return {}; + // return selectedRun.input; // TODO: implement run input view + return { + "Mock Input": { type: "string", value: "Mock Value" }, + }; + }, [selectedRun]); + + const runAgain = useCallback( + () => + api.executeGraph( + agentID, + Object.fromEntries( + Object.entries(agentRunInputs).map(([k, v]) => [k, v.value]), + ), + ), + [api, agentID, agentRunInputs], + ); + + const agentRunOutputs: Record = + useMemo(() => { + if ( + !selectedRun || + !["running", "success", "failed"].includes(selectedRunStatus) || + !("output" in selectedRun) + ) + return {}; + // return selectedRun.output; // TODO: implement run output view + return { + "Mock Output": { type: "string", value: "Mock Value" }, + }; + }, [selectedRun, selectedRunStatus]); + + const runActions: { label: string; callback: () => void }[] = useMemo(() => { + if (!selectedRun) return []; + return [{ label: "Run again", callback: () => runAgain() }]; + }, [selectedRun, runAgain]); + + const agentActions: { label: string; callback: () => void }[] = + useMemo(() => { + if (!agentID) return []; + return [ + { + label: "Open in builder", + callback: () => router.push(`/build?flowID=${agentID}`), + }, + ]; + }, [agentID, router]); + + if (!agent) { + /* TODO: implement loading indicators / skeleton page */ + return Loading...; + } + + return ( +
+ + +
+

+ {agent.name /* TODO: use dynamic/custom run title */} +

+ + + + Info + Output + Input + Rate + + + + + + Info + + +
+ {infoStats.map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
+
+
+
+ + + + + Input + + + {Object.entries(agentRunInputs).map(([key, { value }]) => ( +
+ + +
+ ))} +
+
+
+
+
+ + +
+ ); +} diff --git a/autogpt_platform/frontend/src/app/layout.tsx b/autogpt_platform/frontend/src/app/layout.tsx index 5d1f399b3f37..a9ba0d191f6d 100644 --- a/autogpt_platform/frontend/src/app/layout.tsx +++ b/autogpt_platform/frontend/src/app/layout.tsx @@ -91,7 +91,7 @@ export default async function RootLayout({ }, ]} /> -
{children}
+
{children}
diff --git a/autogpt_platform/frontend/src/components/agptui/AgentRunStatusChip.tsx b/autogpt_platform/frontend/src/components/agptui/AgentRunStatusChip.tsx new file mode 100644 index 000000000000..ff02cb28b50b --- /dev/null +++ b/autogpt_platform/frontend/src/components/agptui/AgentRunStatusChip.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Badge } from "@/components/ui/badge"; + +export type AgentRunStatus = + | "success" + | "failed" + | "queued" + | "running" + | "stopped" + | "scheduled" + | "draft"; + +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" }, +}; + +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 default function AgentRunStatusChip({ + status, +}: { + status: AgentRunStatus; +}): React.ReactElement { + return ( + + {statusData[status].label} + + ); +} diff --git a/autogpt_platform/frontend/src/components/agptui/AgentRunSummaryCard.tsx b/autogpt_platform/frontend/src/components/agptui/AgentRunSummaryCard.tsx new file mode 100644 index 000000000000..d271f66fc4e1 --- /dev/null +++ b/autogpt_platform/frontend/src/components/agptui/AgentRunSummaryCard.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import moment from "moment"; +import { MoreVertical } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +import AgentRunStatusChip, { AgentRunStatus } from "./AgentRunStatusChip"; + +export type AgentRunSummaryProps = { + agentID: string; + agentRunID: string; + status: AgentRunStatus; + title: string; + timestamp: number | Date; + selected?: boolean; + onClick?: () => void; +}; + +export default function AgentRunSummaryCard({ + agentID, + agentRunID, + status, + title, + timestamp, + selected = false, + onClick, +}: AgentRunSummaryProps): React.ReactElement { + return ( + + + + +
+

+ {title} +

+ + + + + + + + Pin into a template + + Rename + Delete + + +
+ +

+ Ran {moment(timestamp).fromNow()} +

+
+
+ ); +} diff --git a/autogpt_platform/frontend/src/components/ui/navigation-menu.tsx b/autogpt_platform/frontend/src/components/ui/navigation-menu.tsx new file mode 100644 index 000000000000..9c9e1836f9f5 --- /dev/null +++ b/autogpt_platform/frontend/src/components/ui/navigation-menu.tsx @@ -0,0 +1,128 @@ +import * as React from "react"; +import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"; +import { cva } from "class-variance-authority"; +import { cn } from "@/lib/utils"; +import { ChevronDownIcon } from "@radix-ui/react-icons"; + +const NavigationMenu = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + +)); +NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName; + +const NavigationMenuList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName; + +const NavigationMenuItem = NavigationMenuPrimitive.Item; + +const navigationMenuTriggerStyle = cva( + "group inline-flex h-9 w-max items-center justify-center rounded-md bg-white px-4 py-2 text-sm font-medium transition-colors hover:bg-neutral-100 hover:text-neutral-900 focus:bg-neutral-100 focus:text-neutral-900 focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-neutral-100/50 data-[state=open]:bg-neutral-100/50 dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:hover:text-neutral-50 dark:focus:bg-neutral-800 dark:focus:text-neutral-50 dark:data-[active]:bg-neutral-800/50 dark:data-[state=open]:bg-neutral-800/50", +); + +const NavigationMenuTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + {""} + +)); +NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName; + +const NavigationMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName; + +const NavigationMenuLink = NavigationMenuPrimitive.Link; + +const NavigationMenuViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ +
+)); +NavigationMenuViewport.displayName = + NavigationMenuPrimitive.Viewport.displayName; + +const NavigationMenuIndicator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +
+ +)); +NavigationMenuIndicator.displayName = + NavigationMenuPrimitive.Indicator.displayName; + +export { + navigationMenuTriggerStyle, + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuLink, + NavigationMenuIndicator, + NavigationMenuViewport, +}; diff --git a/autogpt_platform/frontend/src/components/ui/tabs.tsx b/autogpt_platform/frontend/src/components/ui/tabs.tsx new file mode 100644 index 000000000000..928dcc6f90d1 --- /dev/null +++ b/autogpt_platform/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client"; + +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; + +import { cn } from "@/lib/utils"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts index cc3e638b59d6..9227eb14b8fd 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts @@ -93,10 +93,6 @@ export default class BackendAPI { return this._get(`/graphs`); } - getExecutions(): Promise { - return this._get(`/executions`); - } - getGraph( id: string, version?: number, @@ -145,6 +141,14 @@ export default class BackendAPI { return this._request("POST", `/graphs/${id}/execute`, inputData); } + getExecutions(): Promise { + return this._get(`/executions`); + } + + getGraphExecutions(graphID: string): Promise { + return this._get(`/graphs/${graphID}/executions`); + } + async getGraphExecutionInfo( graphID: string, runID: string, diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts index d2259cf72cad..19cb625b88ba 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts @@ -41,6 +41,8 @@ export type BlockIOSubSchema = | BlockIOSimpleTypeSubSchema | BlockIOCombinedTypeSubSchema; +export type BlockIOSubType = BlockIOSimpleTypeSubSchema["type"]; + type BlockIOSimpleTypeSubSchema = | BlockIOObjectSubSchema | BlockIOCredentialsSubSchema @@ -131,7 +133,7 @@ export const PROVIDER_NAMES = { export type CredentialsProviderName = (typeof PROVIDER_NAMES)[keyof typeof PROVIDER_NAMES]; -export type BlockIOCredentialsSubSchema = BlockIOSubSchemaMeta & { +export type BlockIOCredentialsSubSchema = BlockIOObjectSubSchema & { /* Mirror of backend/data/model.py:CredentialsFieldSchemaExtra */ credentials_provider: CredentialsProviderName[]; credentials_scopes?: string[]; @@ -196,7 +198,7 @@ export type GraphExecution = { ended_at: number; duration: number; total_run_time: number; - status: "INCOMPLETE" | "QUEUED" | "RUNNING" | "COMPLETED" | "FAILED"; + status: "QUEUED" | "RUNNING" | "COMPLETED" | "FAILED"; graph_id: string; graph_version: number; }; diff --git a/autogpt_platform/frontend/yarn.lock b/autogpt_platform/frontend/yarn.lock index 94e55f00e8af..38c311ee0da6 100644 --- a/autogpt_platform/frontend/yarn.lock +++ b/autogpt_platform/frontend/yarn.lock @@ -2342,6 +2342,26 @@ aria-hidden "^1.1.1" react-remove-scroll "^2.6.1" +"@radix-ui/react-navigation-menu@^1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.3.tgz#b76b243235acd229b4e00fa61619547d3edf7c99" + integrity sha512-IQWAsQ7dsLIYDrn0WqPU+cdM7MONTv9nqrLVYoie3BPiabSfUVDe6Fr+oEt0Cofsr9ONDcDe9xhmJbL1Uq1yKg== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-collection" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-direction" "1.1.0" + "@radix-ui/react-dismissable-layer" "1.1.3" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-use-previous" "1.1.0" + "@radix-ui/react-visually-hidden" "1.1.1" + "@radix-ui/react-popover@^1.1.4": version "1.1.4" resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.1.4.tgz#d83104e5fb588870a673b55f3387da4844e5836e" @@ -2502,6 +2522,20 @@ "@radix-ui/react-use-previous" "1.1.0" "@radix-ui/react-use-size" "1.1.0" +"@radix-ui/react-tabs@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.2.tgz#a72da059593cba30fccb30a226d63af686b32854" + integrity sha512-9u/tQJMcC2aGq7KXpGivMm1mgq7oRJKXphDwdypPd/j21j/2znamPU8WkXgnhUaTrSFNIt8XhOyCAupg8/GbwQ== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-direction" "1.1.0" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-roving-focus" "1.1.1" + "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/react-toast@^1.2.4": version "1.2.4" resolved "https://registry.yarnpkg.com/@radix-ui/react-toast/-/react-toast-1.2.4.tgz#52fe0e5f169209b7fa300673491a6bedde940279"