feat: implement error classification and handling for AI generation limits
- Added error classification for daily generation cap and concurrency limits in the PromptNode component, improving user feedback during AI image generation failures. - Enhanced toast notifications to provide specific messages for daily limit and concurrent job errors. - Introduced internal mutations in the credits module to check abuse limits and track usage, ensuring better resource management and user experience. - Updated AI error handling logic to categorize and respond to different error types effectively.
This commit is contained in:
@@ -38,6 +38,7 @@ import { Sparkles, Loader2, Coins } from "lucide-react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { msg } from "@/lib/toast-messages";
|
import { msg } from "@/lib/toast-messages";
|
||||||
|
import { classifyError } from "@/lib/ai-errors";
|
||||||
|
|
||||||
type PromptNodeData = {
|
type PromptNodeData = {
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
@@ -257,7 +258,21 @@ export default function PromptNode({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,12 +103,14 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
onDoubleClick={() => setIsEditing(true)}
|
onClick={() => {
|
||||||
className="min-h-[2rem] cursor-text whitespace-pre-wrap break-words text-sm"
|
if (selected) setIsEditing(true);
|
||||||
|
}}
|
||||||
|
className="nodrag nowheel min-h-[2rem] cursor-text whitespace-pre-wrap break-words text-sm"
|
||||||
>
|
>
|
||||||
{content || (
|
{content || (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
Doppelklick zum Bearbeiten
|
Auswählen, dann hier klicken
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
23
convex/ai.ts
23
convex/ai.ts
@@ -1,6 +1,6 @@
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { action } from "./_generated/server";
|
import { action } from "./_generated/server";
|
||||||
import { api } from "./_generated/api";
|
import { api, internal } from "./_generated/api";
|
||||||
import {
|
import {
|
||||||
generateImageViaOpenRouter,
|
generateImageViaOpenRouter,
|
||||||
DEFAULT_IMAGE_MODEL,
|
DEFAULT_IMAGE_MODEL,
|
||||||
@@ -182,6 +182,9 @@ export const generateImage = action({
|
|||||||
throw new Error(`Unknown model: ${modelId}`);
|
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
|
const reservationId = internalCreditsEnabled
|
||||||
? await ctx.runMutation(api.credits.reserve, {
|
? await ctx.runMutation(api.credits.reserve, {
|
||||||
estimatedCost: modelConfig.creditCost,
|
estimatedCost: modelConfig.creditCost,
|
||||||
@@ -192,15 +195,21 @@ export const generateImage = action({
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// 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, {
|
await ctx.runMutation(api.nodes.updateStatus, {
|
||||||
nodeId: args.nodeId,
|
nodeId: args.nodeId,
|
||||||
status: "executing",
|
status: "executing",
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
let retryCount = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let referenceImageUrl = args.referenceImageUrl?.trim() || undefined;
|
let referenceImageUrl = args.referenceImageUrl?.trim() || undefined;
|
||||||
if (args.referenceStorageId) {
|
if (args.referenceStorageId) {
|
||||||
referenceImageUrl =
|
referenceImageUrl =
|
||||||
@@ -290,6 +299,12 @@ export const generateImage = action({
|
|||||||
});
|
});
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
// Concurrency freigeben wenn Credits deaktiviert
|
||||||
|
// (commit/release übernehmen das bei aktivierten Credits)
|
||||||
|
if (!internalCreditsEnabled) {
|
||||||
|
await ctx.runMutation(internal.credits.decrementConcurrency, {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { query, mutation, internalMutation } from "./_generated/server";
|
import { query, mutation, internalMutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { requireAuth } from "./helpers";
|
import { requireAuth } from "./helpers";
|
||||||
|
import { internal } from "./_generated/api";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Tier-Konfiguration
|
// Tier-Konfiguration
|
||||||
@@ -344,14 +345,14 @@ export const reserve = mutation({
|
|||||||
|
|
||||||
if (dailyUsage && dailyUsage.generationCount >= config.dailyGenerationCap) {
|
if (dailyUsage && dailyUsage.generationCount >= config.dailyGenerationCap) {
|
||||||
throw new Error(
|
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
|
// Concurrency Limit prüfen
|
||||||
if (dailyUsage && dailyUsage.concurrentJobs >= config.concurrencyLimit) {
|
if (dailyUsage && dailyUsage.concurrentJobs >= config.concurrencyLimit) {
|
||||||
throw new Error(
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export type AiErrorCategory =
|
|||||||
| "network"
|
| "network"
|
||||||
| "server"
|
| "server"
|
||||||
| "invalid_request"
|
| "invalid_request"
|
||||||
|
| "daily_cap"
|
||||||
|
| "concurrency"
|
||||||
| "unknown";
|
| "unknown";
|
||||||
|
|
||||||
export interface AiError {
|
export interface AiError {
|
||||||
@@ -52,6 +54,12 @@ const CATEGORY_ALIASES: Record<string, AiErrorCategory> = {
|
|||||||
invalidrequest: "invalid_request",
|
invalidrequest: "invalid_request",
|
||||||
bad_request: "invalid_request",
|
bad_request: "invalid_request",
|
||||||
badrequest: "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 {
|
function normalizeCategory(value: string | undefined): AiErrorCategory | undefined {
|
||||||
@@ -153,6 +161,22 @@ function inferCategoryFromText(text: string): AiErrorCategory {
|
|||||||
return "rate_limited";
|
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 (
|
if (
|
||||||
lower.includes("timeout") ||
|
lower.includes("timeout") ||
|
||||||
lower.includes("timed out") ||
|
lower.includes("timed out") ||
|
||||||
@@ -246,6 +270,20 @@ function defaultsForCategory(category: AiErrorCategory): Omit<AiError, "category
|
|||||||
creditsNotCharged: true,
|
creditsNotCharged: true,
|
||||||
showTopUp: false,
|
showTopUp: false,
|
||||||
};
|
};
|
||||||
|
case "daily_cap":
|
||||||
|
return {
|
||||||
|
message: "Tageslimit erreicht",
|
||||||
|
retryable: false,
|
||||||
|
creditsNotCharged: true,
|
||||||
|
showTopUp: false,
|
||||||
|
};
|
||||||
|
case "concurrency":
|
||||||
|
return {
|
||||||
|
message: "Generierung bereits aktiv",
|
||||||
|
retryable: true,
|
||||||
|
creditsNotCharged: true,
|
||||||
|
showTopUp: false,
|
||||||
|
};
|
||||||
case "unknown":
|
case "unknown":
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export const msg = {
|
|||||||
title: "OpenRouter möglicherweise gestört",
|
title: "OpenRouter möglicherweise gestört",
|
||||||
desc: "Mehrere Generierungen fehlgeschlagen.",
|
desc: "Mehrere Generierungen fehlgeschlagen.",
|
||||||
},
|
},
|
||||||
|
concurrentLimitReached: {
|
||||||
|
title: "Generierung bereits aktiv",
|
||||||
|
desc: "Bitte warte, bis die laufende Generierung abgeschlossen ist.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
export: {
|
export: {
|
||||||
|
|||||||
Reference in New Issue
Block a user