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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
159
convex/ai.ts
159
convex/ai.ts
@@ -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, {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
137
convex/nodes.ts
137
convex/nodes.ts
@@ -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;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user