merge(feature/curious-star): integrate worktree changes into master

This commit is contained in:
2026-04-08 08:14:20 +02:00
9 changed files with 359 additions and 38 deletions

View File

@@ -223,7 +223,7 @@ describe("useCanvasEdgeInsertions", () => {
label: "Prompt", label: "Prompt",
width: 320, width: 320,
height: 220, height: 220,
defaultData: { prompt: "", model: "", aspectRatio: "1:1" }, defaultData: { prompt: "", aspectRatio: "1:1" },
} as CanvasNodeTemplate); } as CanvasNodeTemplate);
}); });
@@ -268,7 +268,7 @@ describe("useCanvasEdgeInsertions", () => {
label: "Prompt", label: "Prompt",
width: 320, width: 320,
height: 220, height: 220,
defaultData: { prompt: "", model: "", aspectRatio: "1:1" }, defaultData: { prompt: "", aspectRatio: "1:1" },
} as CanvasNodeTemplate); } as CanvasNodeTemplate);
}); });
@@ -319,7 +319,7 @@ describe("useCanvasEdgeInsertions", () => {
label: "Prompt", label: "Prompt",
width: 320, width: 320,
height: 220, height: 220,
defaultData: { prompt: "", model: "", aspectRatio: "1:1" }, defaultData: { prompt: "", aspectRatio: "1:1" },
} as CanvasNodeTemplate); } as CanvasNodeTemplate);
}); });
@@ -332,7 +332,7 @@ describe("useCanvasEdgeInsertions", () => {
height: 220, height: 220,
data: { data: {
prompt: "", prompt: "",
model: "", model: "google/gemini-2.5-flash-image",
aspectRatio: "1:1", aspectRatio: "1:1",
canvasId: "canvas-1", canvasId: "canvas-1",
}, },
@@ -650,7 +650,7 @@ describe("useCanvasEdgeInsertions", () => {
label: "Prompt", label: "Prompt",
width: 320, width: 320,
height: 220, height: 220,
defaultData: { prompt: "", model: "", aspectRatio: "1:1" }, defaultData: { prompt: "", model: "google/gemini-2.5-flash-image", aspectRatio: "1:1" },
} as CanvasNodeTemplate); } as CanvasNodeTemplate);
}); });

View File

@@ -4,7 +4,6 @@ import {
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useEffect,
useMemo, useMemo,
useState, useState,
type ReactNode, type ReactNode,
@@ -84,12 +83,6 @@ export function CanvasGraphProvider({
[nodes, previewNodeDataOverrides], [nodes, previewNodeDataOverrides],
); );
useEffect(() => {
if (prunedPreviewNodeDataOverrides !== previewNodeDataOverrides) {
setPreviewNodeDataOverrides(prunedPreviewNodeDataOverrides);
}
}, [previewNodeDataOverrides, prunedPreviewNodeDataOverrides]);
const graph = useMemo( const graph = useMemo(
() => () =>
buildGraphSnapshot(nodes, edges, { buildGraphSnapshot(nodes, edges, {

View File

@@ -18,7 +18,11 @@ import BaseNodeWrapper from "./base-node-wrapper";
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models"; import {
DEFAULT_MODEL_ID,
getAvailableImageModels,
getModel,
} from "@/lib/ai-models";
import { import {
DEFAULT_ASPECT_RATIO, DEFAULT_ASPECT_RATIO,
getAiImageNodeOuterSize, getAiImageNodeOuterSize,
@@ -40,6 +44,7 @@ import { Sparkles, Loader2, Coins } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { classifyError } from "@/lib/ai-errors"; import { classifyError } from "@/lib/ai-errors";
import { normalizePublicTier } from "@/lib/tier-credits";
type PromptNodeData = { type PromptNodeData = {
prompt?: string; prompt?: string;
@@ -63,6 +68,7 @@ export default function PromptNode({
const { getEdges, getNode } = useReactFlow(); const { getEdges, getNode } = useReactFlow();
const [prompt, setPrompt] = useState(nodeData.prompt ?? ""); const [prompt, setPrompt] = useState(nodeData.prompt ?? "");
const [modelId, setModelId] = useState(nodeData.model ?? DEFAULT_MODEL_ID);
const [aspectRatio, setAspectRatio] = useState( const [aspectRatio, setAspectRatio] = useState(
nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO
); );
@@ -72,14 +78,20 @@ export default function PromptNode({
const nodes = useStore((store) => store.nodes); const nodes = useStore((store) => store.nodes);
const promptRef = useRef(prompt); const promptRef = useRef(prompt);
const modelIdRef = useRef(modelId);
const aspectRatioRef = useRef(aspectRatio); const aspectRatioRef = useRef(aspectRatio);
promptRef.current = prompt; promptRef.current = prompt;
modelIdRef.current = modelId;
aspectRatioRef.current = aspectRatio; aspectRatioRef.current = aspectRatio;
useEffect(() => { useEffect(() => {
setPrompt(nodeData.prompt ?? ""); setPrompt(nodeData.prompt ?? "");
}, [nodeData.prompt]); }, [nodeData.prompt]);
useEffect(() => {
setModelId(nodeData.model ?? DEFAULT_MODEL_ID);
}, [nodeData.model]);
useEffect(() => { useEffect(() => {
setAspectRatio(nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO); setAspectRatio(nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO);
}, [nodeData.aspectRatio]); }, [nodeData.aspectRatio]);
@@ -113,7 +125,29 @@ export default function PromptNode({
dataRef.current = data; dataRef.current = data;
const balance = useAuthQuery(api.credits.getBalance); const balance = useAuthQuery(api.credits.getBalance);
const creditCost = getModel(DEFAULT_MODEL_ID)?.creditCost ?? 4; const subscription = useAuthQuery(api.credits.getSubscription);
const userTier = normalizePublicTier(subscription?.tier ?? "free");
const availableModels = useMemo(
() => getAvailableImageModels(userTier),
[userTier],
);
useEffect(() => {
if (availableModels.length === 0) {
return;
}
if (!availableModels.some((model) => model.id === modelId)) {
setModelId(availableModels[0]!.id);
}
}, [availableModels, modelId]);
const selectedModel =
getModel(modelId) ??
availableModels[0] ??
getModel(DEFAULT_MODEL_ID);
const resolvedModelId = selectedModel?.id ?? DEFAULT_MODEL_ID;
const creditCost = selectedModel?.creditCost ?? 4;
const availableCredits = const availableCredits =
balance !== undefined ? balance.balance - balance.reserved : null; balance !== undefined ? balance.balance - balance.reserved : null;
@@ -134,6 +168,7 @@ export default function PromptNode({
data: { data: {
...rest, ...rest,
prompt: promptRef.current, prompt: promptRef.current,
model: modelIdRef.current,
aspectRatio: aspectRatioRef.current, aspectRatio: aspectRatioRef.current,
}, },
}); });
@@ -156,6 +191,14 @@ export default function PromptNode({
[debouncedSave] [debouncedSave]
); );
const handleModelChange = useCallback(
(value: string) => {
setModelId(value);
debouncedSave();
},
[debouncedSave],
);
const handleGenerate = useCallback(async () => { const handleGenerate = useCallback(async () => {
if (!effectivePrompt.trim() || isGenerating) return; if (!effectivePrompt.trim() || isGenerating) return;
if (status.isOffline) { if (status.isOffline) {
@@ -229,8 +272,8 @@ export default function PromptNode({
height: outer.height, height: outer.height,
data: { data: {
prompt: promptToUse, prompt: promptToUse,
model: DEFAULT_MODEL_ID, model: resolvedModelId,
modelTier: "standard", modelTier: selectedModel?.tier ?? "standard",
canvasId, canvasId,
aspectRatio, aspectRatio,
outputWidth: viewport.width, outputWidth: viewport.width,
@@ -249,7 +292,7 @@ export default function PromptNode({
prompt: promptToUse, prompt: promptToUse,
referenceStorageId, referenceStorageId,
referenceImageUrl, referenceImageUrl,
model: DEFAULT_MODEL_ID, model: resolvedModelId,
aspectRatio, aspectRatio,
}), }),
{ {
@@ -285,6 +328,7 @@ export default function PromptNode({
prompt, prompt,
effectivePrompt, effectivePrompt,
aspectRatio, aspectRatio,
resolvedModelId,
isGenerating, isGenerating,
nodeData.canvasId, nodeData.canvasId,
id, id,
@@ -292,6 +336,7 @@ export default function PromptNode({
getNode, getNode,
createNodeConnectedFromSource, createNodeConnectedFromSource,
generateImage, generateImage,
selectedModel?.tier,
creditCost, creditCost,
availableCredits, availableCredits,
hasEnoughCredits, hasEnoughCredits,
@@ -338,6 +383,31 @@ export default function PromptNode({
/> />
)} )}
<div className="flex flex-col gap-1.5">
<Label
htmlFor={`prompt-model-${id}`}
className="text-[11px] font-medium text-muted-foreground"
>
Modell
</Label>
<Select value={resolvedModelId} onValueChange={handleModelChange}>
<SelectTrigger
id={`prompt-model-${id}`}
className="nodrag nowheel w-full"
size="sm"
>
<SelectValue placeholder="Modell" />
</SelectTrigger>
<SelectContent className="nodrag">
{availableModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label <Label
htmlFor={`prompt-format-${id}`} htmlFor={`prompt-format-${id}`}

View File

@@ -15,7 +15,6 @@ export interface OpenRouterModel {
const IMAGE_AND_TEXT_MODALITIES = ["image", "text"] as const; const IMAGE_AND_TEXT_MODALITIES = ["image", "text"] as const;
const IMAGE_ONLY_MODALITIES = ["image"] as const; const IMAGE_ONLY_MODALITIES = ["image"] as const;
export const IMAGE_MODELS: Record<string, OpenRouterModel> = { export const IMAGE_MODELS: Record<string, OpenRouterModel> = {
"google/gemini-2.5-flash-image": { "google/gemini-2.5-flash-image": {
id: "google/gemini-2.5-flash-image", id: "google/gemini-2.5-flash-image",

View File

@@ -1,4 +1,4 @@
import { useRef, useCallback, useEffect } from "react"; import { useRef, useCallback, useEffect, useMemo } from "react";
type DebouncedCallback<Args extends unknown[]> = ((...args: Args) => void) & { type DebouncedCallback<Args extends unknown[]> = ((...args: Args) => void) & {
flush: () => void; flush: () => void;
@@ -53,8 +53,8 @@ export function useDebouncedCallback<Args extends unknown[]>(
} }
}, []); }, []);
const debouncedFn = useCallback( return useMemo(() => {
(...args: Args) => { const debouncedCallback = ((...args: Args) => {
argsRef.current = args; argsRef.current = args;
if (timeoutRef.current) clearTimeout(timeoutRef.current); if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => { timeoutRef.current = setTimeout(() => {
@@ -65,13 +65,11 @@ export function useDebouncedCallback<Args extends unknown[]>(
callbackRef.current(...nextArgs); callbackRef.current(...nextArgs);
} }
}, delay); }, delay);
}, }) as DebouncedCallback<Args>;
[delay],
);
const debouncedCallback = debouncedFn as DebouncedCallback<Args>;
debouncedCallback.flush = flush; debouncedCallback.flush = flush;
debouncedCallback.cancel = cancel; debouncedCallback.cancel = cancel;
return debouncedCallback; return debouncedCallback;
}, [cancel, delay, flush]);
} }

View File

@@ -18,7 +18,7 @@ export const CANVAS_NODE_TEMPLATES = [
label: "Prompt", label: "Prompt",
width: 320, width: 320,
height: 220, height: 220,
defaultData: { prompt: "", model: "", aspectRatio: "1:1" }, defaultData: { prompt: "", model: "google/gemini-2.5-flash-image", aspectRatio: "1:1" },
}, },
{ {
type: "video-prompt", type: "video-prompt",

View File

@@ -235,7 +235,11 @@ export const NODE_DEFAULTS: Record<
> = { > = {
image: { width: 280, height: 200, data: {} }, image: { width: 280, height: 200, data: {} },
text: { width: 256, height: 120, data: { content: "" } }, text: { width: 256, height: 120, data: { content: "" } },
prompt: { width: 288, height: 220, data: { prompt: "", aspectRatio: "1:1" } }, prompt: {
width: 288,
height: 220,
data: { prompt: "", model: "google/gemini-2.5-flash-image", aspectRatio: "1:1" },
},
"video-prompt": { "video-prompt": {
width: 288, width: 288,
height: 220, height: 220,

View File

@@ -38,8 +38,10 @@ function HookHarness(props: HarnessProps) {
const nodesRef = useRef<RFNode[]>(props.liveNodes ?? props.nodes); const nodesRef = useRef<RFNode[]>(props.liveNodes ?? props.nodes);
const edgesRef = useRef<RFEdge[]>(props.liveEdges ?? props.edges); const edgesRef = useRef<RFEdge[]>(props.liveEdges ?? props.edges);
useEffect(() => {
nodesRef.current = props.liveNodes ?? props.nodes; nodesRef.current = props.liveNodes ?? props.nodes;
edgesRef.current = props.liveEdges ?? props.edges; edgesRef.current = props.liveEdges ?? props.edges;
}, [props.liveEdges, props.liveNodes, props.edges, props.nodes]);
const handlers = useCanvasDeleteHandlers({ const handlers = useCanvasDeleteHandlers({
t: ((key: string, values?: Record<string, unknown>) => t: ((key: string, values?: Record<string, unknown>) =>

255
tests/prompt-node.test.ts Normal file
View File

@@ -0,0 +1,255 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Id } from "@/convex/_generated/dataModel";
const mocks = vi.hoisted(() => ({
edges: [] as Array<{ source: string; target: string }>,
nodes: [] as Array<{ id: string; type: string; data: Record<string, unknown> }>,
balance: { balance: 100, reserved: 0 } as { balance: number; reserved: number } | undefined,
subscription: { tier: "starter" as const },
queueNodeDataUpdate: vi.fn(async () => undefined),
createNodeConnectedFromSource: vi.fn(async () => "ai-image-node-1" as Id<"nodes">),
generateImage: vi.fn(async () => ({ queued: true, nodeId: "ai-image-node-1" })),
getEdges: vi.fn(() => [] as Array<{ source: string; target: string }>),
getNode: vi.fn((id: string) =>
id === "prompt-1"
? { id, position: { x: 100, y: 50 }, measured: { width: 280, height: 220 } }
: null,
),
push: vi.fn(),
toastPromise: vi.fn(async <T,>(promise: Promise<T>) => await promise),
toastWarning: vi.fn(),
toastAction: vi.fn(),
toastError: vi.fn(),
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mocks.push }),
}));
vi.mock("convex/react", () => ({
useAction: () => mocks.generateImage,
}));
vi.mock("@/convex/_generated/api", () => ({
api: {
ai: {
generateImage: "ai.generateImage",
},
credits: {
getBalance: "credits.getBalance",
getSubscription: "credits.getSubscription",
},
},
}));
vi.mock("@/hooks/use-auth-query", () => ({
useAuthQuery: (query: string) => {
if (query === "credits.getSubscription") return mocks.subscription;
return mocks.balance;
},
}));
vi.mock("@/hooks/use-debounced-callback", () => ({
useDebouncedCallback: (callback: (...args: Array<unknown>) => void) => callback,
}));
vi.mock("@/components/canvas/canvas-sync-context", () => ({
useCanvasSync: () => ({
queueNodeDataUpdate: mocks.queueNodeDataUpdate,
status: { isOffline: false, isSyncing: false, pendingCount: 0 },
}),
}));
vi.mock("@/components/canvas/canvas-placement-context", () => ({
useCanvasPlacement: () => ({
createNodeConnectedFromSource: mocks.createNodeConnectedFromSource,
}),
}));
vi.mock("@/lib/toast", () => ({
toast: {
promise: mocks.toastPromise,
warning: mocks.toastWarning,
action: mocks.toastAction,
error: mocks.toastError,
},
}));
vi.mock("@/lib/ai-errors", () => ({
classifyError: (error: unknown) => ({
type: "generic",
rawMessage: error instanceof Error ? error.message : String(error),
}),
}));
vi.mock("@/components/ui/label", () => ({
Label: ({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) =>
React.createElement("label", { htmlFor }, children),
}));
vi.mock("@/components/ui/select", () => ({
Select: ({
value,
onValueChange,
children,
}: {
value: string;
onValueChange: (value: string) => void;
children: React.ReactNode;
}) =>
React.createElement(
"select",
{
"data-testid": value.includes("/") ? "model-select" : "format-select",
value,
onChange: (event: Event) => {
onValueChange((event.target as HTMLSelectElement).value);
},
},
children,
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => children,
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => children,
SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) =>
React.createElement("option", { value }, children),
SelectGroup: ({ children }: { children: React.ReactNode }) => children,
SelectLabel: ({ children }: { children: React.ReactNode }) =>
React.createElement("optgroup", { label: String(children) }),
}));
vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({
default: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
}));
vi.mock("@xyflow/react", () => ({
Handle: () => null,
Position: { Left: "left", Right: "right" },
useStore: (selector: (state: { edges: typeof mocks.edges; nodes: typeof mocks.nodes }) => unknown) =>
selector({ edges: mocks.edges, nodes: mocks.nodes }),
useReactFlow: () => ({
getEdges: mocks.getEdges,
getNode: mocks.getNode,
}),
}));
import PromptNode from "@/components/canvas/nodes/prompt-node";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
describe("PromptNode", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
mocks.edges = [];
mocks.nodes = [];
mocks.balance = { balance: 100, reserved: 0 };
mocks.subscription = { tier: "starter" };
mocks.queueNodeDataUpdate.mockClear();
mocks.createNodeConnectedFromSource.mockClear();
mocks.generateImage.mockClear();
mocks.getEdges.mockClear();
mocks.getNode.mockClear();
mocks.push.mockClear();
mocks.toastPromise.mockClear();
mocks.toastWarning.mockClear();
mocks.toastAction.mockClear();
mocks.toastError.mockClear();
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
container?.remove();
container = null;
root = null;
});
it("propagates selected image model into node creation and generation action", async () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(PromptNode, {
id: "prompt-1",
selected: false,
dragging: false,
draggable: true,
selectable: true,
deletable: true,
zIndex: 1,
isConnectable: true,
type: "prompt",
data: {
prompt: "ein neugieriger hund im regen",
aspectRatio: "1:1",
canvasId: "canvas-1",
},
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}),
);
});
const modelSelect = container.querySelector('select[data-testid="model-select"]');
if (!(modelSelect instanceof HTMLSelectElement)) {
throw new Error("Model select not found");
}
await act(async () => {
modelSelect.value = "openai/gpt-5-image-mini";
modelSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
const button = Array.from(container.querySelectorAll("button")).find((element) =>
element.textContent?.includes("Bild generieren"),
);
if (!(button instanceof HTMLButtonElement)) {
throw new Error("Generate button not found");
}
await act(async () => {
button.click();
});
expect(mocks.createNodeConnectedFromSource).toHaveBeenCalledTimes(1);
expect(mocks.createNodeConnectedFromSource).toHaveBeenCalledWith(
expect.objectContaining({
type: "ai-image",
sourceNodeId: "prompt-1",
data: expect.objectContaining({
model: "openai/gpt-5-image-mini",
modelTier: "premium",
}),
}),
);
expect(mocks.generateImage).toHaveBeenCalledTimes(1);
expect(mocks.generateImage).toHaveBeenCalledWith(
expect.objectContaining({
canvasId: "canvas-1",
nodeId: "ai-image-node-1",
prompt: "ein neugieriger hund im regen",
model: "openai/gpt-5-image-mini",
}),
);
});
});