mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-07 22:33:57 -05:00
feat(frontend): use websockets on new library page + fixes (#11526)
## Changes 🏗️ <img width="800" height="614" alt="Screenshot 2025-12-03 at 14 52 46" src="https://github.com/user-attachments/assets/c7012d7a-96d4-4268-a53b-27f2f7322a39" /> - Create a new `useExecutionEvents()` hook that can be used to subscribe to agent executions - now both the **Activity Dropdown** and the new **Library Agent Page** use that - so subscribing to executions is centralised on a single place - Apply a couple of design fixes - Fix not being able to select the new templates tab ## 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] Run the app locally and verify the above
This commit is contained in:
@@ -69,7 +69,7 @@ export function SelectedRunView({
|
||||
|
||||
if (isLoading && !run) {
|
||||
return (
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex-1 space-y-4 px-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/components/molecules/TabsLine/TabsLine";
|
||||
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
|
||||
import { formatInTimezone, getTimezoneDisplayName } from "@/lib/timezone-utils";
|
||||
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
|
||||
import { AgentInputsReadOnly } from "../../modals/AgentInputsReadOnly/AgentInputsReadOnly";
|
||||
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
|
||||
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
|
||||
@@ -68,7 +69,7 @@ export function SelectedScheduleView({
|
||||
|
||||
if (isLoading && !schedule) {
|
||||
return (
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex-1 space-y-4 px-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
@@ -103,7 +104,7 @@ export function SelectedScheduleView({
|
||||
</div>
|
||||
|
||||
<TabsLine defaultValue="input">
|
||||
<TabsLineList>
|
||||
<TabsLineList className={AGENT_LIBRARY_SECTION_PADDING_X}>
|
||||
<TabsLineTrigger value="input">Your input</TabsLineTrigger>
|
||||
<TabsLineTrigger value="schedule">Schedule</TabsLineTrigger>
|
||||
</TabsLineList>
|
||||
|
||||
@@ -23,7 +23,7 @@ interface Props {
|
||||
selectedRunId?: string;
|
||||
onSelectRun: (id: string, tab?: "runs" | "scheduled") => void;
|
||||
onClearSelectedRun?: () => void;
|
||||
onTabChange?: (tab: "runs" | "scheduled") => void;
|
||||
onTabChange?: (tab: "runs" | "scheduled" | "templates") => void;
|
||||
onCountsChange?: (info: {
|
||||
runsCount: number;
|
||||
schedulesCount: number;
|
||||
@@ -74,7 +74,7 @@ export function SidebarRunsList({
|
||||
<TabsLine
|
||||
value={tabValue}
|
||||
onValueChange={(v) => {
|
||||
const value = v as "runs" | "scheduled";
|
||||
const value = v as "runs" | "scheduled" | "templates";
|
||||
onTabChange?.(value);
|
||||
if (value === "runs") {
|
||||
if (runs && runs.length) {
|
||||
@@ -82,12 +82,14 @@ export function SidebarRunsList({
|
||||
} else {
|
||||
onClearSelectedRun?.();
|
||||
}
|
||||
} else {
|
||||
} else if (value === "scheduled") {
|
||||
if (schedules && schedules.length) {
|
||||
onSelectRun(schedules[0].id, "scheduled");
|
||||
} else {
|
||||
onClearSelectedRun?.();
|
||||
}
|
||||
} else if (value === "templates") {
|
||||
onClearSelectedRun?.();
|
||||
}
|
||||
}}
|
||||
className="flex min-h-0 flex-col overflow-hidden"
|
||||
@@ -134,7 +136,7 @@ export function SidebarRunsList({
|
||||
<TabsLineContent
|
||||
value="scheduled"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col",
|
||||
"mt-0 flex min-h-0 flex-1 flex-col",
|
||||
AGENT_LIBRARY_SECTION_PADDING_X,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { GraphExecutionsPaginated } from "@/app/api/__generated__/models/graphExecutionsPaginated";
|
||||
import type { InfiniteData } from "@tanstack/react-query";
|
||||
|
||||
const AGENT_RUNNING_POLL_INTERVAL = 1500;
|
||||
|
||||
function hasValidExecutionsData(
|
||||
page: unknown,
|
||||
): page is { data: GraphExecutionsPaginated } {
|
||||
@@ -16,26 +14,6 @@ function hasValidExecutionsData(
|
||||
);
|
||||
}
|
||||
|
||||
export function getRunsPollingInterval(
|
||||
pages: Array<unknown> | undefined,
|
||||
isRunsTab: boolean,
|
||||
): number | false {
|
||||
if (!isRunsTab || !pages?.length) return false;
|
||||
|
||||
try {
|
||||
const executions = pages.flatMap((page) => {
|
||||
if (!hasValidExecutionsData(page)) return [];
|
||||
return page.data.executions || [];
|
||||
});
|
||||
const hasActive = executions.some(
|
||||
(e) => e.status === "RUNNING" || e.status === "QUEUED",
|
||||
);
|
||||
return hasActive ? AGENT_RUNNING_POLL_INTERVAL : false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function computeRunsCount(
|
||||
infiniteData: InfiniteData<unknown> | undefined,
|
||||
runsLength: number,
|
||||
|
||||
@@ -6,12 +6,13 @@ import { useGetV1ListGraphExecutionsInfinite } from "@/app/api/__generated__/end
|
||||
import { useGetV1ListExecutionSchedulesForAGraph } from "@/app/api/__generated__/endpoints/schedules/schedules";
|
||||
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { useExecutionEvents } from "@/hooks/useExecutionEvents";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { parseAsString, useQueryStates } from "nuqs";
|
||||
import {
|
||||
computeRunsCount,
|
||||
extractRunsFromPages,
|
||||
getNextRunsPageParam,
|
||||
getRunsPollingInterval,
|
||||
} from "./helpers";
|
||||
|
||||
function parseTab(value: string | null): "runs" | "scheduled" | "templates" {
|
||||
@@ -42,6 +43,7 @@ export function useSidebarRunsList({
|
||||
});
|
||||
|
||||
const tabValue = useMemo(() => parseTab(activeTabRaw), [activeTabRaw]);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const runsQuery = useGetV1ListGraphExecutionsInfinite(
|
||||
graphId || "",
|
||||
@@ -49,9 +51,6 @@ export function useSidebarRunsList({
|
||||
{
|
||||
query: {
|
||||
enabled: !!graphId,
|
||||
refetchInterval: (q) =>
|
||||
getRunsPollingInterval(q.state.data?.pages, tabValue === "runs"),
|
||||
refetchIntervalInBackground: true,
|
||||
refetchOnWindowFocus: false,
|
||||
getNextPageParam: getNextRunsPageParam,
|
||||
},
|
||||
@@ -79,6 +78,19 @@ export function useSidebarRunsList({
|
||||
const schedulesCount = schedules.length;
|
||||
const loading = !schedulesQuery.isSuccess || !runsQuery.isSuccess;
|
||||
|
||||
// Update query cache when execution events arrive via websocket
|
||||
useExecutionEvents({
|
||||
graphId: graphId || undefined,
|
||||
enabled: !!graphId && tabValue === "runs",
|
||||
onExecutionUpdate: (_execution) => {
|
||||
// Invalidate and refetch the query to ensure we have the latest data
|
||||
// This is simpler and more reliable than manually updating the cache
|
||||
// The queryKey is stable and includes the graphId, so this only invalidates
|
||||
// queries for this specific graph's executions
|
||||
queryClient.invalidateQueries({ queryKey: runsQuery.queryKey });
|
||||
},
|
||||
});
|
||||
|
||||
// Notify parent about counts and loading state
|
||||
useEffect(() => {
|
||||
if (onCountsChange) {
|
||||
|
||||
@@ -77,7 +77,7 @@ export function useNewAgentLibraryView() {
|
||||
});
|
||||
}
|
||||
|
||||
function handleSetActiveTab(tab: "runs" | "scheduled") {
|
||||
function handleSetActiveTab(tab: "runs" | "scheduled" | "templates") {
|
||||
setQueryStates({
|
||||
activeTab: tab,
|
||||
});
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { useGetV1ListAllExecutions } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
|
||||
import BackendAPI from "@/lib/autogpt-server-api/client";
|
||||
import type { GraphExecution, GraphID } from "@/lib/autogpt-server-api/types";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useExecutionEvents } from "@/hooks/useExecutionEvents";
|
||||
import { useLibraryAgents } from "@/hooks/useLibraryAgents/useLibraryAgents";
|
||||
import type { GraphExecution } from "@/lib/autogpt-server-api/types";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
NotificationState,
|
||||
categorizeExecutions,
|
||||
handleExecutionUpdate,
|
||||
} from "./helpers";
|
||||
import { useLibraryAgents } from "@/hooks/useLibraryAgents/useLibraryAgents";
|
||||
|
||||
export function useAgentActivityDropdown() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [api] = useState(() => new BackendAPI());
|
||||
const { agentInfoMap } = useLibraryAgents();
|
||||
|
||||
const [notifications, setNotifications] = useState<NotificationState>({
|
||||
@@ -23,8 +21,6 @@ export function useAgentActivityDropdown() {
|
||||
totalCount: 0,
|
||||
});
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
const {
|
||||
data: executions,
|
||||
isSuccess: executionsSuccess,
|
||||
@@ -33,6 +29,12 @@ export function useAgentActivityDropdown() {
|
||||
query: { select: (res) => (res.status === 200 ? res.data : null) },
|
||||
});
|
||||
|
||||
// Get all graph IDs from agentInfoMap
|
||||
const graphIds = useMemo(
|
||||
() => Array.from(agentInfoMap.keys()),
|
||||
[agentInfoMap],
|
||||
);
|
||||
|
||||
// Handle real-time execution updates
|
||||
const handleExecutionEvent = useCallback(
|
||||
(execution: GraphExecution) => {
|
||||
@@ -51,45 +53,15 @@ export function useAgentActivityDropdown() {
|
||||
}
|
||||
}, [executions, executionsSuccess, agentInfoMap]);
|
||||
|
||||
// Initialize WebSocket connection for real-time updates
|
||||
useEffect(() => {
|
||||
if (!agentInfoMap.size) return;
|
||||
|
||||
const connectHandler = api.onWebSocketConnect(() => {
|
||||
setIsConnected(true);
|
||||
agentInfoMap.forEach((_, graphId) => {
|
||||
api.subscribeToGraphExecutions(graphId as GraphID).catch((error) => {
|
||||
Sentry.captureException(error, {
|
||||
tags: {
|
||||
graphId,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const disconnectHandler = api.onWebSocketDisconnect(() => {
|
||||
setIsConnected(false);
|
||||
});
|
||||
|
||||
const messageHandler = api.onWebSocketMessage(
|
||||
"graph_execution_event",
|
||||
handleExecutionEvent,
|
||||
);
|
||||
|
||||
api.connectWebSocket();
|
||||
|
||||
return () => {
|
||||
connectHandler();
|
||||
disconnectHandler();
|
||||
messageHandler();
|
||||
api.disconnectWebSocket();
|
||||
};
|
||||
}, [api, handleExecutionEvent, agentInfoMap]);
|
||||
// Subscribe to execution events for all graphs
|
||||
useExecutionEvents({
|
||||
graphIds: graphIds.length > 0 ? graphIds : undefined,
|
||||
enabled: graphIds.length > 0,
|
||||
onExecutionUpdate: handleExecutionEvent,
|
||||
});
|
||||
|
||||
return {
|
||||
...notifications,
|
||||
isConnected,
|
||||
isReady: executionsSuccess,
|
||||
error: executionsError,
|
||||
isOpen,
|
||||
|
||||
99
autogpt_platform/frontend/src/hooks/useExecutionEvents.ts
Normal file
99
autogpt_platform/frontend/src/hooks/useExecutionEvents.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import type { GraphExecution, GraphID } from "@/lib/autogpt-server-api/types";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type ExecutionEventHandler = (execution: GraphExecution) => void;
|
||||
|
||||
interface UseExecutionEventsOptions {
|
||||
graphId?: GraphID | string | null;
|
||||
graphIds?: (GraphID | string)[];
|
||||
enabled?: boolean;
|
||||
onExecutionUpdate?: ExecutionEventHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic hook to subscribe to graph execution events via WebSocket.
|
||||
* Automatically handles subscription/unsubscription and reconnection.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @param options.graphId - The graph ID to subscribe to (single graph)
|
||||
* @param options.graphIds - Array of graph IDs to subscribe to (multiple graphs)
|
||||
* @param options.enabled - Whether the subscription is enabled (default: true)
|
||||
* @param options.onExecutionUpdate - Callback invoked when an execution is updated
|
||||
*/
|
||||
export function useExecutionEvents({
|
||||
graphId,
|
||||
graphIds,
|
||||
enabled = true,
|
||||
onExecutionUpdate,
|
||||
}: UseExecutionEventsOptions) {
|
||||
const api = useBackendAPI();
|
||||
const onExecutionUpdateRef = useRef(onExecutionUpdate);
|
||||
|
||||
useEffect(() => {
|
||||
onExecutionUpdateRef.current = onExecutionUpdate;
|
||||
}, [onExecutionUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const idsToSubscribe = graphIds || (graphId ? [graphId] : []);
|
||||
if (idsToSubscribe.length === 0) return;
|
||||
|
||||
// Normalize IDs to strings for consistent comparison
|
||||
const normalizedIds = idsToSubscribe.map((id) => String(id));
|
||||
const subscribedIds = new Set<string>();
|
||||
|
||||
const handleExecutionEvent = (execution: GraphExecution) => {
|
||||
// Filter by graphIds if provided, using normalized string comparison
|
||||
if (normalizedIds.length > 0) {
|
||||
const executionGraphId = String(execution.graph_id);
|
||||
if (!normalizedIds.includes(executionGraphId)) return;
|
||||
}
|
||||
|
||||
onExecutionUpdateRef.current?.(execution);
|
||||
};
|
||||
|
||||
const connectHandler = api.onWebSocketConnect(() => {
|
||||
normalizedIds.forEach((id) => {
|
||||
// Track subscriptions to avoid duplicate subscriptions
|
||||
if (subscribedIds.has(id)) return;
|
||||
subscribedIds.add(id);
|
||||
|
||||
api
|
||||
.subscribeToGraphExecutions(id as GraphID)
|
||||
.then(() => {
|
||||
console.debug(`Subscribed to execution updates for graph ${id}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to subscribe to execution updates for graph ${id}:`,
|
||||
error,
|
||||
);
|
||||
Sentry.captureException(error, {
|
||||
tags: { graphId: id },
|
||||
});
|
||||
subscribedIds.delete(id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const messageHandler = api.onWebSocketMessage(
|
||||
"graph_execution_event",
|
||||
handleExecutionEvent,
|
||||
);
|
||||
|
||||
api.connectWebSocket();
|
||||
|
||||
return () => {
|
||||
connectHandler();
|
||||
messageHandler();
|
||||
// Note: Backend automatically cleans up subscriptions on websocket disconnect
|
||||
// If IDs change while connected, old subscriptions remain but are filtered client-side
|
||||
subscribedIds.clear();
|
||||
};
|
||||
}, [api, graphId, graphIds, enabled]);
|
||||
}
|
||||
Reference in New Issue
Block a user