feat: enhance canvas and node components with error handling and retry logic
- Integrated retry logic for AI image generation to handle transient errors and improve user experience. - Updated error categorization to provide more informative feedback based on different failure scenarios. - Enhanced node components to display retry attempts and error messages, improving visibility during image generation failures. - Refactored canvas and node components to include retry count in status updates, ensuring accurate tracking of generation attempts.
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
BackgroundVariant,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
import { useConvexAuth, useMutation, useQuery } from "convex/react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
@@ -113,6 +114,9 @@ const EDGE_INTERSECTION_HIGHLIGHT_STYLE: NonNullable<RFEdge["style"]> = {
|
||||
strokeWidth: 2,
|
||||
};
|
||||
|
||||
const GENERATION_FAILURE_WINDOW_MS = 5 * 60 * 1000;
|
||||
const GENERATION_FAILURE_THRESHOLD = 3;
|
||||
|
||||
function getEdgeIdFromInteractionElement(element: Element): string | null {
|
||||
const edgeContainer = element.closest(".react-flow__edge");
|
||||
if (!edgeContainer) return null;
|
||||
@@ -249,6 +253,63 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
const highlightedEdgeOriginalStyleRef = useRef<RFEdge["style"] | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const recentGenerationFailureTimestampsRef = useRef<number[]>([]);
|
||||
const previousNodeStatusRef = useRef<Map<string, string | undefined>>(new Map());
|
||||
const hasInitializedGenerationFailureTrackingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!convexNodes) return;
|
||||
|
||||
const nextNodeStatusMap = new Map<string, string | undefined>();
|
||||
let detectedGenerationFailures = 0;
|
||||
|
||||
for (const node of convexNodes) {
|
||||
nextNodeStatusMap.set(node._id, node.status);
|
||||
|
||||
if (node.type !== "ai-image") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const previousStatus = previousNodeStatusRef.current.get(node._id);
|
||||
if (
|
||||
hasInitializedGenerationFailureTrackingRef.current &&
|
||||
node.status === "error" &&
|
||||
previousStatus !== "error"
|
||||
) {
|
||||
detectedGenerationFailures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
previousNodeStatusRef.current = nextNodeStatusMap;
|
||||
|
||||
if (!hasInitializedGenerationFailureTrackingRef.current) {
|
||||
hasInitializedGenerationFailureTrackingRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (detectedGenerationFailures === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const recentFailures = recentGenerationFailureTimestampsRef.current.filter(
|
||||
(timestamp) => now - timestamp <= GENERATION_FAILURE_WINDOW_MS,
|
||||
);
|
||||
|
||||
for (let index = 0; index < detectedGenerationFailures; index += 1) {
|
||||
recentFailures.push(now);
|
||||
}
|
||||
|
||||
if (recentFailures.length >= GENERATION_FAILURE_THRESHOLD) {
|
||||
toast.error(
|
||||
"Mehrere Generierungen sind fehlgeschlagen. Bitte Prompt, Modell oder Credits prüfen.",
|
||||
);
|
||||
recentGenerationFailureTimestampsRef.current = [];
|
||||
return;
|
||||
}
|
||||
|
||||
recentGenerationFailureTimestampsRef.current = recentFailures;
|
||||
}, [convexNodes]);
|
||||
|
||||
// ─── Convex → Lokaler State Sync ──────────────────────────────
|
||||
useEffect(() => {
|
||||
|
||||
112
components/canvas/connection-banner.tsx
Normal file
112
components/canvas/connection-banner.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useConvexConnectionState } from "convex/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type BannerState = "hidden" | "reconnecting" | "disconnected" | "reconnected";
|
||||
|
||||
const RECONNECTED_HIDE_DELAY_MS = 1800;
|
||||
|
||||
export default function ConnectionBanner() {
|
||||
const connectionState = useConvexConnectionState();
|
||||
const previousConnectedRef = useRef(connectionState.isWebSocketConnected);
|
||||
const [showReconnected, setShowReconnected] = useState(false);
|
||||
const [isBrowserOnline, setIsBrowserOnline] = useState(
|
||||
typeof navigator === "undefined" ? true : navigator.onLine,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsBrowserOnline(true);
|
||||
const handleOffline = () => setIsBrowserOnline(false);
|
||||
|
||||
window.addEventListener("online", handleOnline);
|
||||
window.addEventListener("offline", handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("online", handleOnline);
|
||||
window.removeEventListener("offline", handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const wasConnected = previousConnectedRef.current;
|
||||
const isConnected = connectionState.isWebSocketConnected;
|
||||
const didReconnect = !wasConnected && isConnected && connectionState.connectionCount > 1;
|
||||
|
||||
if (didReconnect) {
|
||||
setShowReconnected(true);
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
setShowReconnected(false);
|
||||
}
|
||||
|
||||
previousConnectedRef.current = isConnected;
|
||||
}, [connectionState.connectionCount, connectionState.isWebSocketConnected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showReconnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setShowReconnected(false);
|
||||
}, RECONNECTED_HIDE_DELAY_MS);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [showReconnected]);
|
||||
|
||||
const bannerState = useMemo<BannerState>(() => {
|
||||
if (connectionState.isWebSocketConnected) {
|
||||
return showReconnected ? "reconnected" : "hidden";
|
||||
}
|
||||
|
||||
if (!isBrowserOnline) {
|
||||
return "disconnected";
|
||||
}
|
||||
|
||||
if (connectionState.hasEverConnected || connectionState.connectionRetries > 0) {
|
||||
return "reconnecting";
|
||||
}
|
||||
|
||||
return "hidden";
|
||||
}, [
|
||||
connectionState.connectionRetries,
|
||||
connectionState.hasEverConnected,
|
||||
connectionState.isWebSocketConnected,
|
||||
isBrowserOnline,
|
||||
showReconnected,
|
||||
]);
|
||||
|
||||
if (bannerState === "hidden") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentByState: Record<Exclude<BannerState, "hidden">, { dotClass: string; text: string }> = {
|
||||
reconnecting: {
|
||||
dotClass: "bg-amber-500",
|
||||
text: "Verbindung wird wiederhergestellt…",
|
||||
},
|
||||
disconnected: {
|
||||
dotClass: "bg-destructive",
|
||||
text: "Keine Verbindung. Wir verbinden uns automatisch erneut.",
|
||||
},
|
||||
reconnected: {
|
||||
dotClass: "bg-emerald-500",
|
||||
text: "Verbindung wiederhergestellt",
|
||||
},
|
||||
};
|
||||
|
||||
const content = contentByState[bannerState];
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute top-3 left-1/2 z-20 -translate-x-1/2">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-card/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur-sm">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", content.dotClass)} aria-hidden="true" />
|
||||
<span>{content.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Coins } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
const TIER_LABELS: Record<string, string> = {
|
||||
free: "Free",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Handle, Position, useReactFlow, type NodeProps, type Node } from "@xyflow/react";
|
||||
import { useAction } from "convex/react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models";
|
||||
import { classifyError, type AiErrorCategory } from "@/lib/ai-errors";
|
||||
import { DEFAULT_ASPECT_RATIO } from "@/lib/image-formats";
|
||||
import {
|
||||
Loader2,
|
||||
@@ -14,6 +16,9 @@ import {
|
||||
RefreshCw,
|
||||
ImageIcon,
|
||||
Coins,
|
||||
Clock3,
|
||||
ShieldAlert,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
|
||||
type AiImageNodeData = {
|
||||
@@ -31,6 +36,7 @@ type AiImageNodeData = {
|
||||
aspectRatio?: string;
|
||||
outputWidth?: number;
|
||||
outputHeight?: number;
|
||||
retryCount?: number;
|
||||
_status?: string;
|
||||
_statusMessage?: string;
|
||||
};
|
||||
@@ -52,6 +58,7 @@ export default function AiImageNode({
|
||||
}: NodeProps<AiImageNode>) {
|
||||
const nodeData = data as AiImageNodeData;
|
||||
const { getEdges, getNode } = useReactFlow();
|
||||
const router = useRouter();
|
||||
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
@@ -60,6 +67,12 @@ export default function AiImageNode({
|
||||
|
||||
const status = (nodeData._status ?? "idle") as NodeStatus;
|
||||
const errorMessage = nodeData._statusMessage;
|
||||
const classifiedError = classifyError(errorMessage ?? localError);
|
||||
|
||||
const executingRetryCount =
|
||||
typeof nodeData.retryCount === "number"
|
||||
? nodeData.retryCount
|
||||
: classifiedError.retryCount;
|
||||
|
||||
const isLoading =
|
||||
status === "executing" ||
|
||||
@@ -111,8 +124,25 @@ export default function AiImageNode({
|
||||
const modelName =
|
||||
getModel(nodeData.model ?? DEFAULT_MODEL_ID)?.name ?? "AI";
|
||||
|
||||
const renderErrorIcon = (category: AiErrorCategory) => {
|
||||
switch (category) {
|
||||
case "insufficient_credits":
|
||||
return <Coins className="h-8 w-8 text-amber-500" />;
|
||||
case "rate_limited":
|
||||
case "timeout":
|
||||
return <Clock3 className="h-8 w-8 text-amber-500" />;
|
||||
case "content_policy":
|
||||
return <ShieldAlert className="h-8 w-8 text-destructive" />;
|
||||
case "network":
|
||||
return <WifiOff className="h-8 w-8 text-destructive" />;
|
||||
default:
|
||||
return <AlertCircle className="h-8 w-8 text-destructive" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper
|
||||
nodeType="ai-image"
|
||||
selected={selected}
|
||||
className="flex h-full w-full min-h-0 min-w-0 flex-col overflow-hidden"
|
||||
>
|
||||
@@ -151,6 +181,13 @@ export default function AiImageNode({
|
||||
{status === "clarifying" && "Clarifying…"}
|
||||
{(status === "executing" || isGenerating) && "Generating…"}
|
||||
</p>
|
||||
{(status === "executing" || isGenerating) &&
|
||||
typeof executingRetryCount === "number" &&
|
||||
executingRetryCount > 0 && (
|
||||
<p className="relative z-10 text-[10px] text-amber-600 dark:text-amber-400">
|
||||
Retry attempt {executingRetryCount}
|
||||
</p>
|
||||
)}
|
||||
<p className="relative z-10 text-[10px] text-muted-foreground/60">
|
||||
{modelName}
|
||||
</p>
|
||||
@@ -159,22 +196,42 @@ export default function AiImageNode({
|
||||
|
||||
{status === "error" && !isLoading && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-muted">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
{renderErrorIcon(classifiedError.category)}
|
||||
<p className="px-4 text-center text-xs font-medium text-destructive">
|
||||
Generation failed
|
||||
{classifiedError.message}
|
||||
</p>
|
||||
<p className="px-6 text-center text-[10px] text-muted-foreground">
|
||||
{errorMessage ?? localError ?? "Unknown error"} — Credits not
|
||||
charged
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRegenerate()}
|
||||
className="nodrag mt-1 flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium transition-colors hover:bg-accent"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Try again
|
||||
</button>
|
||||
{classifiedError.detail && (
|
||||
<p className="px-6 text-center text-[10px] text-muted-foreground">
|
||||
{classifiedError.detail}
|
||||
</p>
|
||||
)}
|
||||
{classifiedError.creditsNotCharged && (
|
||||
<p className="px-6 text-center text-[10px] text-muted-foreground">
|
||||
Credits not charged
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{classifiedError.showTopUp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/settings/billing")}
|
||||
className="nodrag flex items-center gap-1.5 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-1.5 text-xs font-medium text-amber-700 transition-colors hover:bg-amber-500/20 dark:text-amber-300"
|
||||
>
|
||||
<Coins className="h-3 w-3" />
|
||||
Top up credits
|
||||
</button>
|
||||
)}
|
||||
{classifiedError.retryable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRegenerate()}
|
||||
className="nodrag flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium transition-colors hover:bg-accent"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Try again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { NodeErrorBoundary } from "./node-error-boundary";
|
||||
|
||||
interface BaseNodeWrapperProps {
|
||||
nodeType: string;
|
||||
selected?: boolean;
|
||||
status?: string;
|
||||
statusMessage?: string;
|
||||
@@ -11,6 +13,7 @@ interface BaseNodeWrapperProps {
|
||||
}
|
||||
|
||||
export default function BaseNodeWrapper({
|
||||
nodeType,
|
||||
selected,
|
||||
status = "idle",
|
||||
statusMessage,
|
||||
@@ -35,7 +38,7 @@ export default function BaseNodeWrapper({
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
<NodeErrorBoundary nodeType={nodeType}>{children}</NodeErrorBoundary>
|
||||
{status === "error" && statusMessage && (
|
||||
<div className="px-3 pb-2 text-xs text-red-500 truncate">
|
||||
{statusMessage}
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function CompareNode({ data, selected }: NodeProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper selected={selected} className="w-[500px] p-0">
|
||||
<BaseNodeWrapper nodeType="compare" selected={selected} className="w-[500px] p-0">
|
||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground">⚖️ Compare</div>
|
||||
|
||||
<Handle
|
||||
|
||||
@@ -59,6 +59,7 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper
|
||||
nodeType="frame"
|
||||
selected={selected}
|
||||
className="relative h-full w-full border-2 border-dashed border-muted-foreground/40 !bg-transparent p-0 shadow-none"
|
||||
>
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper
|
||||
nodeType="group"
|
||||
selected={selected}
|
||||
className="min-w-[200px] min-h-[150px] p-3 border-dashed"
|
||||
>
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function ImageNode({ id, data, selected }: NodeProps<ImageNode>)
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper selected={selected} status={data._status}>
|
||||
<BaseNodeWrapper nodeType="image" selected={selected} status={data._status}>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
|
||||
66
components/canvas/nodes/node-error-boundary.tsx
Normal file
66
components/canvas/nodes/node-error-boundary.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { Component } from "react";
|
||||
|
||||
interface NodeErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
nodeType: string;
|
||||
}
|
||||
|
||||
interface NodeErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export class NodeErrorBoundary extends Component<
|
||||
NodeErrorBoundaryProps,
|
||||
NodeErrorBoundaryState
|
||||
> {
|
||||
state: NodeErrorBoundaryState = {
|
||||
hasError: false,
|
||||
};
|
||||
|
||||
static getDerivedStateFromError(error: Error): NodeErrorBoundaryState {
|
||||
return {
|
||||
hasError: true,
|
||||
errorMessage: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("Node rendering error", {
|
||||
nodeType: this.props.nodeType,
|
||||
error,
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
}
|
||||
|
||||
private handleRetry = () => {
|
||||
this.setState({ hasError: false, errorMessage: undefined });
|
||||
};
|
||||
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="m-2 rounded-md border border-destructive/40 bg-destructive/5 p-2 text-xs">
|
||||
<p className="font-medium text-destructive">Node render failed ({this.props.nodeType})</p>
|
||||
{this.state.errorMessage && (
|
||||
<p className="mt-1 truncate text-destructive/90" title={this.state.errorMessage}>
|
||||
{this.state.errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.handleRetry}
|
||||
className="nodrag mt-2 rounded border border-destructive/30 px-2 py-1 text-[11px] font-medium text-destructive transition-colors hover:bg-destructive/10"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper selected={selected} className="w-52 p-3">
|
||||
<BaseNodeWrapper nodeType="note" selected={selected} className="w-52 p-3">
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
|
||||
@@ -243,6 +243,7 @@ export default function PromptNode({
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper
|
||||
nodeType="prompt"
|
||||
selected={selected}
|
||||
status={nodeData._status}
|
||||
statusMessage={nodeData._statusMessage}
|
||||
|
||||
@@ -75,7 +75,12 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper selected={selected} status={data._status} className="relative">
|
||||
<BaseNodeWrapper
|
||||
nodeType="text"
|
||||
selected={selected}
|
||||
status={data._status}
|
||||
className="relative"
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
|
||||
Reference in New Issue
Block a user