From 8d62ee27a25fd0957cb85998ca4c5eecb69ad01c Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Thu, 26 Mar 2026 22:15:03 +0100 Subject: [PATCH] feat: enhance dashboard and canvas components with credit management features - Integrated CreditOverview and RecentTransactions components into the dashboard for better credit visibility. - Updated canvas toolbar to display current credit balance using CreditDisplay. - Improved AI image and prompt nodes to show credit costs and handle credit availability checks during image generation. - Added new queries for fetching recent transactions and monthly usage statistics to support dashboard features. - Refactored existing code to streamline credit-related functionalities across components. --- app/dashboard/page.tsx | 230 ++----------------- components/canvas/canvas-toolbar.tsx | 2 + components/canvas/credit-display.tsx | 105 +++++++++ components/canvas/nodes/ai-image-node.tsx | 47 ++-- components/canvas/nodes/prompt-node.tsx | 74 ++++-- components/dashboard/credit-overview.tsx | 140 +++++++++++ components/dashboard/recent-transactions.tsx | 150 ++++++++++++ convex/ai.ts | 10 +- convex/credits.ts | 115 +++++++++- convex/openrouter.ts | 197 +++++++++++++--- lib/ai-models.ts | 3 + lib/format-time.ts | 20 ++ 12 files changed, 796 insertions(+), 297 deletions(-) create mode 100644 components/canvas/credit-display.tsx create mode 100644 components/dashboard/credit-overview.tsx create mode 100644 components/dashboard/recent-transactions.tsx create mode 100644 lib/format-time.ts diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index daa49a4..d71d891 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -6,7 +6,6 @@ import { useState } from "react"; import { useTheme } from "next-themes"; import { useMutation, useQuery } from "convex/react"; import { - Activity, ArrowUpRight, ChevronDown, Coins, @@ -14,12 +13,10 @@ import { Monitor, Moon, Search, - Sparkles, Sun, } from "lucide-react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -32,87 +29,12 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; -import { Progress } from "@/components/ui/progress"; import { api } from "@/convex/_generated/api"; import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; +import { CreditOverview } from "@/components/dashboard/credit-overview"; +import { RecentTransactions } from "@/components/dashboard/recent-transactions"; -const formatEurFromCents = (cents: number) => - new Intl.NumberFormat("de-DE", { - style: "currency", - currency: "EUR", - }).format(cents / 100); - -const mockRuns = [ - { - id: "run-8841", - workspace: "Sommer-Kampagne", - node: "KI-Bild", - model: "flux-pro", - status: "done" as const, - credits: 42, - updated: "vor 12 Min.", - }, - { - id: "run-8839", - workspace: "Produktfotos", - node: "KI-Bild", - model: "flux-schnell", - status: "executing" as const, - credits: 18, - updated: "gerade eben", - }, - { - id: "run-8832", - workspace: "Social Variants", - node: "Compare", - model: "—", - status: "idle" as const, - credits: 0, - updated: "vor 1 Std.", - }, - { - id: "run-8828", - workspace: "Sommer-Kampagne", - node: "KI-Bild", - model: "flux-pro", - status: "error" as const, - credits: 0, - updated: "vor 2 Std.", - }, -]; - -function StatusDot({ status }: { status: (typeof mockRuns)[0]["status"] }) { - const base = "inline-block size-2 rounded-full"; - switch (status) { - case "done": - return ; - case "executing": - return ( - - - - - ); - case "idle": - return ; - case "error": - return ; - } -} - -function statusLabel(status: (typeof mockRuns)[0]["status"]) { - switch (status) { - case "done": - return "Fertig"; - case "executing": - return "Läuft"; - case "idle": - return "Bereit"; - case "error": - return "Fehler"; - } -} function getInitials(nameOrEmail: string) { const normalized = nameOrEmail.trim(); @@ -162,13 +84,6 @@ export default function DashboardPage() { } }; - const balanceCents = 4320; - const reservedCents = 180; - const monthlyPoolCents = 5000; - const usagePercent = Math.round( - ((monthlyPoolCents - balanceCents) / monthlyPoolCents) * 100, - ); - return (
{/* Header */} @@ -257,85 +172,21 @@ export default function DashboardPage() {

- {/* Credits & Active Generation — asymmetric two-column */} -
- {/* Credits Section */} -
-
- - Credit-Guthaben -
-
- {formatEurFromCents(balanceCents)} -
- -
-
- Reserviert - - {formatEurFromCents(reservedCents)} - -
-
-
- - Monatskontingent - - - {usagePercent}% - -
- -
-
- -

- Bei fehlgeschlagenen Jobs werden reservierte Credits automatisch - freigegeben. -

+ {/* Credits Overview */} +
+
+ + Credit-Übersicht
- - {/* Active Generation */} -
-
-
- - Aktive Generierung -
- - - - - - Läuft - -
- -

- Produktfotos — Variante 3/4 -

- -
-
- Fortschritt - 62% -
- -
- -

- Step 2 von 4 —{" "} - flux-schnell -

-
-
+ + {/* Workspaces */}
- Workspaces + Arbeitsbereiche
{isSessionPending || canvases === undefined ? (
- Workspaces werden geladen... + Arbeitsbereiche werden geladen...
) : canvases.length === 0 ? (
- Noch kein Workspace vorhanden. Mit "Neuer Workspace" legst du den + Noch kein Arbeitsbereich vorhanden. Mit „Neuer Arbeitsbereich“ legst du den ersten an.
) : ( @@ -384,58 +235,9 @@ export default function DashboardPage() { )}
- {/* Recent Activity */} -
-
- - Letzte Aktivität -
- -
-
- {mockRuns.map((run) => ( -
- - -
-
- - {run.workspace} - - - {run.node} - -
-
- {run.model !== "—" && ( - - {run.model} - - )} - {run.credits > 0 && ( - <> - · - {run.credits} ct - - )} -
-
- -
- - {statusLabel(run.status)} - -

- {run.updated} -

-
-
- ))} -
-
+ {/* Recent Transactions */} +
+
diff --git a/components/canvas/canvas-toolbar.tsx b/components/canvas/canvas-toolbar.tsx index 5b48a7f..f1c7788 100644 --- a/components/canvas/canvas-toolbar.tsx +++ b/components/canvas/canvas-toolbar.tsx @@ -2,6 +2,7 @@ import { useRef } from "react"; +import { CreditDisplay } from "@/components/canvas/credit-display"; import { ExportButton } from "@/components/canvas/export-button"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; @@ -98,6 +99,7 @@ export default function CanvasToolbar({ ))}
+
); diff --git a/components/canvas/credit-display.tsx b/components/canvas/credit-display.tsx new file mode 100644 index 0000000..8b8eb04 --- /dev/null +++ b/components/canvas/credit-display.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useMutation, useQuery } from "convex/react"; +import { api } from "@/convex/_generated/api"; +import { Coins } from "lucide-react"; +import { toast } from "sonner"; + +const TIER_LABELS: Record = { + free: "Free", + starter: "Starter", + pro: "Pro", + business: "Business", +}; + +const TIER_COLORS: Record = { + free: "text-muted-foreground", + starter: "text-blue-500", + pro: "text-purple-500", + business: "text-amber-500", +}; + +const showTestCreditGrant = + typeof process.env.NEXT_PUBLIC_ALLOW_TEST_CREDIT_GRANT === "string" && + process.env.NEXT_PUBLIC_ALLOW_TEST_CREDIT_GRANT === "true"; + +export function CreditDisplay() { + const balance = useQuery(api.credits.getBalance); + const subscription = useQuery(api.credits.getSubscription); + const grantTestCredits = useMutation(api.credits.grantTestCredits); + + if (balance === undefined || subscription === undefined) { + return ( +
+ +
+
+ ); + } + + const available = balance.balance - balance.reserved; + const tier = subscription.tier; + const tierLabel = TIER_LABELS[tier] ?? tier; + const tierColor = TIER_COLORS[tier] ?? "text-muted-foreground"; + + const isLow = available < 10; + const isEmpty = available <= 0; + + return ( +
+
+ + + {available.toLocaleString("de-DE")} Cr + + {balance.reserved > 0 && ( + + ({balance.reserved} reserved) + + )} + · + {tierLabel} +
+ {showTestCreditGrant && ( + + )} +
+ ); +} diff --git a/components/canvas/nodes/ai-image-node.tsx b/components/canvas/nodes/ai-image-node.tsx index e8ab04a..a263cac 100644 --- a/components/canvas/nodes/ai-image-node.tsx +++ b/components/canvas/nodes/ai-image-node.tsx @@ -8,12 +8,12 @@ import type { Id } from "@/convex/_generated/dataModel"; import BaseNodeWrapper from "./base-node-wrapper"; import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models"; import { DEFAULT_ASPECT_RATIO } from "@/lib/image-formats"; -import { cn, formatEurFromCents } from "@/lib/utils"; import { Loader2, AlertCircle, RefreshCw, ImageIcon, + Coins, } from "lucide-react"; type AiImageNodeData = { @@ -21,6 +21,7 @@ type AiImageNodeData = { url?: string; prompt?: string; model?: string; + modelLabel?: string; modelTier?: string; generatedAt?: number; /** Gebuchte Credits in Euro-Cent (PRD: nach Commit) */ @@ -123,8 +124,9 @@ export default function AiImageNode({ />
-
- 🖼️ AI Image +
+ + AI Image
@@ -186,24 +188,9 @@ export default function AiImageNode({ /> )} - {nodeData.creditCost != null && - nodeData.url && - !isLoading && - status !== "error" && ( -
- {formatEurFromCents(nodeData.creditCost)} -
- )} - {status === "done" && nodeData.url && !isLoading && (
)} diff --git a/components/canvas/nodes/prompt-node.tsx b/components/canvas/nodes/prompt-node.tsx index 4f39dae..8e97711 100644 --- a/components/canvas/nodes/prompt-node.tsx +++ b/components/canvas/nodes/prompt-node.tsx @@ -9,13 +9,13 @@ import { type NodeProps, type Node, } from "@xyflow/react"; -import { useMutation, useAction } from "convex/react"; +import { useMutation, useAction, useQuery } from "convex/react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import BaseNodeWrapper from "./base-node-wrapper"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; -import { DEFAULT_MODEL_ID } from "@/lib/ai-models"; +import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models"; import { DEFAULT_ASPECT_RATIO, getAiImageNodeOuterSize, @@ -33,7 +33,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Sparkles, Loader2 } from "lucide-react"; +import { Sparkles, Loader2, Coins } from "lucide-react"; type PromptNodeData = { prompt?: string; @@ -104,6 +104,14 @@ export default function PromptNode({ const dataRef = useRef(data); dataRef.current = data; + const balance = useQuery(api.credits.getBalance); + const creditCost = getModel(DEFAULT_MODEL_ID)?.creditCost ?? 4; + + const availableCredits = + balance !== undefined ? balance.balance - balance.reserved : null; + const hasEnoughCredits = + availableCredits !== null && availableCredits >= creditCost; + const updateData = useMutation(api.nodes.updateData); const createEdge = useMutation(api.edges.create); const generateImage = useAction(api.ai.generateImage); @@ -248,8 +256,9 @@ export default function PromptNode({ />
-
- ✨ Eingabe +
+ + Eingabe
{inputMeta.hasTextInput ? (
@@ -313,24 +322,45 @@ export default function PromptNode({

{error}

)} - + {availableCredits !== null && !hasEnoughCredits && ( +

+ Not enough credits ({availableCredits} available, {creditCost}{" "} + needed) +

)} - +
= { + free: 50, + starter: 630, + pro: 3602, + business: 7623, +}; + +const TIER_BADGE_STYLES: Record = { + free: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400", + starter: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400", + pro: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400", + business: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400", +}; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function CreditOverview() { + const balance = useQuery(api.credits.getBalance); + const subscription = useQuery(api.credits.getSubscription); + const usageStats = useQuery(api.credits.getUsageStats); + + // ── Loading State ────────────────────────────────────────────────────── + if ( + balance === undefined || + subscription === undefined || + usageStats === undefined + ) { + return ( +
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+
+ ); + } + + // ── Computed Values ──────────────────────────────────────────────────── + const tier = subscription.tier; + const monthlyCredits = TIER_MONTHLY_CREDITS[tier] ?? 0; + const usagePercent = monthlyCredits > 0 + ? Math.min(100, Math.round((usageStats.monthlyUsage / monthlyCredits) * 100)) + : 0; + + const progressColorClass = + usagePercent > 95 + ? "[&>[data-slot=progress-indicator]]:bg-destructive" + : usagePercent >= 80 + ? "[&>[data-slot=progress-indicator]]:bg-amber-500" + : ""; + + return ( +
+
+ {/* ── Block A: Verfügbare Credits ──────────────────────────────── */} +
+

Verfügbare Credits

+
+ + {formatEurFromCents(balance.available)} + + + {tier.charAt(0).toUpperCase() + tier.slice(1)} + +
+ {balance.reserved > 0 && ( +

+ ({formatEurFromCents(balance.reserved)} reserviert) +

+ )} +
+ + {/* ── Block B: Monatlicher Verbrauch ───────────────────────────── */} +
+
+

Monatlicher Verbrauch

+ + {usagePercent}% + +
+ +
+ + {formatEurFromCents(usageStats.monthlyUsage)} von{" "} + {formatEurFromCents(monthlyCredits)} verwendet + + + {usageStats.totalGenerations} Generierungen + +
+
+ + {/* ── Block C: Aufladen ───────────────────────────────────────── */} +
+ +
+
+
+ ); +} diff --git a/components/dashboard/recent-transactions.tsx b/components/dashboard/recent-transactions.tsx new file mode 100644 index 0000000..42613b5 --- /dev/null +++ b/components/dashboard/recent-transactions.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useQuery } from "convex/react"; +import { Activity, Coins } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { api } from "@/convex/_generated/api"; +import { formatEurFromCents, cn } from "@/lib/utils"; +import { formatRelativeTime } from "@/lib/format-time"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function statusBadge(status: string) { + switch (status) { + case "committed": + return Abgeschlossen; + case "reserved": + return ( + + Reserviert + + ); + case "released": + return ( + + Rückerstattet + + ); + case "failed": + return Fehlgeschlagen; + default: + return Unbekannt; + } +} + +function truncatedDescription(text: string, maxLen = 40) { + if (text.length <= maxLen) return text; + return text.slice(0, maxLen) + "…"; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function RecentTransactions() { + const transactions = useQuery(api.credits.getRecentTransactions, { + limit: 10, + }); + + // ── Loading State ────────────────────────────────────────────────────── + if (transactions === undefined) { + return ( +
+
+ + Letzte Aktivität +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+
+ ); + } + + // ── Empty State ──────────────────────────────────────────────────────── + if (transactions.length === 0) { + return ( +
+
+ + Letzte Aktivität +
+
+ +

+ Noch keine Aktivität +

+

+ Erstelle dein erstes KI-Bild im Canvas +

+
+
+ ); + } + + // ── Transaction List ─────────────────────────────────────────────────── + return ( +
+
+ + Letzte Aktivität +
+
+ {transactions.map((t) => { + const isCredit = t.amount > 0; + return ( +
+ {/* Status Indicator */} +
+ {statusBadge(t.status)} +
+ + {/* Description */} +
+

+ {truncatedDescription(t.description)} +

+

+ {formatRelativeTime(t._creationTime)} +

+
+ + {/* Credits */} +
+ + {isCredit ? "+" : "−"} + {formatEurFromCents(Math.abs(t.amount))} + +
+
+ ); + })} +
+
+ ); +} diff --git a/convex/ai.ts b/convex/ai.ts index 0b69c15..585696e 100644 --- a/convex/ai.ts +++ b/convex/ai.ts @@ -17,6 +17,7 @@ export const generateImage = action({ aspectRatio: v.optional(v.string()), }, handler: async (ctx, args) => { + // Auth: über requireAuth in runMutation — kein verschachteltes getCurrentUser (ConvexError → generische Client-Fehler). const internalCreditsEnabled = process.env.INTERNAL_CREDITS_ENABLED === "true"; @@ -31,13 +32,9 @@ export const generateImage = action({ 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, + estimatedCost: modelConfig.creditCost, description: `Bildgenerierung — ${modelConfig.name}`, model: modelId, nodeId: args.nodeId, @@ -76,7 +73,7 @@ export const generateImage = action({ 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; - const creditCost = modelConfig.estimatedCostPerImage; + const creditCost = modelConfig.creditCost; const aspectRatio = args.aspectRatio?.trim() || @@ -89,6 +86,7 @@ export const generateImage = action({ storageId, prompt: args.prompt, model: modelId, + modelLabel: modelConfig.name, modelTier: modelConfig.tier, generatedAt: Date.now(), creditCost, diff --git a/convex/credits.ts b/convex/credits.ts index dc7921d..8482b07 100644 --- a/convex/credits.ts +++ b/convex/credits.ts @@ -85,17 +85,30 @@ export const listTransactions = query({ }); /** - * Aktuelle Subscription des Users abrufen. + * Aktuelle Subscription des Users abrufen (kompakt, immer definiert für die UI). */ export const getSubscription = query({ args: {}, handler: async (ctx) => { const user = await requireAuth(ctx); - return await ctx.db + const row = await ctx.db .query("subscriptions") .withIndex("by_user", (q) => q.eq("userId", user.userId)) .order("desc") .first(); + + if (!row) { + return { + tier: "free" as const, + status: "active" as const, + }; + } + + return { + tier: row.tier, + status: row.status, + currentPeriodEnd: row.currentPeriodEnd, + }; }, }); @@ -119,6 +132,61 @@ export const getDailyUsage = query({ }, }); +/** + * Neueste Transaktionen des Users abrufen (für Dashboard "Recent Activity"). + * Ähnlich wie listTransactions, aber als dedizierter Query mit explizitem Limit. + */ +export const getRecentTransactions = query({ + args: { + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const user = await requireAuth(ctx); + const limit = args.limit ?? 10; + + return await ctx.db + .query("creditTransactions") + .withIndex("by_user", (q) => q.eq("userId", user.userId)) + .order("desc") + .take(limit); + }, +}); + +/** + * Monatliche Credit-Statistiken des Users abrufen (für Dashboard Verbrauchsbalken). + * Berechnet: monatlicher Verbrauch (nur committed usage-Transaktionen) + Anzahl Generierungen. + */ +export const getUsageStats = query({ + args: {}, + handler: async (ctx) => { + const user = await requireAuth(ctx); + + const now = new Date(); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).getTime(); + + const transactions = await ctx.db + .query("creditTransactions") + .withIndex("by_user", (q) => q.eq("userId", user.userId)) + .order("desc") + .collect(); + + const monthlyTransactions = transactions.filter( + (t) => + t._creationTime >= monthStart && + t.status === "committed" && + t.type === "usage" + ); + + return { + monthlyUsage: monthlyTransactions.reduce( + (sum, t) => sum + Math.abs(t.amount), + 0 + ), + totalGenerations: monthlyTransactions.length, + }; + }, +}); + // ============================================================================ // Mutations — Credit Balance Management // ============================================================================ @@ -171,6 +239,49 @@ export const initBalance = mutation({ }, }); +/** + * Nur Testphase: schreibt dem eingeloggten User Gutschrift gut. + * In Produktion deaktiviert, außer ALLOW_TEST_CREDIT_GRANT ist in Convex auf "true" gesetzt. + */ +export const grantTestCredits = mutation({ + args: { + amount: v.optional(v.number()), + }, + handler: async (ctx, { amount = 2000 }) => { + if (process.env.ALLOW_TEST_CREDIT_GRANT !== "true") { + throw new Error("Test-Gutschriften sind deaktiviert (ALLOW_TEST_CREDIT_GRANT)."); + } + if (amount <= 0 || amount > 1_000_000) { + throw new Error("Ungültiger Betrag."); + } + const user = await requireAuth(ctx); + const balance = await ctx.db + .query("creditBalances") + .withIndex("by_user", (q) => q.eq("userId", user.userId)) + .unique(); + + if (!balance) { + throw new Error("Keine Credit-Balance — zuerst einloggen / initBalance."); + } + + const next = balance.balance + amount; + await ctx.db.patch(balance._id, { + balance: next, + updatedAt: Date.now(), + }); + + await ctx.db.insert("creditTransactions", { + userId: user.userId, + amount, + type: "subscription", + status: "committed", + description: `Testphase — Gutschrift (${amount} Cr)`, + }); + + return { newBalance: next }; + }, +}); + // ============================================================================ // Mutations — Reservation + Commit (Kern des Credit-Systems) // ============================================================================ diff --git a/convex/openrouter.ts b/convex/openrouter.ts index 5d8c48b..889381d 100644 --- a/convex/openrouter.ts +++ b/convex/openrouter.ts @@ -5,6 +5,8 @@ export interface OpenRouterModel { name: string; tier: "budget" | "standard" | "premium"; estimatedCostPerImage: number; // in Euro-Cent (for credit reservation) + /** Gleiche Einheit wie UI „Cr“ / lib/ai-models creditCost */ + creditCost: number; } // Phase 1: Gemini 2.5 Flash Image only. @@ -15,6 +17,7 @@ export const IMAGE_MODELS: Record = { name: "Gemini 2.5 Flash", tier: "standard", estimatedCostPerImage: 4, // ~€0.04 in Euro-Cent + creditCost: 4, }, }; @@ -33,6 +36,48 @@ export interface OpenRouterImageResponse { mimeType: string; } +const DATA_IMAGE_URI = + /data:image\/[\w+.+-]+;base64,[A-Za-z0-9+/=\s]+/; + +function firstDataImageUriInString(s: string): string | undefined { + const m = s.match(DATA_IMAGE_URI); + if (!m) return undefined; + return m[0]!.replace(/\s+/g, ""); +} + +function dataUriFromContentPart(p: Record): string | undefined { + const block = (p.image_url ?? p.imageUrl) as + | Record + | undefined; + const url = block?.url; + if (typeof url === "string" && url.startsWith("data:")) { + return url; + } + if (typeof url === "string" && (url.startsWith("http://") || url.startsWith("https://"))) { + return url; + } + + const inline = + (p.inline_data ?? p.inlineData) as + | Record + | undefined; + if (inline && typeof inline.data === "string") { + const mime = + typeof inline.mime_type === "string" + ? inline.mime_type + : typeof inline.mimeType === "string" + ? inline.mimeType + : "image/png"; + return `data:${mime};base64,${inline.data}`; + } + + if (p.type === "text" && typeof p.text === "string") { + return firstDataImageUriInString(p.text); + } + + return undefined; +} + /** * Calls the OpenRouter API to generate an image. * Uses the chat/completions endpoint with a vision-capable model that returns @@ -46,30 +91,31 @@ export async function generateImageViaOpenRouter( ): Promise { const modelId = params.model ?? DEFAULT_IMAGE_MODEL; - // Build message content — text prompt, optionally with a reference image - const userContent: object[] = []; - - if (params.referenceImageUrl) { - userContent.push({ - type: "image_url", - image_url: { url: params.referenceImageUrl }, - }); - } - - userContent.push({ - type: "text", - text: params.prompt, - }); + // Ohne Referenzbild: einfacher String als content — bei Gemini/OpenRouter sonst oft nur Text (refusal/reasoning) statt Bild. + const userMessage = + params.referenceImageUrl != null && params.referenceImageUrl !== "" + ? { + role: "user" as const, + content: [ + { + type: "image_url" as const, + image_url: { url: params.referenceImageUrl }, + }, + { + type: "text" as const, + text: params.prompt, + }, + ], + } + : { + role: "user" as const, + content: params.prompt, + }; const body: Record = { model: modelId, modalities: ["image", "text"], - messages: [ - { - role: "user", - content: userContent, - }, - ], + messages: [userMessage], }; if (params.aspectRatio?.trim()) { @@ -96,21 +142,110 @@ export async function generateImageViaOpenRouter( const data = await response.json(); - // OpenRouter returns generated images in message.images (separate from content) - const images = data?.choices?.[0]?.message?.images; - - if (!images || images.length === 0) { - throw new Error("No image found in OpenRouter response"); + const message = data?.choices?.[0]?.message as Record | undefined; + if (!message) { + throw new Error("OpenRouter: choices[0].message fehlt"); } - const imageUrl = images[0]?.image_url?.url; - if (!imageUrl) { - throw new Error("Image block missing image_url.url"); + let rawImage: string | undefined; + + const images = message.images; + if (Array.isArray(images) && images.length > 0) { + const first = images[0] as Record; + const block = (first.image_url ?? first.imageUrl) as + | Record + | undefined; + const url = block?.url; + if (typeof url === "string") { + rawImage = url; + } } - // The URL is a data URI: "data:image/png;base64," - const dataUri: string = imageUrl; - const [meta, base64Data] = dataUri.split(","); + const content = message.content; + if (!rawImage && Array.isArray(content)) { + for (const part of content) { + if (!part || typeof part !== "object") continue; + const p = part as Record; + const uri = dataUriFromContentPart(p); + if (uri) { + rawImage = uri; + break; + } + } + } + + if (!rawImage && typeof content === "string") { + rawImage = firstDataImageUriInString(content); + } + + const refusal = message.refusal; + if ( + (!rawImage || (!rawImage.startsWith("data:") && !rawImage.startsWith("http"))) && + refusal != null && + String(refusal).length > 0 + ) { + const r = + typeof refusal === "string" ? refusal : JSON.stringify(refusal); + throw new Error(`OpenRouter: Modell lehnt ab — ${r.slice(0, 500)}`); + } + + if ( + !rawImage || + (!rawImage.startsWith("data:") && + !rawImage.startsWith("http://") && + !rawImage.startsWith("https://")) + ) { + const reasoning = + typeof message.reasoning === "string" + ? message.reasoning.slice(0, 400) + : ""; + const contentPreview = + typeof content === "string" + ? content.slice(0, 400) + : Array.isArray(content) + ? JSON.stringify(content).slice(0, 400) + : ""; + throw new Error( + `OpenRouter: kein Bild in der Antwort. Keys: ${Object.keys(message).join(", ")}. ` + + (reasoning ? `reasoning: ${reasoning}` : `content: ${contentPreview}`), + ); + } + + let dataUri = rawImage; + if (rawImage.startsWith("http://") || rawImage.startsWith("https://")) { + const imgRes = await fetch(rawImage); + if (!imgRes.ok) { + throw new Error( + `OpenRouter: Bild-URL konnte nicht geladen werden (${imgRes.status})`, + ); + } + const mimeTypeFromRes = + imgRes.headers.get("content-type") ?? "image/png"; + const buf = await imgRes.arrayBuffer(); + let b64: string; + if (typeof Buffer !== "undefined") { + b64 = Buffer.from(buf).toString("base64"); + } else { + const bytes = new Uint8Array(buf); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]!); + } + b64 = btoa(binary); + } + dataUri = `data:${mimeTypeFromRes};base64,${b64}`; + } + + if (!dataUri.startsWith("data:")) { + throw new Error("OpenRouter: Bild konnte nicht als data-URI erstellt werden"); + } + + const comma = dataUri.indexOf(","); + if (comma === -1) { + throw new Error("OpenRouter: data-URI ohne Base64-Teil"); + } + const meta = dataUri.slice(0, comma); + const base64Data = dataUri.slice(comma + 1); const mimeType = meta.replace("data:", "").replace(";base64", ""); return { diff --git a/lib/ai-models.ts b/lib/ai-models.ts index 5ab5033..1161151 100644 --- a/lib/ai-models.ts +++ b/lib/ai-models.ts @@ -7,6 +7,8 @@ export interface AiModel { tier: "budget" | "standard" | "premium"; description: string; estimatedCost: string; // human-readable, e.g. "~€0.04" + /** Credits pro Generierung — gleiche Einheit wie Convex reserve/commit (Euro-Cent). */ + creditCost: number; minTier: "free" | "starter" | "pro" | "business"; // minimum subscription tier } @@ -17,6 +19,7 @@ export const IMAGE_MODELS: AiModel[] = [ tier: "standard", description: "Fast, high-quality generation", estimatedCost: "~€0.04", + creditCost: 4, minTier: "free", }, // Phase 2 — uncomment when model selector UI is ready: diff --git a/lib/format-time.ts b/lib/format-time.ts new file mode 100644 index 0000000..6a4997f --- /dev/null +++ b/lib/format-time.ts @@ -0,0 +1,20 @@ +/** + * Formatiert einen Timestamp als relative Zeitangabe. + * Beispiele: "Just now", "5m ago", "3h ago", "2d ago", "12. Mär" + */ +export function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return "Just now"; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + return new Date(timestamp).toLocaleDateString("de-DE", { + day: "numeric", + month: "short", + }); +}