|
|
|
@ -419,7 +419,7 @@ function ComposerEditor({ |
|
|
|
const referenceMaterialsRef = useRef<ComposerReferenceMaterial[]>([]); |
|
|
|
const [config, setConfig] = useState<ComposerConfig>(() => configFromDraft(initialDraft)); |
|
|
|
const [prompt, setPrompt] = useState(initialDraft.prompt); |
|
|
|
const [isComposerCollapsed, setIsComposerCollapsed] = useState(false); |
|
|
|
const [isEnlarged, setIsEnlarged] = useState(false); |
|
|
|
const [playingReferenceAudioKey, setPlayingReferenceAudioKey] = useState<string | null>(null); |
|
|
|
const [priceState, setPriceState] = useState<{ amount: number | null; isLoading: boolean }>({ |
|
|
|
amount: null, |
|
|
|
@ -841,6 +841,7 @@ function ComposerEditor({ |
|
|
|
onPersistConfig(previous.context, buildGenerationConfigForContext(previous.context)); |
|
|
|
setConfig(configFromDraft(initialDraft)); |
|
|
|
setPrompt(initialDraft.prompt); |
|
|
|
setIsEnlarged(false); |
|
|
|
referenceMaterialsRef.current = []; |
|
|
|
previousContextRef.current = { key: contextKey, context }; |
|
|
|
// The current local draft belongs to the previous composer identity.
|
|
|
|
@ -851,6 +852,7 @@ function ComposerEditor({ |
|
|
|
if (isHidden) { |
|
|
|
persistCurrentComposerConfig(); |
|
|
|
stopReferenceAudioPreview(); |
|
|
|
setIsEnlarged(false); |
|
|
|
onHiddenPersisted?.(); |
|
|
|
return; |
|
|
|
} |
|
|
|
@ -993,189 +995,213 @@ function ComposerEditor({ |
|
|
|
onPersistConfig(context, generationConfig); |
|
|
|
const { prompt: _prompt, ...nextConfig } = generationConfig; |
|
|
|
void onRun(context, prompt, nextConfig); |
|
|
|
if (isEnlarged) setIsEnlarged(false); |
|
|
|
}, [ |
|
|
|
buildGenerationConfigForContext, |
|
|
|
context, |
|
|
|
isCurrentNodeRunning, |
|
|
|
isEnlarged, |
|
|
|
onCancelRunning, |
|
|
|
onPersistConfig, |
|
|
|
onRun, |
|
|
|
prompt, |
|
|
|
]); |
|
|
|
|
|
|
|
return ( |
|
|
|
// 放大态输入框固定 17 行高度,超出后出现滚动条;普通态沿用 2~4 行。
|
|
|
|
const promptMinHeightPx = isEnlarged ? PROMPT_LINE_HEIGHT_PX * 17 : PROMPT_MIN_HEIGHT_PX; |
|
|
|
const promptMaxHeightPx = isEnlarged ? PROMPT_LINE_HEIGHT_PX * 17 : PROMPT_MAX_HEIGHT_PX; |
|
|
|
|
|
|
|
const composerBody = ( |
|
|
|
<> |
|
|
|
<ComposerFrame> |
|
|
|
<section |
|
|
|
data-testid="node-inline-composer-panel" |
|
|
|
className="nodrag nopan nowheel pointer-events-auto relative flex max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-2)] shadow-[var(--shadow-panel)] backdrop-blur transition-[width] duration-200" |
|
|
|
style={{ width: isComposerCollapsed ? 360 : COMPOSER_WIDTH }} |
|
|
|
onPointerDown={(event) => event.stopPropagation()} |
|
|
|
onMouseDown={(event) => event.stopPropagation()} |
|
|
|
onClick={(event) => event.stopPropagation()} |
|
|
|
> |
|
|
|
<div className="absolute right-3 top-3 z-10 flex items-center gap-1 text-[var(--text-muted)]"> |
|
|
|
<IconButton |
|
|
|
label={isComposerCollapsed ? t("composer.expand") : t("composer.collapse")} |
|
|
|
onClick={() => { |
|
|
|
if (!isComposerCollapsed) persistCurrentComposerConfig(); |
|
|
|
setIsComposerCollapsed((value) => !value); |
|
|
|
}} |
|
|
|
> |
|
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"> |
|
|
|
{isComposerCollapsed ? <path d="M7 17 17 7M8 7h9v9" /> : <path d="M17 7 7 17M16 17H7V8" />} |
|
|
|
</svg> |
|
|
|
</IconButton> |
|
|
|
<ComposerPromptMaterials |
|
|
|
hasMediaHeader={hasComposerMediaHeader} |
|
|
|
headerStatusContent={undefined} |
|
|
|
activeCapability={activeCapability} |
|
|
|
nodeId={context.mode === "node-edit" ? context.node?.id : undefined} |
|
|
|
selectedModel={config.selectedModel} |
|
|
|
connectedTextCards={connectedTextCards} |
|
|
|
referenceMaterials={referenceMaterials} |
|
|
|
playingReferenceAudioKey={playingReferenceAudioKey} |
|
|
|
onRemoveConnectedEdge={(edgeId) => onRemoveEdge(edgeId)} |
|
|
|
onReferenceMaterialDragStart={(event, material) => { |
|
|
|
event.dataTransfer.effectAllowed = "copyMove"; |
|
|
|
event.dataTransfer.setData("application/x-popiart-reference-material", getReferenceMaterialKey(material)); |
|
|
|
}} |
|
|
|
onReferenceMaterialDrop={handleReferenceMaterialDrop} |
|
|
|
onRemoveReferenceMaterial={(material) => material.edgeId && onRemoveEdge(material.edgeId)} |
|
|
|
onToggleReferenceAudio={handleReferenceAudioPreviewToggle} |
|
|
|
onStopReferenceAudio={stopReferenceAudioPreview} |
|
|
|
onVideoSubTypeChange={(subType) => { |
|
|
|
if (context.mode !== "node-edit" || !context.node) return; |
|
|
|
const nextConfig = { |
|
|
|
...config, |
|
|
|
subType, |
|
|
|
parameters: filterTaskRoutingParameters(config.parameters), |
|
|
|
}; |
|
|
|
setConfig(nextConfig); |
|
|
|
updateNodeData(context.node.id, { |
|
|
|
subType, |
|
|
|
parameters: nextConfig.parameters, |
|
|
|
} as Partial<GenerateVideoNodeData>); |
|
|
|
}} |
|
|
|
promptInputRef={promptInputRef} |
|
|
|
promptValue={prompt} |
|
|
|
promptReadOnly={false} |
|
|
|
promptMinHeightPx={promptMinHeightPx} |
|
|
|
promptMaxHeightPx={promptMaxHeightPx} |
|
|
|
referenceItems={mentionReferenceItems} |
|
|
|
onDraftPatch={(patch) => { |
|
|
|
const nextPrompt = typeof patch.prompt === "string" ? patch.prompt : prompt; |
|
|
|
if (typeof patch.prompt === "string") { |
|
|
|
setPrompt(patch.prompt); |
|
|
|
} |
|
|
|
if (patch.parameters) { |
|
|
|
const nextParameters = patch.parameters; |
|
|
|
const nextConfig = { |
|
|
|
...config, |
|
|
|
parameters: nextParameters, |
|
|
|
}; |
|
|
|
setConfig(nextConfig); |
|
|
|
} |
|
|
|
}} |
|
|
|
onSubmitShortcut={runAction} |
|
|
|
bodyContent={processBody} |
|
|
|
/> |
|
|
|
|
|
|
|
{errorMessage && ( |
|
|
|
<div className="nowheel mx-2 mb-2 max-h-20 overflow-y-auto rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-200"> |
|
|
|
{errorMessage} |
|
|
|
</div> |
|
|
|
)} |
|
|
|
|
|
|
|
<div className="flex shrink-0 items-center gap-3 px-2 py-2 text-sm text-[var(--text-secondary)]"> |
|
|
|
{isProcessNode ? ( |
|
|
|
<div className="flex min-w-0 items-center gap-2 text-[var(--text-muted)]"> |
|
|
|
<span className="truncate"> |
|
|
|
{context.node?.type === "easeCurve" |
|
|
|
? ((connectedInputs?.videos.length ?? 0) >= 1 ? t("composer.easeCurveReady") : t("composer.easeCurveNoVideo")) |
|
|
|
: ((connectedInputs?.videos.length ?? 0) >= 2 ? t("composer.videoStitchReady") : t("composer.videoStitchNeedTwo"))} |
|
|
|
</span> |
|
|
|
</div> |
|
|
|
|
|
|
|
{isComposerCollapsed ? ( |
|
|
|
<button |
|
|
|
type="button" |
|
|
|
onClick={() => setIsComposerCollapsed(false)} |
|
|
|
className="flex h-14 min-w-0 items-center gap-3 px-3 pr-14 text-left" |
|
|
|
) : ( |
|
|
|
<ComposerModelConfig |
|
|
|
activeCapability={activeCapability} |
|
|
|
config={config} |
|
|
|
modelSchema={modelSchema} |
|
|
|
extensionFields={extensionFields} |
|
|
|
extraTaskParams={config.extraTaskParams ?? EMPTY_EXTRA_TASK_PARAMS} |
|
|
|
canChooseModel={canChooseModel} |
|
|
|
isProcessNode={false} |
|
|
|
onModelNeedsDetail={onModelNeedsDetail} |
|
|
|
onModelChangeStart={handleModelChangeStart} |
|
|
|
onModelChangeComplete={handleModelChangeComplete} |
|
|
|
onConfigChange={handleConfigChange} |
|
|
|
onExtraTaskParamsChange={handleExtraTaskParamsChange} |
|
|
|
onAspectRatioChange={(value) => { |
|
|
|
if (context.mode === "node-edit" && context.node) onApplyNodeSize(context.node.id, value); |
|
|
|
}} |
|
|
|
getPopupContainer={getComposerModelPopupContainer} |
|
|
|
selectModelPlaceholder={t("composer.selectModel")} |
|
|
|
showLlmModelConfig={false} |
|
|
|
llmModelConfigContent={null} |
|
|
|
llmModelConfigSummary="" |
|
|
|
/> |
|
|
|
)} |
|
|
|
|
|
|
|
<div className="ml-auto flex items-center gap-2"> |
|
|
|
{priceLabel ? ( |
|
|
|
<div |
|
|
|
className="flex h-9 items-center gap-1 text-xs font-medium text-[var(--text-muted)]" |
|
|
|
aria-live="polite" |
|
|
|
title={modelPointCostLabel ?? "Loading points"} |
|
|
|
> |
|
|
|
<span className="min-w-0 flex-1 truncate text-sm text-[var(--text-primary)]"> |
|
|
|
{prompt || config.selectedModel.displayName || t("composer.describeContent")} |
|
|
|
</span> |
|
|
|
<span className="shrink-0 text-xs text-[var(--text-muted)]"> |
|
|
|
{isCurrentNodeRunning ? submitButtonLabel : priceLabel ?? submitButtonLabel} |
|
|
|
</span> |
|
|
|
</button> |
|
|
|
) : ( |
|
|
|
<> |
|
|
|
<ComposerPromptMaterials |
|
|
|
hasMediaHeader={hasComposerMediaHeader} |
|
|
|
headerStatusContent={undefined} |
|
|
|
activeCapability={activeCapability} |
|
|
|
nodeId={context.mode === "node-edit" ? context.node?.id : undefined} |
|
|
|
selectedModel={config.selectedModel} |
|
|
|
connectedTextCards={connectedTextCards} |
|
|
|
referenceMaterials={referenceMaterials} |
|
|
|
playingReferenceAudioKey={playingReferenceAudioKey} |
|
|
|
onRemoveConnectedEdge={(edgeId) => onRemoveEdge(edgeId)} |
|
|
|
onReferenceMaterialDragStart={(event, material) => { |
|
|
|
event.dataTransfer.effectAllowed = "copyMove"; |
|
|
|
event.dataTransfer.setData("application/x-popiart-reference-material", getReferenceMaterialKey(material)); |
|
|
|
}} |
|
|
|
onReferenceMaterialDrop={handleReferenceMaterialDrop} |
|
|
|
onRemoveReferenceMaterial={(material) => material.edgeId && onRemoveEdge(material.edgeId)} |
|
|
|
onToggleReferenceAudio={handleReferenceAudioPreviewToggle} |
|
|
|
onStopReferenceAudio={stopReferenceAudioPreview} |
|
|
|
onVideoSubTypeChange={(subType) => { |
|
|
|
if (context.mode !== "node-edit" || !context.node) return; |
|
|
|
const nextConfig = { |
|
|
|
...config, |
|
|
|
subType, |
|
|
|
parameters: filterTaskRoutingParameters(config.parameters), |
|
|
|
}; |
|
|
|
setConfig(nextConfig); |
|
|
|
updateNodeData(context.node.id, { |
|
|
|
subType, |
|
|
|
parameters: nextConfig.parameters, |
|
|
|
} as Partial<GenerateVideoNodeData>); |
|
|
|
}} |
|
|
|
promptInputRef={promptInputRef} |
|
|
|
promptValue={prompt} |
|
|
|
promptReadOnly={false} |
|
|
|
promptMinHeightPx={PROMPT_MIN_HEIGHT_PX} |
|
|
|
promptMaxHeightPx={PROMPT_MAX_HEIGHT_PX} |
|
|
|
referenceItems={mentionReferenceItems} |
|
|
|
onDraftPatch={(patch) => { |
|
|
|
const nextPrompt = typeof patch.prompt === "string" ? patch.prompt : prompt; |
|
|
|
if (typeof patch.prompt === "string") { |
|
|
|
setPrompt(patch.prompt); |
|
|
|
} |
|
|
|
if (patch.parameters) { |
|
|
|
const nextParameters = patch.parameters; |
|
|
|
const nextConfig = { |
|
|
|
...config, |
|
|
|
parameters: nextParameters, |
|
|
|
}; |
|
|
|
setConfig(nextConfig); |
|
|
|
} |
|
|
|
}} |
|
|
|
onSubmitShortcut={runAction} |
|
|
|
bodyContent={processBody} |
|
|
|
/> |
|
|
|
|
|
|
|
{errorMessage && ( |
|
|
|
<div className="nowheel mx-2 mb-2 max-h-20 overflow-y-auto rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-200"> |
|
|
|
{errorMessage} |
|
|
|
</div> |
|
|
|
)} |
|
|
|
|
|
|
|
<div className="flex shrink-0 items-center gap-3 px-2 py-2 text-sm text-[var(--text-secondary)]"> |
|
|
|
{isProcessNode ? ( |
|
|
|
<div className="flex min-w-0 items-center gap-2 text-[var(--text-muted)]"> |
|
|
|
<span className="truncate"> |
|
|
|
{context.node?.type === "easeCurve" |
|
|
|
? ((connectedInputs?.videos.length ?? 0) >= 1 ? t("composer.easeCurveReady") : t("composer.easeCurveNoVideo")) |
|
|
|
: ((connectedInputs?.videos.length ?? 0) >= 2 ? t("composer.videoStitchReady") : t("composer.videoStitchNeedTwo"))} |
|
|
|
</span> |
|
|
|
</div> |
|
|
|
) : ( |
|
|
|
<ComposerModelConfig |
|
|
|
activeCapability={activeCapability} |
|
|
|
config={config} |
|
|
|
modelSchema={modelSchema} |
|
|
|
extensionFields={extensionFields} |
|
|
|
extraTaskParams={config.extraTaskParams ?? EMPTY_EXTRA_TASK_PARAMS} |
|
|
|
canChooseModel={canChooseModel} |
|
|
|
isProcessNode={false} |
|
|
|
onModelNeedsDetail={onModelNeedsDetail} |
|
|
|
onModelChangeStart={handleModelChangeStart} |
|
|
|
onModelChangeComplete={handleModelChangeComplete} |
|
|
|
onConfigChange={handleConfigChange} |
|
|
|
onExtraTaskParamsChange={handleExtraTaskParamsChange} |
|
|
|
onAspectRatioChange={(value) => { |
|
|
|
if (context.mode === "node-edit" && context.node) onApplyNodeSize(context.node.id, value); |
|
|
|
}} |
|
|
|
getPopupContainer={getComposerModelPopupContainer} |
|
|
|
selectModelPlaceholder={t("composer.selectModel")} |
|
|
|
showLlmModelConfig={false} |
|
|
|
llmModelConfigContent={null} |
|
|
|
llmModelConfigSummary="" |
|
|
|
/> |
|
|
|
)} |
|
|
|
|
|
|
|
<div className="ml-auto flex items-center gap-2"> |
|
|
|
{priceLabel ? ( |
|
|
|
<div |
|
|
|
className="flex h-9 items-center gap-1 text-xs font-medium text-[var(--text-muted)]" |
|
|
|
aria-live="polite" |
|
|
|
title={modelPointCostLabel ?? "Loading points"} |
|
|
|
> |
|
|
|
<svg className="h-3 w-3 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> |
|
|
|
<path d="M13 2 4 14h6l-1 8 10-13h-6l0-7Z" /> |
|
|
|
</svg> |
|
|
|
<span>{priceLabel}</span> |
|
|
|
</div> |
|
|
|
) : null} |
|
|
|
<button |
|
|
|
type="button" |
|
|
|
onClick={runAction} |
|
|
|
disabled={!canClickSubmit} |
|
|
|
className="ml-1 flex h-9 w-9 items-center justify-center rounded-lg bg-[var(--text-primary)] text-[var(--text-inverse)] transition-colors hover:bg-[var(--text-secondary)] disabled:cursor-not-allowed disabled:bg-[var(--surface-active)] disabled:text-[var(--text-disabled)]" |
|
|
|
aria-label={submitButtonLabel} |
|
|
|
title={submitButtonLabel} |
|
|
|
> |
|
|
|
{isCurrentNodeRunning ? ( |
|
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.4"> |
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12M18 6 6 18" /> |
|
|
|
</svg> |
|
|
|
) : ( |
|
|
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4"> |
|
|
|
<path d="M12 19V5" /> |
|
|
|
<path d="m5 12 7-7 7 7" /> |
|
|
|
</svg> |
|
|
|
)} |
|
|
|
</button> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</> |
|
|
|
)} |
|
|
|
</section> |
|
|
|
</ComposerFrame> |
|
|
|
<svg className="h-3 w-3 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> |
|
|
|
<path d="M13 2 4 14h6l-1 8 10-13h-6l0-7Z" /> |
|
|
|
</svg> |
|
|
|
<span>{priceLabel}</span> |
|
|
|
</div> |
|
|
|
) : null} |
|
|
|
<button |
|
|
|
type="button" |
|
|
|
onClick={runAction} |
|
|
|
disabled={!canClickSubmit} |
|
|
|
className="ml-1 flex h-9 w-9 items-center justify-center rounded-lg bg-[var(--text-primary)] text-[var(--text-inverse)] transition-colors hover:bg-[var(--text-secondary)] disabled:cursor-not-allowed disabled:bg-[var(--surface-active)] disabled:text-[var(--text-disabled)]" |
|
|
|
aria-label={submitButtonLabel} |
|
|
|
title={submitButtonLabel} |
|
|
|
> |
|
|
|
{isCurrentNodeRunning ? ( |
|
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.4"> |
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12M18 6 6 18" /> |
|
|
|
</svg> |
|
|
|
) : ( |
|
|
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4"> |
|
|
|
<path d="M12 19V5" /> |
|
|
|
<path d="m5 12 7-7 7 7" /> |
|
|
|
</svg> |
|
|
|
)} |
|
|
|
</button> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</> |
|
|
|
); |
|
|
|
|
|
|
|
if (isEnlarged) { |
|
|
|
return ( |
|
|
|
<Modal |
|
|
|
open |
|
|
|
onCancel={() => { |
|
|
|
persistCurrentComposerConfig(); |
|
|
|
setIsEnlarged(false); |
|
|
|
}} |
|
|
|
footer={null} |
|
|
|
width={880} |
|
|
|
centered |
|
|
|
mask={{ closable: true }} |
|
|
|
destroyOnHidden |
|
|
|
getContainer={() => document.body} |
|
|
|
title={null} |
|
|
|
closable |
|
|
|
closeIcon={ |
|
|
|
<span title={t("composer.shrink")} aria-label={t("composer.shrink")}> |
|
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"> |
|
|
|
<path d="M9 9 4 4m0 0v5m0-5h5M15 15l5 5m0 0v-5m0 5h-5" /> |
|
|
|
</svg> |
|
|
|
</span> |
|
|
|
} |
|
|
|
styles={{ body: { padding: 0 } }} |
|
|
|
className="composer-enlarged-modal" |
|
|
|
> |
|
|
|
<div className="flex flex-col">{composerBody}</div> |
|
|
|
</Modal> |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
return ( |
|
|
|
<ComposerFrame> |
|
|
|
<section |
|
|
|
data-testid="node-inline-composer-panel" |
|
|
|
className="nodrag nopan nowheel pointer-events-auto relative flex max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-2)] shadow-[var(--shadow-panel)] backdrop-blur" |
|
|
|
style={{ width: COMPOSER_WIDTH }} |
|
|
|
onPointerDown={(event) => event.stopPropagation()} |
|
|
|
onMouseDown={(event) => event.stopPropagation()} |
|
|
|
onClick={(event) => event.stopPropagation()} |
|
|
|
> |
|
|
|
<div className="absolute right-3 top-3 z-10 flex items-center gap-1 text-[var(--text-muted)]"> |
|
|
|
<IconButton |
|
|
|
label={t("composer.enlarge")} |
|
|
|
onClick={() => { |
|
|
|
persistCurrentComposerConfig(); |
|
|
|
setIsEnlarged(true); |
|
|
|
}} |
|
|
|
> |
|
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"> |
|
|
|
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" /> |
|
|
|
</svg> |
|
|
|
</IconButton> |
|
|
|
</div> |
|
|
|
|
|
|
|
{composerBody} |
|
|
|
</section> |
|
|
|
</ComposerFrame> |
|
|
|
); |
|
|
|
} |
|
|
|
|