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:
@@ -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()}
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user