Files
lemonspace_app/convex/ai.ts
Matthias 886a530f26 feat: implement internal credits handling in AI image generation
- Added support for internal credits feature in the generateImage action.
- Conditional reservation of credits based on the INTERNAL_CREDITS_ENABLED environment variable.
- Updated error handling to ensure credits are released or committed only when reservations are made.
2026-03-26 20:23:46 +01:00

128 lines
3.6 KiB
TypeScript

import { v } from "convex/values";
import { action } from "./_generated/server";
import { api } from "./_generated/api";
import {
generateImageViaOpenRouter,
DEFAULT_IMAGE_MODEL,
IMAGE_MODELS,
} from "./openrouter";
export const generateImage = action({
args: {
canvasId: v.id("canvases"),
nodeId: v.id("nodes"),
prompt: v.string(),
referenceStorageId: v.optional(v.id("_storage")),
model: v.optional(v.string()),
aspectRatio: v.optional(v.string()),
},
handler: async (ctx, args) => {
const internalCreditsEnabled =
process.env.INTERNAL_CREDITS_ENABLED === "true";
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY is not set");
}
const modelId = args.model ?? DEFAULT_IMAGE_MODEL;
const modelConfig = IMAGE_MODELS[modelId];
if (!modelConfig) {
throw new Error(`Unknown model: ${modelId}`);
}
if (!(await ctx.runQuery(api.auth.getCurrentUser, {}))) {
throw new Error("User not found");
}
const reservationId = internalCreditsEnabled
? await ctx.runMutation(api.credits.reserve, {
estimatedCost: modelConfig.estimatedCostPerImage,
description: `Bildgenerierung — ${modelConfig.name}`,
model: modelId,
nodeId: args.nodeId,
canvasId: args.canvasId,
})
: null;
await ctx.runMutation(api.nodes.updateStatus, {
nodeId: args.nodeId,
status: "executing",
});
try {
let referenceImageUrl: string | undefined;
if (args.referenceStorageId) {
referenceImageUrl =
(await ctx.storage.getUrl(args.referenceStorageId)) ?? undefined;
}
const result = await generateImageViaOpenRouter(apiKey, {
prompt: args.prompt,
referenceImageUrl,
model: modelId,
aspectRatio: args.aspectRatio,
});
const binaryString = atob(result.imageBase64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const blob = new Blob([bytes], { type: result.mimeType });
const storageId = await ctx.storage.store(blob);
const existing = await ctx.runQuery(api.nodes.get, { nodeId: args.nodeId });
if (!existing) throw new Error("Node not found");
const prev = (existing.data ?? {}) as Record<string, unknown>;
const creditCost = modelConfig.estimatedCostPerImage;
const aspectRatio =
args.aspectRatio?.trim() ||
(typeof prev.aspectRatio === "string" ? prev.aspectRatio : undefined);
await ctx.runMutation(api.nodes.updateData, {
nodeId: args.nodeId,
data: {
...prev,
storageId,
prompt: args.prompt,
model: modelId,
modelTier: modelConfig.tier,
generatedAt: Date.now(),
creditCost,
...(aspectRatio ? { aspectRatio } : {}),
},
});
await ctx.runMutation(api.nodes.updateStatus, {
nodeId: args.nodeId,
status: "done",
});
if (reservationId) {
await ctx.runMutation(api.credits.commit, {
transactionId: reservationId,
actualCost: creditCost,
});
}
} catch (error) {
if (reservationId) {
await ctx.runMutation(api.credits.release, {
transactionId: reservationId,
});
}
await ctx.runMutation(api.nodes.updateStatus, {
nodeId: args.nodeId,
status: "error",
statusMessage:
error instanceof Error ? error.message : "Generation failed",
});
throw error;
}
},
});