Files
Genarrative/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx
T
k88936 ac12e929f2 修复图标规范评审问题
迁移旧版 ui 规范快照并隔离跨对话框优化结果。

关闭非幂等图标规范生成重试并统一参考图解析逻辑。

限制 LLM 重试范围并严格校验优化文本与补全参数。

修正图集键色、数量与素材间距提示约束并补充行为测试。

同步图片画布与生成面板技术文档。
2026-08-05 11:32:18 +08:00

781 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ClipboardList, Cpu, ImagePlus } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
type ReactNode,
type RefObject,
type SetStateAction,
useEffect,
useRef,
useState,
} from 'react';
import {
EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH,
refineEditorIconSpecArtStyle,
refineEditorIconSpecPlaySetting,
} from '../../services/image-editor/editorProjectClient';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import {
PlatformSelectField,
PlatformTextField,
} from '../common/PlatformTextField';
import type {
GenerateDialogState,
SpecFormValues,
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import {
calculateEditorSpecGenerationPrice,
calculateEditorUiDesignPrice,
CHARACTER_SPEC_VIEW_OPTIONS,
getEditorImageModelDisplayName,
IMAGE_MODEL_GPT_IMAGE_2,
resolveEditorImageSizeLabel,
SPEC_GENERATION_ASPECT_RATIO,
SPEC_GENERATION_IMAGE_SIZE,
SPEC_GENERATION_MODEL,
} from './ImageCanvasGenerationModel';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
type ImageCanvasSpecGenerationPanelViewProps = {
dialog: GenerateDialogState;
style: CSSProperties;
isGenerationReferenceMenuOpen?: boolean;
generationReferenceButtonRef?: RefObject<HTMLButtonElement | null>;
setIsGenerationReferenceMenuOpen?: Dispatch<SetStateAction<boolean>>;
setIsPickingGenerationReferenceFromCanvas?: Dispatch<SetStateAction<boolean>>;
setGenerateDialog?: Dispatch<SetStateAction<GenerateDialogState | null>>;
renderEditorPortal?: (node: ReactNode) => ReactNode;
buildPortalMenuStyle?: (
anchor: HTMLElement | null,
placement: 'above' | 'below',
) => CSSProperties;
onOpenSpecDialog?: (specType: SpecGenerationType) => void;
onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
onRequestUpload: (target: UploadTarget) => void;
onRememberImageModel?: (model: string) => void;
onSubmit: (dialog: GenerateDialogState) => void;
};
function shouldShowSpecInputPlaceholders(dialog: GenerateDialogState) {
return dialog.specType === 'character' || dialog.specType === 'icon';
}
type IconSpecOptimizationState = {
optimizing: boolean;
undoValue: string | null;
};
const INITIAL_ICON_SPEC_OPTIMIZATION_STATE: IconSpecOptimizationState = {
optimizing: false,
undoValue: null,
};
function getIconSpecPromptLength(value: string) {
return Array.from(value).length;
}
function limitIconSpecPrompt(value: string) {
return Array.from(value)
.slice(0, EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH)
.join('');
}
function isValidIconSpecPrompt(value: string | undefined) {
const normalized = value?.trim() ?? '';
return (
normalized.length > 0 &&
getIconSpecPromptLength(normalized) <= EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH
);
}
export function ImageCanvasSpecGenerationPanelView({
dialog,
style,
isGenerationReferenceMenuOpen = false,
generationReferenceButtonRef,
setIsGenerationReferenceMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
setGenerateDialog,
renderEditorPortal = (node) => node,
buildPortalMenuStyle = () => ({}),
onOpenSpecDialog,
onUpdateSpecFormValue,
onRequestUpload,
onRememberImageModel = () => {},
onSubmit,
}: ImageCanvasSpecGenerationPanelViewProps) {
const isUiDesignDialog = dialog.mode === 'ui-design';
const showSpecInputPlaceholders = shouldShowSpecInputPlaceholders(dialog);
const specSizeLabel = resolveEditorImageSizeLabel({
aspectRatio: SPEC_GENERATION_ASPECT_RATIO,
imageSize: SPEC_GENERATION_IMAGE_SIZE,
});
const specModelLabel = getEditorImageModelDisplayName(SPEC_GENERATION_MODEL);
const referenceLabel = isUiDesignDialog ? 'UI设计图标规范' : '参考图';
const referenceSlotLabel = isUiDesignDialog
? '图标规范'
: dialog.specType === 'character'
? '角色规范'
: '参考图';
const uploadTarget: UploadTarget = isUiDesignDialog
? 'ui-design-icon-spec'
: 'spec-reference';
const reference = isUiDesignDialog
? dialog.uiDesignSpecReference
: dialog.specReference;
const isGenerating = dialog.status === 'generating';
const isIconSpec = dialog.mode === 'spec' && dialog.specType === 'icon';
const [playSettingOptimization, setPlaySettingOptimization] =
useState<IconSpecOptimizationState>(INITIAL_ICON_SPEC_OPTIMIZATION_STATE);
const [artStyleOptimization, setArtStyleOptimization] =
useState<IconSpecOptimizationState>(INITIAL_ICON_SPEC_OPTIMIZATION_STATE);
const [optimizationError, setOptimizationError] = useState<string | null>(
null,
);
const optimizationDialogKey = `${dialog.id ?? 'active'}:${dialog.mode}:${dialog.specType ?? ''}`;
const activeOptimizationDialogKeyRef = useRef(optimizationDialogKey);
const optimizationRequestVersionsRef = useRef({
playSetting: 0,
artStyle: 0,
});
activeOptimizationDialogKeyRef.current = optimizationDialogKey;
useEffect(() => {
optimizationRequestVersionsRef.current.playSetting += 1;
optimizationRequestVersionsRef.current.artStyle += 1;
setPlaySettingOptimization(INITIAL_ICON_SPEC_OPTIMIZATION_STATE);
setArtStyleOptimization(INITIAL_ICON_SPEC_OPTIMIZATION_STATE);
setOptimizationError(null);
}, [dialog.id, dialog.mode, dialog.specType]);
const isOptimizingIconSpec =
playSettingOptimization.optimizing || artStyleOptimization.optimizing;
const hasRequiredIconSpecValues =
isValidIconSpecPrompt(dialog.specValues?.playSetting) &&
isValidIconSpecPrompt(dialog.specValues?.artStyle);
const canSubmit =
!isGenerating &&
(!isIconSpec || (hasRequiredIconSpecValues && !isOptimizingIconSpec));
const specGenerationCost = calculateEditorSpecGenerationPrice(
SPEC_GENERATION_MODEL,
);
useImageCanvasFloatingOptionDismiss({
isOpen: isGenerationReferenceMenuOpen,
boundaryRefs: [generationReferenceButtonRef],
onDismiss: () => setIsGenerationReferenceMenuOpen?.(false),
});
const openReferenceMenu = () => {
if (setIsGenerationReferenceMenuOpen) {
setIsGenerationReferenceMenuOpen((open) => !open);
return;
}
onRequestUpload(uploadTarget);
};
const optimizeIconSpecField = async (field: 'playSetting' | 'artStyle') => {
const isPlaySetting = field === 'playSetting';
const key: keyof SpecFormValues = isPlaySetting
? 'playSetting'
: 'artStyle';
const value = dialog.specValues?.[key]?.trim() ?? '';
if (!value) {
return;
}
const setOptimization = isPlaySetting
? setPlaySettingOptimization
: setArtStyleOptimization;
const requestVersion = optimizationRequestVersionsRef.current[field] + 1;
optimizationRequestVersionsRef.current[field] = requestVersion;
const requestDialogKey = optimizationDialogKey;
setOptimization((current) => ({ ...current, optimizing: true }));
setOptimizationError(null);
try {
const refined = isPlaySetting
? await refineEditorIconSpecPlaySetting(value)
: await refineEditorIconSpecArtStyle(value);
if (
activeOptimizationDialogKeyRef.current !== requestDialogKey ||
optimizationRequestVersionsRef.current[field] !== requestVersion
) {
return;
}
onUpdateSpecFormValue(key, refined);
setOptimization({ optimizing: false, undoValue: value });
} catch (error) {
if (
activeOptimizationDialogKeyRef.current !== requestDialogKey ||
optimizationRequestVersionsRef.current[field] !== requestVersion
) {
return;
}
setOptimization((current) => ({ ...current, optimizing: false }));
const fallbackMessage = isPlaySetting
? '优化玩法设定失败'
: '优化美术风格失败';
const errorMessage =
error instanceof Error && error.message.trim()
? error.message
: fallbackMessage;
setOptimizationError(errorMessage);
}
};
const undoIconSpecOptimization = (field: 'playSetting' | 'artStyle') => {
const isPlaySetting = field === 'playSetting';
const key: keyof SpecFormValues = isPlaySetting
? 'playSetting'
: 'artStyle';
const state = isPlaySetting
? playSettingOptimization
: artStyleOptimization;
if (state.undoValue === null) {
return;
}
onUpdateSpecFormValue(key, state.undoValue);
(isPlaySetting ? setPlaySettingOptimization : setArtStyleOptimization)(
INITIAL_ICON_SPEC_OPTIMIZATION_STATE,
);
setOptimizationError(null);
};
return (
<>
<form
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__spec-composer"
style={style}
role="dialog"
aria-label={isUiDesignDialog ? '生成UI设计图' : '生成规范'}
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (canSubmit) {
onSubmit(dialog);
}
}}
>
<div className="image-canvas-editor__reference-strip">
{isUiDesignDialog || dialog.specType ? (
<ImageCanvasReferenceSlot
buttonRef={generationReferenceButtonRef}
tone={isUiDesignDialog ? 'ui' : 'spec'}
icon={
isUiDesignDialog ? (
<ImagePlus className="h-4 w-4" aria-hidden="true" />
) : (
<ClipboardList className="h-4 w-4" aria-hidden="true" />
)
}
imageSrc={reference?.src}
objectKey={reference?.objectKey}
label={referenceSlotLabel}
ariaLabel={referenceLabel}
title={reference?.label ?? referenceLabel}
disabled={isGenerating}
isAdd={!reference}
onClick={openReferenceMenu}
onRemove={
reference && setGenerateDialog
? () =>
setGenerateDialog((currentDialog) => {
if (isUiDesignDialog) {
return currentDialog?.mode === 'ui-design'
? {
...currentDialog,
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
uiDesignSpecReference: null,
}
: currentDialog;
}
return currentDialog?.mode === 'spec'
? {
...currentDialog,
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
specReference: null,
}
: currentDialog;
})
: undefined
}
removeLabel={`删除${referenceLabel}`}
/>
) : null}
{isUiDesignDialog
? (dialog.generationReferences ?? []).map((item, index) => (
<ImageCanvasReferenceSlot
key={item.id}
tone="default"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
imageSrc={item.src}
objectKey={item.objectKey}
label={`参考图${index + 1}`}
ariaLabel={item.label || `参考图${index + 1}`}
title={item.label}
disabled={isGenerating}
onRemove={
setGenerateDialog
? () =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'ui-design'
? {
...currentDialog,
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
generationReferences: (
currentDialog.generationReferences ?? []
).filter(
(referenceItem) =>
referenceItem.id !== item.id,
),
}
: currentDialog,
)
: undefined
}
removeLabel={`删除${item.label || `参考图${index + 1}`}`}
/>
))
: null}
{isUiDesignDialog ? (
<ImageCanvasReferenceSlot
tone="default"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
label="参考图"
ariaLabel="上传UI设计参考图"
disabled={isGenerating}
isAdd
onClick={() => onRequestUpload('generation-reference')}
/>
) : null}
</div>
<div className="image-canvas-editor__spec-fields">
{isUiDesignDialog ? (
<label className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
<PlatformTextField
variant="textarea"
aria-label="UI设计要求"
value={dialog.prompt}
disabled={isGenerating}
placeholder="你希望这个 UI 长什么样?"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
onChange={(event) => {
const nextPrompt = event.target.value;
if (setGenerateDialog) {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'ui-design'
? {
...currentDialog,
prompt: nextPrompt,
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
}
: currentDialog,
);
return;
}
onUpdateSpecFormValue('customPrompt', nextPrompt);
}}
/>
</label>
) : null}
{!isUiDesignDialog && dialog.specType === 'custom' ? (
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
自定义规范提示词
</PlatformFieldLabel>
<PlatformTextField
variant="textarea"
aria-label="自定义规范提示词"
value={dialog.specValues?.customPrompt ?? ''}
placeholder="写下你想要的任何内容,陶泥儿帮你形成新的约束"
disabled={isGenerating}
size="sm"
density="compact"
className="image-canvas-editor__spec-textarea"
onChange={(event) =>
onUpdateSpecFormValue('customPrompt', event.target.value)
}
/>
</label>
) : null}
{!isUiDesignDialog && isIconSpec ? (
<>
{(
[
{
field: 'playSetting' as const,
key: 'playSetting' as const,
title: '玩法设定',
placeholder: '游戏的核心玩法是什么?',
optimization: playSettingOptimization,
},
{
field: 'artStyle' as const,
key: 'artStyle' as const,
title: '美术风格',
placeholder: '游戏的画风是怎样的?',
optimization: artStyleOptimization,
},
] as const
).map((item) => {
const value = dialog.specValues?.[item.key] ?? '';
return (
<label
key={item.field}
className="image-canvas-editor__field-block"
>
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
{item.title}
</PlatformFieldLabel>
<div>
<PlatformTextField
variant="textarea"
aria-label={item.title}
value={value}
placeholder={item.placeholder}
disabled={isGenerating || item.optimization.optimizing}
size="sm"
density="compact"
className="image-canvas-editor__spec-textarea"
onChange={(event) =>
onUpdateSpecFormValue(
item.key,
limitIconSpecPrompt(event.target.value),
)
}
/>
<div>
<PlatformActionButton
type="button"
tone="ghost"
size="xxs"
shape="pill"
aria-label={`一键优化${item.title}`}
disabled={
isGenerating ||
item.optimization.optimizing ||
!isValidIconSpecPrompt(value)
}
onClick={() => optimizeIconSpecField(item.field)}
>
{item.optimization.optimizing
? '正在优化'
: '✦ 一键优化'}
</PlatformActionButton>
{item.optimization.undoValue !== null &&
!item.optimization.optimizing ? (
<>
<span aria-hidden="true"></span>
<PlatformActionButton
type="button"
tone="ghost"
size="xxs"
shape="pill"
aria-label={`撤销${item.title}优化`}
disabled={isGenerating}
onClick={() =>
undoIconSpecOptimization(item.field)
}
>
撤销
</PlatformActionButton>
</>
) : null}
</div>
</div>
</label>
);
})}
</>
) : null}
{!isUiDesignDialog && dialog.specType !== 'custom' && !isIconSpec ? (
<>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
玩法设定
</PlatformFieldLabel>
<PlatformTextField
aria-label="玩法设定"
value={dialog.specValues?.playSetting ?? ''}
placeholder={
showSpecInputPlaceholders
? '这是什么类型的游戏?'
: undefined
}
disabled={isGenerating}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
onChange={(event) =>
onUpdateSpecFormValue('playSetting', event.target.value)
}
/>
</label>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
美术风格
</PlatformFieldLabel>
<PlatformTextField
aria-label="美术风格"
value={dialog.specValues?.artStyle ?? ''}
placeholder={
showSpecInputPlaceholders
? '游戏的画风是怎样的?'
: undefined
}
disabled={isGenerating}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
onChange={(event) =>
onUpdateSpecFormValue('artStyle', event.target.value)
}
/>
</label>
{dialog.specType === 'character' ? (
<>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
头身比
</PlatformFieldLabel>
<PlatformSelectField
aria-label="头身比"
value={dialog.specValues?.bodyRatio ?? '3'}
disabled={isGenerating}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
onChange={(event) =>
onUpdateSpecFormValue('bodyRatio', event.target.value)
}
>
{['2', '3', '4', '5', '6'].map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</PlatformSelectField>
</label>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
角色视角
</PlatformFieldLabel>
<PlatformSelectField
aria-label="角色视角"
value={dialog.specValues?.characterView ?? ''}
disabled={isGenerating}
size="sm"
density="compact"
className="image-canvas-editor__spec-input"
onChange={(event) =>
onUpdateSpecFormValue(
'characterView',
event.target.value,
)
}
>
{CHARACTER_SPEC_VIEW_OPTIONS.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</PlatformSelectField>
</label>
</>
) : null}
</>
) : null}
</div>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{dialog.errorMessage}
</PlatformStatusMessage>
) : null}
{optimizationError ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{optimizationError}
</PlatformStatusMessage>
) : null}
<div className="image-canvas-editor__generation-composer-footer image-canvas-editor__spec-footer">
{isUiDesignDialog ? (
<ImageCanvasGenerationImageOptionsView
dialog={dialog}
setGenerateDialog={setGenerateDialog ?? (() => undefined)}
includeDimensions
onRememberImageModel={onRememberImageModel}
lockedModel={IMAGE_MODEL_GPT_IMAGE_2}
cost={calculateEditorUiDesignPrice(
IMAGE_MODEL_GPT_IMAGE_2,
dialog.imageSize,
)}
submitLabel="生成"
submitAriaLabel="生成UI设计图"
optionLabelPrefix="生成图片"
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
/>
) : (
<>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions image-canvas-editor__readonly-generation-option">
<PlatformInlineOptionButton
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
aria-label={`生成图片尺寸 ${specSizeLabel}`}
disabled
>
{specSizeLabel}
</PlatformInlineOptionButton>
</div>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model image-canvas-editor__readonly-generation-option">
<PlatformInlineOptionButton
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
aria-label={`生成图片模型 ${specModelLabel}`}
disabled
>
<span className="image-canvas-editor__model-trigger-label">
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Cpu />
</span>
<span>{specModelLabel}</span>
</span>
</PlatformInlineOptionButton>
</div>
<PlatformActionButton
type="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit image-canvas-editor__spec-submit"
disabled={!canSubmit}
aria-label="提交生成规范"
>
{isGenerating ? (
'生成中'
) : (
<>
<span>生成</span>
<span className="image-canvas-editor__mud-point-inline">
{specGenerationCost}泥点
</span>
</>
)}
</PlatformActionButton>
</>
)}
</div>
</form>
{isGenerationReferenceMenuOpen && generationReferenceButtonRef
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu"
label="参考图来源"
placement="top-start"
style={buildPortalMenuStyle(
generationReferenceButtonRef.current,
'above',
)}
>
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen?.(false);
setIsPickingGenerationReferenceFromCanvas?.(true);
}}
>
从画布中选择
</PlatformFloatingMenuItem>
{isUiDesignDialog ? (
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen?.(false);
onOpenSpecDialog?.('icon');
}}
>
新建图标规范
</PlatformFloatingMenuItem>
) : null}
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen?.(false);
setIsPickingGenerationReferenceFromCanvas?.(false);
onRequestUpload(uploadTarget);
}}
>
上传图片
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
</>
);
}