Enhance canvas sidebar and toolbar with improved UI and state management

- Integrated NextImage for logo display in the canvas sidebar, enhancing visual consistency.
- Updated canvas name handling in the toolbar to ensure proper display and accessibility.
- Refactored sidebar layout for better responsiveness and user experience.
- Improved state management for category collapsibility in the sidebar, allowing for a more intuitive navigation experience.
This commit is contained in:
2026-04-03 13:51:41 +02:00
parent ef98acd0de
commit 3aaad38e06
9 changed files with 477 additions and 169 deletions

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import NextImage from "next/image";
import { import {
Bot, Bot,
ClipboardList, ClipboardList,
@@ -160,77 +161,98 @@ export default function CanvasSidebar({
</div> </div>
</div> </div>
) : ( ) : (
<div className="border-b border-border/80 px-4 py-4"> <div className="border-b border-border/80 px-4 py-5">
{canvas === undefined ? ( <div className="flex min-h-8 items-center">
<div className="h-12 animate-pulse rounded-md bg-muted/50" /> <div className="relative">
) : ( <NextImage
<> src="/logos/lemonspace-logo-v2-black-rgb.svg"
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> alt="LemonSpace"
Canvas width={140}
</p> height={27}
<h1 className="mt-1 line-clamp-2 text-base font-semibold leading-snug text-foreground"> className="h-auto w-[8.75rem] dark:hidden"
{canvas?.name ?? "…"} priority
</h1> />
</> <NextImage
)} src="/logos/lemonspace-logo-v2-white-rgb.svg"
alt="LemonSpace"
width={140}
height={27}
className="hidden h-auto w-[8.75rem] dark:block"
priority
/>
</div>
</div>
</div> </div>
)} )}
<div <div className="relative min-h-0 flex-1">
className={cn( <div
"flex-1 overflow-y-auto overscroll-contain", className={cn(
railMode ? "p-2" : "p-3", "h-full overflow-y-auto overscroll-contain",
)} railMode ? "p-2 pb-20" : "p-3 pb-28",
> )}
{railMode ? ( >
<div className="flex flex-col gap-1.5"> {railMode ? (
{railEntries.map((entry) => ( <div className="flex flex-col gap-1.5">
<SidebarRow key={entry.type} entry={entry} compact /> {railEntries.map((entry) => (
))} <SidebarRow key={entry.type} entry={entry} compact />
</div> ))}
) : ( </div>
<> ) : (
{NODE_CATEGORIES_ORDERED.map((categoryId) => { <>
const entries = byCategory.get(categoryId) ?? []; {NODE_CATEGORIES_ORDERED.map((categoryId) => {
if (entries.length === 0) return null; const entries = byCategory.get(categoryId) ?? [];
const { label } = NODE_CATEGORY_META[categoryId]; if (entries.length === 0) return null;
const isCollapsed = collapsedByCategory[categoryId] ?? categoryId !== "source"; const { label } = NODE_CATEGORY_META[categoryId];
return ( const isCollapsed = collapsedByCategory[categoryId] ?? categoryId !== "source";
<div key={categoryId} className="mb-4 last:mb-0"> return (
<button <div key={categoryId} className="mb-4 last:mb-0">
type="button" <button
onClick={() => type="button"
setCollapsedByCategory((prev) => ({ onClick={() =>
...prev, setCollapsedByCategory((prev) => ({
[categoryId]: !(prev[categoryId] ?? categoryId !== "source"), ...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} 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-controls={`sidebar-category-${categoryId}`} aria-expanded={!isCollapsed}
> aria-controls={`sidebar-category-${categoryId}`}
<span>{label}</span> >
{isCollapsed ? ( <span>{label}</span>
<ChevronRight className="size-3.5 shrink-0" /> {isCollapsed ? (
) : ( <ChevronRight className="size-3.5 shrink-0" />
<ChevronDown className="size-3.5 shrink-0" /> ) : (
)} <ChevronDown className="size-3.5 shrink-0" />
</button> )}
{!isCollapsed ? ( </button>
<div id={`sidebar-category-${categoryId}`} className="flex flex-col gap-1.5"> {!isCollapsed ? (
{entries.map((entry) => ( <div id={`sidebar-category-${categoryId}`} className="flex flex-col gap-1.5">
<SidebarRow key={entry.type} entry={entry} /> {entries.map((entry) => (
))} <SidebarRow key={entry.type} entry={entry} />
</div> ))}
) : null} </div>
</div> ) : null}
); </div>
})} );
</> })}
)} </>
)}
</div>
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 z-10",
railMode
? "h-16 bg-gradient-to-t from-black via-black/80 to-transparent"
: "h-24 bg-gradient-to-t from-black via-black/80 to-transparent",
)}
/>
</div> </div>
<CanvasUserMenu compact={railMode} /> <div className="relative z-20 bg-background">
<CanvasUserMenu compact={railMode} />
</div>
</aside> </aside>
); );
} }

View File

@@ -65,6 +65,7 @@ export default function CanvasToolbar({
}; };
const byCategory = catalogEntriesByCategory(); const byCategory = catalogEntriesByCategory();
const resolvedCanvasName = canvasName?.trim() || "Unbenannter Canvas";
const toolBtn = (tool: CanvasNavTool, icon: React.ReactNode, label: string) => ( const toolBtn = (tool: CanvasNavTool, icon: React.ReactNode, label: string) => (
<Button <Button
@@ -82,7 +83,7 @@ export default function CanvasToolbar({
); );
return ( return (
<div className="absolute top-4 left-1/2 z-10 flex max-w-[min(calc(100vw-12rem),52rem)] -translate-x-1/2 items-center gap-0.5 rounded-xl border border-border/80 bg-card/95 p-1.5 shadow-lg backdrop-blur-sm"> <div className="absolute top-4 left-1/2 z-10 flex w-[min(calc(100vw-9rem),64rem)] items-center gap-0.5 rounded-xl border border-border/80 bg-card/95 p-1.5 shadow-lg backdrop-blur-sm -translate-x-1/2">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -181,7 +182,14 @@ export default function CanvasToolbar({
<div className="mx-1 h-6 w-px shrink-0 bg-border/80" /> <div className="mx-1 h-6 w-px shrink-0 bg-border/80" />
<div className="flex min-w-0 flex-1 items-center justify-end gap-1 sm:flex-initial"> <div className="flex min-w-0 flex-1 items-center justify-end gap-1">
<div
className="min-w-0 max-w-28 rounded-lg border border-border/70 bg-background/80 px-3 py-1.5 text-sm font-semibold text-foreground shadow-sm sm:max-w-40 md:max-w-52"
title={resolvedCanvasName}
aria-label={`Canvas-Name: ${resolvedCanvasName}`}
>
<span className="block truncate">{resolvedCanvasName}</span>
</div>
<CreditDisplay /> <CreditDisplay />
<ExportButton canvasName={canvasName ?? "canvas"} /> <ExportButton canvasName={canvasName ?? "canvas"} />
</div> </div>

View File

@@ -2990,7 +2990,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
<AssetBrowserTargetContext.Provider value={assetBrowserTargetApi}> <AssetBrowserTargetContext.Provider value={assetBrowserTargetApi}>
<div className="relative h-full w-full"> <div className="relative h-full w-full">
<CanvasToolbar <CanvasToolbar
canvasName={canvas?.name ?? "canvas"} canvasName={canvas?.name}
activeTool={navTool} activeTool={navTool}
onToolChange={handleNavToolChange} onToolChange={handleNavToolChange}
/> />

View File

@@ -153,16 +153,35 @@ async function generateImageWithAutoRetry(
) => Promise<void> ) => Promise<void>
) { ) {
let lastError: unknown = null; let lastError: unknown = null;
const startedAt = Date.now();
for (let attempt = 0; attempt <= MAX_IMAGE_RETRIES; attempt++) { for (let attempt = 0; attempt <= MAX_IMAGE_RETRIES; attempt++) {
const attemptStartedAt = Date.now();
try { 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) { } catch (error) {
lastError = error; lastError = error;
const { retryable, category } = categorizeError(error); const { retryable, category } = categorizeError(error);
const retryCount = attempt + 1; const retryCount = attempt + 1;
const hasRemainingRetry = retryCount <= MAX_IMAGE_RETRIES; 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) { if (!retryable || !hasRemainingRetry) {
throw error; throw error;
} }
@@ -289,11 +308,21 @@ export const generateAndStoreImage = internalAction({
aspectRatio: v.optional(v.string()), aspectRatio: v.optional(v.string()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const startedAt = Date.now();
const apiKey = process.env.OPENROUTER_API_KEY; const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) { if (!apiKey) {
throw new Error("OPENROUTER_API_KEY is not set"); 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 retryCount = 0;
let referenceImageUrl = args.referenceImageUrl?.trim() || undefined; let referenceImageUrl = args.referenceImageUrl?.trim() || undefined;
if (args.referenceStorageId) { if (args.referenceStorageId) {
@@ -301,38 +330,64 @@ export const generateAndStoreImage = internalAction({
(await ctx.storage.getUrl(args.referenceStorageId)) ?? undefined; (await ctx.storage.getUrl(args.referenceStorageId)) ?? undefined;
} }
const result = await generateImageWithAutoRetry( try {
() => const result = await generateImageWithAutoRetry(
generateImageViaOpenRouter(apiKey, { () =>
prompt: args.prompt, generateImageViaOpenRouter(apiKey, {
referenceImageUrl, prompt: args.prompt,
model: args.model, referenceImageUrl,
aspectRatio: args.aspectRatio, model: args.model,
}), aspectRatio: args.aspectRatio,
async (nextRetryCount, maxRetries, failure) => { }),
retryCount = nextRetryCount; async (nextRetryCount, maxRetries, failure) => {
await ctx.runMutation(internal.ai.markNodeRetry, { retryCount = nextRetryCount;
nodeId: args.nodeId, await ctx.runMutation(internal.ai.markNodeRetry, {
retryCount: nextRetryCount, nodeId: args.nodeId,
maxRetries, retryCount: nextRetryCount,
failureMessage: failure.message, 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 storageStartedAt = Date.now();
const bytes = new Uint8Array(binaryString.length); const blob = new Blob([bytes], { type: result.mimeType });
for (let i = 0; i < binaryString.length; i++) { const storageId = await ctx.storage.store(blob);
bytes[i] = binaryString.charCodeAt(i); 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(), userId: v.string(),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const startedAt = Date.now();
console.info("[processImageGeneration] start", { console.info("[processImageGeneration] start", {
nodeId: args.nodeId, nodeId: args.nodeId,
reservationId: args.reservationId ?? null, reservationId: args.reservationId ?? null,
@@ -384,7 +440,23 @@ export const processImageGeneration = internalAction({
actualCost: creditCost, actualCost: creditCost,
}); });
} }
console.info("[processImageGeneration] success", {
nodeId: args.nodeId,
retryCount,
totalDurationMs: Date.now() - startedAt,
reservationId: args.reservationId ?? null,
});
} catch (error) { } 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) { if (args.reservationId) {
try { try {
await ctx.runMutation(internal.credits.releaseInternal, { await ctx.runMutation(internal.credits.releaseInternal, {
@@ -406,6 +478,13 @@ export const processImageGeneration = internalAction({
userId: args.userId, 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()), aspectRatio: v.optional(v.string()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const startedAt = Date.now();
const canvas = await ctx.runQuery(api.canvases.get, { const canvas = await ctx.runQuery(api.canvases.get, {
canvasId: args.canvasId, canvasId: args.canvasId,
}); });
@@ -477,8 +557,27 @@ export const generateImage = action({
userId, userId,
}); });
backgroundJobScheduled = true; 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 }; return { queued: true as const, nodeId: args.nodeId };
} catch (error) { } 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) { if (reservationId) {
try { try {
await ctx.runMutation(api.credits.release, { await ctx.runMutation(api.credits.release, {

View File

@@ -103,31 +103,75 @@ export const listTransactions = query({
export const getSubscription = query({ export const getSubscription = query({
args: {}, args: {},
handler: async (ctx) => { handler: async (ctx) => {
const user = await optionalAuth(ctx); const startedAt = Date.now();
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();
if (!row) { try {
return { console.info("[credits.getSubscription] start", {
tier: "free" as const, durationMs: Date.now() - startedAt,
status: "active" as const, });
};
}
return { const user = await optionalAuth(ctx);
tier: row.tier, console.info("[credits.getSubscription] auth resolved", {
status: row.status, durationMs: Date.now() - startedAt,
currentPeriodEnd: row.currentPeriodEnd, 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;
}
}, },
}); });

View File

@@ -89,16 +89,29 @@ async function assertConnectionPolicy(
export const list = query({ export const list = query({
args: { canvasId: v.id("canvases") }, args: { canvasId: v.id("canvases") },
handler: async (ctx, { canvasId }) => { handler: async (ctx, { canvasId }) => {
const startedAt = Date.now();
const user = await requireAuth(ctx); const user = await requireAuth(ctx);
const canvas = await ctx.db.get(canvasId); const canvas = await ctx.db.get(canvasId);
if (!canvas || canvas.ownerId !== user.userId) { if (!canvas || canvas.ownerId !== user.userId) {
return []; return [];
} }
return await ctx.db const edges = await ctx.db
.query("edges") .query("edges")
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId)) .withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
.collect(); .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;
}, },
}); });

View File

@@ -1,6 +1,8 @@
import { QueryCtx, MutationCtx } from "./_generated/server"; import { QueryCtx, MutationCtx } from "./_generated/server";
import { authComponent } from "./auth"; import { authComponent } from "./auth";
const AUTH_PERFORMANCE_LOG_THRESHOLD_MS = 100;
type SafeAuthUser = NonNullable< type SafeAuthUser = NonNullable<
Awaited<ReturnType<typeof authComponent.safeGetAuthUser>> Awaited<ReturnType<typeof authComponent.safeGetAuthUser>>
>; >;
@@ -15,10 +17,18 @@ export type AuthUser = Omit<SafeAuthUser, "userId"> & { userId: string };
export async function requireAuth( export async function requireAuth(
ctx: QueryCtx | MutationCtx ctx: QueryCtx | MutationCtx
): Promise<AuthUser> { ): Promise<AuthUser> {
const startedAt = Date.now();
const user = await authComponent.safeGetAuthUser(ctx); 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) { if (!user) {
const identity = await ctx.auth.getUserIdentity(); const identity = await ctx.auth.getUserIdentity();
console.error("[requireAuth] safeGetAuthUser returned null", { console.error("[requireAuth] safeGetAuthUser returned null", {
durationMs,
hasIdentity: Boolean(identity), hasIdentity: Boolean(identity),
identityIssuer: identity?.issuer ?? null, identityIssuer: identity?.issuer ?? null,
identitySubject: identity?.subject ?? null, identitySubject: identity?.subject ?? null,
@@ -28,6 +38,7 @@ export async function requireAuth(
const userId = user.userId ?? String(user._id); const userId = user.userId ?? String(user._id);
if (!userId) { if (!userId) {
console.error("[requireAuth] safeGetAuthUser returned user without userId", { console.error("[requireAuth] safeGetAuthUser returned user without userId", {
durationMs,
userRecordId: String(user._id), userRecordId: String(user._id),
}); });
throw new Error("Unauthenticated"); throw new Error("Unauthenticated");
@@ -41,7 +52,14 @@ export async function requireAuth(
export async function optionalAuth( export async function optionalAuth(
ctx: QueryCtx | MutationCtx ctx: QueryCtx | MutationCtx
): Promise<AuthUser | null> { ): Promise<AuthUser | null> {
const startedAt = Date.now();
const user = await authComponent.safeGetAuthUser(ctx); 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) { if (!user) {
return null; return null;
} }

View File

@@ -76,6 +76,14 @@ const ADJUSTMENT_MIN_WIDTH = 240;
const PERFORMANCE_LOG_THRESHOLD_MS = 250; 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 RenderOutputResolution = (typeof RENDER_OUTPUT_RESOLUTIONS)[number];
type RenderFormat = (typeof RENDER_FORMATS)[number]; type RenderFormat = (typeof RENDER_FORMATS)[number];
@@ -545,13 +553,27 @@ async function resolveNodeReferenceForWrite(
export const list = query({ export const list = query({
args: { canvasId: v.id("canvases") }, args: { canvasId: v.id("canvases") },
handler: async (ctx, { canvasId }) => { handler: async (ctx, { canvasId }) => {
const startedAt = Date.now();
const user = await requireAuth(ctx); const user = await requireAuth(ctx);
await getCanvasOrThrow(ctx, canvasId, user.userId); await getCanvasOrThrow(ctx, canvasId, user.userId);
return await ctx.db const nodes = await ctx.db
.query("nodes") .query("nodes")
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId)) .withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
.collect(); .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()), clientRequestId: v.optional(v.string()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const user = await requireAuth(ctx); const startedAt = Date.now();
await getCanvasOrThrow(ctx, args.canvasId, user.userId); const approxDataBytes = estimateSerializedBytes(args.data);
const existingNodeId = await getIdempotentNodeCreateResult(ctx, { console.info("[nodes.create] start", {
userId: user.userId,
mutation: "nodes.create",
clientRequestId: args.clientRequestId,
canvasId: args.canvasId, 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;
}, },
}); });

View File

@@ -92,6 +92,14 @@ export async function generateImageViaOpenRouter(
params: GenerateImageParams params: GenerateImageParams
): Promise<OpenRouterImageResponse> { ): Promise<OpenRouterImageResponse> {
const modelId = params.model ?? DEFAULT_IMAGE_MODEL; 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. // Ohne Referenzbild: einfacher String als content — bei Gemini/OpenRouter sonst oft nur Text (refusal/reasoning) statt Bild.
const userMessage = const userMessage =
@@ -126,15 +134,33 @@ export async function generateImageViaOpenRouter(
}; };
} }
const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, { let response: Response;
method: "POST",
headers: { try {
Authorization: `Bearer ${apiKey}`, response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
"Content-Type": "application/json", method: "POST",
"HTTP-Referer": "https://app.lemonspace.io", headers: {
"X-Title": "LemonSpace", Authorization: `Bearer ${apiKey}`,
}, "Content-Type": "application/json",
body: JSON.stringify(body), "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) { if (!response.ok) {
@@ -221,6 +247,7 @@ export async function generateImageViaOpenRouter(
let dataUri = rawImage; let dataUri = rawImage;
if (rawImage.startsWith("http://") || rawImage.startsWith("https://")) { if (rawImage.startsWith("http://") || rawImage.startsWith("https://")) {
const imageDownloadStartedAt = Date.now();
const imgRes = await fetch(rawImage); const imgRes = await fetch(rawImage);
if (!imgRes.ok) { if (!imgRes.ok) {
throw new ConvexError({ throw new ConvexError({
@@ -231,6 +258,12 @@ export async function generateImageViaOpenRouter(
const mimeTypeFromRes = const mimeTypeFromRes =
imgRes.headers.get("content-type") ?? "image/png"; imgRes.headers.get("content-type") ?? "image/png";
const buf = await imgRes.arrayBuffer(); const buf = await imgRes.arrayBuffer();
console.info("[openrouter] image downloaded", {
modelId,
durationMs: Date.now() - imageDownloadStartedAt,
bytes: buf.byteLength,
mimeType: mimeTypeFromRes,
});
let b64: string; let b64: string;
if (typeof Buffer !== "undefined") { if (typeof Buffer !== "undefined") {
b64 = Buffer.from(buf).toString("base64"); b64 = Buffer.from(buf).toString("base64");
@@ -257,6 +290,14 @@ export async function generateImageViaOpenRouter(
const base64Data = dataUri.slice(comma + 1); const base64Data = dataUri.slice(comma + 1);
const mimeType = meta.replace("data:", "").replace(";base64", ""); 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 { return {
imageBase64: base64Data, imageBase64: base64Data,
mimeType: mimeType || "image/png", mimeType: mimeType || "image/png",