diff --git a/components/canvas/CLAUDE.md b/components/canvas/CLAUDE.md
index 72f1f0d..63f2f7b 100644
--- a/components/canvas/CLAUDE.md
+++ b/components/canvas/CLAUDE.md
@@ -139,9 +139,9 @@ Im **Light Mode** wird der eigentliche Edge-`stroke` ebenfalls aus dieser Akzent
| Datei | Zweck |
|-------|-------|
| `canvas-shell.tsx` | Client-Layout-Wrapper für Sidebar/Main inkl. Resizing, Auto-Collapse und Rail-Mode-Umschaltung |
-| `canvas-toolbar.tsx` | Werkzeug-Leiste (Select, Pan, Zoom-Controls) |
-| `canvas-app-menu.tsx` | App-Menü (Einstellungen, Logout, Canvas-Name) |
-| `canvas-sidebar.tsx` | Node-Palette links; unterstützt Full-Mode und Rail-Mode (icon-only) |
+| `canvas-toolbar.tsx` | Werkzeug-Leiste (Select, Pan, Zoom-Controls) inkl. Canvas-Name im rechten Cluster neben Credits/Export |
+| `canvas-app-menu.tsx` | App-Menü oben rechts (Umbenennen, Löschen, Theme) |
+| `canvas-sidebar.tsx` | Node-Palette links; zeigt im Full-Mode das LemonSpace-Wordmark, im Rail-Mode einen kompakten Header und vor dem User-Menü einen visuellen Bottom-Fade |
| `canvas-command-palette.tsx` | Cmd+K Command Palette |
| `canvas-connection-drop-menu.tsx` | Kontext-Menü beim Loslassen einer Verbindung |
| `canvas-node-template-picker.tsx` | Node aus Template einfügen |
@@ -163,8 +163,13 @@ Im **Light Mode** wird der eigentliche Edge-`stroke` ebenfalls aus dieser Akzent
- Sidebar ist `collapsible`; bei Unterschreiten von `minSize` wird auf `collapsedSize` reduziert.
- Eingeklappt bedeutet nicht „unsichtbar“: `collapsedSize` ist absichtlich > 0 (`64px`), damit ein sichtbarer Rail bleibt.
- `canvas-shell.tsx` schaltet per `onResize` abhängig von der tatsächlichen Pixelbreite zwischen Full-Mode und Rail-Mode um (`railMode` Prop an `CanvasSidebar`).
+- Im Full-Mode zeigt die Sidebar **nicht** mehr den Canvas-Namen, sondern das LemonSpace-Wordmark aus `public/logos/`:
+ - Light Mode → `lemonspace-logo-v2-black-rgb.svg`
+ - Dark Mode → `lemonspace-logo-v2-white-rgb.svg`
+- Der Canvas-Name liegt stattdessen in der Toolbar (`canvas-toolbar.tsx`) als kompakter, truncating Label/Chip im rechten Bereich.
- `CanvasUserMenu` unterstützt ebenfalls einen kompakten Rail-Mode über `compact`.
- Scroll-Chaining ist begrenzt (`overscroll-contain` in der Sidebar-Scrollfläche + `overscroll-none` am Shell-Root), um visuelle Artefakte beim Scrollen am Ende zu verhindern.
+- Vor dem `CanvasUserMenu` liegt im Sidebar-Body ein `pointer-events-none` Bottom-Fade (schwarz → transparent), der die unteren Palette-Einträge nur visuell ausblendet; Scrollen, Drag-and-Drop und Klicks bleiben unverändert funktionsfähig.
---
diff --git a/components/canvas/canvas-sidebar.tsx b/components/canvas/canvas-sidebar.tsx
index 18ef893..442596a 100644
--- a/components/canvas/canvas-sidebar.tsx
+++ b/components/canvas/canvas-sidebar.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import NextImage from "next/image";
import {
Bot,
ClipboardList,
@@ -160,77 +161,98 @@ export default function CanvasSidebar({
) : (
-
- {canvas === undefined ? (
-
- ) : (
- <>
-
- Canvas
-
-
- {canvas?.name ?? "…"}
-
- >
- )}
+
)}
-
- {railMode ? (
-
- {railEntries.map((entry) => (
-
- ))}
-
- ) : (
- <>
- {NODE_CATEGORIES_ORDERED.map((categoryId) => {
- const entries = byCategory.get(categoryId) ?? [];
- if (entries.length === 0) return null;
- const { label } = NODE_CATEGORY_META[categoryId];
- const isCollapsed = collapsedByCategory[categoryId] ?? categoryId !== "source";
- return (
-
-
- setCollapsedByCategory((prev) => ({
- ...prev,
- [categoryId]: !(prev[categoryId] ?? categoryId !== "source"),
- }))
- }
- className="mb-2 flex w-full items-center justify-between rounded-md px-0.5 py-1 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
- aria-expanded={!isCollapsed}
- aria-controls={`sidebar-category-${categoryId}`}
- >
- {label}
- {isCollapsed ? (
-
- ) : (
-
- )}
-
- {!isCollapsed ? (
-
- ) : null}
-
- );
- })}
- >
- )}
+
+
+ {railMode ? (
+
+ {railEntries.map((entry) => (
+
+ ))}
+
+ ) : (
+ <>
+ {NODE_CATEGORIES_ORDERED.map((categoryId) => {
+ const entries = byCategory.get(categoryId) ?? [];
+ if (entries.length === 0) return null;
+ const { label } = NODE_CATEGORY_META[categoryId];
+ const isCollapsed = collapsedByCategory[categoryId] ?? categoryId !== "source";
+ return (
+
+
+ setCollapsedByCategory((prev) => ({
+ ...prev,
+ [categoryId]: !(prev[categoryId] ?? categoryId !== "source"),
+ }))
+ }
+ className="mb-2 flex w-full items-center justify-between rounded-md px-0.5 py-1 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
+ aria-expanded={!isCollapsed}
+ aria-controls={`sidebar-category-${categoryId}`}
+ >
+ {label}
+ {isCollapsed ? (
+
+ ) : (
+
+ )}
+
+ {!isCollapsed ? (
+
+ ) : null}
+
+ );
+ })}
+ >
+ )}
+
+
-
+
+
+
);
}
diff --git a/components/canvas/canvas-toolbar.tsx b/components/canvas/canvas-toolbar.tsx
index 345810f..343c8d6 100644
--- a/components/canvas/canvas-toolbar.tsx
+++ b/components/canvas/canvas-toolbar.tsx
@@ -65,6 +65,7 @@ export default function CanvasToolbar({
};
const byCategory = catalogEntriesByCategory();
+ const resolvedCanvasName = canvasName?.trim() || "Unbenannter Canvas";
const toolBtn = (tool: CanvasNavTool, icon: React.ReactNode, label: string) => (
+
-
+
+
+ {resolvedCanvasName}
+
diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx
index 72827c3..c5efbc8 100644
--- a/components/canvas/canvas.tsx
+++ b/components/canvas/canvas.tsx
@@ -3037,7 +3037,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
diff --git a/convex/ai.ts b/convex/ai.ts
index 50101cb..cc60582 100644
--- a/convex/ai.ts
+++ b/convex/ai.ts
@@ -153,16 +153,35 @@ async function generateImageWithAutoRetry(
) => Promise
) {
let lastError: unknown = null;
+ const startedAt = Date.now();
for (let attempt = 0; attempt <= MAX_IMAGE_RETRIES; attempt++) {
+ const attemptStartedAt = Date.now();
try {
- return await operation();
+ const result = await operation();
+ console.info("[generateImageWithAutoRetry] success", {
+ attempts: attempt + 1,
+ totalDurationMs: Date.now() - startedAt,
+ lastAttemptDurationMs: Date.now() - attemptStartedAt,
+ });
+ return result;
} catch (error) {
lastError = error;
const { retryable, category } = categorizeError(error);
const retryCount = attempt + 1;
const hasRemainingRetry = retryCount <= MAX_IMAGE_RETRIES;
+ console.warn("[generateImageWithAutoRetry] attempt failed", {
+ attempt: retryCount,
+ maxAttempts: MAX_IMAGE_RETRIES + 1,
+ retryable,
+ hasRemainingRetry,
+ category,
+ attemptDurationMs: Date.now() - attemptStartedAt,
+ totalDurationMs: Date.now() - startedAt,
+ message: errorMessage(error),
+ });
+
if (!retryable || !hasRemainingRetry) {
throw error;
}
@@ -289,11 +308,21 @@ export const generateAndStoreImage = internalAction({
aspectRatio: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const startedAt = Date.now();
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY is not set");
}
+ console.info("[generateAndStoreImage] start", {
+ nodeId: args.nodeId,
+ model: args.model,
+ hasReferenceStorageId: Boolean(args.referenceStorageId),
+ hasReferenceImageUrl: Boolean(args.referenceImageUrl?.trim()),
+ aspectRatio: args.aspectRatio?.trim() || null,
+ promptLength: args.prompt.length,
+ });
+
let retryCount = 0;
let referenceImageUrl = args.referenceImageUrl?.trim() || undefined;
if (args.referenceStorageId) {
@@ -301,38 +330,64 @@ export const generateAndStoreImage = internalAction({
(await ctx.storage.getUrl(args.referenceStorageId)) ?? undefined;
}
- const result = await generateImageWithAutoRetry(
- () =>
- generateImageViaOpenRouter(apiKey, {
- prompt: args.prompt,
- referenceImageUrl,
- model: args.model,
- aspectRatio: args.aspectRatio,
- }),
- async (nextRetryCount, maxRetries, failure) => {
- retryCount = nextRetryCount;
- await ctx.runMutation(internal.ai.markNodeRetry, {
- nodeId: args.nodeId,
- retryCount: nextRetryCount,
- maxRetries,
- failureMessage: failure.message,
- });
+ try {
+ const result = await generateImageWithAutoRetry(
+ () =>
+ generateImageViaOpenRouter(apiKey, {
+ prompt: args.prompt,
+ referenceImageUrl,
+ model: args.model,
+ aspectRatio: args.aspectRatio,
+ }),
+ async (nextRetryCount, maxRetries, failure) => {
+ retryCount = nextRetryCount;
+ await ctx.runMutation(internal.ai.markNodeRetry, {
+ nodeId: args.nodeId,
+ retryCount: nextRetryCount,
+ maxRetries,
+ failureMessage: failure.message,
+ });
+ }
+ );
+
+ const decodeStartedAt = Date.now();
+ const binaryString = atob(result.imageBase64);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
}
- );
+ console.info("[generateAndStoreImage] image decoded", {
+ nodeId: args.nodeId,
+ retryCount,
+ decodeDurationMs: Date.now() - decodeStartedAt,
+ bytes: bytes.length,
+ totalDurationMs: Date.now() - startedAt,
+ });
- 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 storageStartedAt = Date.now();
+ const blob = new Blob([bytes], { type: result.mimeType });
+ const storageId = await ctx.storage.store(blob);
+ console.info("[generateAndStoreImage] image stored", {
+ nodeId: args.nodeId,
+ retryCount,
+ storageDurationMs: Date.now() - storageStartedAt,
+ totalDurationMs: Date.now() - startedAt,
+ });
+
+ return {
+ storageId: storageId as Id<"_storage">,
+ retryCount,
+ };
+ } catch (error) {
+ console.error("[generateAndStoreImage] failed", {
+ nodeId: args.nodeId,
+ retryCount,
+ totalDurationMs: Date.now() - startedAt,
+ message: errorMessage(error),
+ category: categorizeError(error).category,
+ });
+ throw error;
}
-
- const blob = new Blob([bytes], { type: result.mimeType });
- const storageId = await ctx.storage.store(blob);
-
- return {
- storageId: storageId as Id<"_storage">,
- retryCount,
- };
},
});
@@ -349,6 +404,7 @@ export const processImageGeneration = internalAction({
userId: v.string(),
},
handler: async (ctx, args) => {
+ const startedAt = Date.now();
console.info("[processImageGeneration] start", {
nodeId: args.nodeId,
reservationId: args.reservationId ?? null,
@@ -384,7 +440,23 @@ export const processImageGeneration = internalAction({
actualCost: creditCost,
});
}
+
+ console.info("[processImageGeneration] success", {
+ nodeId: args.nodeId,
+ retryCount,
+ totalDurationMs: Date.now() - startedAt,
+ reservationId: args.reservationId ?? null,
+ });
} catch (error) {
+ console.error("[processImageGeneration] failed", {
+ nodeId: args.nodeId,
+ retryCount,
+ totalDurationMs: Date.now() - startedAt,
+ reservationId: args.reservationId ?? null,
+ category: categorizeError(error).category,
+ message: errorMessage(error),
+ });
+
if (args.reservationId) {
try {
await ctx.runMutation(internal.credits.releaseInternal, {
@@ -406,6 +478,13 @@ export const processImageGeneration = internalAction({
userId: args.userId,
});
}
+
+ console.info("[processImageGeneration] finished", {
+ nodeId: args.nodeId,
+ retryCount,
+ totalDurationMs: Date.now() - startedAt,
+ shouldDecrementConcurrency: args.shouldDecrementConcurrency,
+ });
}
},
});
@@ -421,6 +500,7 @@ export const generateImage = action({
aspectRatio: v.optional(v.string()),
},
handler: async (ctx, args) => {
+ const startedAt = Date.now();
const canvas = await ctx.runQuery(api.canvases.get, {
canvasId: args.canvasId,
});
@@ -477,8 +557,27 @@ export const generateImage = action({
userId,
});
backgroundJobScheduled = true;
+ console.info("[generateImage] background job scheduled", {
+ nodeId: args.nodeId,
+ canvasId: args.canvasId,
+ modelId,
+ reservationId: reservationId ?? null,
+ usageIncremented,
+ durationMs: Date.now() - startedAt,
+ });
return { queued: true as const, nodeId: args.nodeId };
} catch (error) {
+ console.error("[generateImage] scheduling failed", {
+ nodeId: args.nodeId,
+ canvasId: args.canvasId,
+ modelId,
+ reservationId: reservationId ?? null,
+ usageIncremented,
+ durationMs: Date.now() - startedAt,
+ category: categorizeError(error).category,
+ message: errorMessage(error),
+ });
+
if (reservationId) {
try {
await ctx.runMutation(api.credits.release, {
diff --git a/convex/credits.ts b/convex/credits.ts
index bdaebd6..004f4a1 100644
--- a/convex/credits.ts
+++ b/convex/credits.ts
@@ -103,31 +103,75 @@ export const listTransactions = query({
export const getSubscription = query({
args: {},
handler: async (ctx) => {
- const user = await optionalAuth(ctx);
- if (!user) {
- return {
- tier: "free" as const,
- status: "active" as const,
- };
- }
- const row = await ctx.db
- .query("subscriptions")
- .withIndex("by_user", (q) => q.eq("userId", user.userId))
- .order("desc")
- .first();
+ const startedAt = Date.now();
- if (!row) {
- return {
- tier: "free" as const,
- status: "active" as const,
- };
- }
+ try {
+ console.info("[credits.getSubscription] start", {
+ durationMs: Date.now() - startedAt,
+ });
- return {
- tier: row.tier,
- status: row.status,
- currentPeriodEnd: row.currentPeriodEnd,
- };
+ const user = await optionalAuth(ctx);
+ console.info("[credits.getSubscription] auth resolved", {
+ durationMs: Date.now() - startedAt,
+ userId: user?.userId ?? null,
+ });
+
+ if (!user) {
+ return {
+ tier: "free" as const,
+ status: "active" as const,
+ };
+ }
+
+ const row = await ctx.db
+ .query("subscriptions")
+ .withIndex("by_user", (q) => q.eq("userId", user.userId))
+ .order("desc")
+ .first();
+
+ console.info("[credits.getSubscription] subscription query resolved", {
+ userId: user.userId,
+ durationMs: Date.now() - startedAt,
+ foundRow: Boolean(row),
+ });
+
+ if (!row) {
+ console.info("[credits.getSubscription] no subscription row", {
+ userId: user.userId,
+ durationMs: Date.now() - startedAt,
+ });
+
+ return {
+ tier: "free" as const,
+ status: "active" as const,
+ };
+ }
+
+ console.info("[credits.getSubscription] resolved subscription", {
+ userId: user.userId,
+ subscriptionId: row._id,
+ tier: row.tier,
+ status: row.status,
+ currentPeriodEnd: row.currentPeriodEnd,
+ durationMs: Date.now() - startedAt,
+ });
+
+ return {
+ tier: row.tier,
+ status: row.status,
+ currentPeriodEnd: row.currentPeriodEnd,
+ };
+ } catch (error) {
+ const identity = await ctx.auth.getUserIdentity();
+ console.error("[credits.getSubscription] failed", {
+ durationMs: Date.now() - startedAt,
+ hasIdentity: Boolean(identity),
+ identityIssuer: identity?.issuer ?? null,
+ identitySubject: identity?.subject ?? null,
+ message: error instanceof Error ? error.message : String(error),
+ });
+ throw error;
+ }
},
});
diff --git a/convex/edges.ts b/convex/edges.ts
index 6c4c0fc..6413ae2 100644
--- a/convex/edges.ts
+++ b/convex/edges.ts
@@ -89,16 +89,29 @@ async function assertConnectionPolicy(
export const list = query({
args: { canvasId: v.id("canvases") },
handler: async (ctx, { canvasId }) => {
+ const startedAt = Date.now();
const user = await requireAuth(ctx);
const canvas = await ctx.db.get(canvasId);
if (!canvas || canvas.ownerId !== user.userId) {
return [];
}
- return await ctx.db
+ const edges = await ctx.db
.query("edges")
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
.collect();
+
+ const durationMs = Date.now() - startedAt;
+ if (durationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
+ console.warn("[edges.list] slow list query", {
+ canvasId,
+ userId: user.userId,
+ edgeCount: edges.length,
+ durationMs,
+ });
+ }
+
+ return edges;
},
});
diff --git a/convex/helpers.ts b/convex/helpers.ts
index 4db3984..ba2bae7 100644
--- a/convex/helpers.ts
+++ b/convex/helpers.ts
@@ -1,6 +1,8 @@
import { QueryCtx, MutationCtx } from "./_generated/server";
import { authComponent } from "./auth";
+const AUTH_PERFORMANCE_LOG_THRESHOLD_MS = 100;
+
type SafeAuthUser = NonNullable<
Awaited>
>;
@@ -15,10 +17,18 @@ export type AuthUser = Omit & { userId: string };
export async function requireAuth(
ctx: QueryCtx | MutationCtx
): Promise {
+ const startedAt = Date.now();
const user = await authComponent.safeGetAuthUser(ctx);
+ const durationMs = Date.now() - startedAt;
+ if (durationMs >= AUTH_PERFORMANCE_LOG_THRESHOLD_MS) {
+ console.warn("[requireAuth] slow auth lookup", {
+ durationMs,
+ });
+ }
if (!user) {
const identity = await ctx.auth.getUserIdentity();
console.error("[requireAuth] safeGetAuthUser returned null", {
+ durationMs,
hasIdentity: Boolean(identity),
identityIssuer: identity?.issuer ?? null,
identitySubject: identity?.subject ?? null,
@@ -28,6 +38,7 @@ export async function requireAuth(
const userId = user.userId ?? String(user._id);
if (!userId) {
console.error("[requireAuth] safeGetAuthUser returned user without userId", {
+ durationMs,
userRecordId: String(user._id),
});
throw new Error("Unauthenticated");
@@ -41,7 +52,14 @@ export async function requireAuth(
export async function optionalAuth(
ctx: QueryCtx | MutationCtx
): Promise {
+ const startedAt = Date.now();
const user = await authComponent.safeGetAuthUser(ctx);
+ const durationMs = Date.now() - startedAt;
+ if (durationMs >= AUTH_PERFORMANCE_LOG_THRESHOLD_MS) {
+ console.warn("[optionalAuth] slow auth lookup", {
+ durationMs,
+ });
+ }
if (!user) {
return null;
}
diff --git a/convex/nodes.ts b/convex/nodes.ts
index 6b20166..8f0925b 100644
--- a/convex/nodes.ts
+++ b/convex/nodes.ts
@@ -76,6 +76,14 @@ const ADJUSTMENT_MIN_WIDTH = 240;
const PERFORMANCE_LOG_THRESHOLD_MS = 250;
+function estimateSerializedBytes(value: unknown): number | null {
+ try {
+ return JSON.stringify(value)?.length ?? 0;
+ } catch {
+ return null;
+ }
+}
+
type RenderOutputResolution = (typeof RENDER_OUTPUT_RESOLUTIONS)[number];
type RenderFormat = (typeof RENDER_FORMATS)[number];
@@ -545,13 +553,27 @@ async function resolveNodeReferenceForWrite(
export const list = query({
args: { canvasId: v.id("canvases") },
handler: async (ctx, { canvasId }) => {
+ const startedAt = Date.now();
const user = await requireAuth(ctx);
await getCanvasOrThrow(ctx, canvasId, user.userId);
- return await ctx.db
+ const nodes = await ctx.db
.query("nodes")
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
.collect();
+
+ const durationMs = Date.now() - startedAt;
+ if (durationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
+ console.warn("[nodes.list] slow list query", {
+ canvasId,
+ userId: user.userId,
+ nodeCount: nodes.length,
+ approxPayloadBytes: estimateSerializedBytes(nodes),
+ durationMs,
+ });
+ }
+
+ return nodes;
},
});
@@ -678,46 +700,87 @@ export const create = mutation({
clientRequestId: v.optional(v.string()),
},
handler: async (ctx, args) => {
- const user = await requireAuth(ctx);
- await getCanvasOrThrow(ctx, args.canvasId, user.userId);
+ const startedAt = Date.now();
+ const approxDataBytes = estimateSerializedBytes(args.data);
- const existingNodeId = await getIdempotentNodeCreateResult(ctx, {
- userId: user.userId,
- mutation: "nodes.create",
- clientRequestId: args.clientRequestId,
+ console.info("[nodes.create] start", {
canvasId: args.canvasId,
+ type: args.type,
+ clientRequestId: args.clientRequestId ?? null,
+ approxDataBytes,
});
- if (existingNodeId) {
- return existingNodeId;
+
+ try {
+ const user = await requireAuth(ctx);
+ const authDurationMs = Date.now() - startedAt;
+ await getCanvasOrThrow(ctx, args.canvasId, user.userId);
+
+ const existingNodeId = await getIdempotentNodeCreateResult(ctx, {
+ userId: user.userId,
+ mutation: "nodes.create",
+ clientRequestId: args.clientRequestId,
+ canvasId: args.canvasId,
+ });
+ if (existingNodeId) {
+ console.info("[nodes.create] idempotent hit", {
+ canvasId: args.canvasId,
+ type: args.type,
+ userId: user.userId,
+ authDurationMs,
+ totalDurationMs: Date.now() - startedAt,
+ existingNodeId,
+ });
+ return existingNodeId;
+ }
+
+ const normalizedData = normalizeNodeDataForWrite(args.type, args.data);
+
+ const nodeId = await ctx.db.insert("nodes", {
+ canvasId: args.canvasId,
+ type: args.type as Doc<"nodes">["type"],
+ positionX: args.positionX,
+ positionY: args.positionY,
+ width: args.width,
+ height: args.height,
+ status: "idle",
+ retryCount: 0,
+ data: normalizedData,
+ parentId: args.parentId,
+ zIndex: args.zIndex,
+ });
+
+ // Canvas updatedAt aktualisieren
+ await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
+ await rememberIdempotentNodeCreateResult(ctx, {
+ userId: user.userId,
+ mutation: "nodes.create",
+ clientRequestId: args.clientRequestId,
+ canvasId: args.canvasId,
+ nodeId,
+ });
+
+ console.info("[nodes.create] success", {
+ canvasId: args.canvasId,
+ type: args.type,
+ userId: user.userId,
+ nodeId,
+ approxDataBytes,
+ authDurationMs,
+ totalDurationMs: Date.now() - startedAt,
+ });
+
+ return nodeId;
+ } catch (error) {
+ console.error("[nodes.create] failed", {
+ canvasId: args.canvasId,
+ type: args.type,
+ clientRequestId: args.clientRequestId ?? null,
+ approxDataBytes,
+ totalDurationMs: Date.now() - startedAt,
+ message: error instanceof Error ? error.message : String(error),
+ });
+ throw error;
}
-
- const normalizedData = normalizeNodeDataForWrite(args.type, args.data);
-
- const nodeId = await ctx.db.insert("nodes", {
- canvasId: args.canvasId,
- type: args.type as Doc<"nodes">["type"],
- positionX: args.positionX,
- positionY: args.positionY,
- width: args.width,
- height: args.height,
- status: "idle",
- retryCount: 0,
- data: normalizedData,
- parentId: args.parentId,
- zIndex: args.zIndex,
- });
-
- // Canvas updatedAt aktualisieren
- await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
- await rememberIdempotentNodeCreateResult(ctx, {
- userId: user.userId,
- mutation: "nodes.create",
- clientRequestId: args.clientRequestId,
- canvasId: args.canvasId,
- nodeId,
- });
-
- return nodeId;
},
});
diff --git a/convex/openrouter.ts b/convex/openrouter.ts
index 601046e..9ea65ce 100644
--- a/convex/openrouter.ts
+++ b/convex/openrouter.ts
@@ -92,6 +92,14 @@ export async function generateImageViaOpenRouter(
params: GenerateImageParams
): Promise {
const modelId = params.model ?? DEFAULT_IMAGE_MODEL;
+ const requestStartedAt = Date.now();
+
+ console.info("[openrouter] request start", {
+ modelId,
+ hasReferenceImageUrl: Boolean(params.referenceImageUrl),
+ aspectRatio: params.aspectRatio?.trim() || null,
+ promptLength: params.prompt.length,
+ });
// Ohne Referenzbild: einfacher String als content — bei Gemini/OpenRouter sonst oft nur Text (refusal/reasoning) statt Bild.
const userMessage =
@@ -126,15 +134,33 @@ export async function generateImageViaOpenRouter(
};
}
- const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
- method: "POST",
- headers: {
- Authorization: `Bearer ${apiKey}`,
- "Content-Type": "application/json",
- "HTTP-Referer": "https://app.lemonspace.io",
- "X-Title": "LemonSpace",
- },
- body: JSON.stringify(body),
+ let response: Response;
+
+ try {
+ response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ "Content-Type": "application/json",
+ "HTTP-Referer": "https://app.lemonspace.io",
+ "X-Title": "LemonSpace",
+ },
+ body: JSON.stringify(body),
+ });
+ } catch (error) {
+ console.error("[openrouter] request failed", {
+ modelId,
+ durationMs: Date.now() - requestStartedAt,
+ message: error instanceof Error ? error.message : String(error),
+ });
+ throw error;
+ }
+
+ console.info("[openrouter] response received", {
+ modelId,
+ status: response.status,
+ ok: response.ok,
+ durationMs: Date.now() - requestStartedAt,
});
if (!response.ok) {
@@ -221,6 +247,7 @@ export async function generateImageViaOpenRouter(
let dataUri = rawImage;
if (rawImage.startsWith("http://") || rawImage.startsWith("https://")) {
+ const imageDownloadStartedAt = Date.now();
const imgRes = await fetch(rawImage);
if (!imgRes.ok) {
throw new ConvexError({
@@ -231,6 +258,12 @@ export async function generateImageViaOpenRouter(
const mimeTypeFromRes =
imgRes.headers.get("content-type") ?? "image/png";
const buf = await imgRes.arrayBuffer();
+ console.info("[openrouter] image downloaded", {
+ modelId,
+ durationMs: Date.now() - imageDownloadStartedAt,
+ bytes: buf.byteLength,
+ mimeType: mimeTypeFromRes,
+ });
let b64: string;
if (typeof Buffer !== "undefined") {
b64 = Buffer.from(buf).toString("base64");
@@ -257,6 +290,14 @@ export async function generateImageViaOpenRouter(
const base64Data = dataUri.slice(comma + 1);
const mimeType = meta.replace("data:", "").replace(";base64", "");
+ console.info("[openrouter] image parsed", {
+ modelId,
+ durationMs: Date.now() - requestStartedAt,
+ mimeType: mimeType || "image/png",
+ base64Length: base64Data.length,
+ source: rawImage.startsWith("data:") ? "inline" : "remote-url",
+ });
+
return {
imageBase64: base64Data,
mimeType: mimeType || "image/png",