mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(frontend): new library agent view setup (#10652)
## Changes 🏗️ Setup for the new Agent Runs page: <img width="900" height="521" alt="Screenshot 2025-08-15 at 14 36 34" src="https://github.com/user-attachments/assets/460d6611-4b15-4878-92d3-b477dc4453a9" /> It is behind a feature flag in Launch Darkly, `new-agent-runs`, so we can progressively enable in staging and later on production. ### Other improvements <img width="350" height="291" alt="Screenshot_2025-08-15_at_14 28 08" src="https://github.com/user-attachments/assets/972d2a1a-a4cd-4e92-b6d7-2dcf7f57c2db" /> - Added a new `<ErrorCard />` component to paint gracefully API errors when fetching data - Moved some sub-components of the old library page to a nested `/components` folder 📁 Behind a feature flag ## Checklist 📋 ### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Tested with the feature flag ON and OFF ### For configuration changes: None --------- Co-authored-by: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com>
This commit is contained in:
1284
autogpt_platform/frontend/pnpm-lock.yaml
generated
1284
autogpt_platform/frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { useAgentRunsView } from "./useAgentRunsView";
|
||||
import { AgentRunsLoading } from "./components/AgentRunsLoading";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
|
||||
export function AgentRunsView() {
|
||||
const { response, ready, error, agentId } = useAgentRunsView();
|
||||
|
||||
// Handle loading state
|
||||
if (!ready) {
|
||||
return <AgentRunsLoading />;
|
||||
}
|
||||
|
||||
// Handle errors - check for query error first, then response errors
|
||||
if (error || (response && response.status !== 200)) {
|
||||
return (
|
||||
<ErrorCard
|
||||
isSuccess={false}
|
||||
responseError={error || undefined}
|
||||
httpError={
|
||||
response?.status !== 200
|
||||
? {
|
||||
status: response?.status,
|
||||
statusText: "Request failed",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
context="agent"
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle missing data
|
||||
if (!response?.data) {
|
||||
return (
|
||||
<ErrorCard
|
||||
isSuccess={false}
|
||||
responseError={{ message: "No agent data found" }}
|
||||
context="agent"
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const agent = response.data;
|
||||
|
||||
return (
|
||||
<div className="grid h-screen grid-cols-[25%_85%] gap-4 pt-8">
|
||||
{/* Left Sidebar - 30% */}
|
||||
<div className="bg-gray-50 p-4">
|
||||
<Button variant="primary" size="large" className="w-full">
|
||||
<Plus size={20} /> New Run
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main Content - 70% */}
|
||||
<div className="p-4">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ name: "My Library", link: "/library" },
|
||||
{ name: agent.name, link: `/library/agents/${agentId}` },
|
||||
]}
|
||||
/>
|
||||
{/* Main content will go here */}
|
||||
<div className="mt-4 text-gray-600">Main content area</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export function AgentRunsLoading() {
|
||||
return (
|
||||
<div className="px-6 py-6">
|
||||
<div className="flex h-screen w-full gap-4">
|
||||
{/* Left Sidebar */}
|
||||
<div className="w-80 space-y-4">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 space-y-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useGetV2GetLibraryAgent } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
export function useAgentRunsView() {
|
||||
const { id } = useParams();
|
||||
const agentId = id as string;
|
||||
const { data: response, isSuccess, error } = useGetV2GetLibraryAgent(agentId);
|
||||
|
||||
return {
|
||||
agentId: id,
|
||||
ready: isSuccess,
|
||||
error,
|
||||
response,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
"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/agptui/delete-confirm-dialog";
|
||||
import type { ButtonAction } from "@/components/agptui/types";
|
||||
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import LoadingBox, { LoadingSpinner } from "@/components/ui/loading";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { AgentRunDetailsView } from "./components/agent-run-details-view";
|
||||
import { AgentRunDraftView } from "./components/agent-run-draft-view";
|
||||
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 { 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 [agentRuns, setAgentRuns] = useState<GraphExecutionMeta[]>([]);
|
||||
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 {
|
||||
state: onboardingState,
|
||||
updateState: updateOnboardingState,
|
||||
incrementRuns,
|
||||
} = useOnboarding();
|
||||
const [copyAgentDialogOpen, setCopyAgentDialogOpen] = useState(false);
|
||||
|
||||
// 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],
|
||||
);
|
||||
|
||||
// Reward user for viewing results of their onboarding agent
|
||||
useEffect(() => {
|
||||
if (
|
||||
!onboardingState ||
|
||||
!selectedRun ||
|
||||
onboardingState.completedSteps.includes("GET_RESULTS")
|
||||
)
|
||||
return;
|
||||
|
||||
if (selectedRun.id === onboardingState.onboardingAgentExecutionId) {
|
||||
updateOnboardingState({
|
||||
completedSteps: [...onboardingState.completedSteps, "GET_RESULTS"],
|
||||
});
|
||||
}
|
||||
}, [selectedRun, onboardingState, updateOnboardingState]);
|
||||
|
||||
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([
|
||||
api.getGraphExecutions(agent.graph_id),
|
||||
api.listLibraryAgentPresets({
|
||||
graph_id: agent.graph_id,
|
||||
page_size: 100,
|
||||
}),
|
||||
]).then(([runs, presets]) => {
|
||||
setAgentRuns(runs);
|
||||
setAgentPresets(presets.presets);
|
||||
|
||||
// Preload the corresponding graph versions for the latest 10 runs
|
||||
new Set(runs.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 current;
|
||||
else if (!latest.started_at) return latest;
|
||||
return latest.started_at > current.started_at ? latest : current;
|
||||
}, agentRuns[0]);
|
||||
selectRun(latestRun.id);
|
||||
} 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;
|
||||
|
||||
if (data.status == "COMPLETED") {
|
||||
incrementRuns();
|
||||
}
|
||||
|
||||
setAgentRuns((prev) => {
|
||||
const index = prev.findIndex((run) => run.id === data.id);
|
||||
if (index === -1) {
|
||||
return [...prev, data];
|
||||
}
|
||||
const newRuns = [...prev];
|
||||
newRuns[index] = { ...newRuns[index], ...data };
|
||||
return newRuns;
|
||||
});
|
||||
if (data.id === selectedView.id) {
|
||||
setSelectedRun((prev) => ({ ...prev, ...data }));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
detachExecUpdateHandler();
|
||||
};
|
||||
}, [api, agent?.graph_id, selectedView.id, incrementRuns]);
|
||||
|
||||
// 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 ?? 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();
|
||||
}
|
||||
setAgentRuns((runs) => runs.filter((r) => r.id !== 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 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 lg:w-auto lg:border-b-0 lg:border-r"
|
||||
agent={agent}
|
||||
agentRuns={agentRuns}
|
||||
agentPresets={agentPresets}
|
||||
schedules={schedules}
|
||||
selectedView={selectedView}
|
||||
onSelectRun={selectRun}
|
||||
onSelectPreset={selectPreset}
|
||||
onSelectSchedule={selectSchedule}
|
||||
onSelectDraftNewRun={openRunDraftView}
|
||||
doDeleteRun={setConfirmingDeleteAgentRun}
|
||||
doDeletePreset={setConfirmingDeleteAgentPreset}
|
||||
doDeleteSchedule={deleteSchedule}
|
||||
/>
|
||||
|
||||
<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}
|
||||
deleteRun={() => setConfirmingDeleteAgentRun(selectedRun)}
|
||||
/>
|
||||
) : null
|
||||
) : selectedView.type == "run" ? (
|
||||
/* Draft new runs / Create new presets */
|
||||
<AgentRunDraftView
|
||||
graph={graph}
|
||||
triggerSetupInfo={agent.trigger_setup_info}
|
||||
onRun={selectRun}
|
||||
onCreateSchedule={onCreateSchedule}
|
||||
onCreatePreset={onCreatePreset}
|
||||
agentActions={agentActions}
|
||||
/>
|
||||
) : selectedView.type == "preset" ? (
|
||||
/* Edit & update presets */
|
||||
<AgentRunDraftView
|
||||
graph={graph}
|
||||
triggerSetupInfo={agent.trigger_setup_info}
|
||||
agentPreset={
|
||||
agentPresets.find((preset) => preset.id == selectedView.id)!
|
||||
}
|
||||
onRun={selectRun}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
} from "@/components/agents/agent-run-status-chip";
|
||||
import useCredits from "@/hooks/useCredits";
|
||||
|
||||
export default function AgentRunDetailsView({
|
||||
export function AgentRunDetailsView({
|
||||
agent,
|
||||
graph,
|
||||
run,
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
useToastOnFail,
|
||||
} from "@/components/molecules/Toast/use-toast";
|
||||
|
||||
export default function AgentRunDraftView({
|
||||
export function AgentRunDraftView({
|
||||
graph,
|
||||
agentPreset,
|
||||
triggerSetupInfo,
|
||||
@@ -19,7 +19,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
|
||||
import { agentRunStatusMap } from "@/components/agents/agent-run-status-chip";
|
||||
import AgentRunSummaryCard from "@/components/agents/agent-run-summary-card";
|
||||
import { Button } from "../atoms/Button/Button";
|
||||
import { Button } from "../../../../../../../../components/atoms/Button/Button";
|
||||
|
||||
interface AgentRunsSelectorListProps {
|
||||
agent: LibraryAgent;
|
||||
@@ -38,7 +38,7 @@ interface AgentRunsSelectorListProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AgentRunsSelectorList({
|
||||
export function AgentRunsSelectorList({
|
||||
agent,
|
||||
agentRuns,
|
||||
agentPresets,
|
||||
@@ -20,7 +20,7 @@ import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
|
||||
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
|
||||
import { PlayIcon } from "lucide-react";
|
||||
|
||||
export default function AgentScheduleDetailsView({
|
||||
export function AgentScheduleDetailsView({
|
||||
graph,
|
||||
schedule,
|
||||
agentActions,
|
||||
@@ -1,21 +1,3 @@
|
||||
import AgentFlowListSkeleton from "@/components/monitor/skeletons/AgentFlowListSkeleton";
|
||||
import React from "react";
|
||||
import FlowRunsListSkeleton from "@/components/monitor/skeletons/FlowRunsListSkeleton";
|
||||
import FlowRunsStatusSkeleton from "@/components/monitor/skeletons/FlowRunsStatusSkeleton";
|
||||
import { AgentRunsLoading } from "./components/AgentRunsView/components/AgentRunsLoading";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
export default AgentRunsLoading;
|
||||
|
||||
@@ -1,622 +1,16 @@
|
||||
"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 { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
|
||||
import AgentRunDetailsView from "@/components/agents/agent-run-details-view";
|
||||
import AgentRunDraftView from "@/components/agents/agent-run-draft-view";
|
||||
import AgentRunsSelectorList from "@/components/agents/agent-runs-selector-list";
|
||||
import AgentScheduleDetailsView from "@/components/agents/agent-schedule-details-view";
|
||||
import DeleteConfirmDialog from "@/components/agptui/delete-confirm-dialog";
|
||||
import type { ButtonAction } from "@/components/agptui/types";
|
||||
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import LoadingBox, { LoadingSpinner } from "@/components/ui/loading";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { OldAgentLibraryView } from "./components/OldAgentLibraryView/OldAgentLibraryView";
|
||||
import { AgentRunsView } from "./components/AgentRunsView/AgentRunsView";
|
||||
|
||||
export default function AgentRunsPage(): React.ReactElement {
|
||||
const { id: agentID }: { id: LibraryAgentID } = useParams();
|
||||
const [executionId, setExecutionId] = useQueryState("executionId");
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const api = useBackendAPI();
|
||||
export default function AgentLibraryPage() {
|
||||
const isNewAgentRunsEnabled = useGetFlag(Flag.NEW_AGENT_RUNS);
|
||||
|
||||
// ============================ STATE =============================
|
||||
|
||||
const [graph, setGraph] = useState<Graph | null>(null); // Graph version corresponding to LibraryAgent
|
||||
const [agent, setAgent] = useState<LibraryAgent | null>(null);
|
||||
const [agentRuns, setAgentRuns] = useState<GraphExecutionMeta[]>([]);
|
||||
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 {
|
||||
state: onboardingState,
|
||||
updateState: updateOnboardingState,
|
||||
incrementRuns,
|
||||
} = useOnboarding();
|
||||
const [copyAgentDialogOpen, setCopyAgentDialogOpen] = useState(false);
|
||||
|
||||
// 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],
|
||||
);
|
||||
|
||||
// Reward user for viewing results of their onboarding agent
|
||||
useEffect(() => {
|
||||
if (
|
||||
!onboardingState ||
|
||||
!selectedRun ||
|
||||
onboardingState.completedSteps.includes("GET_RESULTS")
|
||||
)
|
||||
return;
|
||||
|
||||
if (selectedRun.id === onboardingState.onboardingAgentExecutionId) {
|
||||
updateOnboardingState({
|
||||
completedSteps: [...onboardingState.completedSteps, "GET_RESULTS"],
|
||||
});
|
||||
}
|
||||
}, [selectedRun, onboardingState, updateOnboardingState]);
|
||||
|
||||
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([
|
||||
api.getGraphExecutions(agent.graph_id),
|
||||
api.listLibraryAgentPresets({
|
||||
graph_id: agent.graph_id,
|
||||
page_size: 100,
|
||||
}),
|
||||
]).then(([runs, presets]) => {
|
||||
setAgentRuns(runs);
|
||||
setAgentPresets(presets.presets);
|
||||
|
||||
// Preload the corresponding graph versions for the latest 10 runs
|
||||
new Set(runs.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 current;
|
||||
else if (!latest.started_at) return latest;
|
||||
return latest.started_at > current.started_at ? latest : current;
|
||||
}, agentRuns[0]);
|
||||
selectRun(latestRun.id);
|
||||
} 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;
|
||||
|
||||
if (data.status == "COMPLETED") {
|
||||
incrementRuns();
|
||||
}
|
||||
|
||||
setAgentRuns((prev) => {
|
||||
const index = prev.findIndex((run) => run.id === data.id);
|
||||
if (index === -1) {
|
||||
return [...prev, data];
|
||||
}
|
||||
const newRuns = [...prev];
|
||||
newRuns[index] = { ...newRuns[index], ...data };
|
||||
return newRuns;
|
||||
});
|
||||
if (data.id === selectedView.id) {
|
||||
setSelectedRun((prev) => ({ ...prev, ...data }));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
detachExecUpdateHandler();
|
||||
};
|
||||
}, [api, agent?.graph_id, selectedView.id, incrementRuns]);
|
||||
|
||||
// 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 ?? 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();
|
||||
}
|
||||
setAgentRuns((runs) => runs.filter((r) => r.id !== 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 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]" />;
|
||||
if (isNewAgentRunsEnabled) {
|
||||
return <AgentRunsView />;
|
||||
}
|
||||
|
||||
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 lg:w-auto lg:border-b-0 lg:border-r"
|
||||
agent={agent}
|
||||
agentRuns={agentRuns}
|
||||
agentPresets={agentPresets}
|
||||
schedules={schedules}
|
||||
selectedView={selectedView}
|
||||
onSelectRun={selectRun}
|
||||
onSelectPreset={selectPreset}
|
||||
onSelectSchedule={selectSchedule}
|
||||
onSelectDraftNewRun={openRunDraftView}
|
||||
doDeleteRun={setConfirmingDeleteAgentRun}
|
||||
doDeletePreset={setConfirmingDeleteAgentPreset}
|
||||
doDeleteSchedule={deleteSchedule}
|
||||
/>
|
||||
|
||||
<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}
|
||||
deleteRun={() => setConfirmingDeleteAgentRun(selectedRun)}
|
||||
/>
|
||||
) : null
|
||||
) : selectedView.type == "run" ? (
|
||||
/* Draft new runs / Create new presets */
|
||||
<AgentRunDraftView
|
||||
graph={graph}
|
||||
triggerSetupInfo={agent.trigger_setup_info}
|
||||
onRun={selectRun}
|
||||
onCreateSchedule={onCreateSchedule}
|
||||
onCreatePreset={onCreatePreset}
|
||||
agentActions={agentActions}
|
||||
/>
|
||||
) : selectedView.type == "preset" ? (
|
||||
/* Edit & update presets */
|
||||
<AgentRunDraftView
|
||||
graph={graph}
|
||||
triggerSetupInfo={agent.trigger_setup_info}
|
||||
agentPreset={
|
||||
agentPresets.find((preset) => preset.id == selectedView.id)!
|
||||
}
|
||||
onRun={selectRun}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <OldAgentLibraryView />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import BackendAPI from "@/lib/autogpt-server-api";
|
||||
import { BreadCrumbs } from "@/components/agptui/BreadCrumbs";
|
||||
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
|
||||
import { AgentInfo } from "@/components/agptui/AgentInfo";
|
||||
import { AgentImages } from "@/components/agptui/AgentImages";
|
||||
import { AgentsSection } from "@/components/agptui/composite/AgentsSection";
|
||||
@@ -73,7 +73,7 @@ export default async function MarketplaceAgentPage({
|
||||
return (
|
||||
<div className="mx-auto w-screen max-w-[1360px]">
|
||||
<main className="mt-5 px-4">
|
||||
<BreadCrumbs items={breadcrumbs} />
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
<div className="mt-4 flex flex-col items-start gap-4 sm:mt-6 sm:gap-6 md:mt-8 md:flex-row md:gap-8">
|
||||
<div className="w-full md:w-auto md:shrink-0">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import BackendAPI from "@/lib/autogpt-server-api";
|
||||
import { AgentsSection } from "@/components/agptui/composite/AgentsSection";
|
||||
import { BreadCrumbs } from "@/components/agptui/BreadCrumbs";
|
||||
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
|
||||
import { Metadata } from "next";
|
||||
import { CreatorInfoCard } from "@/components/agptui/CreatorInfoCard";
|
||||
import { CreatorLinks } from "@/components/agptui/CreatorLinks";
|
||||
@@ -49,7 +49,7 @@ export default async function Page({
|
||||
return (
|
||||
<div className="mx-auto w-screen max-w-[1360px]">
|
||||
<main className="mt-5 px-4">
|
||||
<BreadCrumbs
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ name: "Store", link: "/marketplace" },
|
||||
{ name: creator.name, link: "#" },
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface BreadcrumbItem {
|
||||
name: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
interface BreadCrumbsProps {
|
||||
items: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
export const BreadCrumbs: React.FC<BreadCrumbsProps> = ({ items }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
{/*
|
||||
Commented out for now, but keeping until we have approval to remove
|
||||
<button className="flex h-12 w-12 items-center justify-center rounded-full border border-neutral-200 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800">
|
||||
<IconLeftArrow className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
|
||||
</button>
|
||||
<button className="flex h-12 w-12 items-center justify-center rounded-full border border-neutral-200 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800">
|
||||
<IconRightArrow className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
|
||||
</button> */}
|
||||
<div className="flex h-auto flex-wrap items-center justify-start gap-4 rounded-[5rem] dark:bg-transparent">
|
||||
{items.map((item, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Link href={item.link}>
|
||||
<span className="rounded py-1 pr-2 text-xl font-medium leading-9 tracking-tight text-[#272727] transition-colors duration-200 hover:text-gray-400 dark:text-neutral-100 dark:hover:text-gray-500">
|
||||
{item.name}
|
||||
</span>
|
||||
</Link>
|
||||
{index < items.length - 1 && (
|
||||
<span className="text-center text-2xl font-normal text-black dark:text-neutral-100">
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
import { Link } from "@/components/atoms/Link/Link";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
|
||||
interface BreadcrumbItem {
|
||||
name: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
export function Breadcrumbs({ items }: Props) {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-auto flex-wrap items-center justify-start gap-3 rounded-[5rem] dark:bg-transparent">
|
||||
{items.map((item, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Link
|
||||
href={item.link}
|
||||
className="text-zinc-700 transition-colors hover:text-zinc-900 hover:no-underline"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
{index < items.length - 1 && (
|
||||
<Text variant="body-medium" className="text-zinc-700">
|
||||
/
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { ErrorCard } from "./ErrorCard";
|
||||
import { ArrowClockwise } from "@phosphor-icons/react";
|
||||
|
||||
const meta: Meta<typeof ErrorCard> = {
|
||||
title: "Molecules/ErrorCard",
|
||||
component: ErrorCard,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
## ErrorCard Component
|
||||
|
||||
A reusable error card component that handles API query responses gracefully with elegant styling and user-friendly messaging.
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **Purple gradient border** - Elegant gradient from purple to light pastel purple
|
||||
- **Smart error handling** - Automatically detects HTTP errors vs response errors
|
||||
- **User-friendly messages** - Non-technical, funny error messages for HTTP errors
|
||||
- **Loading state** - Supports custom loading slot or default spinner
|
||||
- **Action buttons** - Sentry error reporting with toast notifications and Discord help link
|
||||
- **Phosphor icons** - Uses phosphor icons throughout
|
||||
- **TypeScript support** - Full TypeScript interface support
|
||||
|
||||
### 🎯 Magic Usage Pattern
|
||||
|
||||
Just pass your API hook results directly:
|
||||
|
||||
\`\`\`tsx
|
||||
<ErrorCard
|
||||
isSuccess={isSuccess}
|
||||
responseError={error || undefined}
|
||||
httpError={response?.status !== 200 ? {
|
||||
status: response?.status,
|
||||
statusText: "Request failed"
|
||||
} : undefined}
|
||||
context="agent data"
|
||||
onRetry={refetch}
|
||||
/>
|
||||
\`\`\`
|
||||
|
||||
The component will automatically:
|
||||
1. Show loading spinner if not successful and no errors
|
||||
2. Show custom loading slot if provided
|
||||
3. Handle HTTP errors with friendly messages
|
||||
4. Handle response errors with technical details
|
||||
5. Report errors to Sentry with comprehensive context
|
||||
6. Show toast notifications for error reporting feedback
|
||||
7. Provide retry and Discord help options
|
||||
8. Hide itself if successful and no errors
|
||||
|
||||
### 🎭 User-Friendly HTTP Error Messages
|
||||
|
||||
- **500+**: "Our servers are having a bit of a moment 🤖"
|
||||
- **404**: "We couldn't find what you're looking for. It might have wandered off somewhere! 🔍"
|
||||
- **403**: "You don't have permission to access this. Maybe you need to sign in again? 🔐"
|
||||
- **429**: "Whoa there, speed racer! You're making requests too quickly. Take a breather and try again. ⏱️"
|
||||
- **400+**: "Something's not quite right with your request. Double-check and try again! ✨"
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
isSuccess: {
|
||||
control: "boolean",
|
||||
description: "Whether the API request was successful",
|
||||
table: {
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
},
|
||||
responseError: {
|
||||
control: "object",
|
||||
description: "Error object from API response (validation errors, etc.)",
|
||||
},
|
||||
httpError: {
|
||||
control: "object",
|
||||
description: "HTTP error object with status code and message",
|
||||
},
|
||||
context: {
|
||||
control: "text",
|
||||
description: "Context for the error message (e.g., 'user data', 'agent')",
|
||||
table: {
|
||||
defaultValue: { summary: '"data"' },
|
||||
},
|
||||
},
|
||||
loadingSlot: {
|
||||
control: false,
|
||||
description:
|
||||
"Custom loading component to show instead of default spinner",
|
||||
},
|
||||
onRetry: {
|
||||
control: false,
|
||||
description:
|
||||
"Callback function for retry button (button only shows if provided)",
|
||||
},
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes to apply",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/**
|
||||
* Response errors are typically validation errors from the API.
|
||||
* They show the technical error message in a code block for debugging.
|
||||
*/
|
||||
export const ResponseError: Story = {
|
||||
args: {
|
||||
isSuccess: false,
|
||||
responseError: {
|
||||
detail: [{ msg: "Invalid authentication credentials provided" }],
|
||||
},
|
||||
context: "user data",
|
||||
onRetry: () => alert("Retry clicked!"),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP 500+ errors get a friendly, non-technical message about server issues.
|
||||
*/
|
||||
export const HttpError500: Story = {
|
||||
args: {
|
||||
isSuccess: false,
|
||||
httpError: {
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
},
|
||||
context: "agent data",
|
||||
onRetry: () => alert("Retry clicked!"),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP 404 errors get a playful message about missing resources.
|
||||
*/
|
||||
export const HttpError404: Story = {
|
||||
args: {
|
||||
isSuccess: false,
|
||||
httpError: {
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
},
|
||||
context: "agent data",
|
||||
onRetry: () => alert("Retry clicked!"),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP 429 errors get a humorous rate limiting message.
|
||||
*/
|
||||
export const HttpError429: Story = {
|
||||
args: {
|
||||
isSuccess: false,
|
||||
httpError: {
|
||||
status: 429,
|
||||
statusText: "Too Many Requests",
|
||||
},
|
||||
context: "API data",
|
||||
onRetry: () => alert("Retry clicked!"),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP 403 errors suggest re-authentication.
|
||||
*/
|
||||
export const HttpError403: Story = {
|
||||
args: {
|
||||
isSuccess: false,
|
||||
httpError: {
|
||||
status: 403,
|
||||
statusText: "Forbidden",
|
||||
},
|
||||
context: "user profile",
|
||||
onRetry: () => alert("Retry clicked!"),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Default loading state shows a spinning phosphor icon.
|
||||
*/
|
||||
export const LoadingState: Story = {
|
||||
args: {
|
||||
isSuccess: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* You can provide a custom loading component via the loadingSlot prop.
|
||||
*/
|
||||
export const CustomLoadingSlot: Story = {
|
||||
args: {
|
||||
loadingSlot: (
|
||||
<div className="flex items-center gap-3">
|
||||
<ArrowClockwise
|
||||
size={20}
|
||||
weight="bold"
|
||||
className="animate-spin text-purple-500"
|
||||
/>
|
||||
<span className="text-zinc-600">Loading your awesome data...</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Response errors can also have string error details instead of arrays.
|
||||
*/
|
||||
export const StringErrorDetail: Story = {
|
||||
args: {
|
||||
isSuccess: false,
|
||||
responseError: {
|
||||
detail: "Something went wrong with the database connection",
|
||||
},
|
||||
context: "database",
|
||||
onRetry: () => alert("Retry clicked!"),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* If no onRetry callback is provided, the retry button won't appear.
|
||||
*/
|
||||
export const NoRetryButton: Story = {
|
||||
args: {
|
||||
isSuccess: false,
|
||||
responseError: {
|
||||
message: "This error cannot be retried",
|
||||
},
|
||||
context: "configuration",
|
||||
// No onRetry prop - button won't show
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Response errors with just a message property.
|
||||
*/
|
||||
export const SimpleMessage: Story = {
|
||||
args: {
|
||||
isSuccess: false,
|
||||
responseError: {
|
||||
message: "User session has expired",
|
||||
},
|
||||
context: "authentication",
|
||||
onRetry: () => alert("Retry clicked!"),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Typical usage pattern with React Query or similar data fetching hooks.
|
||||
*/
|
||||
export const TypicalUsage: Story = {
|
||||
args: {
|
||||
isSuccess: false,
|
||||
responseError: {
|
||||
detail: [{ msg: "Agent not found in library" }],
|
||||
},
|
||||
context: "agent",
|
||||
onRetry: () => alert("This would typically call refetch() or similar"),
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: `
|
||||
This shows how you'd typically use ErrorCard with a data fetching hook:
|
||||
|
||||
\`\`\`tsx
|
||||
function MyComponent() {
|
||||
const { data: response, isSuccess, error } = useApiHook();
|
||||
|
||||
return (
|
||||
<ErrorCard
|
||||
isSuccess={isSuccess}
|
||||
responseError={error || undefined}
|
||||
httpError={response?.status !== 200 ? {
|
||||
status: response?.status,
|
||||
statusText: "Request failed"
|
||||
} : undefined}
|
||||
context="agent"
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
\`\`\`
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import { getErrorMessage, getHttpErrorMessage, isHttpError } from "./helpers";
|
||||
import { CardWrapper } from "./components/CardWrapper";
|
||||
import { ErrorHeader } from "./components/ErrorHeader";
|
||||
import { ErrorMessage } from "./components/ErrorMessage";
|
||||
import { ActionButtons } from "./components/ActionButtons";
|
||||
|
||||
export interface ErrorCardProps {
|
||||
isSuccess?: boolean;
|
||||
responseError?: {
|
||||
detail?: Array<{ msg: string }> | string;
|
||||
message?: string;
|
||||
};
|
||||
httpError?: {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
message?: string;
|
||||
};
|
||||
context?: string;
|
||||
loadingSlot?: React.ReactNode;
|
||||
onRetry?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ErrorCard({
|
||||
isSuccess = false,
|
||||
responseError,
|
||||
httpError,
|
||||
context = "data",
|
||||
onRetry,
|
||||
className = "",
|
||||
}: ErrorCardProps) {
|
||||
if (isSuccess && !responseError && !httpError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isHttp = isHttpError(httpError);
|
||||
|
||||
const errorMessage = isHttp
|
||||
? getHttpErrorMessage(httpError)
|
||||
: getErrorMessage(responseError);
|
||||
|
||||
return (
|
||||
<CardWrapper className={className}>
|
||||
<div className="relative space-y-4 p-6">
|
||||
<ErrorHeader />
|
||||
<ErrorMessage
|
||||
isHttpError={isHttp}
|
||||
errorMessage={errorMessage}
|
||||
context={context}
|
||||
/>
|
||||
<ActionButtons
|
||||
onRetry={onRetry}
|
||||
responseError={responseError}
|
||||
httpError={httpError}
|
||||
context={context}
|
||||
/>
|
||||
</div>
|
||||
</CardWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
import { ArrowClockwise, Bug, DiscordLogo } from "@phosphor-icons/react";
|
||||
import { handleReportError } from "../helpers";
|
||||
import { ErrorCardProps } from "../ErrorCard";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
|
||||
interface ActionButtonsProps {
|
||||
onRetry?: () => void;
|
||||
responseError?: ErrorCardProps["responseError"];
|
||||
httpError?: ErrorCardProps["httpError"];
|
||||
context: string;
|
||||
}
|
||||
|
||||
export function ActionButtons({
|
||||
onRetry,
|
||||
responseError,
|
||||
httpError,
|
||||
context,
|
||||
}: ActionButtonsProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pt-2 sm:flex-row">
|
||||
{onRetry && (
|
||||
<Button onClick={onRetry} variant="outline" size="small">
|
||||
<ArrowClockwise size={16} weight="bold" />
|
||||
Try Again
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => handleReportError(responseError, httpError, context)}
|
||||
variant="ghost"
|
||||
size="small"
|
||||
>
|
||||
<Bug size={16} weight="bold" />
|
||||
Report Error
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
as="NextLink"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
href="https://discord.gg/autogpt"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<DiscordLogo size={16} weight="fill" />
|
||||
Get Help
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import { colors } from "@/components/styles/colors";
|
||||
|
||||
interface CardWrapperProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardWrapper({ children, className = "" }: CardWrapperProps) {
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-xl ${className}`}>
|
||||
{/* Purple gradient border */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-xl p-[1px]"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${colors.zinc[500]}, ${colors.zinc[200]}, ${colors.zinc[100]})`,
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full rounded-xl bg-white" />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Warning } from "@phosphor-icons/react";
|
||||
|
||||
export function ErrorHeader() {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<Warning size={24} weight="fill" className="text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<Text variant="large-semibold" className="text-zinc-800">
|
||||
Something went wrong
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
|
||||
interface ErrorMessageProps {
|
||||
isHttpError: boolean;
|
||||
errorMessage: string;
|
||||
context: string;
|
||||
}
|
||||
|
||||
export function ErrorMessage({
|
||||
isHttpError,
|
||||
errorMessage,
|
||||
context,
|
||||
}: ErrorMessageProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{isHttpError ? (
|
||||
<Text variant="body" className="text-zinc-700">
|
||||
{errorMessage}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text variant="body" className="text-zinc-700">
|
||||
We had the following error when retrieving {context ?? "your data"}:
|
||||
</Text>
|
||||
<div className="rounded-lg border border-zinc-100 bg-zinc-50 p-3">
|
||||
<Text variant="body" className="!text-red-700">
|
||||
{errorMessage}
|
||||
</Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { ArrowClockwise } from "@phosphor-icons/react";
|
||||
|
||||
interface LoadingStateProps {
|
||||
loadingSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LoadingState({ loadingSlot }: LoadingStateProps) {
|
||||
return (
|
||||
<div className="relative flex items-center justify-center gap-3 p-6">
|
||||
{loadingSlot || (
|
||||
<>
|
||||
<ArrowClockwise
|
||||
size={20}
|
||||
weight="bold"
|
||||
className="animate-spin text-purple-500"
|
||||
/>
|
||||
<Text variant="body" className="text-zinc-600">
|
||||
Loading...
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { ErrorCardProps } from "./ErrorCard";
|
||||
|
||||
export function getErrorMessage(
|
||||
error: ErrorCardProps["responseError"],
|
||||
): string {
|
||||
if (!error) return "Unknown error occurred";
|
||||
|
||||
if (typeof error.detail === "string") return error.detail;
|
||||
if (Array.isArray(error.detail) && error.detail.length > 0) {
|
||||
return error.detail[0].msg;
|
||||
}
|
||||
if (error.message) return error.message;
|
||||
|
||||
return "Unknown error occurred";
|
||||
}
|
||||
|
||||
export function getHttpErrorMessage(
|
||||
httpError: ErrorCardProps["httpError"],
|
||||
): string {
|
||||
if (!httpError) return "";
|
||||
|
||||
const status = httpError.status || 0;
|
||||
|
||||
if (status >= 500) {
|
||||
return "An internal server error has occurred. Please try again in a few minutes.";
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
return "The requested resource could not be found. Please verify the URL and try again.";
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
return "Access to this resource is forbidden. Please check your permissions or sign in again.";
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
return "Too many requests have been made. Please wait a moment before trying again.";
|
||||
}
|
||||
|
||||
if (status >= 400) {
|
||||
return "The request could not be processed. Please review your input and try again.";
|
||||
}
|
||||
|
||||
return "An unexpected error has occurred. Our team has been notified and is working to resolve the issue.";
|
||||
}
|
||||
|
||||
export function shouldShowError(
|
||||
isSuccess: boolean,
|
||||
responseError?: ErrorCardProps["responseError"],
|
||||
httpError?: ErrorCardProps["httpError"],
|
||||
): boolean {
|
||||
return !isSuccess || !!responseError || !!httpError;
|
||||
}
|
||||
|
||||
export function isHttpError(httpError?: ErrorCardProps["httpError"]): boolean {
|
||||
return !!httpError;
|
||||
}
|
||||
|
||||
export function handleReportError(
|
||||
responseError?: ErrorCardProps["responseError"],
|
||||
httpError?: ErrorCardProps["httpError"],
|
||||
context?: string,
|
||||
): void {
|
||||
try {
|
||||
// Import Sentry dynamically to avoid SSR issues
|
||||
import("@sentry/nextjs").then((Sentry) => {
|
||||
// Create a comprehensive error object for Sentry
|
||||
const errorData = {
|
||||
responseError,
|
||||
httpError,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent:
|
||||
typeof window !== "undefined" ? window.navigator.userAgent : "server",
|
||||
url: typeof window !== "undefined" ? window.location.href : "unknown",
|
||||
};
|
||||
|
||||
// Create an error object that Sentry can capture
|
||||
const errorMessage = httpError
|
||||
? `HTTP ${httpError.status} - ${httpError.statusText || "Error"}`
|
||||
: responseError
|
||||
? `Response Error - ${getErrorMessage(responseError)}`
|
||||
: "Unknown Error";
|
||||
|
||||
const error = new Error(
|
||||
`ErrorCard: ${context ? `${context} ` : ""}${errorMessage}`,
|
||||
);
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("component", "ErrorCard");
|
||||
scope.setTag("errorType", httpError ? "http" : "response");
|
||||
scope.setContext("errorDetails", errorData);
|
||||
|
||||
if (context) {
|
||||
scope.setTag("context", context);
|
||||
}
|
||||
|
||||
if (httpError?.status) {
|
||||
scope.setTag("httpStatus", httpError.status.toString());
|
||||
}
|
||||
|
||||
Sentry.captureException(error);
|
||||
});
|
||||
|
||||
// Show success toast notification after pressing the report error button
|
||||
import("sonner").then(({ toast }) => {
|
||||
toast.success("Error reported successfully", {
|
||||
description:
|
||||
"Thank you for helping us improve! Our team has been notified.",
|
||||
duration: 4000,
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to report error to Sentry:", error);
|
||||
// Fallback toast notification
|
||||
import("sonner").then(({ toast }) => {
|
||||
toast.error("Failed to report error", {
|
||||
description: "Please try again or contact support directly.",
|
||||
duration: 4000,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import AgentRunDraftView from "@/components/agents/agent-run-draft-view";
|
||||
import { AgentRunDraftView } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-draft-view";
|
||||
|
||||
interface RunInputDialogProps {
|
||||
isOpen: boolean;
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useFlags } from "launchdarkly-react-client-sdk";
|
||||
|
||||
export enum Flag {
|
||||
BETA_BLOCKS = "beta-blocks",
|
||||
AGENT_ACTIVITY = "agent-activity",
|
||||
NEW_BLOCK_MENU = "new-block-menu",
|
||||
NEW_AGENT_RUNS = "new-agent-runs",
|
||||
}
|
||||
|
||||
export type FlagValues = {
|
||||
[Flag.BETA_BLOCKS]: string[];
|
||||
[Flag.AGENT_ACTIVITY]: boolean;
|
||||
[Flag.NEW_BLOCK_MENU]: boolean;
|
||||
[Flag.NEW_AGENT_RUNS]: boolean;
|
||||
};
|
||||
|
||||
const isTest = process.env.NEXT_PUBLIC_PW_TEST === "true";
|
||||
@@ -17,7 +21,8 @@ const isTest = process.env.NEXT_PUBLIC_PW_TEST === "true";
|
||||
const mockFlags = {
|
||||
[Flag.BETA_BLOCKS]: [],
|
||||
[Flag.AGENT_ACTIVITY]: true,
|
||||
[Flag.NEW_BLOCK_MENU]: false, // TODO: change to true when new block menu is ready
|
||||
[Flag.NEW_BLOCK_MENU]: false,
|
||||
[Flag.NEW_AGENT_RUNS]: false,
|
||||
};
|
||||
|
||||
export function useGetFlag<T extends Flag>(flag: T): FlagValues[T] | null {
|
||||
|
||||
Reference in New Issue
Block a user