"use client"; import { useCallback, useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; 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 ErrorType } from "@/lib/ai-errors"; import { DEFAULT_ASPECT_RATIO } from "@/lib/image-formats"; import { toast } from "@/lib/toast"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { Loader2, AlertCircle, RefreshCw, ImageIcon, Coins, Clock3, ShieldAlert, WifiOff, Maximize2, X, } from "lucide-react"; import { Dialog, DialogContent, DialogTitle, } from "@/components/ui/dialog"; type AiImageNodeData = { storageId?: string; url?: string; prompt?: string; model?: string; modelLabel?: string; modelTier?: string; generatedAt?: number; /** Gebuchte Credits in Euro-Cent (PRD: nach Commit) */ creditCost?: number; canvasId?: string; /** OpenRouter image_config.aspect_ratio */ aspectRatio?: string; outputWidth?: number; outputHeight?: number; retryCount?: number; _status?: string; _statusMessage?: string; }; export type AiImageNode = Node; type NodeStatus = | "idle" | "analyzing" | "clarifying" | "executing" | "done" | "error"; export default function AiImageNode({ id, data, selected, }: NodeProps) { const t = useTranslations('toasts'); const nodeData = data as AiImageNodeData; const { getEdges, getNode } = useReactFlow(); const { status: syncStatus } = useCanvasSync(); const router = useRouter(); const [isGenerating, setIsGenerating] = useState(false); const [localError, setLocalError] = useState(null); const [isOutputFullscreenOpen, setIsOutputFullscreenOpen] = useState(false); const generateImage = useAction(api.ai.generateImage); 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" || status === "analyzing" || status === "clarifying" || isGenerating; const handleRegenerate = useCallback(async () => { if (isLoading) return; if (syncStatus.isOffline) { toast.warning( "Offline aktuell nicht unterstützt", "KI-Generierung benötigt eine aktive Verbindung.", ); return; } setLocalError(null); setIsGenerating(true); try { const canvasId = nodeData.canvasId as Id<"canvases">; if (!canvasId) throw new Error("Missing canvasId"); const prompt = nodeData.prompt; if (!prompt) throw new Error("No prompt — Generierung vom Prompt-Knoten aus starten"); const edges = getEdges(); const incomingEdges = edges.filter((e) => e.target === id); let referenceStorageId: Id<"_storage"> | undefined; let referenceImageUrl: string | undefined; for (const edge of incomingEdges) { const src = getNode(edge.source); if (src?.type === "image") { const srcData = src.data as { storageId?: string }; if (srcData.storageId) { referenceStorageId = srcData.storageId as Id<"_storage">; break; } } if (src?.type === "asset") { const srcData = src.data as { previewUrl?: string; url?: string }; referenceImageUrl = srcData.url ?? srcData.previewUrl; } } const modelId = nodeData.model ?? DEFAULT_MODEL_ID; await toast.promise( generateImage({ canvasId, nodeId: id as Id<"nodes">, prompt, referenceStorageId, referenceImageUrl, model: modelId, aspectRatio: nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO, }), { loading: t('ai.generating'), success: t('ai.generationQueued'), error: t('ai.generationFailed'), description: { success: t('ai.generationQueuedDesc'), error: t('ai.creditsNotCharged'), }, }, ); } catch (err) { setLocalError(err instanceof Error ? err.message : t('ai.generationFailed')); } finally { setIsGenerating(false); } }, [isLoading, syncStatus.isOffline, nodeData, id, getEdges, getNode, generateImage, t]); const modelName = getModel(nodeData.model ?? DEFAULT_MODEL_ID)?.name ?? "AI"; const renderErrorIcon = (type: ErrorType) => { switch (type) { case "insufficientCredits": return ; case "rateLimited": case "timeout": return ; case "contentPolicy": return ; case "networkError": return ; default: return ; } }; return ( , onClick: () => setIsOutputFullscreenOpen(true), disabled: !nodeData.url, }, ]} className="flex h-full w-full min-h-0 min-w-0 flex-col" >
Bildausgabe
{status === "idle" && !nodeData.url && (

Verbinde einen Prompt-Knoten und starte die Generierung dort.

)} {isLoading && (

{status === "analyzing" && "Analyzing…"} {status === "clarifying" && "Clarifying…"} {(status === "executing" || isGenerating) && "Generating…"}

{(status === "executing" || isGenerating) && typeof executingRetryCount === "number" && executingRetryCount > 0 && (

Retry attempt {executingRetryCount}

)}

{modelName}

)} {status === "error" && !isLoading && (
{renderErrorIcon(classifiedError.type)}

{classifiedError.rawMessage}

{classifiedError.creditsNotCharged && (

Credits not charged

)}
{classifiedError.showTopUp && ( )} {classifiedError.retryable && ( )}
)} {nodeData.url && !isLoading && ( // eslint-disable-next-line @next/next/no-img-element {nodeData.prompt )} {status === "done" && nodeData.url && !isLoading && (
)}
{nodeData.prompt && (

{nodeData.prompt}

{status === "done" && nodeData.creditCost != null ? (
{nodeData.modelLabel ?? modelName} ·{" "} {nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO} {nodeData.creditCost} Cr
) : (

{modelName} · {nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO}

)}
)} KI-Bildausgabe
{nodeData.url ? ( // eslint-disable-next-line @next/next/no-img-element {nodeData.prompt ) : (
Keine Bildausgabe verfügbar
)}
); }