diff --git a/app/globals.css b/app/globals.css index 9d24ede..c530d90 100644 --- a/app/globals.css +++ b/app/globals.css @@ -127,4 +127,19 @@ html { @apply font-sans; } +} + +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + +@layer utilities { + .animate-shimmer { + animation: shimmer 1.5s ease-in-out infinite; + } } \ No newline at end of file diff --git a/components/canvas/canvas-toolbar.tsx b/components/canvas/canvas-toolbar.tsx index c602328..661e536 100644 --- a/components/canvas/canvas-toolbar.tsx +++ b/components/canvas/canvas-toolbar.tsx @@ -26,7 +26,7 @@ const nodeTemplates = [ label: "Prompt", width: 320, height: 140, - defaultData: { content: "", model: "" }, + defaultData: { prompt: "", model: "" }, }, { type: "note", @@ -67,7 +67,7 @@ export default function CanvasToolbar({ canvasId }: CanvasToolbarProps) { positionY: 100 + offset, width, height, - data, + data: { ...data, canvasId }, }); }; diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 9b70758..b007018 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -192,7 +192,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { positionY: position.y, width: defaults.width, height: defaults.height, - data: defaults.data, + data: { ...defaults.data, canvasId }, }); }, [screenToFlowPosition, createNode, canvasId], diff --git a/components/canvas/nodes/ai-image-node.tsx b/components/canvas/nodes/ai-image-node.tsx index 301fc8e..6c5abf5 100644 --- a/components/canvas/nodes/ai-image-node.tsx +++ b/components/canvas/nodes/ai-image-node.tsx @@ -1,78 +1,209 @@ "use client"; -import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; +import { useCallback, useState } from "react"; +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 { + Loader2, + AlertCircle, + RefreshCw, + ImageIcon, +} from "lucide-react"; type AiImageNodeData = { + storageId?: string; url?: string; prompt?: string; model?: string; + modelTier?: string; + generatedAt?: number; + canvasId?: string; _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 status = data._status ?? "idle"; + const nodeData = data as AiImageNodeData; + const { getEdges, getNode } = useReactFlow(); + + const [isGenerating, setIsGenerating] = useState(false); + const [localError, setLocalError] = useState(null); + + const generateImage = useAction(api.ai.generateImage); + + const status = (nodeData._status ?? "idle") as NodeStatus; + const errorMessage = nodeData._statusMessage; + + const isLoading = + status === "executing" || + status === "analyzing" || + status === "clarifying" || + isGenerating; + + const handleRegenerate = useCallback(async () => { + if (isLoading) 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 — use Generate from a Prompt node"); + + const edges = getEdges(); + const incomingEdges = edges.filter((e) => e.target === id); + let referenceStorageId: Id<"_storage"> | 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; + } + } + } + + await generateImage({ + canvasId, + nodeId: id as Id<"nodes">, + prompt, + referenceStorageId, + model: nodeData.model ?? DEFAULT_MODEL_ID, + }); + } catch (err) { + setLocalError(err instanceof Error ? err.message : "Generation failed"); + } finally { + setIsGenerating(false); + } + }, [isLoading, nodeData, id, getEdges, getNode, generateImage]); + + const modelName = + getModel(nodeData.model ?? DEFAULT_MODEL_ID)?.name ?? "AI"; return ( - -
-
- 🤖 KI-Bild -
+ + - {status === "executing" && ( -
-
+
+
+ 🖼️ AI Image +
+
+ +
+ {status === "idle" && !nodeData.url && ( +
+ +

+ Connect a Prompt node and click Generate +

)} - {status === "done" && data.url && ( + {isLoading && ( +
+
+
+
+ +

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

+

+ {modelName} +

+
+ )} + + {status === "error" && !isLoading && ( +
+ +

+ Generation failed +

+

+ {errorMessage ?? localError ?? "Unknown error"} — Credits not + charged +

+ +
+ )} + + {nodeData.url && !isLoading && ( + // eslint-disable-next-line @next/next/no-img-element {data.prompt )} - {status === "error" && ( -
- {data._statusMessage ?? "Fehler bei der Generierung"} + {status === "done" && nodeData.url && !isLoading && ( +
+
)} - - {status === "idle" && ( -
- Prompt verbinden -
- )} - - {data.prompt && status === "done" && ( -

- {data.prompt} -

- )}
- + {nodeData.prompt && ( +
+

+ {nodeData.prompt} +

+

+ {modelName} +

+
+ )} + ); diff --git a/components/canvas/nodes/prompt-node.tsx b/components/canvas/nodes/prompt-node.tsx index bceb846..579de0a 100644 --- a/components/canvas/nodes/prompt-node.tsx +++ b/components/canvas/nodes/prompt-node.tsx @@ -1,16 +1,19 @@ "use client"; -import { useState, useCallback, useEffect } from "react"; -import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; -import { useMutation } from "convex/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Handle, Position, useReactFlow, type NodeProps, type Node } from "@xyflow/react"; +import { useMutation, useAction } from "convex/react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; -import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import BaseNodeWrapper from "./base-node-wrapper"; +import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; +import { DEFAULT_MODEL_ID } from "@/lib/ai-models"; +import { Sparkles, Loader2 } from "lucide-react"; type PromptNodeData = { prompt?: string; model?: string; + canvasId?: string; _status?: string; _statusMessage?: string; }; @@ -22,82 +25,166 @@ export default function PromptNode({ data, selected, }: NodeProps) { - const updateData = useMutation(api.nodes.updateData); - const [prompt, setPrompt] = useState(data.prompt ?? ""); - const [isEditing, setIsEditing] = useState(false); + const nodeData = data as PromptNodeData; + const { getEdges, getNode } = useReactFlow(); + + const [prompt, setPrompt] = useState(nodeData.prompt ?? ""); + const [isGenerating, setIsGenerating] = useState(false); + const [error, setError] = useState(null); useEffect(() => { - if (!isEditing) { - setPrompt(data.prompt ?? ""); - } - }, [data.prompt, isEditing]); + setPrompt(nodeData.prompt ?? ""); + }, [nodeData.prompt]); - const savePrompt = useDebouncedCallback( - (newPrompt: string) => { - updateData({ - nodeId: id as Id<"nodes">, + const dataRef = useRef(data); + dataRef.current = data; + + const updateData = useMutation(api.nodes.updateData); + const createNode = useMutation(api.nodes.create); + const generateImage = useAction(api.ai.generateImage); + + const debouncedSave = useDebouncedCallback((value: string) => { + const raw = dataRef.current as Record; + const { _status, _statusMessage, ...rest } = raw; + void _status; + void _statusMessage; + updateData({ + nodeId: id as Id<"nodes">, + data: { ...rest, prompt: value }, + }); + }, 500); + + const handlePromptChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setPrompt(value); + debouncedSave(value); + }, + [debouncedSave] + ); + + const handleGenerate = useCallback(async () => { + if (!prompt.trim() || isGenerating) return; + setError(null); + setIsGenerating(true); + + try { + const canvasId = nodeData.canvasId as Id<"canvases">; + if (!canvasId) throw new Error("Missing canvasId on node"); + + const edges = getEdges(); + const incomingEdges = edges.filter((e) => e.target === id); + let referenceStorageId: Id<"_storage"> | undefined; + + for (const edge of incomingEdges) { + const sourceNode = getNode(edge.source); + if (sourceNode?.type === "image") { + const srcData = sourceNode.data as { storageId?: string }; + if (srcData.storageId) { + referenceStorageId = srcData.storageId as Id<"_storage">; + break; + } + } + } + + 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 aiNodeId = await createNode({ + canvasId, + type: "ai-image", + positionX: posX, + positionY: posY, + width: 320, + height: 320, data: { - ...data, - prompt: newPrompt, - _status: undefined, - _statusMessage: undefined, + prompt, + model: DEFAULT_MODEL_ID, + modelTier: "standard", + canvasId, }, }); - }, - 500, - ); - const handleChange = useCallback( - (e: React.ChangeEvent) => { - const newPrompt = e.target.value; - setPrompt(newPrompt); - savePrompt(newPrompt); - }, - [savePrompt], - ); + await generateImage({ + canvasId, + nodeId: aiNodeId, + prompt, + referenceStorageId, + model: DEFAULT_MODEL_ID, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Generation failed"); + } finally { + setIsGenerating(false); + } + }, [ + prompt, + isGenerating, + nodeData.canvasId, + id, + getEdges, + getNode, + createNode, + generateImage, + ]); return ( -
-
+ + +
+
✨ Prompt
- {isEditing ? ( -