You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
437 lines
14 KiB
437 lines
14 KiB
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
import { Select } from "antd";
|
|
import { ProviderType } from "@/types";
|
|
import { ModelParameter } from "@/lib/providers/types";
|
|
import { TranslationKey, useI18n } from "@/i18n";
|
|
|
|
/** Reorder items so they read column-first in a row-based CSS grid.
|
|
* e.g. [1,2,3,4,5,6,7,8] with 2 cols → [1,5,2,6,3,7,4,8] */
|
|
function reorderColumnFirst<T>(items: T[], cols: number): T[] {
|
|
const rows = Math.ceil(items.length / cols);
|
|
const result: T[] = [];
|
|
for (let r = 0; r < rows; r++) {
|
|
for (let c = 0; c < cols; c++) {
|
|
const idx = c * rows + r;
|
|
if (idx < items.length) result.push(items[idx]);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
interface ModelParametersProps {
|
|
modelId?: string;
|
|
provider?: ProviderType;
|
|
schema?: ModelParameter[];
|
|
isLoading?: boolean;
|
|
error?: string | null;
|
|
parameters: Record<string, unknown>;
|
|
onParametersChange: (parameters: Record<string, unknown>) => void;
|
|
onExpandChange?: (expanded: boolean, parameterCount: number) => void;
|
|
hiddenParameterNames?: string[];
|
|
pruneHiddenParameters?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Collapsible parameter inputs for external provider models.
|
|
* and renders appropriate inputs based on parameter types.
|
|
*/
|
|
function ModelParametersInner({
|
|
modelId,
|
|
schema = [],
|
|
isLoading = false,
|
|
error = null,
|
|
parameters,
|
|
onParametersChange,
|
|
onExpandChange,
|
|
hiddenParameterNames,
|
|
pruneHiddenParameters = true,
|
|
}: ModelParametersProps) {
|
|
const { t } = useI18n();
|
|
const lastExpandCountRef = useRef<number | null>(null);
|
|
|
|
const hiddenParameterSet = useMemo(
|
|
() => new Set(hiddenParameterNames ?? []),
|
|
[hiddenParameterNames]
|
|
);
|
|
const visibleSchema = useMemo(
|
|
() => schema.filter((param) => !hiddenParameterSet.has(param.name)),
|
|
[hiddenParameterSet, schema]
|
|
);
|
|
const visibleParameterCount = visibleSchema.length;
|
|
|
|
// Remove parameters controlled by a higher-priority surface, such as the bottom composer.
|
|
useEffect(() => {
|
|
if (!pruneHiddenParameters || 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, pruneHiddenParameters]);
|
|
|
|
// Pre-populate visible schema defaults into parameters
|
|
useEffect(() => {
|
|
if (visibleSchema.length === 0) return;
|
|
const baseParameters = { ...parameters };
|
|
if (pruneHiddenParameters) {
|
|
for (const name of hiddenParameterSet) {
|
|
delete baseParameters[name];
|
|
}
|
|
}
|
|
const defaults: Record<string, unknown> = {};
|
|
let hasNewDefaults = false;
|
|
for (const param of visibleSchema) {
|
|
const currentValue = baseParameters[param.name];
|
|
const enumValues = Array.isArray(param.enum) ? param.enum : [];
|
|
const hasInvalidEnumValue =
|
|
enumValues.length > 0 &&
|
|
currentValue !== undefined &&
|
|
!enumValues.some((item) => String(item) === String(currentValue));
|
|
const defaultValue = param.default ?? enumValues[0];
|
|
if ((currentValue === undefined || hasInvalidEnumValue) && defaultValue !== undefined) {
|
|
defaults[param.name] = defaultValue;
|
|
hasNewDefaults = true;
|
|
}
|
|
}
|
|
if (hasNewDefaults) {
|
|
onParametersChange({ ...baseParameters, ...defaults });
|
|
}
|
|
}, [visibleSchema, parameters, onParametersChange, hiddenParameterSet, pruneHiddenParameters]);
|
|
|
|
// Notify parent to resize node when schema loads
|
|
useEffect(() => {
|
|
if (visibleParameterCount === 0) {
|
|
lastExpandCountRef.current = null;
|
|
return;
|
|
}
|
|
if (lastExpandCountRef.current === visibleParameterCount) return;
|
|
lastExpandCountRef.current = visibleParameterCount;
|
|
if (visibleParameterCount > 0 && onExpandChange) {
|
|
onExpandChange(true, visibleParameterCount);
|
|
}
|
|
}, [visibleParameterCount, onExpandChange]);
|
|
|
|
const handleParameterChange = useCallback(
|
|
(name: string, value: unknown) => {
|
|
// Create new parameters object with updated value
|
|
const newParams = { ...parameters };
|
|
|
|
// If value is empty/undefined, remove the parameter
|
|
if (value === "" || value === undefined || value === null) {
|
|
delete newParams[name];
|
|
} else {
|
|
newParams[name] = value;
|
|
}
|
|
|
|
onParametersChange(newParams);
|
|
},
|
|
[parameters, onParametersChange]
|
|
);
|
|
|
|
const sortedSchema = useMemo(() => {
|
|
return [...visibleSchema].sort((a, b) => {
|
|
// Sort order: dropdowns first, then numbers, then strings, then checkboxes last
|
|
const typeOrder = (p: ModelParameter) => {
|
|
if (p.options && p.options.length > 0) return 0; // dropdowns first
|
|
if (p.type === "number" || p.type === "integer") return 1;
|
|
if (p.type === "boolean") return 3; // checkboxes last
|
|
return 2; // string and other
|
|
};
|
|
return typeOrder(a) - typeOrder(b);
|
|
});
|
|
}, [visibleSchema]);
|
|
|
|
const useGrid = sortedSchema.length > 4;
|
|
const gridRef = useRef<HTMLDivElement>(null);
|
|
const [colCount, setColCount] = useState(1);
|
|
|
|
useEffect(() => {
|
|
const el = gridRef.current;
|
|
if (!el || !useGrid) { setColCount(1); return; }
|
|
let rafId: number;
|
|
const observer = new ResizeObserver(() => {
|
|
cancelAnimationFrame(rafId);
|
|
rafId = requestAnimationFrame(() => {
|
|
const cols = getComputedStyle(el).gridTemplateColumns.split(" ").length;
|
|
setColCount(prev => prev === cols ? prev : cols);
|
|
});
|
|
});
|
|
observer.observe(el);
|
|
return () => {
|
|
cancelAnimationFrame(rafId);
|
|
observer.disconnect();
|
|
};
|
|
}, [useGrid]);
|
|
|
|
const displaySchema = useMemo(() => {
|
|
return useGrid && colCount > 1
|
|
? reorderColumnFirst(sortedSchema, colCount)
|
|
: sortedSchema;
|
|
}, [sortedSchema, useGrid, colCount]);
|
|
|
|
// Don't render if no model selected
|
|
if (!modelId) {
|
|
return null;
|
|
}
|
|
|
|
// Don't render if no schema available and not loading
|
|
if (!isLoading && visibleSchema.length === 0 && !error) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="shrink-0">
|
|
{error ? (
|
|
<span className="text-[9px] text-red-400">{error}</span>
|
|
) : isLoading ? (
|
|
<span className="text-[9px] text-neutral-500">{t("node.loadingParameters")}</span>
|
|
) : visibleSchema.length === 0 ? (
|
|
<span className="text-[9px] text-neutral-500">{t("node.noParameters")}</span>
|
|
) : (
|
|
<div
|
|
ref={gridRef}
|
|
className={useGrid
|
|
? "grid grid-cols-[repeat(auto-fill,minmax(min(180px,100%),1fr))] max-w-[420px] gap-x-6 gap-y-1.5"
|
|
: "space-y-1.5 max-w-[280px]"
|
|
}
|
|
>
|
|
{displaySchema.map((param) => (
|
|
<ParameterInput
|
|
key={param.name}
|
|
param={param}
|
|
name={param.name}
|
|
value={parameters[param.name]}
|
|
onChange={handleParameterChange}
|
|
t={t}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ParameterInputProps {
|
|
param: ModelParameter;
|
|
name: string;
|
|
value: unknown;
|
|
onChange: (name: string, value: unknown) => void;
|
|
t: (key: TranslationKey, vars?: Record<string, string | number>) => string;
|
|
}
|
|
|
|
/**
|
|
* Individual parameter input based on type.
|
|
* Text and number inputs use local state during editing to prevent
|
|
* cursor-jump issues caused by React Flow re-renders on store updates.
|
|
*/
|
|
function ParameterInputInner({ param, name, value, onChange, t }: ParameterInputProps) {
|
|
// Stable callback that passes name along with value
|
|
const handleChange = useCallback((value: unknown) => {
|
|
onChange(name, value);
|
|
}, [name, onChange]);
|
|
const labelMap: Record<string, TranslationKey> = {
|
|
size: "node.size",
|
|
quality: "node.quality",
|
|
voice: "node.voice",
|
|
model: "node.model",
|
|
aspect_ratio: "node.aspectRatio",
|
|
resolution: "node.resolution",
|
|
duration: "node.duration",
|
|
duration_seconds: "node.duration",
|
|
durationSeconds: "node.duration",
|
|
sound: "node.sound",
|
|
generate_audio: "node.sound",
|
|
enable_audio: "node.sound",
|
|
with_audio: "node.sound",
|
|
audio_enabled: "node.sound",
|
|
};
|
|
const displayName = param.label ?? (labelMap[param.name]
|
|
? t(labelMap[param.name])
|
|
: param.name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()));
|
|
|
|
// Local state for text/number inputs to prevent cursor jumping
|
|
const [localValue, setLocalValue] = useState<string>(() => {
|
|
if (value === undefined || value === null) return "";
|
|
return String(value);
|
|
});
|
|
const isFocusedRef = useRef(false);
|
|
|
|
// Sync from store when not focused (external changes)
|
|
useEffect(() => {
|
|
if (!isFocusedRef.current) {
|
|
setLocalValue(value === undefined || value === null ? "" : String(value));
|
|
}
|
|
}, [value]);
|
|
|
|
// Determine input type and render accordingly
|
|
if (param.options && param.options.length > 0) {
|
|
const selectOptions = param.options.map((option, index) => ({
|
|
value: String(index),
|
|
label: option.label,
|
|
rawValue: option.value,
|
|
}));
|
|
const selectedOption = selectOptions.find((option) =>
|
|
Object.is(option.rawValue, value) || String(option.rawValue) === String(value ?? "")
|
|
);
|
|
|
|
// Render label/value option pairs from the model schema.
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<label
|
|
className="text-[11px] text-neutral-400 shrink-0"
|
|
title={param.description || undefined}
|
|
>
|
|
{displayName}
|
|
</label>
|
|
<Select
|
|
value={selectedOption?.value}
|
|
onChange={(optionKey) => {
|
|
const option = selectOptions.find((item) => item.value === optionKey);
|
|
handleChange(option?.rawValue);
|
|
}}
|
|
options={selectOptions.map((option) => ({
|
|
value: option.value,
|
|
label: option.label,
|
|
}))}
|
|
size="small"
|
|
popupMatchSelectWidth={false}
|
|
className="nodrag nopan flex-1 min-w-0 text-[11px] [&_.ant-select-selection-item]:text-[11px] [&_.ant-select-selector]:!text-[11px]"
|
|
classNames={{
|
|
popup: {
|
|
root: "nodrag nopan [&_.ant-select-item-option-content]:text-[11px]",
|
|
},
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (param.type === "boolean") {
|
|
// Use schema default when value not explicitly set
|
|
const effectiveValue = value !== undefined ? Boolean(value) : Boolean(param.default);
|
|
|
|
// Boolean: render as checkbox
|
|
return (
|
|
<label
|
|
className="flex items-center gap-1.5 text-[11px] text-neutral-300 cursor-pointer"
|
|
title={param.description || undefined}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={effectiveValue}
|
|
onChange={(e) => handleChange(e.target.checked)}
|
|
className="nodrag nopan w-3 h-3 rounded bg-[#1a1a1a] text-neutral-600 focus:ring-1 focus:ring-neutral-600 focus:ring-offset-0"
|
|
/>
|
|
<span>{displayName}</span>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
if (param.type === "number" || param.type === "integer") {
|
|
const hasMin = param.minimum !== undefined;
|
|
const hasMax = param.maximum !== undefined;
|
|
|
|
// Validate current value against constraints
|
|
let validationError: string | null = null;
|
|
if (localValue !== "" && !isNaN(Number(localValue))) {
|
|
const num = Number(localValue);
|
|
if (hasMin && num < param.minimum!) {
|
|
validationError = `Min: ${param.minimum}`;
|
|
} else if (hasMax && num > param.maximum!) {
|
|
validationError = `Max: ${param.maximum}`;
|
|
} else if (param.type === "integer" && !Number.isInteger(num)) {
|
|
validationError = "Must be integer";
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-0.5">
|
|
<div className="flex items-center gap-2">
|
|
<label
|
|
className="text-[11px] text-neutral-400 shrink-0 flex items-center gap-1"
|
|
title={param.description || undefined}
|
|
>
|
|
{displayName}
|
|
{hasMin && hasMax && (
|
|
<span className="text-neutral-500 text-[9px]">
|
|
({param.minimum}-{param.maximum})
|
|
</span>
|
|
)}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={localValue}
|
|
min={param.minimum}
|
|
max={param.maximum}
|
|
step={param.type === "integer" ? 1 : 0.1}
|
|
onFocus={() => { isFocusedRef.current = true; }}
|
|
onChange={(e) => {
|
|
setLocalValue(e.target.value);
|
|
}}
|
|
onBlur={() => {
|
|
isFocusedRef.current = false;
|
|
if (localValue === "") {
|
|
handleChange(undefined);
|
|
} else {
|
|
const num = param.type === "integer" ? parseInt(localValue, 10) : parseFloat(localValue);
|
|
handleChange(isNaN(num) ? undefined : num);
|
|
}
|
|
}}
|
|
placeholder={param.default !== undefined ? `${param.default}` : undefined}
|
|
className={`nodrag nopan flex-1 min-w-0 text-[11px] py-1 px-2 rounded-md bg-[#1a1a1a] focus:outline-none focus:ring-1 text-white placeholder:text-neutral-500 ${
|
|
validationError
|
|
? "ring-1 ring-red-500"
|
|
: "focus:ring-neutral-600"
|
|
}`}
|
|
/>
|
|
</div>
|
|
{validationError && (
|
|
<span className="text-[9px] text-red-400">{validationError}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Skip array type for now (complex)
|
|
if (param.type === "array") {
|
|
return null;
|
|
}
|
|
|
|
// Default: string input — uses local state, syncs to store on blur
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<label
|
|
className="text-[11px] text-neutral-400 shrink-0"
|
|
title={param.description || undefined}
|
|
>
|
|
{displayName}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={localValue}
|
|
onFocus={() => { isFocusedRef.current = true; }}
|
|
onChange={(e) => {
|
|
setLocalValue(e.target.value);
|
|
}}
|
|
onBlur={() => {
|
|
isFocusedRef.current = false;
|
|
handleChange(localValue || undefined);
|
|
}}
|
|
placeholder={param.default !== undefined ? `${param.default}` : undefined}
|
|
className="nodrag nopan flex-1 min-w-0 text-[11px] py-1 px-2 rounded-md bg-[#1a1a1a] focus:outline-none focus:ring-1 focus:ring-neutral-600 text-white placeholder:text-neutral-500"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Memoized exports to prevent unnecessary re-renders
|
|
export const ModelParameters = React.memo(ModelParametersInner);
|
|
const ParameterInput = React.memo(ParameterInputInner);
|
|
|