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.
 
 

1452 lines
58 KiB

"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type DragEvent as ReactDragEvent,
type ReactNode,
} from "react";
import { useReactFlow } from "@xyflow/react";
import { Modal } from "antd";
import { useI18n } from "@/i18n";
import { useWorkflowStore } from "@/store/workflowStore";
import { useModelStore, type PopiModelDetail } from "@/store/modelStore";
import { useGenerationPreferenceStore } from "@/store/generationPreferenceStore";
import { getNodeOutputMediaType } from "@/utils/nodeHandles";
import { getGenerationNodeSizeForAspectRatio } from "@/utils/nodeDimensions";
import {
deriveComposerContext,
deriveComposerContextForNodeId,
type ComposerCapability,
type ComposerContext,
} from "@/utils/composerNodeDescriptor";
import {
buildInitialGenerationNodeData,
buildSubmittableGenerationConfig,
filterTaskRoutingParameters,
readAudioGenerationConfig,
readImageGenerationConfig,
readVideoGenerationConfig,
type GenerationNodeConfig,
type PopiserverTaskPayloadMedia,
} from "@/utils/generationConfig";
import {
buildComposerReferenceMaterials,
getReferenceMaterialKey,
orderReferenceMaterials,
readReferenceSubjectList,
syncPromptReferenceMaterials,
type ComposerReferenceInput,
type ComposerReferenceMaterial,
type ComposerReferenceMaterialType,
} from "@/utils/composerReferenceSubjects";
import {
buildPopiserverPriceQuoteRequest,
getQuotedPointAmount,
type PopiserverPriceQuoteResponse,
} from "@/utils/composerPricing";
import {
buildComposerPricingContext,
buildFinalComposerPrompt,
} from "@/utils/composerPricingContext";
import { selectedModelCapabilities } from "@/utils/selectedModel";
import {
ComposerPromptMaterials,
type ComposerConnectedTextCard,
} from "@/components/composer/ComposerPromptMaterials";
import { ComposerModelConfig } from "@/components/composer/ComposerModelConfig";
import { ComposerProcessBody } from "@/components/composer/ComposerProcessBody";
import type { ComposerMentionReferenceItem } from "@/components/composer/ComposerMentionInput";
import type {
EaseCurveNodeData,
GenerateAudioNodeData,
GenerateVideoNodeData,
ImageInputNodeData,
LLMGenerateNodeData,
LLMModelType,
NanoBananaNodeData,
NodeType,
SelectedModel,
SmartTextNodeData,
VideoStitchNodeData,
WorkflowEdge,
WorkflowNode,
WorkflowNodeData,
} from "@/types";
import type { ModelParameter } from "@/lib/providers/types";
import type { ConnectedInputs } from "@/store/utils/connectedInputs";
type ComposerDraft = GenerationNodeConfig;
export type ComposerGenerationConfig = Omit<GenerationNodeConfig, "prompt">;
type ComposerConfig = ComposerGenerationConfig;
type ComposerPreferenceOptions = {
nodeSize?: { width: number; height: number } | null;
};
type PricingInputsSnapshot = {
config: ComposerConfig;
prompt: string;
activeModelDetail: PopiModelDetail | undefined;
modelSchema: ModelParameter[];
extensionFields?: unknown;
connectedInputs: ConnectedInputs | null;
connectedVideoDurationSeconds: number;
popiserverPricingMedia: PopiserverTaskPayloadMedia;
isHidden: boolean;
};
const COMPOSER_WIDTH = 630;
const PROMPT_LINE_HEIGHT_PX = 24;
const PROMPT_MIN_HEIGHT_PX = PROMPT_LINE_HEIGHT_PX * 2;
const PROMPT_MAX_HEIGHT_PX = PROMPT_LINE_HEIGHT_PX * 4;
const EMPTY_EXTRA_TASK_PARAMS: Record<string, unknown> = {};
const EMPTY_SELECTED_MODEL: SelectedModel = {
provider: "popiserver",
modelId: "",
displayName: "",
capabilities: [],
};
const DEFAULT_PROMPT_TEXT_MODEL: SelectedModel = {
provider: "popiserver",
modelId: "",
displayName: "",
capabilities: ["text-to-text"],
};
function getComposerModelPopupContainer(): HTMLElement {
return document.body;
}
function finiteNumberFromUnknown(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number.parseFloat(value.trim());
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function getConnectedTextItems(inputs: { text: string | null; textItems: string[] } | null | undefined): string[] {
if (!inputs) return [];
const items = inputs.textItems.length > 0 ? inputs.textItems : inputs.text ? [inputs.text] : [];
return items.map((item) => item.trim()).filter(Boolean);
}
function hasAnyCapability(model: SelectedModel, capabilities: string[]): boolean {
return Boolean(selectedModelCapabilities(model).some((capability) => capabilities.includes(capability)));
}
function modelSupportsCapability(model: SelectedModel, capability: ComposerCapability): boolean {
if (capability === "all") return true;
if (capability === "image") return hasAnyCapability(model, ["text-to-image", "image-to-image"]);
if (capability === "video") return hasAnyCapability(model, ["text-to-video", "image-to-video", "video-to-video", "audio-to-video"]);
if (capability === "3d") return hasAnyCapability(model, ["text-to-3d", "image-to-3d"]);
if (capability === "llm") return hasAnyCapability(model, ["text-to-text"]);
return hasAnyCapability(model, ["text-to-audio"]);
}
function storedModelFitsCapability(model: SelectedModel, capability: ComposerCapability): boolean {
if (capability === "all") return true;
if (selectedModelCapabilities(model).length === 0) return true;
return modelSupportsCapability(model, capability);
}
function inferCapabilityFromModel(model: SelectedModel): Exclude<ComposerCapability, "all"> | null {
if (modelSupportsCapability(model, "video")) return "video";
if (modelSupportsCapability(model, "3d")) return "3d";
if (modelSupportsCapability(model, "audio")) return "audio";
if (modelSupportsCapability(model, "image")) return "image";
if (modelSupportsCapability(model, "llm")) return "llm";
return null;
}
function inferNodeTypeFromModel(model: SelectedModel): NodeType | null {
const capability = inferCapabilityFromModel(model);
if (capability === "video") return "smartVideo";
if (capability === "3d") return "generate3d";
if (capability === "audio") return "smartAudio";
if (capability === "image") return "smartImage";
if (capability === "llm") return "smartText";
return null;
}
function preferenceTypeForCapability(capability: ComposerCapability): "image" | "video" | "audio" | null {
if (capability === "image") return "image";
if (capability === "video") return "video";
if (capability === "audio") return "audio";
return null;
}
function formatPointAmount(amount: number): string {
if (Number.isInteger(amount)) return String(amount);
return amount.toFixed(2).replace(/\.?0+$/, "");
}
function createEmptyDraft(): ComposerDraft {
return {
prompt: "",
selectedModel: EMPTY_SELECTED_MODEL,
inputImages: [],
batchSize: 1,
audioVoiceId: undefined,
referenceSubjectList: [],
parameters: {},
extraTaskParams: {},
};
}
function configFromDraft(draft: ComposerDraft): ComposerConfig {
const { prompt: _prompt, ...config } = draft;
return config;
}
function buildComposerGenerationConfig(config: ComposerConfig, prompt: string): GenerationNodeConfig {
return {
...config,
prompt,
};
}
function referenceSubjectsToMaterials(subjects: ComposerConfig["referenceSubjectList"]): ComposerReferenceMaterial[] {
return readReferenceSubjectList(subjects).map((subject, index) => ({
id: index + 1,
type: subject.type,
url: subject.url,
name: subject.name.startsWith("@") ? subject.name : `@${subject.name}`,
sourceNodeId: subject.id,
...(subject.thumbnailUrl ? { thumbnailUrl: subject.thumbnailUrl } : {}),
...(subject.duration ? { duration: subject.duration } : {}),
}));
}
function createPromptDraft(node: WorkflowNode): ComposerDraft {
const data = node.data as { prompt?: unknown };
return {
...createEmptyDraft(),
prompt: typeof data.prompt === "string" ? data.prompt : "",
selectedModel: DEFAULT_PROMPT_TEXT_MODEL,
};
}
function createSelectedModelFromLlmData(data: LLMGenerateNodeData): SelectedModel {
if (data.selectedModel?.provider === "popiserver") return data.selectedModel;
const model = data.model || "";
return {
provider: "popiserver",
modelId: model,
displayName: model,
capabilities: ["text-to-text"],
};
}
function readDraftFromContext(context: ComposerContext): ComposerDraft {
if (context.mode === "empty-create") return createEmptyDraft();
if (context.mode === "unsupported-node" && context.node?.type === "prompt") return createPromptDraft(context.node);
if (!context.node) return createEmptyDraft();
if (context.node.type === "smartImage" || context.node.type === "nanoBanana") {
return readImageGenerationConfig(context.node.data as NanoBananaNodeData, EMPTY_SELECTED_MODEL);
}
if (context.node.type === "smartVideo" || context.node.type === "generateVideo") {
return readVideoGenerationConfig(context.node.data as GenerateVideoNodeData, EMPTY_SELECTED_MODEL);
}
if (context.node.type === "smartAudio" || context.node.type === "generateAudio") {
return readAudioGenerationConfig(context.node.data as GenerateAudioNodeData, EMPTY_SELECTED_MODEL);
}
if (context.node.type === "llmGenerate" || context.node.type === "smartText") {
const data = context.node.data as LLMGenerateNodeData;
return {
...createEmptyDraft(),
prompt: data.inputPrompt ?? "",
selectedModel: createSelectedModelFromLlmData(data),
parameters: {},
};
}
return createEmptyDraft();
}
function contextIdentity(context: ComposerContext): string {
return `${context.mode}:${context.node?.id ?? "none"}:${context.nodeType ?? "none"}:${context.selectedCount}`;
}
function getConcreteGenerationNodeType(nodeType: NodeType | null): NodeType | null {
if (nodeType === "smartImage") return "nanoBanana";
if (nodeType === "smartVideo") return "generateVideo";
if (nodeType === "smartAudio") return "generateAudio";
return null;
}
function buildInitialDataForNode(nodeType: NodeType, draft: ComposerDraft): Partial<WorkflowNodeData> {
if (
nodeType === "smartImage" ||
nodeType === "smartVideo" ||
nodeType === "smartAudio" ||
nodeType === "nanoBanana" ||
nodeType === "generateVideo" ||
nodeType === "generate3d" ||
nodeType === "generateAudio"
) {
return buildInitialGenerationNodeData(nodeType, draft);
}
if (nodeType === "llmGenerate" || nodeType === "smartText") {
return {
...(nodeType === "smartText" ? { manualLocked: false, manualText: "" } : {}),
inputPrompt: draft.prompt,
inputImages: [],
inputVideos: [],
outputText: null,
provider: "popiserver",
model: draft.selectedModel.modelId as LLMModelType,
selectedModel: {
...draft.selectedModel,
capabilities: draft.selectedModel.capabilities ?? ["text-to-text"],
},
status: "idle",
error: null,
} as Partial<LLMGenerateNodeData | SmartTextNodeData>;
}
return {};
}
function readWorkflowNodeSize(node: WorkflowNode | null | undefined): { width: number; height: number } | null {
if (!node) return null;
const width = typeof node.measured?.width === "number"
? node.measured.width
: typeof node.style?.width === "number"
? node.style.width
: typeof node.width === "number"
? node.width
: null;
const height = typeof node.measured?.height === "number"
? node.measured.height
: typeof node.style?.height === "number"
? node.style.height
: typeof node.height === "number"
? node.height
: null;
return width && height && Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0
? { width, height }
: null;
}
function buildComposerPersistParameters(node: WorkflowNode, config: ComposerConfig): ComposerConfig["parameters"] {
void node;
return filterTaskRoutingParameters(config.parameters);
}
function buildComposerConfigPersistPatch(node: WorkflowNode, config: GenerationNodeConfig): Partial<WorkflowNodeData> {
const nodeType = node.type;
const parameters = buildComposerPersistParameters(node, config);
if (nodeType === "smartImage" || nodeType === "nanoBanana") {
return {
inputPrompt: config.prompt.trim(),
selectedModel: config.selectedModel,
batchSize: config.batchSize,
parameters,
extraTaskParams: config.extraTaskParams ?? {},
} as Partial<NanoBananaNodeData>;
}
if (nodeType === "smartVideo" || nodeType === "generateVideo") {
return {
inputPrompt: config.prompt.trim(),
selectedModel: config.selectedModel,
parameters,
extraTaskParams: config.extraTaskParams ?? {},
subType: config.subType,
} as Partial<GenerateVideoNodeData>;
}
if (nodeType === "smartAudio" || nodeType === "generateAudio") {
return {
inputPrompt: config.prompt.trim(),
selectedModel: config.selectedModel,
audioVoiceId: config.audioVoiceId,
parameters,
extraTaskParams: config.extraTaskParams ?? {},
} as Partial<GenerateAudioNodeData>;
}
if (nodeType === "llmGenerate" || nodeType === "smartText") {
return {
inputPrompt: config.prompt,
selectedModel: config.selectedModel,
model: config.selectedModel.modelId as LLMModelType,
} as Partial<LLMGenerateNodeData | SmartTextNodeData>;
}
return {};
}
function getConnectedEdgesByType(
targetNodeId: string,
nodes: WorkflowNode[],
edges: WorkflowEdge[],
type: ComposerReferenceMaterialType
): Array<{ id: string; sourceNodeId: string }> {
return edges
.filter((edge) => {
if (edge.target !== targetNodeId) return false;
const sourceNode = nodes.find((node) => node.id === edge.source);
return sourceNode ? getNodeOutputMediaType(sourceNode) === type : false;
})
.map((edge) => ({ id: edge.id, sourceNodeId: edge.source }));
}
function getConnectedEdgeIdsByType(
targetNodeId: string,
nodes: WorkflowNode[],
edges: WorkflowEdge[],
type: ComposerReferenceMaterialType | "text"
): string[] {
return edges
.filter((edge) => {
if (edge.target !== targetNodeId) return false;
const sourceNode = nodes.find((node) => node.id === edge.source);
return sourceNode ? getNodeOutputMediaType(sourceNode) === type : false;
})
.map((edge) => edge.id);
}
function getReferenceImageThumbnailUrl(sourceNode: WorkflowNode | undefined): string | undefined {
const previewImg = (sourceNode?.data as { previewImg?: unknown } | undefined)?.previewImg;
return typeof previewImg === "string" && previewImg.length > 0 ? previewImg : undefined;
}
function getReferenceVideoDurationSeconds(videos: ComposerReferenceInput[]): number {
const seenUrls = new Set<string>();
const totalDuration = videos.reduce((sum, video) => {
if (seenUrls.has(video.url)) return sum;
seenUrls.add(video.url);
const duration = finiteNumberFromUnknown(video.duration);
return duration !== null && duration > 0 ? sum + duration : sum;
}, 0);
return Math.ceil(totalDuration);
}
function uniqueReferenceVideoInputs(videos: ComposerReferenceInput[]): ComposerReferenceInput[] {
const seenUrls = new Set<string>();
return videos.filter((video) => {
if (seenUrls.has(video.url)) return false;
seenUrls.add(video.url);
return true;
});
}
function readComposerAspectRatio(config: ComposerConfig): string | undefined {
const value = config.parameters?.ratio ?? config.parameters?.aspectRatio ?? config.parameters?.aspect_ratio;
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function IconButton({ label, children, onClick }: { label: string; children: ReactNode; onClick?: () => void }) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
>
{children}
</button>
);
}
function ComposerFrame({ children }: { children: ReactNode }) {
return (
<div data-testid="node-inline-composer" data-composer-popup-root="true" className="pointer-events-none">
{children}
</div>
);
}
interface GenerationComposerProps {
targetNodeId: string;
embedded?: boolean;
isHidden?: boolean;
onHiddenPersisted?: () => void;
}
interface ComposerEditorProps {
context: ComposerContext;
contextKey: string;
initialDraft: ComposerDraft;
nodes: WorkflowNode[];
edges: WorkflowEdge[];
dimmedNodeIds: Set<string>;
isCurrentNodeRunning: boolean;
embedded: boolean;
isHidden: boolean;
onRun: (context: ComposerContext, prompt: string, config: ComposerConfig) => Promise<void>;
onCancelRunning: () => void;
onRemoveEdge: (edgeId: string) => void;
onApplyNodeSize: (nodeId: string, aspectRatio: unknown) => void;
onModelNeedsDetail: (modelId: string) => Promise<PopiModelDetail | null>;
onPersistConfig: (context: ComposerContext, config: GenerationNodeConfig) => void;
onHiddenPersisted?: () => void;
}
export function GenerationComposer({ targetNodeId, embedded = false, isHidden = false, onHiddenPersisted }: GenerationComposerProps) {
const { t } = useI18n();
const nodes = useWorkflowStore((state) => state.nodes);
const edges = useWorkflowStore((state) => state.edges);
const dimmedNodeIds = useWorkflowStore((state) => state.dimmedNodeIds);
const runningNodeIds = useWorkflowStore((state) => state.runningNodeIds);
const addNode = useWorkflowStore((state) => state.addNode);
const convertNodeType = useWorkflowStore((state) => state.convertNodeType);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const cancelNodeTask = useWorkflowStore((state) => state.cancelNodeTask);
const removeEdge = useWorkflowStore((state) => state.removeEdge);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const onNodesChange = useWorkflowStore((state) => state.onNodesChange);
const fetchModelDetail = useModelStore((state) => state.fetchModelDetail);
const updateImagePreference = useGenerationPreferenceStore((state) => state.updateImagePreference);
const updateVideoPreference = useGenerationPreferenceStore((state) => state.updateVideoPreference);
const updateAudioPreference = useGenerationPreferenceStore((state) => state.updateAudioPreference);
const { screenToFlowPosition } = useReactFlow();
const context = useMemo(
() => {
const selectedNodes = nodes.filter((node) => node.selected);
if (!isHidden && selectedNodes.length > 1) {
return deriveComposerContext(nodes, edges);
}
const targetNode = nodes.find((node) => node.id === targetNodeId);
const selectedNode = selectedNodes[0];
const effectiveTargetNodeId = !isHidden && selectedNodes.length === 1 && selectedNode && selectedNode.id !== targetNodeId
? selectedNode.id
: targetNode?.id;
return effectiveTargetNodeId
? deriveComposerContextForNodeId(nodes, edges, effectiveTargetNodeId)
: deriveComposerContextForNodeId(
nodes,
edges,
selectedNode?.id ?? targetNodeId
);
},
[edges, isHidden, nodes, targetNodeId]
);
const contextKey = contextIdentity(context);
const initialConfig = useMemo(() => readDraftFromContext(context), [contextKey, context]);
const activeModelId = initialConfig.selectedModel.modelId;
const activeComposerNodeId =
(context.mode === "node-edit" || context.mode === "process-node" || context.mode === "unsupported-node")
? context.node?.id ?? null
: null;
const isCurrentNodeRunning = activeComposerNodeId ? runningNodeIds.has(activeComposerNodeId) : false;
useEffect(() => {
if (isHidden || !embedded || !activeModelId) return;
void fetchModelDetail(activeModelId);
}, [activeModelId, embedded, fetchModelDetail, isHidden]);
const applyNodeSize = useCallback((nodeId: string, aspectRatioValue: unknown) => {
const nextSize = getGenerationNodeSizeForAspectRatio(aspectRatioValue);
if (!nextSize) return;
onNodesChange([{ id: nodeId, type: "dimensions", dimensions: nextSize, setAttributes: true }]);
}, [onNodesChange]);
const buildTargetPosition = useCallback((size?: { width: number; height: number }) => {
const width = size?.width ?? 300;
const height = size?.height ?? 280;
return screenToFlowPosition({
x: window.innerWidth / 2 - width / 2,
y: window.innerHeight / 2 - height / 2,
});
}, [screenToFlowPosition]);
const rememberGenerationPreference = useCallback((
capability: ComposerCapability,
nextConfig: ComposerConfig,
options: ComposerPreferenceOptions = {}
) => {
const preferenceType = preferenceTypeForCapability(capability);
const patch = {
...nextConfig,
model: nextConfig.selectedModel,
...(options.nodeSize ? { nodeSize: options.nodeSize } : {}),
};
if (preferenceType === "image") {
updateImagePreference(patch);
return;
}
if (preferenceType === "video") {
updateVideoPreference(patch);
return;
}
if (preferenceType === "audio") {
updateAudioPreference(patch);
}
}, [updateAudioPreference, updateImagePreference, updateVideoPreference]);
const runComposer = useCallback(async (runContext: ComposerContext, prompt: string, config: ComposerConfig) => {
const generationConfig = buildComposerGenerationConfig(config, prompt);
let submitNode = runContext.node;
if (runContext.mode === "node-edit" && runContext.node) {
submitNode = useWorkflowStore.getState().nodes.find((node) => node.id === runContext.node?.id) ?? runContext.node;
}
const connectedInputs =
(runContext.mode === "node-edit" || runContext.mode === "process-node") && submitNode
? useWorkflowStore.getState().getConnectedInputs(submitNode.id)
: null;
const finalPrompt = buildFinalComposerPrompt(prompt, connectedInputs);
const submitNodeType = runContext.mode === "empty-create" ? inferNodeTypeFromModel(config.selectedModel) : runContext.nodeType;
const submitModelDetail = generationConfig.selectedModel.modelId ? await fetchModelDetail(generationConfig.selectedModel.modelId) : null;
const submitConfig = buildSubmittableGenerationConfig({
...generationConfig,
prompt: finalPrompt,
}, submitModelDetail);
const submitCapability = runContext.mode === "empty-create"
? inferCapabilityFromModel(submitConfig.selectedModel)
: runContext.capability;
const submitAspectRatio = readComposerAspectRatio(submitConfig);
const submitNodeSize = getGenerationNodeSizeForAspectRatio(submitAspectRatio) ?? readWorkflowNodeSize(submitNode);
if (submitCapability) rememberGenerationPreference(submitCapability, submitConfig, { nodeSize: submitNodeSize });
if (runContext.mode === "unsupported-node" && runContext.node?.type === "imageInput") {
const imageData = runContext.node.data as ImageInputNodeData;
if (!imageData.image) return;
const selectedModel = modelSupportsCapability(submitConfig.selectedModel, "image")
? submitConfig.selectedModel
: EMPTY_SELECTED_MODEL;
if (!selectedModel.modelId) return;
const nextNodeId = addNode(
"smartImage",
buildTargetPosition(submitNodeSize ?? undefined),
buildInitialDataForNode("smartImage", {
...submitConfig,
selectedModel,
inputImages: [imageData.image],
}),
submitNodeSize ?? undefined
);
selectSingleNode(nextNodeId);
await regenerateNode(nextNodeId);
return;
}
if (runContext.mode === "empty-create") {
if (!submitNodeType) return;
const nextNodeId = addNode(
submitNodeType,
buildTargetPosition(submitNodeSize ?? undefined),
buildInitialDataForNode(submitNodeType, submitConfig),
submitNodeSize ?? undefined
);
selectSingleNode(nextNodeId);
await regenerateNode(nextNodeId);
return;
}
if (runContext.mode === "node-edit" && submitNode) {
const concreteNodeType = getConcreteGenerationNodeType(submitNode.type);
if (concreteNodeType) {
convertNodeType(submitNode.id, concreteNodeType, {
...buildInitialDataForNode(concreteNodeType, submitConfig),
inputPrompt: prompt.trim(),
referenceSubjectList: generationConfig.referenceSubjectList,
});
applyNodeSize(submitNode.id, submitAspectRatio);
await regenerateNode(submitNode.id);
return;
}
updateNodeData(submitNode.id, {
...buildInitialDataForNode(submitNode.type, submitConfig),
inputPrompt: prompt.trim(),
referenceSubjectList: generationConfig.referenceSubjectList,
});
await regenerateNode(submitNode.id);
return;
}
if (
runContext.mode === "process-node" &&
(runContext.node?.type === "videoStitch" || runContext.node?.type === "easeCurve")
) {
await regenerateNode(runContext.node.id);
}
}, [
addNode,
applyNodeSize,
buildTargetPosition,
convertNodeType,
fetchModelDetail,
rememberGenerationPreference,
regenerateNode,
selectSingleNode,
updateNodeData,
]);
const persistComposerConfig = useCallback((persistContext: ComposerContext, config: GenerationNodeConfig) => {
const currentNode = persistContext.node
? useWorkflowStore.getState().nodes.find((node) => node.id === persistContext.node?.id) ?? persistContext.node
: null;
rememberGenerationPreference(persistContext.capability, config, { nodeSize: readWorkflowNodeSize(currentNode) });
if (!currentNode || persistContext.mode !== "node-edit") return;
const patch = buildComposerConfigPersistPatch(currentNode, config);
if (Object.keys(patch).length === 0) return;
updateNodeData(currentNode.id, patch);
}, [rememberGenerationPreference, updateNodeData]);
const cancelRunning = useCallback(() => {
if (!activeComposerNodeId) return;
Modal.confirm({
title: t("composer.cancelTaskTitle"),
content: t("composer.cancelTaskConfirm"),
okText: t("composer.cancelTaskOk"),
cancelText: t("composer.cancelTaskContinue"),
okButtonProps: { danger: true },
onOk: () => cancelNodeTask(activeComposerNodeId),
});
}, [activeComposerNodeId, cancelNodeTask, t]);
if (context.mode === "multi-select" || (context.mode === "unsupported-node" && !context.node)) {
return null;
}
return (
<ComposerEditor
context={context}
contextKey={contextKey}
initialDraft={initialConfig}
nodes={nodes}
edges={edges}
dimmedNodeIds={dimmedNodeIds}
isCurrentNodeRunning={isCurrentNodeRunning}
embedded={embedded}
isHidden={isHidden}
onRun={runComposer}
onCancelRunning={cancelRunning}
onRemoveEdge={removeEdge}
onApplyNodeSize={applyNodeSize}
onModelNeedsDetail={(modelId) => fetchModelDetail(modelId)}
onPersistConfig={persistComposerConfig}
onHiddenPersisted={onHiddenPersisted}
/>
);
}
function ComposerEditor({
context,
contextKey,
initialDraft,
nodes,
edges,
dimmedNodeIds,
isCurrentNodeRunning,
embedded,
isHidden,
onRun,
onCancelRunning,
onRemoveEdge,
onApplyNodeSize,
onModelNeedsDetail,
onPersistConfig,
onHiddenPersisted,
}: ComposerEditorProps) {
void dimmedNodeIds;
void embedded;
const { t } = useI18n();
const promptInputRef = useRef<HTMLDivElement>(null);
const referenceAudioPreviewRef = useRef<HTMLAudioElement | null>(null);
const referenceMaterialsRef = useRef<ComposerReferenceMaterial[]>([]);
const [config, setConfig] = useState<ComposerConfig>(() => configFromDraft(initialDraft));
const [prompt, setPrompt] = useState(initialDraft.prompt);
const [isComposerCollapsed, setIsComposerCollapsed] = useState(false);
const [playingReferenceAudioKey, setPlayingReferenceAudioKey] = useState<string | null>(null);
const [priceState, setPriceState] = useState<{ amount: number | null; isLoading: boolean }>({
amount: null,
isLoading: false,
});
const priceRequestIdRef = useRef(0);
const suppressAutoPriceRefreshRef = useRef(false);
const modelChangeCompletionTimerRef = useRef<number | null>(null);
const priceRefreshTimerRef = useRef<number | null>(null);
const pricingInputsRef = useRef<PricingInputsSnapshot>({
config,
prompt,
activeModelDetail: undefined,
modelSchema: [],
extensionFields: undefined,
connectedInputs: null,
connectedVideoDurationSeconds: 0,
popiserverPricingMedia: {},
isHidden,
});
const reorderNodeReferenceSubjectList = useWorkflowStore((state) => state.reorderNodeReferenceSubjectList);
const updateNodeExtraTaskParams = useWorkflowStore((state) => state.updateNodeExtraTaskParams);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const buildGenerationConfigForContext = useCallback((
targetContext: ComposerContext,
nextConfig: ComposerConfig = config,
nextPrompt: string = prompt
) => {
if (targetContext.mode !== "node-edit" || !targetContext.node) {
return buildComposerGenerationConfig(nextConfig, nextPrompt);
}
const latestNode = useWorkflowStore.getState().nodes.find((node) => node.id === targetContext.node?.id);
const latestSubjects = latestNode
? readReferenceSubjectList((latestNode.data as { referenceSubjectList?: unknown }).referenceSubjectList)
: readReferenceSubjectList((targetContext.node.data as { referenceSubjectList?: unknown }).referenceSubjectList);
return buildComposerGenerationConfig({
...nextConfig,
referenceSubjectList: latestSubjects,
}, nextPrompt);
}, [config, prompt]);
const persistCurrentComposerConfig = useCallback(() => {
onPersistConfig(context, buildGenerationConfigForContext(context));
}, [buildGenerationConfigForContext, context, onPersistConfig]);
const connectedInputs = useMemo(() => {
if ((context.mode !== "node-edit" && context.mode !== "process-node") || !context.node) return null;
return useWorkflowStore.getState().getConnectedInputs(context.node.id);
}, [context, nodes, edges]);
const connectedTextItems = useMemo(() => getConnectedTextItems(connectedInputs), [connectedInputs]);
const connectedTextCards = useMemo<ComposerConnectedTextCard[]>(() => {
if (!context.node) return connectedTextItems.map((text) => ({ text }));
const textEdgeIds = getConnectedEdgeIdsByType(context.node.id, nodes, edges, "text");
return connectedTextItems.map((text, index) => ({
text,
...(textEdgeIds[index] ? { edgeId: textEdgeIds[index] } : {}),
}));
}, [connectedTextItems, context.node, edges, nodes]);
const currentModelId = config.selectedModel.modelId;
const activeModelDetail = useModelStore((state) => currentModelId ? state.detailsById[currentModelId] : undefined);
const modelSchema = activeModelDetail?.parameters ?? [];
const extensionFields = activeModelDetail?.extensionFields;
const activeCapability = context.mode === "empty-create"
? inferCapabilityFromModel(config.selectedModel) ?? "image"
: context.capability;
const isProcessNode = context.mode === "process-node" && (context.node?.type === "videoStitch" || context.node?.type === "easeCurve");
const modelIsValid = context.mode === "empty-create"
? Boolean(inferNodeTypeFromModel(config.selectedModel))
: storedModelFitsCapability(config.selectedModel, context.capability);
const canChooseModel = context.mode === "empty-create" || context.mode === "node-edit";
const connectedReferenceImages = context.mode === "node-edit" ? connectedInputs?.images ?? [] : [];
const connectedReferenceAudio = context.mode === "node-edit" ? connectedInputs?.audio ?? [] : [];
const referenceImages = connectedReferenceImages.length > 0 ? connectedReferenceImages : config.inputImages;
const estimateReferenceVideoInputs = useMemo<ComposerReferenceInput[]>(() => {
const videoItems = context.mode === "node-edit" ? connectedInputs?.videoItems ?? [] : [];
const videoEdges =
context.mode === "node-edit" && context.node
? getConnectedEdgesByType(context.node.id, nodes, edges, "video")
: [];
return uniqueReferenceVideoInputs(videoItems.map((video, index) => ({
url: video.url,
...(video.duration && video.duration > 0 ? { duration: video.duration } : {}),
...(videoEdges[index]?.id ? { edgeId: videoEdges[index].id } : {}),
...(videoEdges[index]?.sourceNodeId ? { sourceNodeId: videoEdges[index].sourceNodeId } : {}),
})));
}, [connectedInputs?.videoItems, context.mode, context.node, edges, nodes]);
const referenceImageInputs = useMemo<ComposerReferenceInput[]>(() => {
const imageEdges =
context.mode === "node-edit" && context.node && connectedReferenceImages.length > 0
? getConnectedEdgesByType(context.node.id, nodes, edges, "image")
: [];
return referenceImages.map((url, index) => {
const edge = imageEdges[index];
const sourceNodeId = edge?.sourceNodeId;
const thumbnailUrl = sourceNodeId
? getReferenceImageThumbnailUrl(nodes.find((node) => node.id === sourceNodeId))
: undefined;
return {
url,
...(thumbnailUrl ? { thumbnailUrl } : {}),
...(edge?.id ? { edgeId: edge.id } : {}),
...(sourceNodeId ? { sourceNodeId } : {}),
};
});
}, [connectedReferenceImages.length, context.mode, context.node, edges, nodes, referenceImages]);
const referenceAudioInputs = useMemo<ComposerReferenceInput[]>(() => {
const audioEdges =
context.mode === "node-edit" && context.node
? getConnectedEdgesByType(context.node.id, nodes, edges, "audio")
: [];
return connectedReferenceAudio.map((url, index) => ({
url,
...(audioEdges[index]?.id ? { edgeId: audioEdges[index].id } : {}),
...(audioEdges[index]?.sourceNodeId ? { sourceNodeId: audioEdges[index].sourceNodeId } : {}),
}));
}, [connectedReferenceAudio, context.mode, context.node, edges, nodes]);
const baseReferenceMaterials = useMemo(() => buildComposerReferenceMaterials(
referenceImageInputs,
estimateReferenceVideoInputs,
referenceAudioInputs,
), [estimateReferenceVideoInputs, referenceAudioInputs, referenceImageInputs]);
const liveReferenceSubjectOrder = useMemo(() => {
if (context.mode !== "node-edit" || !context.node) return [];
const nodeData = context.node.data as { referenceSubjectList?: unknown };
return readReferenceSubjectList(nodeData.referenceSubjectList).map((item) => item.id);
}, [context.mode, context.node]);
const liveReferenceSubjectOrderKey = useMemo(
() => liveReferenceSubjectOrder.join("|"),
[liveReferenceSubjectOrder]
);
const referenceMaterials = useMemo(
() => orderReferenceMaterials(baseReferenceMaterials, liveReferenceSubjectOrder),
[baseReferenceMaterials, liveReferenceSubjectOrderKey]
);
const referenceMaterialsKey = useMemo(
() => referenceMaterials
.map((material) => `${getReferenceMaterialKey(material)}:${material.type}:${material.url}:${material.name}:${material.thumbnailUrl ?? ""}:${material.duration ?? ""}`)
.join("|"),
[referenceMaterials]
);
useEffect(() => {
const previousMaterials = referenceMaterialsRef.current.length > 0
? referenceMaterialsRef.current
: referenceSubjectsToMaterials(config.referenceSubjectList);
referenceMaterialsRef.current = referenceMaterials;
const nextPrompt = syncPromptReferenceMaterials(prompt, previousMaterials, referenceMaterials);
if (nextPrompt === prompt) return;
setPrompt(nextPrompt);
}, [config.referenceSubjectList, prompt, referenceMaterialsKey, referenceMaterials]);
const connectedVideoDurationSeconds = useMemo(
() => getReferenceVideoDurationSeconds(estimateReferenceVideoInputs),
[estimateReferenceVideoInputs]
);
const popiserverPricingMedia = useMemo(() => ({
images: referenceImages,
videos: estimateReferenceVideoInputs.map((input) => input.url),
voices: referenceAudioInputs.map((input) => input.url),
}), [estimateReferenceVideoInputs, referenceAudioInputs, referenceImages]);
pricingInputsRef.current = {
config,
prompt,
activeModelDetail,
modelSchema,
extensionFields,
connectedInputs,
connectedVideoDurationSeconds,
popiserverPricingMedia,
isHidden,
};
const refreshPrice = useCallback(async (
nextConfig: ComposerConfig,
nextPrompt = prompt,
detailOverride?: PopiModelDetail | null,
inputs: PricingInputsSnapshot = pricingInputsRef.current
) => {
const requestId = priceRequestIdRef.current + 1;
priceRequestIdRef.current = requestId;
if (!nextConfig.selectedModel.modelId) {
setPriceState({ amount: null, isLoading: false });
return;
}
setPriceState({ amount: null, isLoading: true });
const detail = detailOverride !== undefined
? detailOverride
: await onModelNeedsDetail(nextConfig.selectedModel.modelId);
if (priceRequestIdRef.current !== requestId) return;
const generationConfig = buildComposerGenerationConfig(nextConfig, nextPrompt);
const normalizedConfig = buildSubmittableGenerationConfig(
generationConfig,
detail ?? inputs.activeModelDetail ?? { parameters: inputs.modelSchema, extensionFields: inputs.extensionFields }
);
const quoteRequest = buildPopiserverPriceQuoteRequest(buildComposerPricingContext({
config: normalizedConfig,
prompt: nextPrompt,
capability: context.capability,
connectedInputs: inputs.connectedInputs,
modelDetail: detail,
media: inputs.popiserverPricingMedia,
inputVideoDurationSeconds: inputs.connectedVideoDurationSeconds,
}));
if (!quoteRequest) {
setPriceState({ amount: null, isLoading: false });
return;
}
try {
const response = await fetch("/api/points/quote", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(quoteRequest),
});
const data = response.ok ? await response.json() as PopiserverPriceQuoteResponse : null;
const amount = data ? getQuotedPointAmount(data) : null;
if (priceRequestIdRef.current === requestId) {
setPriceState({ amount, isLoading: false });
}
} catch {
if (priceRequestIdRef.current === requestId) setPriceState({ amount: null, isLoading: false });
}
}, [
context.capability,
context.mode,
context.node,
onModelNeedsDetail,
prompt,
]);
const handleConfigChange = useCallback((config: ComposerConfig) => {
setConfig(config);
onPersistConfig(context, buildGenerationConfigForContext(context, config, prompt));
}, [buildGenerationConfigForContext, context, onPersistConfig, prompt]);
const handleExtraTaskParamsChange = useCallback((values: Record<string, unknown>) => {
if (context.node) updateNodeExtraTaskParams(context.node.id, values);
const nextConfig = {
...config,
extraTaskParams: values,
};
setConfig(nextConfig);
}, [config, context.node, updateNodeExtraTaskParams]);
const schedulePriceRefresh = useCallback(() => {
if (priceRefreshTimerRef.current !== null) {
window.clearTimeout(priceRefreshTimerRef.current);
}
priceRefreshTimerRef.current = window.setTimeout(() => {
priceRefreshTimerRef.current = null;
const latest = pricingInputsRef.current;
if (latest.isHidden) return;
if (suppressAutoPriceRefreshRef.current) return;
if (!latest.config.selectedModel.modelId) return;
void refreshPrice(latest.config, latest.prompt, latest.activeModelDetail, latest);
}, 200);
}, [refreshPrice]);
const clearScheduledPriceRefresh = useCallback(() => {
if (priceRefreshTimerRef.current === null) return;
window.clearTimeout(priceRefreshTimerRef.current);
priceRefreshTimerRef.current = null;
}, []);
useEffect(() => {
if (isHidden) {
clearScheduledPriceRefresh();
return;
}
if (suppressAutoPriceRefreshRef.current) return;
if (!config.selectedModel.modelId) return;
schedulePriceRefresh();
}, [
clearScheduledPriceRefresh,
config.audioVoiceId,
config.extraTaskParams,
config.batchSize,
config.parameters,
config.referenceSubjectList,
config.selectedModel.modelId,
connectedVideoDurationSeconds,
isHidden,
popiserverPricingMedia,
prompt,
referenceMaterialsKey,
schedulePriceRefresh,
]);
const hasPromptForSubmit = prompt.trim().length > 0 || connectedTextItems.length > 0;
const submitNodeType = context.mode === "empty-create" ? inferNodeTypeFromModel(config.selectedModel) : context.nodeType;
const hasVideoSubTypes =
activeCapability === "video" &&
Array.isArray(config.selectedModel.subTypes) &&
config.selectedModel.subTypes.length > 0;
const hasReference = referenceMaterials.length > 0;
const hasRequiredPromptOrReference =
submitNodeType === "smartAudio" || submitNodeType === "generateAudio" || submitNodeType === "llmGenerate" || submitNodeType === "smartText"
? hasPromptForSubmit
: hasPromptForSubmit || hasReference;
const videoStitchData = context.node?.type === "videoStitch" ? context.node.data as VideoStitchNodeData : null;
const easeCurveData = context.node?.type === "easeCurve" ? context.node.data as EaseCurveNodeData : null;
const canRunVideoStitch = Boolean(videoStitchData && !isCurrentNodeRunning && videoStitchData.encoderSupported !== false && (connectedInputs?.videos.length ?? 0) >= 2);
const canRunEaseCurve = Boolean(easeCurveData && !isCurrentNodeRunning && easeCurveData.encoderSupported !== false && (connectedInputs?.videos.length ?? 0) >= 1);
const canSubmit =
canRunVideoStitch ||
canRunEaseCurve ||
(!isCurrentNodeRunning && (context.mode === "empty-create" || context.mode === "node-edit") && hasRequiredPromptOrReference && modelIsValid) ||
(context.mode === "unsupported-node" && context.node?.type === "imageInput" && modelSupportsCapability(config.selectedModel, "image"));
const canClickSubmit = isCurrentNodeRunning || canSubmit;
const isPriceLoading = priceState.isLoading;
const dynamicEstimatedPointAmount = priceState.amount;
const modelPointCostLabel = dynamicEstimatedPointAmount !== null
? t("composer.pointCost", { count: formatPointAmount(dynamicEstimatedPointAmount) })
: null;
const submitButtonLabel = isCurrentNodeRunning ? "Cancel task" : t("composer.generate");
const priceLabel = modelPointCostLabel ?? (isPriceLoading ? "..." : null);
const hasComposerMediaHeader =
isProcessNode || connectedTextItems.length > 0 || referenceMaterials.length > 0 || hasVideoSubTypes;
const nodeData = context.node?.data as (NanoBananaNodeData | GenerateVideoNodeData | GenerateAudioNodeData | VideoStitchNodeData | EaseCurveNodeData | LLMGenerateNodeData | SmartTextNodeData | undefined);
const errorMessage = nodeData?.status === "error" ? nodeData.error : null;
const stopReferenceAudioPreview = useCallback(() => {
const audio = referenceAudioPreviewRef.current;
if (audio) {
audio.pause();
audio.currentTime = 0;
audio.onended = null;
referenceAudioPreviewRef.current = null;
}
setPlayingReferenceAudioKey(null);
}, []);
const previousContextRef = useRef<{ key: string; context: ComposerContext } | null>({
key: contextKey,
context,
});
useEffect(() => {
if (isHidden) return;
const previous = previousContextRef.current;
if (!previous) {
previousContextRef.current = { key: contextKey, context };
return;
}
if (previous.key === contextKey) {
previousContextRef.current = { key: contextKey, context };
return;
}
onPersistConfig(previous.context, buildGenerationConfigForContext(previous.context));
setConfig(configFromDraft(initialDraft));
setPrompt(initialDraft.prompt);
referenceMaterialsRef.current = [];
previousContextRef.current = { key: contextKey, context };
// The current local draft belongs to the previous composer identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contextKey]);
useEffect(() => {
if (isHidden) {
persistCurrentComposerConfig();
stopReferenceAudioPreview();
onHiddenPersisted?.();
return;
}
setConfig(configFromDraft(initialDraft));
setPrompt(initialDraft.prompt);
referenceMaterialsRef.current = [];
previousContextRef.current = { key: contextKey, context };
// Hidden composer state is discarded; reopening hydrates from the store.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isHidden]);
useEffect(() => () => stopReferenceAudioPreview(), [stopReferenceAudioPreview]);
const handleReferenceAudioPreviewToggle = useCallback((material: ComposerReferenceMaterial) => {
const materialKey = getReferenceMaterialKey(material);
if (playingReferenceAudioKey === materialKey) {
stopReferenceAudioPreview();
return;
}
stopReferenceAudioPreview();
const audio = new Audio(material.url);
audio.onended = () => {
if (referenceAudioPreviewRef.current === audio) {
referenceAudioPreviewRef.current = null;
setPlayingReferenceAudioKey(null);
}
};
referenceAudioPreviewRef.current = audio;
void audio.play()
.then(() => {
if (referenceAudioPreviewRef.current === audio) setPlayingReferenceAudioKey(materialKey);
})
.catch(() => {
if (referenceAudioPreviewRef.current === audio) {
referenceAudioPreviewRef.current = null;
setPlayingReferenceAudioKey(null);
}
});
}, [playingReferenceAudioKey, stopReferenceAudioPreview]);
const handleReferenceMaterialDrop = useCallback((event: ReactDragEvent<HTMLElement>, targetMaterial: ComposerReferenceMaterial) => {
const sourceKey = event.dataTransfer.getData("application/x-popiart-reference-material");
const sourceMaterial = referenceMaterials.find((material) => getReferenceMaterialKey(material) === sourceKey);
if (!sourceMaterial || getReferenceMaterialKey(sourceMaterial) === getReferenceMaterialKey(targetMaterial)) return;
const nextMaterials = [...referenceMaterials];
const sourceIndex = nextMaterials.findIndex((material) => getReferenceMaterialKey(material) === getReferenceMaterialKey(sourceMaterial));
const targetIndex = nextMaterials.findIndex((material) => getReferenceMaterialKey(material) === getReferenceMaterialKey(targetMaterial));
if (sourceIndex < 0 || targetIndex < 0) return;
const [removed] = nextMaterials.splice(sourceIndex, 1);
nextMaterials.splice(targetIndex, 0, removed);
const orderedNextMaterials = orderReferenceMaterials(nextMaterials, nextMaterials.map(getReferenceMaterialKey));
referenceMaterialsRef.current = orderedNextMaterials;
const nextPrompt = syncPromptReferenceMaterials(prompt, referenceMaterials, orderedNextMaterials);
if (nextPrompt !== prompt) {
setPrompt(nextPrompt);
}
if (context.mode === "node-edit" && context.node) {
reorderNodeReferenceSubjectList(context.node.id, sourceKey, getReferenceMaterialKey(targetMaterial));
}
}, [context.mode, context.node, prompt, referenceMaterials, reorderNodeReferenceSubjectList]);
const handleModelChangeStart = useCallback(() => {
suppressAutoPriceRefreshRef.current = true;
}, []);
const handleModelChangeComplete = useCallback((nextConfig: ComposerConfig, detail: PopiModelDetail | null) => {
if (modelChangeCompletionTimerRef.current !== null) {
window.clearTimeout(modelChangeCompletionTimerRef.current);
}
modelChangeCompletionTimerRef.current = window.setTimeout(() => {
modelChangeCompletionTimerRef.current = null;
suppressAutoPriceRefreshRef.current = false;
const latest = pricingInputsRef.current;
pricingInputsRef.current = {
...latest,
config: nextConfig,
activeModelDetail: detail ?? latest.activeModelDetail,
};
if (latest.isHidden || !nextConfig.selectedModel.modelId) return;
schedulePriceRefresh();
}, 0);
}, [schedulePriceRefresh]);
useEffect(() => () => {
if (modelChangeCompletionTimerRef.current !== null) {
window.clearTimeout(modelChangeCompletionTimerRef.current);
}
if (priceRefreshTimerRef.current !== null) {
window.clearTimeout(priceRefreshTimerRef.current);
}
}, []);
const processBody = isProcessNode ? (
<ComposerProcessBody
node={context.node}
nodes={nodes}
edges={edges}
videoStitchData={videoStitchData}
easeCurveData={easeCurveData}
/>
) : undefined;
const mentionReferenceItems = useMemo<ComposerMentionReferenceItem[]>(() => {
if (referenceMaterials.length === 0) {
return [{
key: "canvas-assets",
label: t("composer.canvasAssets"),
value: `@${t("composer.canvasAssets")} `,
}];
}
return referenceMaterials.map((material) => ({
key: `${material.type}-${material.id}-${material.url.slice(0, 24)}`,
label: material.name.replace(/^@/, ""),
value: `${material.name} `,
image: material.type === "image" ? material.thumbnailUrl ?? material.url : undefined,
sourceNodeId: material.sourceNodeId,
referenceType: material.type,
url: material.url,
thumbnailUrl: material.thumbnailUrl,
duration: material.duration,
}));
}, [referenceMaterials, t]);
const runAction = useCallback(() => {
if (isCurrentNodeRunning) {
onCancelRunning();
return;
}
const generationConfig = buildGenerationConfigForContext(context);
onPersistConfig(context, generationConfig);
const { prompt: _prompt, ...nextConfig } = generationConfig;
void onRun(context, prompt, nextConfig);
}, [
buildGenerationConfigForContext,
context,
isCurrentNodeRunning,
onCancelRunning,
onPersistConfig,
onRun,
prompt,
]);
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 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>
</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"
>
<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>
</>
);
}