feat: integrate credit cost tracking in AI image generation and prompt nodes

- Added credit cost tracking to AI image nodes, displaying the cost in Euro-Cent.
- Updated prompt node to create edges between prompt and AI image nodes during image generation.
- Enhanced Convex action to include credit cost in image generation data handling.
- Introduced utility function for formatting Euro-Cent values for better user display.
This commit is contained in:
Matthias
2026-03-25 18:27:45 +01:00
parent 8d6ce275f8
commit fffdae3a9c
4 changed files with 43 additions and 3 deletions

View File

@@ -7,6 +7,7 @@ import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models"; import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models";
import { cn, formatEurFromCents } from "@/lib/utils";
import { import {
Loader2, Loader2,
AlertCircle, AlertCircle,
@@ -21,6 +22,8 @@ type AiImageNodeData = {
model?: string; model?: string;
modelTier?: string; modelTier?: string;
generatedAt?: number; generatedAt?: number;
/** Gebuchte Credits in Euro-Cent (PRD: nach Commit) */
creditCost?: number;
canvasId?: string; canvasId?: string;
_status?: string; _status?: string;
_statusMessage?: string; _statusMessage?: string;
@@ -116,7 +119,7 @@ export default function AiImageNode({
</div> </div>
</div> </div>
<div className="relative h-[320px] overflow-hidden bg-muted"> <div className="group relative h-[320px] overflow-hidden bg-muted">
{status === "idle" && !nodeData.url && ( {status === "idle" && !nodeData.url && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground"> <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
<ImageIcon className="h-10 w-10 opacity-30" /> <ImageIcon className="h-10 w-10 opacity-30" />
@@ -174,8 +177,25 @@ export default function AiImageNode({
/> />
)} )}
{nodeData.creditCost != null &&
nodeData.url &&
!isLoading &&
status !== "error" && (
<div
className="pointer-events-none absolute bottom-2 right-2 z-[15] rounded-md border border-border/80 bg-background/85 px-1.5 py-0.5 text-[10px] tabular-nums text-muted-foreground shadow-sm backdrop-blur-sm"
title="Gebuchte Credits (Cent) für diese Generierung"
>
{formatEurFromCents(nodeData.creditCost)}
</div>
)}
{status === "done" && nodeData.url && !isLoading && ( {status === "done" && nodeData.url && !isLoading && (
<div className="absolute inset-0 z-20 flex items-end justify-end p-2 opacity-0 transition-opacity hover:opacity-100"> <div
className={cn(
"absolute right-2 z-20 opacity-0 transition-opacity group-hover:opacity-100",
nodeData.creditCost != null ? "bottom-12" : "bottom-2",
)}
>
<button <button
type="button" type="button"
onClick={() => void handleRegenerate()} onClick={() => void handleRegenerate()}

View File

@@ -41,6 +41,7 @@ export default function PromptNode({
const updateData = useMutation(api.nodes.updateData); const updateData = useMutation(api.nodes.updateData);
const createNode = useMutation(api.nodes.create); const createNode = useMutation(api.nodes.create);
const createEdge = useMutation(api.edges.create);
const generateImage = useAction(api.ai.generateImage); const generateImage = useAction(api.ai.generateImage);
const debouncedSave = useDebouncedCallback((value: string) => { const debouncedSave = useDebouncedCallback((value: string) => {
@@ -107,6 +108,14 @@ export default function PromptNode({
}, },
}); });
await createEdge({
canvasId,
sourceNodeId: id as Id<"nodes">,
targetNodeId: aiNodeId,
sourceHandle: "prompt-out",
targetHandle: "prompt-in",
});
await generateImage({ await generateImage({
canvasId, canvasId,
nodeId: aiNodeId, nodeId: aiNodeId,
@@ -127,6 +136,7 @@ export default function PromptNode({
getEdges, getEdges,
getNode, getNode,
createNode, createNode,
createEdge,
generateImage, generateImage,
]); ]);

View File

@@ -69,6 +69,7 @@ export const generateImage = action({
const existing = await ctx.runQuery(api.nodes.get, { nodeId: args.nodeId }); const existing = await ctx.runQuery(api.nodes.get, { nodeId: args.nodeId });
if (!existing) throw new Error("Node not found"); if (!existing) throw new Error("Node not found");
const prev = (existing.data ?? {}) as Record<string, unknown>; const prev = (existing.data ?? {}) as Record<string, unknown>;
const creditCost = modelConfig.estimatedCostPerImage;
await ctx.runMutation(api.nodes.updateData, { await ctx.runMutation(api.nodes.updateData, {
nodeId: args.nodeId, nodeId: args.nodeId,
@@ -79,6 +80,7 @@ export const generateImage = action({
model: modelId, model: modelId,
modelTier: modelConfig.tier, modelTier: modelConfig.tier,
generatedAt: Date.now(), generatedAt: Date.now(),
creditCost,
}, },
}); });
@@ -89,7 +91,7 @@ export const generateImage = action({
await ctx.runMutation(api.credits.commit, { await ctx.runMutation(api.credits.commit, {
transactionId: reservationId, transactionId: reservationId,
actualCost: modelConfig.estimatedCostPerImage, actualCost: creditCost,
}); });
} catch (error) { } catch (error) {
await ctx.runMutation(api.credits.release, { await ctx.runMutation(api.credits.release, {

View File

@@ -4,3 +4,11 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
/** Credits / Preise: Werte sind Euro-Cent (siehe PRD, Manifest). */
export function formatEurFromCents(cents: number) {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(cents / 100)
}