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:
Matthias
2026-03-27 11:35:18 +01:00
parent 99a359f330
commit 5da0204163
28 changed files with 1180 additions and 35 deletions

View File

@@ -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>
)}

View File

@@ -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}

View File

@@ -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

View File

@@ -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"
>

View File

@@ -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"
>

View File

@@ -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}

View 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;
}
}

View File

@@ -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}

View File

@@ -243,6 +243,7 @@ export default function PromptNode({
return (
<BaseNodeWrapper
nodeType="prompt"
selected={selected}
status={nodeData._status}
statusMessage={nodeData._statusMessage}

View File

@@ -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}