Browse Source

Optimize NewApiWG image parameter flow

feature/canvas-chatbot-copilot
yuanzoo 2 months ago
parent
commit
70c05c6f7e
  1. 22
      prd-image-workflow.md
  2. 19
      src/app/api/generate/__tests__/route.test.ts
  3. 58
      src/app/api/generate/providers/__tests__/newapiwg.test.ts
  4. 103
      src/app/api/generate/providers/newapiwg.ts
  5. 4
      src/app/api/generate/route.ts
  6. 43
      src/components/__tests__/ModelParameters.test.tsx
  7. 11
      src/components/nodes/GenerateImageNode.tsx
  8. 57
      src/components/nodes/ModelParameters.tsx

22
prd-image-workflow.md

@ -1,8 +1,8 @@
# Product Requirements Document # Product Requirements Document
## Node-Based Image Annotation & Generation Workflow ## Node-Based Image Annotation & Generation Workflow
**Version:** 1.0 **Version:** 1.0
**Last Updated:** December 2025 **Last Updated:** May 2026
**Status:** Draft **Status:** Draft
--- ---
@ -146,10 +146,11 @@ Output Node → [image input]
| NB-03 | "Generate" button for manual trigger (when not in workflow run) | Should | | NB-03 | "Generate" button for manual trigger (when not in workflow run) | Should |
| NB-04 | Display generation status (idle/loading/complete/error) | Must | | NB-04 | Display generation status (idle/loading/complete/error) | Must |
| NB-05 | Show thumbnail of generated result | Must | | NB-05 | Show thumbnail of generated result | Must |
| NB-06 | Output: generated image as base64 | Must | | NB-06 | Output: generated image as base64 or provider URL for immediate preview | Must |
| NB-07 | Display error messages from API failures | Must | | NB-07 | Display error messages from API failures | Must |
| NB-08 | Aspect ratio selector (1:1, 16:9, 9:16, 4:3, 3:4) | Should | | NB-08 | Aspect ratio selector (1:1, 16:9, 9:16, 4:3, 3:4) | Should |
| NB-09 | Resolution selector (1K, 2K) | Should | | NB-09 | Resolution selector (1K, 2K) | Should |
| NB-10 | For provider-hosted image URLs, show the URL immediately and persist to local storage asynchronously | Must |
**UI:** **UI:**
- Two input handles (image, prompt) on left - Two input handles (image, prompt) on left
@ -259,6 +260,21 @@ Output Node → [image input]
**Description:** When user clicks "Run Workflow," execute all nodes in dependency order. **Description:** When user clicks "Run Workflow," execute all nodes in dependency order.
#### 3.5.1 Provider URL-First Image Persistence
**Context:** Some NewApiWG image models, such as `gpt-image-2-all`, return a provider-hosted image URL in the upstream response. Synchronously downloading that URL before updating the node makes the gateway appear fast while the PopiArt node remains in loading state.
**Product Strategy:** Prefer fast visual feedback without abandoning local-first persistence.
| ID | Requirement | Priority |
|----|-------------|----------|
| WF-URL-01 | Display provider-hosted image URLs immediately when the generation API returns them | Must |
| WF-URL-02 | Download and save provider-hosted image URLs to the workflow `generations` folder asynchronously | Must |
| WF-URL-03 | Treat the saved local generation as the durable project artifact after persistence completes | Must |
| WF-URL-04 | For chained generation, pass provider URLs directly to models that accept URL image inputs | Should |
| WF-URL-05 | For models that require inline image data, convert provider URLs only at the next provider boundary instead of blocking initial preview | Should |
| WF-URL-06 | If local persistence fails, keep the provider URL visible and surface save errors through existing logging without failing the completed generation | Should |
| ID | Requirement | Priority | | ID | Requirement | Priority |
|----|-------------|----------| |----|-------------|----------|
| WE-01 | Validate workflow before execution (all required inputs connected) | Must | | WE-01 | Validate workflow before execution (all required inputs connected) | Must |

19
src/app/api/generate/__tests__/route.test.ts

@ -1009,21 +1009,22 @@ describe("/api/generate route", () => {
], ],
}), }),
}); });
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ "content-type": "image/png" }),
arrayBuffer: () => Promise.resolve(new Uint8Array(Buffer.from("generated-image")).buffer),
});
const request = createMockPostRequest( const request = createMockPostRequest(
{ {
prompt: "A cafe scene", prompt: "A cafe scene",
aspectRatio: "9:16",
resolution: "2K",
selectedModel: { selectedModel: {
provider: "newapiwg", provider: "newapiwg",
modelId: "gpt-image-2-all", modelId: "gpt-image-2-all",
displayName: "gpt-image-2-all", displayName: "gpt-image-2-all",
capabilities: ["text-to-image", "image-to-image"], capabilities: ["text-to-image", "image-to-image"],
}, },
parameters: {
quality: "auto",
size: "1024x1024",
},
}, },
{ "X-NewApiWG-Key": "test-newapiwg-key" } { "X-NewApiWG-Key": "test-newapiwg-key" }
); );
@ -1033,8 +1034,14 @@ describe("/api/generate route", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(data.success).toBe(true); expect(data.success).toBe(true);
expect(data.image).toMatch(/^data:image\/png;base64,/); expect(data.image).toBe("https://cdn.example.com/generated-image.png");
expect(data.contentType).toBe("image"); expect(data.contentType).toBe("image");
expect(mockFetch).toHaveBeenCalledTimes(1);
const upstreamBody = JSON.parse(mockFetch.mock.calls[0][1].body as string);
expect(upstreamBody.quality).toBe("auto");
expect(upstreamBody.size).toBe("1024x1536");
expect(upstreamBody.aspectRatio).toBeUndefined();
expect(upstreamBody.resolution).toBeUndefined();
}); });
it("should pass top-level aspect ratio and resolution to native NewApiWG image models", async () => { it("should pass top-level aspect ratio and resolution to native NewApiWG image models", async () => {

58
src/app/api/generate/providers/__tests__/newapiwg.test.ts

@ -268,6 +268,30 @@ describe("NewApiWG generation payloads", () => {
}); });
}); });
it("converts HTTP reference images only when Gemini native generation needs inline data", async () => {
const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
model: {
id: "apiyi_nano_banana_2",
name: "apiyi_nano_banana_2",
description: null,
provider: "newapiwg",
capabilities: ["text-to-image", "image-to-image"],
},
prompt: "draw a banana",
images: ["https://cdn.example.com/seedream.png"],
}));
expect(result.success).toBe(true);
expect(capturedBody!.contents).toEqual([
{
parts: [
{ text: "draw a banana" },
{ inlineData: { mimeType: "image/png", data: "YWJj" } },
],
},
]);
});
it("accepts Gemini native fileData URLs without image file extensions", async () => { it("accepts Gemini native fileData URLs without image file extensions", async () => {
vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request, init?: RequestInit) => { vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
capturedBody = init?.body ? JSON.parse(init.body as string) : null; capturedBody = init?.body ? JSON.parse(init.body as string) : null;
@ -369,6 +393,7 @@ describe("NewApiWG generation payloads", () => {
}); });
it("extracts markdown image URLs from gpt-image-2-all chat responses", async () => { it("extracts markdown image URLs from gpt-image-2-all chat responses", async () => {
const fetchMock = vi.mocked(fetch);
const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
model: { model: {
id: "gpt-image-2-all", id: "gpt-image-2-all",
@ -395,6 +420,37 @@ describe("NewApiWG generation payloads", () => {
{ type: "image_url", image_url: { url: "data:image/png;base64,input" } }, { type: "image_url", image_url: { url: "data:image/png;base64,input" } },
], ],
}]); }]);
expect(fetchMock.mock.calls.some(([url]) =>
String(url).includes("r2cdn.copilotbase.com")
)).toBe(false);
});
it("lets composer aspect ratio override stale OpenAI-compatible size parameters", async () => {
await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
model: {
id: "gpt-image-2-all",
name: "gpt-image-2-all",
description: null,
provider: "newapiwg",
capabilities: ["text-to-image", "image-to-image"],
},
prompt: "draw a portrait",
parameters: {
quality: "auto",
size: "1024x1024",
aspectRatio: "9:16",
aspect_ratio: "9:16",
resolution: "2K",
imageSize: "2K",
},
}));
expect(capturedBody!.quality).toBe("auto");
expect(capturedBody!.size).toBe("1024x1536");
expect(capturedBody!.aspectRatio).toBeUndefined();
expect(capturedBody!.aspect_ratio).toBeUndefined();
expect(capturedBody!.resolution).toBeUndefined();
expect(capturedBody!.imageSize).toBeUndefined();
}); });
it("routes Seedream image models through images generations", async () => { it("routes Seedream image models through images generations", async () => {
@ -413,7 +469,7 @@ describe("NewApiWG generation payloads", () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.outputs?.[0]).toEqual({ expect(result.outputs?.[0]).toEqual({
type: "image", type: "image",
data: "data:image/png;base64,YWJj", data: "",
url: "https://cdn.example.com/seedream.png", url: "https://cdn.example.com/seedream.png",
}); });
expect(capturedBody!.model).toBe("seedream-5-0-260128"); expect(capturedBody!.model).toBe("seedream-5-0-260128");

103
src/app/api/generate/providers/newapiwg.ts

@ -288,14 +288,6 @@ async function fetchNewApiWGJsonWithRetry(
throw new Error("unreachable"); throw new Error("unreachable");
} }
async function urlToDataUrl(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch generated media: ${response.status}`);
const contentType = response.headers.get("content-type") || "application/octet-stream";
const buffer = Buffer.from(await response.arrayBuffer());
return `data:${contentType};base64,${buffer.toString("base64")}`;
}
function getNewApiWGTaskId(data: unknown): string | null { function getNewApiWGTaskId(data: unknown): string | null {
if (!data || typeof data !== "object") return null; if (!data || typeof data !== "object") return null;
const record = data as Record<string, unknown>; const record = data as Record<string, unknown>;
@ -499,25 +491,27 @@ function ceilToMultiple(value: number, multiple: number): number {
} }
function resolveNewApiWGImageSize(parameters: Record<string, unknown> | undefined): string { function resolveNewApiWGImageSize(parameters: Record<string, unknown> | undefined): string {
const explicitSize = getStringParameter(parameters, "size");
if (explicitSize) return explicitSize;
const requestedSize = const requestedSize =
getStringParameter(parameters, "imageSize") ?? getStringParameter(parameters, "imageSize") ??
getStringParameter(parameters, "resolution"); getStringParameter(parameters, "resolution");
if (!requestedSize) return "1024x1024"; if (requestedSize) {
if (/^\d+x\d+$/i.test(requestedSize)) return requestedSize; if (/^\d+x\d+$/i.test(requestedSize)) return requestedSize;
const targetPixels = NEWAPIWG_IMAGE_RESOLUTION_PIXELS[requestedSize]; const targetPixels = NEWAPIWG_IMAGE_RESOLUTION_PIXELS[requestedSize];
if (!targetPixels) return "1024x1024"; if (targetPixels) {
const ratio = parseAspectRatio(
getStringParameter(parameters, "aspectRatio") ??
getStringParameter(parameters, "aspect_ratio")
);
const width = ceilToMultiple(Math.sqrt(targetPixels * ratio.width / ratio.height), 8);
const height = ceilToMultiple(Math.sqrt(targetPixels * ratio.height / ratio.width), 8);
return `${width}x${height}`;
}
}
const ratio = parseAspectRatio( const explicitSize = getStringParameter(parameters, "size");
getStringParameter(parameters, "aspectRatio") ?? if (explicitSize) return explicitSize;
getStringParameter(parameters, "aspect_ratio") return "1024x1024";
);
const width = ceilToMultiple(Math.sqrt(targetPixels * ratio.width / ratio.height), 8);
const height = ceilToMultiple(Math.sqrt(targetPixels * ratio.height / ratio.width), 8);
return `${width}x${height}`;
} }
function isImagenGenerationModel(modelId: string): boolean { function isImagenGenerationModel(modelId: string): boolean {
@ -528,6 +522,33 @@ function isOpenAIChatImageModel(modelId: string): boolean {
return /(^|[_\-/])sora[_\-/]?image$/i.test(modelId) || modelId === "gpt-image-2-all"; return /(^|[_\-/])sora[_\-/]?image$/i.test(modelId) || modelId === "gpt-image-2-all";
} }
function resolveOpenAIChatImageSize(parameters: Record<string, unknown> | undefined): string | null {
const aspectRatio =
getStringParameter(parameters, "aspectRatio") ??
getStringParameter(parameters, "aspect_ratio");
if (aspectRatio) {
const ratio = parseAspectRatio(aspectRatio);
if (ratio.width < ratio.height) return "1024x1536";
if (ratio.width > ratio.height) return "1536x1024";
return "1024x1024";
}
return getStringParameter(parameters, "size");
}
function normalizeOpenAIChatImageParameters(
parameters: Record<string, unknown> | undefined
): Record<string, unknown> {
const normalized = { ...(parameters || {}) };
const size = resolveOpenAIChatImageSize(parameters);
if (size) normalized.size = size;
delete normalized.aspectRatio;
delete normalized.aspect_ratio;
delete normalized.resolution;
delete normalized.imageSize;
return normalized;
}
function isGeminiNativeImageModel(modelId: string): boolean { function isGeminiNativeImageModel(modelId: string): boolean {
return /gemini-.*image-preview/i.test(modelId) || /nano[_\-/]?banana/i.test(modelId); return /gemini-.*image-preview/i.test(modelId) || /nano[_\-/]?banana/i.test(modelId);
} }
@ -556,6 +577,10 @@ function getImageUrlCandidate(value: unknown): string | null {
return null; return null;
} }
function isHttpUrl(value: string): boolean {
return value.startsWith("http://") || value.startsWith("https://");
}
function extractImageFromText(text: string): string | null { function extractImageFromText(text: string): string | null {
const dataUrl = text.match(/data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/i)?.[0]; const dataUrl = text.match(/data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/i)?.[0];
if (dataUrl) return dataUrl; if (dataUrl) return dataUrl;
@ -651,7 +676,7 @@ async function generateImageViaNewApiWGChat(
messages: [{ role: "user", content }], messages: [{ role: "user", content }],
stream: false, stream: false,
...dynamicInputsWithoutPrompt(input), ...dynamicInputsWithoutPrompt(input),
...(input.parameters || {}), ...normalizeOpenAIChatImageParameters(input.parameters),
}), }),
}, input.model.name, "NewApiWG chat image API error"); }, input.model.name, "NewApiWG chat image API error");
@ -674,11 +699,7 @@ async function generateImageViaNewApiWGChat(
} }
if (image.startsWith("http")) { if (image.startsWith("http")) {
try { return { success: true, outputs: [{ type: "image", data: "", url: image }] };
return { success: true, outputs: [{ type: "image", data: await urlToDataUrl(image), url: image }] };
} catch {
return { success: true, outputs: [{ type: "image", data: "", url: image }] };
}
} }
return { success: true, outputs: [{ type: "image", data: image }] }; return { success: true, outputs: [{ type: "image", data: image }] };
@ -690,6 +711,20 @@ function dataUrlToInlineData(image: string): { mimeType: string; data: string }
return { mimeType: match[1], data: match[2] }; return { mimeType: match[1], data: match[2] };
} }
async function imageUrlToInlineData(image: string): Promise<{ mimeType: string; data: string } | null> {
if (!isHttpUrl(image)) return null;
const response = await fetch(image);
if (!response.ok) throw new Error(`Failed to fetch reference image: ${response.status}`);
const contentType = response.headers.get("content-type");
const mimeType = contentType?.startsWith("image/") ? contentType : "image/png";
const buffer = Buffer.from(await response.arrayBuffer());
return { mimeType, data: buffer.toString("base64") };
}
async function imageToInlineData(image: string): Promise<{ mimeType: string; data: string } | null> {
return dataUrlToInlineData(image) ?? imageUrlToInlineData(image);
}
function extractGeminiNativeImage(value: unknown): string | null { function extractGeminiNativeImage(value: unknown): string | null {
const direct = extractNewApiWGImage(value); const direct = extractNewApiWGImage(value);
if (direct) return direct; if (direct) return direct;
@ -739,7 +774,7 @@ async function generateImageViaNewApiWGGemini(
): Promise<GenerationOutput> { ): Promise<GenerationOutput> {
const parts: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [{ text: prompt }]; const parts: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [{ text: prompt }];
for (const image of input.images || []) { for (const image of input.images || []) {
const inlineData = dataUrlToInlineData(image); const inlineData = await imageToInlineData(image);
if (inlineData) parts.push({ inlineData }); if (inlineData) parts.push({ inlineData });
} }
@ -908,8 +943,12 @@ export async function generateWithNewApiWG(
} }
if (typeof url === "string" && url.length > 0) { if (typeof url === "string" && url.length > 0) {
const dataUrl = url.startsWith("data:") ? url : await urlToDataUrl(url); if (url.startsWith("data:")) {
return { success: true, outputs: [{ type: isVideo ? "video" : "image", data: dataUrl, url }] }; return { success: true, outputs: [{ type: isVideo ? "video" : "image", data: url }] };
}
if (isHttpUrl(url)) {
return { success: true, outputs: [{ type: isVideo ? "video" : "image", data: "", url }] };
}
} }
return { success: false, error: `No ${isVideo ? "video" : "image"} returned from NewApiWG` }; return { success: false, error: `No ${isVideo ? "video" : "image"} returned from NewApiWG` };

4
src/app/api/generate/route.ts

@ -89,11 +89,11 @@ function mergeImageGenerationParameters(
resolution: string | undefined resolution: string | undefined
): Record<string, unknown> | undefined { ): Record<string, unknown> | undefined {
const merged = { ...(parameters || {}) }; const merged = { ...(parameters || {}) };
if (aspectRatio && merged.aspectRatio === undefined && merged.aspect_ratio === undefined) { if (aspectRatio) {
merged.aspectRatio = aspectRatio; merged.aspectRatio = aspectRatio;
merged.aspect_ratio = aspectRatio; merged.aspect_ratio = aspectRatio;
} }
if (resolution && merged.resolution === undefined && merged.imageSize === undefined) { if (resolution) {
merged.resolution = resolution; merged.resolution = resolution;
merged.imageSize = resolution; merged.imageSize = resolution;
} }

43
src/components/__tests__/ModelParameters.test.tsx

@ -217,6 +217,49 @@ describe("ModelParameters", () => {
}); });
describe("Parameter Count Display", () => { describe("Parameter Count Display", () => {
it("should hide composer-priority parameters and keep visible defaults", async () => {
const onParametersChange = vi.fn();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
parameters: [
createMockParameter({
name: "quality",
type: "string",
enum: ["auto", "high"],
default: "auto",
}),
createMockParameter({
name: "size",
type: "string",
enum: ["1024x1024", "1024x1536"],
default: "1024x1024",
}),
],
}),
});
render(
<ModelParameters
{...defaultProps}
parameters={{ size: "1024x1024" }}
onParametersChange={onParametersChange}
hiddenParameterNames={["size"]}
/>
);
await waitFor(() => {
expect(screen.getByText("Quality")).toBeInTheDocument();
});
expect(screen.queryByText("Size")).not.toBeInTheDocument();
expect(onParametersChange).toHaveBeenCalledWith({});
await waitFor(() => {
expect(onParametersChange).toHaveBeenCalledWith({ quality: "auto" });
});
});
it("should render all parameters that have values", async () => { it("should render all parameters that have values", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true, ok: true,

11
src/components/nodes/GenerateImageNode.tsx

@ -51,6 +51,16 @@ const EXTENDED_ASPECT_RATIOS: AspectRatio[] = ["1:1", "1:4", "1:8", "2:3", "3:2"
// Resolutions per model (nano-banana-pro: 1K-4K, nano-banana-2: 512-4K) // Resolutions per model (nano-banana-pro: 1K-4K, nano-banana-2: 512-4K)
const RESOLUTIONS_PRO: Resolution[] = ["1K", "2K", "4K"]; const RESOLUTIONS_PRO: Resolution[] = ["1K", "2K", "4K"];
const RESOLUTIONS_NB2: Resolution[] = ["512", "1K", "2K", "4K"]; const RESOLUTIONS_NB2: Resolution[] = ["512", "1K", "2K", "4K"];
const COMPOSER_PRIORITY_IMAGE_PARAMETERS = [
"size",
"image_size",
"output_size",
"dimensions",
"aspectRatio",
"aspect_ratio",
"resolution",
"imageSize",
];
// Hardcoded Gemini image models (always available) // Hardcoded Gemini image models (always available)
const GEMINI_IMAGE_MODELS: { value: ModelType; label: string }[] = [ const GEMINI_IMAGE_MODELS: { value: ModelType; label: string }[] = [
@ -708,6 +718,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
parameters={nodeData.parameters || {}} parameters={nodeData.parameters || {}}
onParametersChange={handleParametersChange} onParametersChange={handleParametersChange}
onInputsLoaded={handleInputsLoaded} onInputsLoaded={handleInputsLoaded}
hiddenParameterNames={currentProvider === "newapiwg" ? COMPOSER_PRIORITY_IMAGE_PARAMETERS : undefined}
/> />
)} )}
</> </>

57
src/components/nodes/ModelParameters.tsx

@ -62,6 +62,7 @@ interface ModelParametersProps {
onParametersChange: (parameters: Record<string, unknown>) => void; onParametersChange: (parameters: Record<string, unknown>) => void;
onExpandChange?: (expanded: boolean, parameterCount: number) => void; onExpandChange?: (expanded: boolean, parameterCount: number) => void;
onInputsLoaded?: (inputs: ModelInputDef[]) => void; onInputsLoaded?: (inputs: ModelInputDef[]) => void;
hiddenParameterNames?: string[];
} }
/** /**
@ -76,6 +77,7 @@ function ModelParametersInner({
onParametersChange, onParametersChange,
onExpandChange, onExpandChange,
onInputsLoaded, onInputsLoaded,
hiddenParameterNames,
}: ModelParametersProps) { }: ModelParametersProps) {
const { t } = useI18n(); const { t } = useI18n();
const [schema, setSchema] = useState<ModelParameter[]>([]); const [schema, setSchema] = useState<ModelParameter[]>([]);
@ -153,28 +155,57 @@ function ModelParametersInner({
fetchSchema(); fetchSchema();
}, [modelId, provider, replicateApiKey, falApiKey, kieApiKey, wavespeedApiKey, onInputsLoaded]); }, [modelId, provider, replicateApiKey, falApiKey, kieApiKey, wavespeedApiKey, onInputsLoaded]);
// Pre-populate schema defaults into parameters const hiddenParameterSet = useMemo(
() => new Set(hiddenParameterNames ?? []),
[hiddenParameterNames]
);
const visibleSchema = useMemo(
() => schema.filter((param) => !hiddenParameterSet.has(param.name)),
[hiddenParameterSet, schema]
);
// Remove parameters controlled by a higher-priority surface, such as the bottom composer.
useEffect(() => { useEffect(() => {
if (schema.length === 0) return; if (hiddenParameterSet.size === 0) return;
const nextParameters = { ...parameters };
let changed = false;
for (const name of hiddenParameterSet) {
if (Object.prototype.hasOwnProperty.call(nextParameters, name)) {
delete nextParameters[name];
changed = true;
}
}
if (changed) {
onParametersChange(nextParameters);
}
}, [hiddenParameterSet, onParametersChange, parameters]);
// Pre-populate visible schema defaults into parameters
useEffect(() => {
if (visibleSchema.length === 0) return;
const baseParameters = { ...parameters };
for (const name of hiddenParameterSet) {
delete baseParameters[name];
}
const defaults: Record<string, unknown> = {}; const defaults: Record<string, unknown> = {};
let hasNewDefaults = false; let hasNewDefaults = false;
for (const param of schema) { for (const param of visibleSchema) {
if (param.default !== undefined && parameters[param.name] === undefined) { if (param.default !== undefined && baseParameters[param.name] === undefined) {
defaults[param.name] = param.default; defaults[param.name] = param.default;
hasNewDefaults = true; hasNewDefaults = true;
} }
} }
if (hasNewDefaults) { if (hasNewDefaults) {
onParametersChange({ ...parameters, ...defaults }); onParametersChange({ ...baseParameters, ...defaults });
} }
}, [schema, parameters, onParametersChange]); }, [visibleSchema, parameters, onParametersChange]);
// Notify parent to resize node when schema loads // Notify parent to resize node when schema loads
useEffect(() => { useEffect(() => {
if (schema.length > 0 && onExpandChange) { if (visibleSchema.length > 0 && onExpandChange) {
onExpandChange(true, schema.length); onExpandChange(true, visibleSchema.length);
} }
}, [schema, onExpandChange]); }, [visibleSchema, onExpandChange]);
const handleParameterChange = useCallback( const handleParameterChange = useCallback(
(name: string, value: unknown) => { (name: string, value: unknown) => {
@ -194,7 +225,7 @@ function ModelParametersInner({
); );
const sortedSchema = useMemo(() => { const sortedSchema = useMemo(() => {
return [...schema].sort((a, b) => { return [...visibleSchema].sort((a, b) => {
// Sort order: dropdowns first, then numbers, then strings, then checkboxes last // Sort order: dropdowns first, then numbers, then strings, then checkboxes last
const typeOrder = (p: ModelParameter) => { const typeOrder = (p: ModelParameter) => {
if (p.enum && p.enum.length > 0) return 0; // dropdowns first if (p.enum && p.enum.length > 0) return 0; // dropdowns first
@ -204,7 +235,7 @@ function ModelParametersInner({
}; };
return typeOrder(a) - typeOrder(b); return typeOrder(a) - typeOrder(b);
}); });
}, [schema]); }, [visibleSchema]);
const useGrid = sortedSchema.length > 4; const useGrid = sortedSchema.length > 4;
const gridRef = useRef<HTMLDivElement>(null); const gridRef = useRef<HTMLDivElement>(null);
@ -240,7 +271,7 @@ function ModelParametersInner({
} }
// Don't render if no schema available and not loading // Don't render if no schema available and not loading
if (!isLoading && schema.length === 0 && !error) { if (!isLoading && visibleSchema.length === 0 && !error) {
return null; return null;
} }
@ -250,7 +281,7 @@ function ModelParametersInner({
<span className="text-[9px] text-red-400">{error}</span> <span className="text-[9px] text-red-400">{error}</span>
) : isLoading ? ( ) : isLoading ? (
<span className="text-[9px] text-neutral-500">{t("node.loadingParameters")}</span> <span className="text-[9px] text-neutral-500">{t("node.loadingParameters")}</span>
) : schema.length === 0 ? ( ) : visibleSchema.length === 0 ? (
<span className="text-[9px] text-neutral-500">{t("node.noParameters")}</span> <span className="text-[9px] text-neutral-500">{t("node.noParameters")}</span>
) : ( ) : (
<div <div

Loading…
Cancel
Save