diff --git a/components/canvas/nodes/prompt-node.tsx b/components/canvas/nodes/prompt-node.tsx index 7c1be54..4545663 100644 --- a/components/canvas/nodes/prompt-node.tsx +++ b/components/canvas/nodes/prompt-node.tsx @@ -38,6 +38,7 @@ 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; @@ -257,7 +258,21 @@ export default function PromptNode({ }, ); } catch (err) { - setError(err instanceof Error ? err.message : msg.ai.generationFailed.title); + 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); } diff --git a/components/canvas/nodes/text-node.tsx b/components/canvas/nodes/text-node.tsx index 69740d3..f8ad917 100644 --- a/components/canvas/nodes/text-node.tsx +++ b/components/canvas/nodes/text-node.tsx @@ -103,12 +103,14 @@ export default function TextNode({ id, data, selected }: NodeProps) { /> ) : (
setIsEditing(true)} - className="min-h-[2rem] cursor-text whitespace-pre-wrap break-words text-sm" + onClick={() => { + if (selected) setIsEditing(true); + }} + className="nodrag nowheel min-h-[2rem] cursor-text whitespace-pre-wrap break-words text-sm" > {content || ( - Doppelklick zum Bearbeiten + Auswählen, dann hier klicken )}
diff --git a/convex/ai.ts b/convex/ai.ts index c87e2f2..b10dabb 100644 --- a/convex/ai.ts +++ b/convex/ai.ts @@ -1,6 +1,6 @@ import { v } from "convex/values"; import { action } from "./_generated/server"; -import { api } from "./_generated/api"; +import { api, internal } from "./_generated/api"; import { generateImageViaOpenRouter, DEFAULT_IMAGE_MODEL, @@ -182,6 +182,9 @@ export const generateImage = action({ throw new Error(`Unknown model: ${modelId}`); } + // Abuse-Check vor allem anderen — immer, unabhängig von Credits + await ctx.runMutation(internal.credits.checkAbuseLimits, {}); + const reservationId = internalCreditsEnabled ? await ctx.runMutation(api.credits.reserve, { estimatedCost: modelConfig.creditCost, @@ -192,15 +195,21 @@ export const generateImage = action({ }) : null; - await ctx.runMutation(api.nodes.updateStatus, { - nodeId: args.nodeId, - status: "executing", - retryCount: 0, - }); + // Usage-Tracking wenn Credits deaktiviert (reserve übernimmt das bei aktivierten Credits) + if (!internalCreditsEnabled) { + await ctx.runMutation(internal.credits.incrementUsage, {}); + } let retryCount = 0; try { + // Status auf "executing" setzen — im try-Block damit Fehler den catch erreichen + await ctx.runMutation(api.nodes.updateStatus, { + nodeId: args.nodeId, + status: "executing", + retryCount: 0, + }); + let referenceImageUrl = args.referenceImageUrl?.trim() || undefined; if (args.referenceStorageId) { referenceImageUrl = @@ -290,6 +299,12 @@ export const generateImage = action({ }); throw error; + } finally { + // Concurrency freigeben wenn Credits deaktiviert + // (commit/release übernehmen das bei aktivierten Credits) + if (!internalCreditsEnabled) { + await ctx.runMutation(internal.credits.decrementConcurrency, {}); + } } }, }); diff --git a/convex/credits.ts b/convex/credits.ts index 6c05472..d394d43 100644 --- a/convex/credits.ts +++ b/convex/credits.ts @@ -1,6 +1,7 @@ import { query, mutation, internalMutation } from "./_generated/server"; import { v } from "convex/values"; import { requireAuth } from "./helpers"; +import { internal } from "./_generated/api"; // ============================================================================ // Tier-Konfiguration @@ -344,14 +345,14 @@ export const reserve = mutation({ if (dailyUsage && dailyUsage.generationCount >= config.dailyGenerationCap) { throw new Error( - `Daily generation limit reached (${config.dailyGenerationCap}/${tier})` + `daily_cap:Tageslimit erreicht (${config.dailyGenerationCap} Generierungen/Tag im ${tier}-Tier)` ); } // Concurrency Limit prüfen if (dailyUsage && dailyUsage.concurrentJobs >= config.concurrencyLimit) { throw new Error( - `Concurrent job limit reached (${config.concurrencyLimit}/${tier})` + `concurrency:Bereits ${config.concurrencyLimit} Generierung(en) aktiv — bitte warten` ); } @@ -652,3 +653,109 @@ export const topUp = mutation({ }); }, }); + +// ============================================================================ +// Internal Mutations — Abuse Prevention (für ai.ts Action) +// ============================================================================ + +/** + * Prüft Daily Cap und Concurrency — wirft Fehler bei Verstoß. + * Wird von generateImage aufgerufen BEVOR Credits reserviert werden. + * Wird benötigt, wenn INTERNAL_CREDITS_ENABLED !== "true". + */ +export const checkAbuseLimits = internalMutation({ + args: {}, + handler: async (ctx) => { + const user = await requireAuth(ctx); + + const subscription = await ctx.db + .query("subscriptions") + .withIndex("by_user", (q) => q.eq("userId", user.userId)) + .order("desc") + .first(); + const tier = (subscription?.tier ?? "free") as Tier; + const config = TIER_CONFIG[tier]; + + const today = new Date().toISOString().split("T")[0]; + const usage = await ctx.db + .query("dailyUsage") + .withIndex("by_user_date", (q) => + q.eq("userId", user.userId).eq("date", today) + ) + .unique(); + + const dailyCount = usage?.generationCount ?? 0; + if (dailyCount >= config.dailyGenerationCap) { + throw new Error( + `daily_cap:Tageslimit erreicht (${config.dailyGenerationCap} Generierungen/Tag im ${tier}-Tier)` + ); + } + + const currentConcurrency = usage?.concurrentJobs ?? 0; + if (currentConcurrency >= config.concurrencyLimit) { + throw new Error( + `concurrency:Bereits ${config.concurrencyLimit} Generierung(en) aktiv — bitte warten` + ); + } + }, +}); + +/** + * Erhöht generationCount und concurrentJobs atomar. + * Nur aufrufen wenn INTERNAL_CREDITS_ENABLED !== "true" + * (reserve übernimmt das bei aktivierten Credits). + */ +export const incrementUsage = internalMutation({ + args: {}, + handler: async (ctx) => { + const user = await requireAuth(ctx); + const today = new Date().toISOString().split("T")[0]; + + const usage = await ctx.db + .query("dailyUsage") + .withIndex("by_user_date", (q) => + q.eq("userId", user.userId).eq("date", today) + ) + .unique(); + + if (usage) { + await ctx.db.patch(usage._id, { + generationCount: usage.generationCount + 1, + concurrentJobs: usage.concurrentJobs + 1, + }); + } else { + await ctx.db.insert("dailyUsage", { + userId: user.userId, + date: today, + generationCount: 1, + concurrentJobs: 1, + }); + } + }, +}); + +/** + * Verringert concurrentJobs um 1 (Minimum 0). + * Nur aufrufen wenn INTERNAL_CREDITS_ENABLED !== "true" + * (commit/release übernehmen das bei aktivierten Credits). + */ +export const decrementConcurrency = internalMutation({ + args: {}, + handler: async (ctx) => { + const user = await requireAuth(ctx); + const today = new Date().toISOString().split("T")[0]; + + const usage = await ctx.db + .query("dailyUsage") + .withIndex("by_user_date", (q) => + q.eq("userId", user.userId).eq("date", today) + ) + .unique(); + + if (usage && usage.concurrentJobs > 0) { + await ctx.db.patch(usage._id, { + concurrentJobs: usage.concurrentJobs - 1, + }); + } + }, +}); diff --git a/lib/ai-errors.ts b/lib/ai-errors.ts index 606f263..6dea288 100644 --- a/lib/ai-errors.ts +++ b/lib/ai-errors.ts @@ -6,6 +6,8 @@ export type AiErrorCategory = | "network" | "server" | "invalid_request" + | "daily_cap" + | "concurrency" | "unknown"; export interface AiError { @@ -52,6 +54,12 @@ const CATEGORY_ALIASES: Record = { invalidrequest: "invalid_request", bad_request: "invalid_request", badrequest: "invalid_request", + daily_cap: "daily_cap", + dailycap: "daily_cap", + daily_limit: "daily_cap", + dailylimit: "daily_cap", + concurrency: "concurrency", + concurrent: "concurrency", }; function normalizeCategory(value: string | undefined): AiErrorCategory | undefined { @@ -153,6 +161,22 @@ function inferCategoryFromText(text: string): AiErrorCategory { return "rate_limited"; } + if ( + lower.includes("daily_cap") || + lower.includes("tageslimit erreicht") || + lower.includes("daily generation limit") + ) { + return "daily_cap"; + } + + if ( + lower.includes("concurrency") || + lower.includes("generierung(en) aktiv") || + lower.includes("concurrent job limit") + ) { + return "concurrency"; + } + if ( lower.includes("timeout") || lower.includes("timed out") || @@ -246,6 +270,20 @@ function defaultsForCategory(category: AiErrorCategory): Omit