Merge branch 'dev' of github.com:Significant-Gravitas/AutoGPT into aryshare-revid

This commit is contained in:
Zamil Majdy
2025-05-20 15:05:13 +01:00
19 changed files with 208 additions and 85 deletions

View File

@@ -24,6 +24,7 @@ from prisma.models import (
)
from prisma.types import (
AgentGraphExecutionCreateInput,
AgentGraphExecutionUpdateManyMutationInput,
AgentGraphExecutionWhereInput,
AgentNodeExecutionCreateInput,
AgentNodeExecutionInputOutputCreateInput,
@@ -572,9 +573,15 @@ async def update_graph_execution_stats(
status: ExecutionStatus,
stats: GraphExecutionStats | None = None,
) -> GraphExecution | None:
data = stats.model_dump() if stats else {}
if isinstance(data.get("error"), Exception):
data["error"] = str(data["error"])
update_data: AgentGraphExecutionUpdateManyMutationInput = {
"executionStatus": status
}
if stats:
stats_dict = stats.model_dump()
if isinstance(stats_dict.get("error"), Exception):
stats_dict["error"] = str(stats_dict["error"])
update_data["stats"] = Json(stats_dict)
updated_count = await AgentGraphExecution.prisma().update_many(
where={
@@ -584,10 +591,7 @@ async def update_graph_execution_stats(
{"executionStatus": ExecutionStatus.QUEUED},
],
},
data={
"executionStatus": status,
"stats": Json(data),
},
data=update_data,
)
if updated_count == 0:
return None

View File

@@ -660,11 +660,15 @@ async def _cancel_execution(graph_exec_id: str):
exchange=execution_utils.GRAPH_EXECUTION_CANCEL_EXCHANGE,
)
# Update the status of the graph & node executions
await execution_db.update_graph_execution_stats(
# Update the status of the graph execution
graph_execution = await execution_db.update_graph_execution_stats(
graph_exec_id,
execution_db.ExecutionStatus.TERMINATED,
)
if graph_execution:
await execution_event_bus().publish(graph_execution)
# Update the status of the node executions
node_execs = [
node_exec.model_copy(update={"status": execution_db.ExecutionStatus.TERMINATED})
for node_exec in await execution_db.get_node_executions(
@@ -676,7 +680,6 @@ async def _cancel_execution(graph_exec_id: str):
],
)
]
await execution_db.update_node_execution_status_batch(
[node_exec.node_exec_id for node_exec in node_execs],
execution_db.ExecutionStatus.TERMINATED,

View File

@@ -39,9 +39,11 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { LoadingSpinner } from "@/components/ui/loading";
export default function AgentRunsPage(): React.ReactElement {
const { id: agentID }: { id: LibraryAgentID } = useParams();
const { toast } = useToast();
const router = useRouter();
const api = useBackendAPI();
@@ -69,7 +71,6 @@ export default function AgentRunsPage(): React.ReactElement {
const { state: onboardingState, updateState: updateOnboardingState } =
useOnboarding();
const [copyAgentDialogOpen, setCopyAgentDialogOpen] = useState(false);
const { toast } = useToast();
const openRunDraftView = useCallback(() => {
selectView({ type: "run" });
@@ -120,7 +121,11 @@ export default function AgentRunsPage(): React.ReactElement {
}
}, [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);
@@ -156,6 +161,44 @@ export default function AgentRunsPage(): React.ReactElement {
// 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, // show until connection is re-established
dismissable: false,
});
});
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

View File

@@ -16,7 +16,7 @@ import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import useSupabase from "@/hooks/useSupabase";
import Spinner from "@/components/Spinner";
import LoadingBox from "@/components/ui/loading";
import {
AuthCard,
AuthHeader,
@@ -98,7 +98,7 @@ export default function LoginPage() {
}
if (isUserLoading || user) {
return <Spinner className="h-[80vh]" />;
return <LoadingBox className="h-[80vh]" />;
}
if (!supabase) {

View File

@@ -27,7 +27,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import useSupabase from "@/hooks/useSupabase";
import Spinner from "@/components/Spinner";
import LoadingBox from "@/components/ui/loading";
export default function PrivatePage() {
const { supabase, user, isUserLoading } = useSupabase();
@@ -123,7 +123,7 @@ export default function PrivatePage() {
);
if (isUserLoading) {
return <Spinner className="h-[80vh]" />;
return <LoadingBox className="h-[80vh]" />;
}
if (!user || !supabase) {

View File

@@ -24,7 +24,7 @@ import { useCallback, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { changePassword, sendResetEmail } from "./actions";
import Spinner from "@/components/Spinner";
import LoadingBox from "@/components/ui/loading";
import { getBehaveAs } from "@/lib/utils";
import { useTurnstile } from "@/hooks/useTurnstile";
@@ -134,7 +134,7 @@ export default function ResetPasswordPage() {
);
if (isUserLoading) {
return <Spinner className="h-[80vh]" />;
return <LoadingBox className="h-[80vh]" />;
}
if (!supabase) {

View File

@@ -18,7 +18,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { Checkbox } from "@/components/ui/checkbox";
import useSupabase from "@/hooks/useSupabase";
import Spinner from "@/components/Spinner";
import LoadingBox from "@/components/ui/loading";
import {
AuthCard,
AuthHeader,
@@ -94,7 +94,7 @@ export default function SignupPage() {
}
if (isUserLoading || user) {
return <Spinner className="h-[80vh]" />;
return <LoadingBox className="h-[80vh]" />;
}
if (!supabase) {

View File

@@ -10,7 +10,6 @@ import "./globals.css";
import { Toaster } from "@/components/ui/toaster";
import { Providers } from "@/app/providers";
import TallyPopupSimple from "@/components/TallyPopup";
import OttoChatWidget from "@/components/OttoChatWidget";
import { GoogleAnalytics } from "@/components/analytics/google-analytics";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
@@ -57,9 +56,6 @@ export default async function RootLayout({
<div className="flex min-h-screen flex-col items-stretch justify-items-stretch">
{children}
<TallyPopupSimple />
<Suspense fallback={null}>
<OttoChatWidget />
</Suspense>
</div>
<Toaster />
</Providers>

View File

@@ -1,11 +1,12 @@
"use client";
import React, {
createContext,
useState,
useCallback,
useEffect,
useRef,
MouseEvent,
createContext,
Suspense,
} from "react";
import {
ReactFlow,
@@ -48,6 +49,7 @@ import RunnerUIWrapper, {
RunnerUIWrapperRef,
} from "@/components/RunnerUIWrapper";
import PrimaryActionBar from "@/components/PrimaryActionButton";
import OttoChatWidget from "@/components/OttoChatWidget";
import { useToast } from "@/components/ui/use-toast";
import { useCopyPaste } from "../hooks/useCopyPaste";
import { CronScheduler } from "./cronScheduler";
@@ -676,7 +678,7 @@ const FlowEditor: React.FC<{
<Controls />
<Background className="dark:bg-slate-800" />
<ControlPanel
className="absolute z-10"
className="absolute z-20"
controls={editorControls}
topChildren={
<BlocksControl
@@ -701,6 +703,7 @@ const FlowEditor: React.FC<{
}
></ControlPanel>
<PrimaryActionBar
className="absolute bottom-0 left-1/2 z-20 -translate-x-1/2"
onClickAgentOutputs={() => runnerUIRef.current?.openRunnerOutput()}
onClickRunAgent={() => {
if (!savedAgent) {
@@ -740,6 +743,12 @@ const FlowEditor: React.FC<{
scheduleRunner={scheduleRunner}
requestSaveAndRun={requestSaveAndRun}
/>
<Suspense fallback={null}>
<OttoChatWidget
graphID={flowID}
className="fixed bottom-4 right-4 z-20"
/>
</Suspense>
</FlowContext.Provider>
);
};

View File

@@ -1,32 +1,30 @@
"use client";
import React, { useEffect, useState, useRef } from "react";
import { useSearchParams, usePathname } from "next/navigation";
import { useToast } from "@/components/ui/use-toast";
import useAgentGraph from "../hooks/useAgentGraph";
import ReactMarkdown from "react-markdown";
import { GraphID } from "@/lib/autogpt-server-api/types";
import type { GraphID } from "@/lib/autogpt-server-api/types";
import { askOtto } from "@/app/(platform)/build/actions";
import { cn } from "@/lib/utils";
interface Message {
type: "user" | "assistant";
content: string;
}
const OttoChatWidget = () => {
export default function OttoChatWidget({
graphID,
className,
}: {
graphID?: GraphID;
className?: string;
}): React.ReactNode {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const [includeGraphData, setIncludeGraphData] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const searchParams = useSearchParams();
const pathname = usePathname();
const flowID = searchParams.get("flowID");
const { nodes, edges } = useAgentGraph(
flowID ? (flowID as GraphID) : undefined,
);
const { toast } = useToast();
useEffect(() => {
// Add welcome message when component mounts
@@ -34,7 +32,7 @@ const OttoChatWidget = () => {
setMessages([
{
type: "assistant",
content: "Hello im Otto! Ask me anything about AutoGPT!",
content: "Hello, I am Otto! Ask me anything about AutoGPT!",
},
]);
}
@@ -84,7 +82,7 @@ const OttoChatWidget = () => {
userMessage,
conversationHistory,
includeGraphData,
flowID || undefined,
graphID,
);
// Check if the response contains an error
@@ -131,13 +129,13 @@ const OttoChatWidget = () => {
};
// Don't render the chat widget if we're not on the build page or in local mode
if (process.env.NEXT_PUBLIC_BEHAVE_AS !== "CLOUD" || pathname !== "/build") {
if (process.env.NEXT_PUBLIC_BEHAVE_AS !== "CLOUD") {
return null;
}
if (!isOpen) {
return (
<div className="fixed bottom-4 right-4 z-50">
<div className={className}>
<button
onClick={() => setIsOpen(true)}
className="inline-flex h-14 w-14 items-center justify-center whitespace-nowrap rounded-2xl bg-[rgba(65,65,64,1)] text-neutral-50 shadow transition-colors hover:bg-neutral-900/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90 dark:focus-visible:ring-neutral-300"
@@ -160,7 +158,13 @@ const OttoChatWidget = () => {
}
return (
<div className="fixed bottom-4 right-4 z-50 flex h-[600px] w-[600px] flex-col rounded-lg border bg-background shadow-xl">
<div
className={cn(
"flex h-[600px] w-[600px] flex-col rounded-lg border bg-background shadow-xl",
className,
"z-40",
)}
>
{/* Header */}
<div className="flex items-center justify-between border-b p-4">
<h2 className="font-semibold">Otto Assistant</h2>
@@ -269,7 +273,7 @@ const OttoChatWidget = () => {
Send
</button>
</div>
{nodes && edges && (
{graphID && (
<button
type="button"
onClick={() => {
@@ -303,6 +307,4 @@ const OttoChatWidget = () => {
</form>
</div>
);
};
export default OttoChatWidget;
}

View File

@@ -1,13 +1,14 @@
import React, { useState } from "react";
import { Button } from "./ui/button";
import { Clock, LogOut, ChevronLeft } from "lucide-react";
import React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { FaSpinner } from "react-icons/fa";
import { Clock, LogOut } from "lucide-react";
import { IconPlay, IconSquare } from "@/components/ui/icons";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { FaSpinner } from "react-icons/fa";
interface PrimaryActionBarProps {
onClickAgentOutputs: () => void;
@@ -18,6 +19,7 @@ interface PrimaryActionBarProps {
isScheduling: boolean;
requestStopRun: () => void;
runAgentTooltip: string;
className?: string;
}
const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
@@ -29,6 +31,7 @@ const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
isScheduling,
requestStopRun,
runAgentTooltip,
className,
}) => {
const runButtonLabel = !isRunning ? "Run" : "Stop";
@@ -37,8 +40,13 @@ const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
const runButtonOnClick = !isRunning ? onClickRunAgent : requestStopRun;
return (
<div className="absolute bottom-0 left-1/2 z-50 flex w-fit -translate-x-1/2 transform select-none items-center justify-center p-4">
<div className={`flex gap-1 md:gap-4`}>
<div
className={cn(
"flex w-fit select-none items-center justify-center p-4",
className,
)}
>
<div className="flex gap-1 md:gap-4">
<Tooltip key="ViewOutputs" delayDuration={500}>
<TooltipTrigger asChild>
<Button

View File

@@ -1,11 +0,0 @@
import { LoaderCircle } from "lucide-react";
export default function Spinner({ className }: { className?: string }) {
const spinnerClasses = `mr-2 h-16 w-16 animate-spin ${className || ""}`;
return (
<div className="flex items-center justify-center">
<LoaderCircle className={spinnerClasses} />
</div>
);
}

View File

@@ -56,7 +56,7 @@ const TallyPopupSimple = () => {
};
return (
<div className="fixed bottom-1 right-24 z-50 hidden select-none items-center gap-4 p-3 transition-all duration-300 ease-in-out md:flex">
<div className="fixed bottom-1 right-24 z-20 hidden select-none items-center gap-4 p-3 transition-all duration-300 ease-in-out md:flex">
{show_tutorial && (
<Button
variant="default"

View File

@@ -133,7 +133,8 @@ export default function AgentRunDetailsView({
| null
| undefined = useMemo(() => {
if (!("outputs" in run)) return undefined;
if (!["running", "success", "failed"].includes(runStatus)) return null;
if (!["running", "success", "failed", "stopped"].includes(runStatus))
return null;
// Add type info from agent input schema
return Object.fromEntries(

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo, useState } from "react";
import { LoadingSpinner } from "@/components/ui/loading";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { useCallback, useMemo, useState } from "react";
import Spinner from "../Spinner";
const variants = {
default: "bg-zinc-700 hover:bg-zinc-800",
@@ -55,7 +55,7 @@ export default function OnboardingButton({
if (href && !disabled) {
return (
<Link href={href} onClick={onClickInternal} className={buttonClasses}>
{isLoading && <Spinner className="h-5 w-5" />}
{isLoading && <LoadingSpinner className="mr-2 size-5" />}
{icon && !isLoading && <>{icon}</>}
{children}
</Link>
@@ -68,7 +68,7 @@ export default function OnboardingButton({
disabled={disabled}
className={buttonClasses}
>
{isLoading && <Spinner className="h-5 w-5" />}
{isLoading && <LoadingSpinner className="mr-2 size-5" />}
{icon && !isLoading && <>{icon}</>}
{children}
</button>

View File

@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils";
import { LoaderCircle } from "lucide-react";
export default function LoadingBox({
className,
spinnerSize,
}: {
className?: string;
spinnerSize?: string | number;
}) {
const spinnerSizeClass =
typeof spinnerSize == "string"
? `size-[${spinnerSize}]`
: typeof spinnerSize == "number"
? `size-${spinnerSize}`
: undefined;
return (
<div className={cn("flex items-center justify-center", className)}>
<LoadingSpinner className={spinnerSizeClass} />
</div>
);
}
export function LoadingSpinner({ className }: { className?: string }) {
return <LoaderCircle className={cn("size-16 animate-spin", className)} />;
}

View File

@@ -13,10 +13,18 @@ import { useToast } from "@/components/ui/use-toast";
export function Toaster() {
const { toasts } = useToast();
// This neat little feature makes the toaster buggy due to the following issue:
// https://github.com/radix-ui/primitives/issues/2233
// TODO: Re-enable when the above issue is fixed:
// const swipeThreshold = toasts.some((toast) => toast.dismissable === false)
// ? Infinity
// : undefined;
const swipeThreshold = undefined;
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<ToastProvider swipeThreshold={swipeThreshold}>
{toasts.map(
({ id, title, description, action, dismissable, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
@@ -25,10 +33,10 @@ export function Toaster() {
)}
</div>
{action}
<ToastClose />
{dismissable !== false && <ToastClose />}
</Toast>
);
})}
),
)}
<ToastViewport />
</ToastProvider>
);

View File

@@ -13,6 +13,7 @@ type ToasterToast = ToastProps & {
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
dismissable?: boolean;
};
const actionTypes = {

View File

@@ -68,6 +68,7 @@ export default class BackendAPI {
private webSocket: WebSocket | null = null;
private wsConnecting: Promise<void> | null = null;
private wsOnConnectHandlers: Set<() => void> = new Set();
private wsOnDisconnectHandlers: Set<() => void> = new Set();
private wsMessageHandlers: Record<string, Set<(data: any) => void>> = {};
readonly HEARTBEAT_INTERVAL = 100_000; // 100 seconds
@@ -943,43 +944,69 @@ export default class BackendAPI {
return () => this.wsOnConnectHandlers.delete(handler);
}
/**
* All handlers are invoked when the WebSocket disconnects.
*
* @returns a detacher for the passed handler.
*/
onWebSocketDisconnect(handler: () => void): () => void {
this.wsOnDisconnectHandlers.add(handler);
// Return detacher
return () => this.wsOnDisconnectHandlers.delete(handler);
}
async connectWebSocket(): Promise<void> {
this.wsConnecting ??= new Promise(async (resolve, reject) => {
return (this.wsConnecting ??= new Promise(async (resolve, reject) => {
try {
const token =
(await this.supabaseClient?.auth.getSession())?.data.session
?.access_token || "";
const wsUrlWithToken = `${this.wsUrl}?token=${token}`;
this.webSocket = new WebSocket(wsUrlWithToken);
this.webSocket.state = "connecting";
this.webSocket.onopen = () => {
this.webSocket!.state = "connected";
console.info("[BackendAPI] WebSocket connected to", this.wsUrl);
this._startWSHeartbeat(); // Start heartbeat when connection opens
this.wsOnConnectHandlers.forEach((handler) => handler());
resolve();
};
this.webSocket.onclose = (event) => {
console.warn("WebSocket connection closed", event);
if (this.webSocket?.state == "connecting") {
console.error(
`[BackendAPI] WebSocket failed to connect: ${event.reason}`,
event,
);
} else if (this.webSocket?.state == "connected") {
console.warn(
`[BackendAPI] WebSocket connection closed: ${event.reason}`,
event,
);
}
this.webSocket!.state = "closed";
this._stopWSHeartbeat(); // Stop heartbeat when connection closes
this.wsConnecting = null;
this.wsOnDisconnectHandlers.forEach((handler) => handler());
// Attempt to reconnect after a delay
setTimeout(() => this.connectWebSocket(), 1000);
setTimeout(() => this.connectWebSocket().then(resolve), 1000);
};
this.webSocket.onerror = (error) => {
console.error("WebSocket error:", error);
this._stopWSHeartbeat(); // Stop heartbeat on error
this.wsConnecting = null;
reject(error);
if (this.webSocket?.state == "connected") {
console.error("[BackendAPI] WebSocket error:", error);
}
};
this.webSocket.onmessage = (event) => this._handleWSMessage(event);
} catch (error) {
console.error("Error connecting to WebSocket:", error);
console.error("[BackendAPI] Error connecting to WebSocket:", error);
reject(error);
}
});
return this.wsConnecting;
}));
}
disconnectWebSocket() {
@@ -1048,6 +1075,12 @@ export default class BackendAPI {
}
}
declare global {
interface WebSocket {
state: "connecting" | "connected" | "closed";
}
}
/* *** UTILITY TYPES *** */
type GraphCreateRequestBody = {