Files
Genarrative/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx
T
JenkenB 991fea965f Normalize nanobanana aliases to real model
Unify UI aliases for the Nanobanana image model by mapping display and legacy names to the real model ID (gemini-3.1-flash-image-preview). Added normalizeEditorImageModel and used it across generation, dialog, submission and workflow code paths to ensure consistent model selection and pricing. Server-side editor_generation_config and editor_project were updated to accept the display alias (nanobanana2) and legacy alias (nano-banana) for validation/pricing. Tests and docs were added/updated to cover alias normalization and related UI behavior; minor code formatting tweaks included.
2026-06-19 13:05:00 +08:00

333 lines
12 KiB
TypeScript

import { Check, ChevronDown, Cpu } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
type ReactNode,
type SetStateAction,
useCallback,
useRef,
useState,
} from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
import {
EDITOR_IMAGE_DIMENSION_OPTIONS,
EDITOR_IMAGE_MODEL_OPTIONS,
IMAGE_MODEL_NANOBANANA2,
normalizeEditorImageModel,
resizeGenerationPlaceholderToImageSelection,
} from './ImageCanvasGenerationModel';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
type ImageCanvasGenerationImageOptionsViewProps = {
dialog: GenerateDialogState;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
includeDimensions: boolean;
onRememberImageModel: (model: string) => void;
optionLabelPrefix?: string;
cost?: number;
submitLabel?: string;
submitAriaLabel?: string;
renderEditorPortal?: (node: ReactNode) => ReactNode;
buildPortalMenuStyle?: (
anchor: HTMLElement | null,
placement: 'above' | 'below',
) => CSSProperties;
};
type OpenPanel = 'dimensions' | 'model' | null;
function resetFailedDialogStatus(dialog: GenerateDialogState) {
return {
...dialog,
status: dialog.status === 'failed' ? 'idle' : dialog.status,
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
};
}
function getImageDimensionOptions(model: string | null | undefined) {
return (
EDITOR_IMAGE_DIMENSION_OPTIONS[
(model ?? IMAGE_MODEL_NANOBANANA2) as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_NANOBANANA2]
);
}
function normalizeImageDialogSelection(dialog: GenerateDialogState) {
const model = normalizeEditorImageModel(dialog.imageModel ?? IMAGE_MODEL_NANOBANANA2);
const options = getImageDimensionOptions(model);
const aspectRatios = options.aspectRatios as readonly string[];
const imageSizes = options.imageSizes as readonly string[];
return {
model,
aspectRatio:
dialog.aspectRatio && aspectRatios.includes(dialog.aspectRatio)
? dialog.aspectRatio
: options.aspectRatios[0],
imageSize:
dialog.imageSize && imageSizes.includes(dialog.imageSize)
? dialog.imageSize
: (options.imageSizes.find((size) => size === '1K') ??
options.imageSizes[0]),
options,
};
}
function getImageModelLabel(value: string) {
return (
EDITOR_IMAGE_MODEL_OPTIONS.find((option) => option.value === value)
?.label ?? value
);
}
function OptionChoice({
children,
selected,
className,
onClick,
}: {
children: ReactNode;
selected: boolean;
className?: string;
onClick: () => void;
}) {
return (
<button
type="button"
className={['image-canvas-editor__option-popover-choice', className]
.filter(Boolean)
.join(' ')}
aria-pressed={selected}
onClick={onClick}
>
{children}
</button>
);
}
export function ImageCanvasGenerationImageOptionsView({
dialog,
setGenerateDialog,
includeDimensions,
onRememberImageModel,
optionLabelPrefix,
cost,
submitLabel = '生成',
submitAriaLabel = '生成',
renderEditorPortal = (node) => node,
buildPortalMenuStyle = () => ({}),
}: ImageCanvasGenerationImageOptionsViewProps) {
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
const dimensionsButtonRef = useRef<HTMLButtonElement | null>(null);
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
const selection = normalizeImageDialogSelection(dialog);
const selectedModelLabel = getImageModelLabel(selection.model);
const isGenerating = dialog.status === 'generating';
const labelPrefix = optionLabelPrefix ?? '生成图片';
const dismissOpenPanel = useCallback(() => setOpenPanel(null), []);
useImageCanvasFloatingOptionDismiss({
isOpen: openPanel !== null,
boundaryRefs: [dimensionsButtonRef, modelButtonRef],
onDismiss: dismissOpenPanel,
});
const updateDialog = (patch: Partial<GenerateDialogState>) => {
setGenerateDialog((currentDialog) =>
currentDialog && currentDialog.mode === dialog.mode
? resizeGenerationPlaceholderToImageSelection({
...resetFailedDialogStatus(currentDialog),
...patch,
})
: currentDialog,
);
};
const updateImageModel = (model: string) => {
onRememberImageModel(model);
setGenerateDialog((currentDialog) => {
if (!currentDialog || currentDialog.mode !== dialog.mode) {
return currentDialog;
}
const nextOptions = getImageDimensionOptions(model);
const nextAspectRatios = nextOptions.aspectRatios as readonly string[];
const nextImageSizes = nextOptions.imageSizes as readonly string[];
return resizeGenerationPlaceholderToImageSelection({
...resetFailedDialogStatus(currentDialog),
imageModel: model,
aspectRatio:
currentDialog.aspectRatio &&
nextAspectRatios.includes(currentDialog.aspectRatio)
? currentDialog.aspectRatio
: nextOptions.aspectRatios[0],
imageSize:
currentDialog.imageSize &&
nextImageSizes.includes(currentDialog.imageSize)
? currentDialog.imageSize
: (nextOptions.imageSizes.find((size) => size === '1K') ??
nextOptions.imageSizes[0]),
});
});
};
const togglePanel = (panel: Exclude<OpenPanel, null>) => {
setOpenPanel((currentPanel) => (currentPanel === panel ? null : panel));
};
return (
<>
{includeDimensions ? (
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
<PlatformInlineOptionButton
ref={dimensionsButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
aria-label={`${labelPrefix}尺寸 ${selection.aspectRatio} · ${selection.imageSize}`}
aria-expanded={openPanel === 'dimensions'}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => togglePanel('dimensions')}
>
{selection.aspectRatio} · {selection.imageSize}
</PlatformInlineOptionButton>
{openPanel === 'dimensions'
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__portal-menu"
label={`${labelPrefix}尺寸选项`}
placement="top-start"
style={buildPortalMenuStyle(
dimensionsButtonRef.current,
'above',
)}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-sections">
<div className="image-canvas-editor__option-popover-section">
<span className="image-canvas-editor__option-popover-title">
比例
</span>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--card">
{selection.options.aspectRatios.map((aspectRatio) => (
<OptionChoice
key={aspectRatio}
selected={selection.aspectRatio === aspectRatio}
className="image-canvas-editor__option-popover-choice--ratio"
onClick={() => updateDialog({ aspectRatio })}
>
<span
className="image-canvas-editor__ratio-wireframe"
data-ratio={aspectRatio}
aria-hidden="true"
/>
<span>{aspectRatio}</span>
</OptionChoice>
))}
</div>
</div>
<div className="image-canvas-editor__option-popover-section">
<span className="image-canvas-editor__option-popover-title">
尺寸
</span>
<div className="image-canvas-editor__option-popover-items">
{selection.options.imageSizes.map((imageSize) => (
<OptionChoice
key={imageSize}
selected={selection.imageSize === imageSize}
onClick={() => updateDialog({ imageSize })}
>
{imageSize}
</OptionChoice>
))}
</div>
</div>
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
) : null}
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model">
<PlatformInlineOptionButton
ref={modelButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
aria-label={`${labelPrefix}模型 ${selectedModelLabel}`}
aria-expanded={openPanel === 'model'}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => togglePanel('model')}
>
<span className="image-canvas-editor__model-trigger-label">
<span className="image-canvas-editor__model-icon" aria-hidden="true">
<Cpu />
</span>
<span>{selectedModelLabel}</span>
</span>
</PlatformInlineOptionButton>
{openPanel === 'model'
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--model image-canvas-editor__portal-menu"
label={`${labelPrefix}模型选项`}
placement="top-start"
style={buildPortalMenuStyle(modelButtonRef.current, 'above')}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--model">
{EDITOR_IMAGE_MODEL_OPTIONS.map((option) => {
const selected = selection.model === option.value;
return (
<OptionChoice
key={option.value}
selected={selected}
className="image-canvas-editor__option-popover-choice--model"
onClick={() => updateImageModel(option.value)}
>
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Cpu />
</span>
<span>{option.label}</span>
<Check
className="image-canvas-editor__option-selected-check"
data-visible={selected}
aria-hidden="true"
/>
</OptionChoice>
);
})}
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
{typeof cost === 'number' ? (
<PlatformActionButton
type="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={isGenerating}
aria-label={submitAriaLabel}
>
{isGenerating ? (
'生成中'
) : (
<>
<span>{submitLabel}</span>
<span className="image-canvas-editor__mud-point-inline">
{cost}泥点
</span>
</>
)}
</PlatformActionButton>
) : null}
</>
);
}