"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Handle, Position, useReactFlow, useStore, type NodeProps, type Node, } from "@xyflow/react"; import { useMutation, useAction } from "convex/react"; import { useAuthQuery } from "@/hooks/use-auth-query"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import BaseNodeWrapper from "./base-node-wrapper"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models"; import { DEFAULT_ASPECT_RATIO, getAiImageNodeOuterSize, getImageViewportSize, IMAGE_FORMAT_GROUP_LABELS, IMAGE_FORMAT_PRESETS, } from "@/lib/image-formats"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Sparkles, Loader2, Coins } from "lucide-react"; import { useRouter } from "next/navigation"; import { toast } from "@/lib/toast"; import { msg } from "@/lib/toast-messages"; import { classifyError } from "@/lib/ai-errors"; type PromptNodeData = { prompt?: string; aspectRatio?: string; model?: string; canvasId?: string; _status?: string; _statusMessage?: string; }; export type PromptNode = Node; export default function PromptNode({ id, data, selected, }: NodeProps) { const nodeData = data as PromptNodeData; const router = useRouter(); const { getEdges, getNode } = useReactFlow(); const [prompt, setPrompt] = useState(nodeData.prompt ?? ""); const [aspectRatio, setAspectRatio] = useState( nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO ); const [isGenerating, setIsGenerating] = useState(false); const [error, setError] = useState(null); const edges = useStore((store) => store.edges); const nodes = useStore((store) => store.nodes); const promptRef = useRef(prompt); const aspectRatioRef = useRef(aspectRatio); promptRef.current = prompt; aspectRatioRef.current = aspectRatio; useEffect(() => { setPrompt(nodeData.prompt ?? ""); }, [nodeData.prompt]); useEffect(() => { setAspectRatio(nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO); }, [nodeData.aspectRatio]); const inputMeta = useMemo(() => { const incomingEdges = edges.filter((edge) => edge.target === id); let textPrompt: string | undefined; let hasTextInput = false; for (const edge of incomingEdges) { const sourceNode = nodes.find((node) => node.id === edge.source); if (sourceNode?.type !== "text") continue; hasTextInput = true; const sourceData = sourceNode.data as { content?: string }; if (typeof sourceData.content === "string") { textPrompt = sourceData.content; break; } } return { hasTextInput, textPrompt: textPrompt ?? "", }; }, [edges, id, nodes]); const effectivePrompt = inputMeta.hasTextInput ? inputMeta.textPrompt : prompt; const dataRef = useRef(data); dataRef.current = data; const balance = useAuthQuery(api.credits.getBalance); const creditCost = getModel(DEFAULT_MODEL_ID)?.creditCost ?? 4; const availableCredits = balance !== undefined ? balance.balance - balance.reserved : null; const hasEnoughCredits = availableCredits !== null && availableCredits >= creditCost; const updateData = useMutation(api.nodes.updateData); const generateImage = useAction(api.ai.generateImage); const { createNodeConnectedFromSource } = useCanvasPlacement(); const debouncedSave = useDebouncedCallback(() => { const raw = dataRef.current as Record; const { _status, _statusMessage, ...rest } = raw; void _status; void _statusMessage; updateData({ nodeId: id as Id<"nodes">, data: { ...rest, prompt: promptRef.current, aspectRatio: aspectRatioRef.current, }, }); }, 500); const handlePromptChange = useCallback( (e: React.ChangeEvent) => { const value = e.target.value; setPrompt(value); debouncedSave(); }, [debouncedSave] ); const handleAspectRatioChange = useCallback( (value: string) => { setAspectRatio(value); debouncedSave(); }, [debouncedSave] ); const handleGenerate = useCallback(async () => { if (!effectivePrompt.trim() || isGenerating) return; if (availableCredits !== null && !hasEnoughCredits) { const { title, desc } = msg.ai.insufficientCredits( creditCost, availableCredits, ); toast.action(title, { description: desc, label: msg.billing.topUp, onClick: () => router.push("/settings/billing"), type: "warning", }); return; } setError(null); setIsGenerating(true); try { const canvasId = nodeData.canvasId as Id<"canvases">; if (!canvasId) throw new Error("Canvas-ID fehlt in der Node"); const currentEdges = getEdges(); const incomingEdges = currentEdges.filter((e) => e.target === id); let connectedTextPrompt: string | undefined; let referenceStorageId: Id<"_storage"> | undefined; let referenceImageUrl: string | undefined; for (const edge of incomingEdges) { const sourceNode = getNode(edge.source); if (sourceNode?.type === "text") { const srcData = sourceNode.data as { content?: string }; if (typeof srcData.content === "string") { connectedTextPrompt = srcData.content; } } if (sourceNode?.type === "image") { const srcData = sourceNode.data as { storageId?: string }; if (srcData.storageId) { referenceStorageId = srcData.storageId as Id<"_storage">; } } if (sourceNode?.type === "asset") { const srcData = sourceNode.data as { previewUrl?: string; url?: string }; referenceImageUrl = srcData.url ?? srcData.previewUrl; } } const promptToUse = (connectedTextPrompt ?? prompt).trim(); if (!promptToUse) return; const currentNode = getNode(id); const offsetX = (currentNode?.measured?.width ?? 280) + 32; const posX = (currentNode?.position?.x ?? 0) + offsetX; const posY = currentNode?.position?.y ?? 0; const viewport = getImageViewportSize(aspectRatio); const outer = getAiImageNodeOuterSize(viewport); const clientRequestId = crypto.randomUUID(); const aiNodeId = await createNodeConnectedFromSource({ type: "ai-image", position: { x: posX, y: posY }, width: outer.width, height: outer.height, data: { prompt: promptToUse, model: DEFAULT_MODEL_ID, modelTier: "standard", canvasId, aspectRatio, outputWidth: viewport.width, outputHeight: viewport.height, }, clientRequestId, sourceNodeId: id as Id<"nodes">, sourceHandle: "prompt-out", targetHandle: "prompt-in", }); await toast.promise( generateImage({ canvasId, nodeId: aiNodeId, prompt: promptToUse, referenceStorageId, referenceImageUrl, model: DEFAULT_MODEL_ID, aspectRatio, }), { loading: msg.ai.generating.title, success: msg.ai.generationQueued.title, error: msg.ai.generationFailed.title, description: { success: msg.ai.generationQueuedDesc, error: msg.ai.creditsNotCharged, }, }, ); } catch (err) { const classified = classifyError(err); if (classified.category === "daily_cap") { toast.error( msg.billing.dailyLimitReached(0).title, "Morgen stehen wieder Generierungen zur Verfügung.", ); } else if (classified.category === "concurrency") { toast.warning( msg.ai.concurrentLimitReached.title, msg.ai.concurrentLimitReached.desc, ); } else { setError(classified.message || msg.ai.generationFailed.title); } } finally { setIsGenerating(false); } }, [ prompt, effectivePrompt, aspectRatio, isGenerating, nodeData.canvasId, id, getEdges, getNode, createNodeConnectedFromSource, generateImage, creditCost, availableCredits, hasEnoughCredits, router, ]); return (
KI-Bild
{inputMeta.hasTextInput ? (

Prompt aus verbundener Text-Node

{inputMeta.textPrompt.trim() || "(Verbundene Text-Node ist leer)"}

) : (