Browse Source

处理 工作流保存问题

feature/071
yun 2 weeks ago
parent
commit
40a59c0ed0
  1. 9
      src/components/FloatingActionBar.tsx
  2. 13
      src/components/Header.tsx
  3. 7
      src/components/PopiaiEmbedBridge.tsx
  4. 44
      src/components/WorkflowCanvas.tsx
  5. 9
      src/components/WorkflowSaveModal.tsx
  6. 4
      src/components/__tests__/FloatingActionBar.test.tsx
  7. 55
      src/components/composer/GenerationComposer.tsx
  8. 11
      src/hooks/useCanvasWorkflowAutoUpdate.ts
  9. 104
      src/store/__tests__/generationPreferenceStore.test.ts
  10. 11
      src/store/__tests__/workflowStore.integration.test.ts
  11. 95
      src/store/generationPreferenceStore.ts
  12. 21
      src/store/workflowStore.ts
  13. 56
      src/utils/defaultGenerationNodeModel.ts

9
src/components/FloatingActionBar.tsx

@ -7,7 +7,7 @@ import { useWorkflowStore } from "@/store/workflowStore";
import { NodeType } from "@/types";
import { useReactFlow } from "@xyflow/react";
import { useI18n } from "@/i18n";
import { buildDefaultGenerationNodeData } from "@/utils/defaultGenerationNodeModel";
import { buildDefaultGenerationNodeCreation } from "@/utils/defaultGenerationNodeModel";
import { GenerateNodeMenu } from "./GenerateNodeMenu";
import { ResourceManagerDrawer } from "./ResourceManagerDrawer";
import { MyCreationPickerModal } from "./MyCreationPickerModal";
@ -34,12 +34,13 @@ function GenerateComboButton() {
const handleAddNode = async (type: NodeType) => {
const center = getPaneCenter();
const { data, size } = await buildDefaultGenerationNodeCreation(type);
const position = screenToFlowPosition({
x: center.x + Math.random() * 100 - 50,
y: center.y + Math.random() * 100 - 50,
x: center.x - (size?.width ?? 0) / 2 + Math.random() * 100 - 50,
y: center.y - (size?.height ?? 0) / 2 + Math.random() * 100 - 50,
});
const nodeId = addNode(type, position, await buildDefaultGenerationNodeData(type));
const nodeId = addNode(type, position, data, size);
selectSingleNode(nodeId);
setIsOpen(false);
};

13
src/components/Header.tsx

@ -2,6 +2,7 @@
import { useState, useMemo, useCallback, useEffect, type ReactNode } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import {
CommentOutlined,
DownloadOutlined,
@ -96,9 +97,10 @@ function HeaderIconButton({
export function Header() {
const { t } = useI18n();
const searchParams = useSearchParams();
const urlWorkflowId = searchParams?.get("workflowId") ?? null;
const {
workflowName,
workflowId,
nodes,
edges,
edgeStyle,
@ -109,7 +111,6 @@ export function Header() {
revertToSnapshot,
} = useWorkflowStore(useShallow((state) => ({
workflowName: state.workflowName,
workflowId: state.workflowId,
nodes: state.nodes,
edges: state.edges,
edgeStyle: state.edgeStyle,
@ -130,7 +131,7 @@ export function Header() {
const handleDownloadWorkflowJson = useCallback(() => {
const currentWorkflowName = workflowNameDraft.trim() || workflowName?.trim() || "未命名";
const workflow = buildSerializedWorkflow({
workflowId,
workflowId: urlWorkflowId,
workflowName,
nameOverride: currentWorkflowName,
nodes,
@ -150,7 +151,7 @@ export function Header() {
document.body.removeChild(link);
URL.revokeObjectURL(url);
markAsSaved();
}, [workflowId, workflowName, workflowNameDraft, nodes, edges, edgeStyle, groups, markAsSaved]);
}, [urlWorkflowId, workflowName, workflowNameDraft, nodes, edges, edgeStyle, groups, markAsSaved]);
useEffect(() => {
setWorkflowNameDraft(workflowName || "");
@ -164,7 +165,7 @@ export function Header() {
}
setWorkflowName(nextName);
if (workflowId) {
if (urlWorkflowId) {
setCurrentCanvasWorkflow({
title: nextName,
description: currentCanvasWorkflowDescription,
@ -173,7 +174,7 @@ export function Header() {
}, [
workflowNameDraft,
workflowName,
workflowId,
urlWorkflowId,
setWorkflowName,
currentCanvasWorkflowDescription,
setCurrentCanvasWorkflow,

7
src/components/PopiaiEmbedBridge.tsx

@ -272,12 +272,17 @@ function saveSessionWorkflow(sessionId: string) {
);
}
function getUrlWorkflowId(): string | null {
if (typeof window === "undefined") return null;
return new URLSearchParams(window.location.search).get("workflowId");
}
function buildSnapshot(sessionId: string | null) {
const state = useWorkflowStore.getState();
return {
sessionId,
workflowId: state.workflowId,
workflowId: getUrlWorkflowId(),
workflowName: state.workflowName,
nodeCount: state.nodes.length,
edgeCount: state.edges.length,

44
src/components/WorkflowCanvas.tsx

@ -1,6 +1,7 @@
"use client";
import { useCallback, useRef, useState, useEffect, DragEvent, useMemo, MouseEvent as ReactMouseEvent } from "react";
import { useSearchParams } from "next/navigation";
import {
ReactFlow,
Background,
@ -66,7 +67,7 @@ import { TutorialOverlay } from "./onboarding/TutorialOverlay";
import { useFTUXStore } from "@/store/ftuxStore";
import { useI18n } from "@/i18n";
import { isPopiProviderMode } from "@/lib/providerMode";
import { buildDefaultGenerationNodeData } from "@/utils/defaultGenerationNodeModel";
import { buildDefaultGenerationNodeCreation } from "@/utils/defaultGenerationNodeModel";
import { isSmartImageGenerateMode } from "@/utils/smartImageMode";
import { isSmartAudioGenerateMode } from "@/utils/smartAudioMode";
import { deriveSmartTextMode, isSmartTextLlmMode } from "@/utils/smartTextMode";
@ -519,6 +520,8 @@ function getCanvasPanOnDrag(disabled: boolean): PanOnDragValue {
export function WorkflowCanvas() {
const { t } = useI18n();
const searchParams = useSearchParams();
const urlWorkflowId = searchParams?.get("workflowId") ?? null;
const popiProviderMode = isPopiProviderMode();
const { nodes, edges, groups, isModalOpen, navigationTarget, canvasNavigationSettings, dimmedNodeIds, skippedNodeIds, providerSettings, isRunning, runningNodeIds } =
useWorkflowStore(useShallow((state) => ({
@ -541,6 +544,7 @@ export function WorkflowCanvas() {
const addNodeToGroup = useWorkflowStore((state) => state.addNodeToGroup);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const loadWorkflow = useWorkflowStore((state) => state.loadWorkflow);
const markAsUnsaved = useWorkflowStore((state) => state.markAsUnsaved);
const getNodeById = useWorkflowStore((state) => state.getNodeById);
const setNodeGroupId = useWorkflowStore((state) => state.setNodeGroupId);
const executeSelectedNodes = useWorkflowStore((state) => state.executeSelectedNodes);
@ -1208,10 +1212,11 @@ export function WorkflowCanvas() {
const handleCanvasNodeMenuSelect = useCallback(async (type: NodeType) => {
if (!canvasNodeMenu) return;
const nodeId = addNode(type, canvasNodeMenu.flowPosition, await buildDefaultGenerationNodeData(type));
const { data, size } = await buildDefaultGenerationNodeCreation(type);
const nodeId = addNode(type, canvasNodeMenu.flowPosition, data, size);
selectSingleNode(nodeId);
setCanvasNodeMenu(null);
}, [addNode, canvasNodeMenu, selectSingleNode]);
}, [addNode, buildDefaultGenerationNodeCreation, canvasNodeMenu, selectSingleNode]);
// Inline parameters mode (for showing Browse in header)
const { inlineParametersEnabled } = useInlineParameters();
@ -1581,7 +1586,15 @@ export function WorkflowCanvas() {
if (data.success && data.workflow) {
captureSnapshot(); // Capture BEFORE loading new workflow
await loadWorkflow(data.workflow, undefined, { preserveSnapshot: true });
await loadWorkflow(
{
...data.workflow,
...(urlWorkflowId ? { id: urlWorkflowId } : { id: undefined }),
},
undefined,
{ preserveSnapshot: true }
);
markAsUnsaved();
closeCanvasAssistant();
showToast(t("workflow.generatedSuccess"), "success");
} else {
@ -1593,7 +1606,7 @@ export function WorkflowCanvas() {
} finally {
setIsBuildingWorkflow(false);
}
}, [loadWorkflow, showToast, captureSnapshot, t, providerSettings, closeCanvasAssistant]);
}, [loadWorkflow, showToast, captureSnapshot, t, providerSettings, closeCanvasAssistant, urlWorkflowId, markAsUnsaved]);
// Create lightweight workflow state for chat (strip base64 images)
const chatWorkflowState = useMemo(() => {
@ -1669,11 +1682,13 @@ export function WorkflowCanvas() {
const nodeType = selection.type as NodeType;
// Create the new node at the drop position (empty - tutorial will populate after connection)
const { data, size } = await buildDefaultGenerationNodeCreation(nodeType);
const newNodeId = addNodeWithOptionalGroup(
nodeType,
flowPosition,
sourceGroupId,
await buildDefaultGenerationNodeData(nodeType)
data,
size
);
// Tutorial tracking
@ -1913,7 +1928,7 @@ export function WorkflowCanvas() {
selectSingleNode(newNodeId);
setConnectionDrop(null);
},
[connectionDrop, addNodeWithOptionalGroup, onConnect, nodes, edges, getImageFromNode, updateNodeData, tutorialActive, selectSingleNode]
[connectionDrop, addNodeWithOptionalGroup, buildDefaultGenerationNodeCreation, onConnect, nodes, edges, getImageFromNode, updateNodeData, tutorialActive, selectSingleNode]
);
const handleCloseDropMenu = useCallback(() => {
@ -2124,12 +2139,14 @@ export function WorkflowCanvas() {
if (nodeType) {
event.preventDefault();
const { centerX, centerY } = getViewportCenter();
const dims = defaultNodeDimensions[nodeType];
void (async () => {
const { data, size } = await buildDefaultGenerationNodeCreation(nodeType);
const dims = size ?? defaultNodeDimensions[nodeType];
const nodeId = addNode(
nodeType,
{ x: centerX - dims.width / 2, y: centerY - dims.height / 2 },
await buildDefaultGenerationNodeData(nodeType)
data,
size
);
selectSingleNode(nodeId);
})();
@ -2510,7 +2527,8 @@ export function WorkflowCanvas() {
x: event.clientX,
y: event.clientY,
});
const nodeId = addNode(nodeType, position, await buildDefaultGenerationNodeData(nodeType));
const { data, size } = await buildDefaultGenerationNodeCreation(nodeType);
const nodeId = addNode(nodeType, position, data, size);
selectSingleNode(nodeId);
return;
}
@ -2614,7 +2632,11 @@ export function WorkflowCanvas() {
try {
const workflow = JSON.parse(e.target?.result as string) as WorkflowFile;
if (workflow.version && workflow.nodes && workflow.edges) {
await loadWorkflow(workflow);
await loadWorkflow({
...workflow,
...(urlWorkflowId ? { id: urlWorkflowId } : { id: undefined }),
});
markAsUnsaved();
} else {
alert(t("workflow.invalidWorkflowFile"));
}

9
src/components/WorkflowSaveModal.tsx

@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { App, Form, Input, Modal } from "antd";
import { useSearchParams } from "next/navigation";
import { useShallow } from "zustand/shallow";
import { useI18n } from "@/i18n";
import { updateCanvasWorkflow } from "@/lib/canvasWorkflowApi";
@ -23,10 +24,11 @@ interface WorkflowSaveFormValues {
export function WorkflowSaveModal({ isOpen, onClose }: WorkflowSaveModalProps) {
const { t } = useI18n();
const { message } = App.useApp();
const searchParams = useSearchParams();
const urlWorkflowId = searchParams?.get("workflowId") ?? null;
const [form] = Form.useForm<WorkflowSaveFormValues>();
const [isSubmitting, setIsSubmitting] = useState(false);
const {
workflowId,
workflowName,
nodes,
edges,
@ -34,7 +36,6 @@ export function WorkflowSaveModal({ isOpen, onClose }: WorkflowSaveModalProps) {
groups,
markAsSaved,
} = useWorkflowStore(useShallow((state) => ({
workflowId: state.workflowId,
workflowName: state.workflowName,
nodes: state.nodes,
edges: state.edges,
@ -52,7 +53,7 @@ export function WorkflowSaveModal({ isOpen, onClose }: WorkflowSaveModalProps) {
setCurrentCanvasWorkflow: state.setCurrentCanvasWorkflow,
})));
const numericWorkflowId = workflowId ? Number(workflowId) : null;
const numericWorkflowId = urlWorkflowId ? Number(urlWorkflowId) : null;
const isUpdate = Boolean(numericWorkflowId && Number.isInteger(numericWorkflowId));
useEffect(() => {
@ -81,7 +82,7 @@ export function WorkflowSaveModal({ isOpen, onClose }: WorkflowSaveModalProps) {
}
const workflow = buildSerializedWorkflow({
workflowId,
workflowId: urlWorkflowId,
workflowName,
nameOverride: title,
nodes,

4
src/components/__tests__/FloatingActionBar.test.tsx

@ -85,7 +85,7 @@ vi.mock("@/store/workflowStore", () => ({
}));
vi.mock("@/utils/defaultGenerationNodeModel", () => ({
buildDefaultGenerationNodeData: vi.fn(async () => undefined),
buildDefaultGenerationNodeCreation: vi.fn(async () => ({ data: undefined, size: undefined })),
}));
vi.mock("@/components/modals/ModelSearchDialog", () => ({
@ -219,7 +219,7 @@ describe("FloatingActionBar", () => {
fireEvent.click(screen.getByText("Annotate"));
await waitFor(() => {
expect(mockAddNode).toHaveBeenCalledWith("annotation", expect.any(Object), undefined);
expect(mockAddNode).toHaveBeenCalledWith("annotation", expect.any(Object), undefined, undefined);
});
expect(mockSelectSingleNode).toHaveBeenCalledWith("new-node-id");
});

55
src/components/composer/GenerationComposer.tsx

@ -83,6 +83,10 @@ 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;
@ -309,6 +313,27 @@ function buildInitialDataForNode(nodeType: NodeType, draft: ComposerDraft): Part
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);
@ -532,11 +557,16 @@ export function GenerationComposer({ targetNodeId, embedded = false, isHidden =
});
}, [screenToFlowPosition]);
const rememberGenerationPreference = useCallback((capability: ComposerCapability, nextConfig: ComposerConfig) => {
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);
@ -572,8 +602,9 @@ export function GenerationComposer({ targetNodeId, embedded = false, isHidden =
const submitCapability = runContext.mode === "empty-create"
? inferCapabilityFromModel(submitConfig.selectedModel)
: runContext.capability;
if (submitCapability) rememberGenerationPreference(submitCapability, submitConfig);
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;
@ -582,16 +613,15 @@ export function GenerationComposer({ targetNodeId, embedded = false, isHidden =
? submitConfig.selectedModel
: EMPTY_SELECTED_MODEL;
if (!selectedModel.modelId) return;
const nodeSize = getGenerationNodeSizeForAspectRatio(submitAspectRatio);
const nextNodeId = addNode(
"smartImage",
buildTargetPosition(nodeSize ?? undefined),
buildTargetPosition(submitNodeSize ?? undefined),
buildInitialDataForNode("smartImage", {
...submitConfig,
selectedModel,
inputImages: [imageData.image],
}),
nodeSize ?? undefined
submitNodeSize ?? undefined
);
selectSingleNode(nextNodeId);
await regenerateNode(nextNodeId);
@ -600,12 +630,11 @@ export function GenerationComposer({ targetNodeId, embedded = false, isHidden =
if (runContext.mode === "empty-create") {
if (!submitNodeType) return;
const nodeSize = getGenerationNodeSizeForAspectRatio(submitAspectRatio);
const nextNodeId = addNode(
submitNodeType,
buildTargetPosition(nodeSize ?? undefined),
buildTargetPosition(submitNodeSize ?? undefined),
buildInitialDataForNode(submitNodeType, submitConfig),
nodeSize ?? undefined
submitNodeSize ?? undefined
);
selectSingleNode(nextNodeId);
await regenerateNode(nextNodeId);
@ -652,12 +681,14 @@ export function GenerationComposer({ targetNodeId, embedded = false, isHidden =
]);
const persistComposerConfig = useCallback((persistContext: ComposerContext, config: GenerationNodeConfig) => {
rememberGenerationPreference(persistContext.capability, config);
if (!persistContext.node || persistContext.mode !== "node-edit") return;
const currentNode = useWorkflowStore.getState().nodes.find((node) => node.id === persistContext.node?.id) ?? persistContext.node;
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(persistContext.node.id, patch);
updateNodeData(currentNode.id, patch);
}, [rememberGenerationPreference, updateNodeData]);
const cancelRunning = useCallback(() => {

11
src/hooks/useCanvasWorkflowAutoUpdate.ts

@ -2,6 +2,7 @@
import { useEffect, useRef } from "react";
import { App } from "antd";
import { useSearchParams } from "next/navigation";
import { useShallow } from "zustand/shallow";
import { useI18n } from "@/i18n";
import { updateCanvasWorkflow } from "@/lib/canvasWorkflowApi";
@ -15,6 +16,8 @@ const CANVAS_WORKFLOW_AUTO_UPDATE_DELAY_MS = 5_000;
export function useCanvasWorkflowAutoUpdate() {
const { t } = useI18n();
const { message } = App.useApp();
const searchParams = useSearchParams();
const urlWorkflowId = searchParams?.get("workflowId") ?? null;
const saveVersionRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -29,7 +32,6 @@ export function useCanvasWorkflowAutoUpdate() {
})));
const {
workflowId,
workflowName,
nodes,
edges,
@ -38,7 +40,6 @@ export function useCanvasWorkflowAutoUpdate() {
hasUnsavedChanges,
markAsSaved,
} = useWorkflowStore(useShallow((state) => ({
workflowId: state.workflowId,
workflowName: state.workflowName,
nodes: state.nodes,
edges: state.edges,
@ -54,7 +55,7 @@ export function useCanvasWorkflowAutoUpdate() {
timerRef.current = null;
}
const numericWorkflowId = workflowId ? Number(workflowId) : null;
const numericWorkflowId = urlWorkflowId ? Number(urlWorkflowId) : null;
if (!numericWorkflowId || !Number.isInteger(numericWorkflowId) || !hasUnsavedChanges || isLoadingCanvasWorkflow) {
return;
}
@ -64,7 +65,7 @@ export function useCanvasWorkflowAutoUpdate() {
timerRef.current = setTimeout(() => {
const workflow = buildSerializedWorkflow({
workflowId,
workflowId: urlWorkflowId,
workflowName,
nameOverride: workflowName || currentCanvasWorkflowTitle || undefined,
nodes,
@ -104,7 +105,7 @@ export function useCanvasWorkflowAutoUpdate() {
currentCanvasWorkflowTitle,
currentCanvasWorkflowDescription,
isLoadingCanvasWorkflow,
workflowId,
urlWorkflowId,
workflowName,
nodes,
edges,

104
src/store/__tests__/generationPreferenceStore.test.ts

@ -0,0 +1,104 @@
import { describe, expect, it, beforeEach } from "vitest";
import { useGenerationPreferenceStore, createInitialGenerationPreferences } from "@/store/generationPreferenceStore";
import { buildDefaultGenerationNodeCreation, buildDefaultGenerationNodeData } from "@/utils/defaultGenerationNodeModel";
import type { SelectedModel } from "@/types";
const selectedModel: SelectedModel = {
provider: "popiserver",
modelId: "image-model",
displayName: "Image Model",
capabilities: ["text-to-image"],
};
type GenerationPreferenceState = ReturnType<typeof useGenerationPreferenceStore.getState>;
type ImagePreferencePatch = Parameters<GenerationPreferenceState["updateImagePreference"]>[0];
describe("generationPreferenceStore", () => {
beforeEach(() => {
useGenerationPreferenceStore.setState(createInitialGenerationPreferences());
});
it("keeps only model configuration fields when remembering image preferences", async () => {
useGenerationPreferenceStore.getState().updateImagePreference({
selectedModel,
batchSize: 3,
aspectRatio: "16:9",
resolution: "2K",
parameters: {
ratio: "16:9",
images: ["parameter-image-should-stay-if-schema-owns-it"],
type: 1,
},
extraTaskParams: { generate_audio: false },
subType: 101,
nodeSize: { width: 512, height: 288 },
prompt: "do not remember prompts",
inputImages: ["https://example.com/reference.png"],
inputVideos: ["https://example.com/reference.mp4"],
voices: ["https://example.com/reference.wav"],
referenceSubjectList: [{ id: "1", type: "image", name: "Reference" }],
} as ImagePreferencePatch);
const imagePreference = useGenerationPreferenceStore.getState().image as unknown as Record<string, unknown>;
expect(imagePreference.model).toEqual(selectedModel);
expect(imagePreference.batchSize).toBe(3);
expect(imagePreference.aspectRatio).toBe("16:9");
expect(imagePreference.resolution).toBe("2K");
expect(imagePreference.parameters).toEqual({
ratio: "16:9",
images: ["parameter-image-should-stay-if-schema-owns-it"],
type: 1,
});
expect(imagePreference.extraTaskParams).toEqual({ generate_audio: false });
expect(imagePreference.subType).toBe(101);
expect(imagePreference.nodeSize).toEqual({ width: 512, height: 288 });
expect(imagePreference).not.toHaveProperty("prompt");
expect(imagePreference).not.toHaveProperty("inputImages");
expect(imagePreference).not.toHaveProperty("inputVideos");
expect(imagePreference).not.toHaveProperty("voices");
expect(imagePreference).not.toHaveProperty("referenceSubjectList");
const newNodeData = await buildDefaultGenerationNodeData("nanoBanana");
expect(newNodeData).toMatchObject({
selectedModel,
batchSize: 3,
aspectRatio: "16:9",
resolution: "2K",
parameters: {
ratio: "16:9",
images: ["parameter-image-should-stay-if-schema-owns-it"],
},
extraTaskParams: { generate_audio: false },
subType: 101,
});
expect(newNodeData).not.toHaveProperty("prompt");
expect(newNodeData).not.toHaveProperty("inputImages");
expect(newNodeData).not.toHaveProperty("inputVideos");
expect(newNodeData).not.toHaveProperty("voices");
expect(newNodeData).not.toHaveProperty("referenceSubjectList");
await expect(buildDefaultGenerationNodeCreation("nanoBanana")).resolves.toEqual({
data: expect.any(Object),
size: undefined,
});
});
it("uses remembered node size only when no aspect ratio can drive creation size", async () => {
useGenerationPreferenceStore.getState().updateVideoPreference({
selectedModel: {
provider: "popiserver",
modelId: "video-model",
displayName: "Video Model",
capabilities: ["text-to-video"],
},
parameters: { duration: 5 },
nodeSize: { width: 640, height: 360 },
});
await expect(buildDefaultGenerationNodeCreation("generateVideo")).resolves.toEqual({
data: expect.any(Object),
size: { width: 640, height: 360 },
});
});
});

11
src/store/__tests__/workflowStore.integration.test.ts

@ -509,6 +509,17 @@ describe("workflowStore integration tests", () => {
expect(node?.style).toMatchObject({ width: 203, height: 360 });
});
it("infers generation node dimensions from model parameter ratio", () => {
const nodeId = useWorkflowStore.getState().addNode(
"generateVideo",
{ x: 120, y: 160 },
{ parameters: { ratio: "16:9" } }
);
const node = useWorkflowStore.getState().nodes.find((candidate) => candidate.id === nodeId);
expect(node?.style).toMatchObject({ width: 360, height: 203 });
});
it("infers media node dimensions from initial media dimensions", () => {
const nodeId = useWorkflowStore.getState().addNode(
"smartImage",

95
src/store/generationPreferenceStore.ts

@ -8,39 +8,116 @@ import {
export type GenerationPreferenceType = "image" | "video" | "audio";
export interface GenerationPreferenceNodeSize {
width: number;
height: number;
}
export interface ImageGenerationPreference {
[key: string]: unknown;
type: "image";
model: SelectedModel;
aspectRatio: string;
resolution: string;
batchSize: GenerationBatchSize;
parameters?: Record<string, unknown>;
extraTaskParams?: Record<string, unknown>;
subType?: number;
nodeSize?: GenerationPreferenceNodeSize;
}
export interface VideoGenerationPreference {
[key: string]: unknown;
type: "video";
model: SelectedModel;
parameters?: Record<string, unknown>;
extraTaskParams?: Record<string, unknown>;
subType?: number;
batchSize: GenerationBatchSize;
nodeSize?: GenerationPreferenceNodeSize;
}
export interface AudioGenerationPreference {
[key: string]: unknown;
type: "audio";
model: SelectedModel;
voiceId?: string;
parameters?: Record<string, unknown>;
extraTaskParams?: Record<string, unknown>;
subType?: number;
batchSize?: GenerationBatchSize;
nodeSize?: GenerationPreferenceNodeSize;
}
type GenerationPreferencePatch = {
model?: SelectedModel;
selectedModel?: SelectedModel;
aspectRatio?: unknown;
resolution?: unknown;
parameters?: unknown;
extraTaskParams?: unknown;
subType?: unknown;
batchSize?: unknown;
voiceId?: unknown;
audioVoiceId?: unknown;
nodeSize?: unknown;
};
interface GenerationPreferenceState {
image: ImageGenerationPreference;
video: VideoGenerationPreference;
audio: AudioGenerationPreference;
updateImagePreference: (patch: Partial<Omit<ImageGenerationPreference, "type">>) => void;
updateVideoPreference: (patch: Partial<Omit<VideoGenerationPreference, "type">>) => void;
updateAudioPreference: (patch: Partial<Omit<AudioGenerationPreference, "type">>) => void;
updateImagePreference: (patch: GenerationPreferencePatch) => void;
updateVideoPreference: (patch: GenerationPreferencePatch) => void;
updateAudioPreference: (patch: GenerationPreferencePatch) => void;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function normalizeCommonPreferencePatch(patch: GenerationPreferencePatch) {
const next: {
model?: SelectedModel;
parameters?: Record<string, unknown>;
extraTaskParams?: Record<string, unknown>;
subType?: number;
batchSize?: GenerationBatchSize;
nodeSize?: GenerationPreferenceNodeSize;
} = {};
const model = patch.model ?? patch.selectedModel;
if (model) next.model = model;
if (isRecord(patch.parameters)) next.parameters = { ...patch.parameters };
if (isRecord(patch.extraTaskParams)) next.extraTaskParams = { ...patch.extraTaskParams };
if (typeof patch.subType === "number") next.subType = patch.subType;
if (typeof patch.batchSize === "number") next.batchSize = patch.batchSize;
if (isRecord(patch.nodeSize)) {
const width = patch.nodeSize.width;
const height = patch.nodeSize.height;
if (typeof width === "number" && Number.isFinite(width) && width > 0 &&
typeof height === "number" && Number.isFinite(height) && height > 0) {
next.nodeSize = { width, height };
}
}
return next;
}
function normalizeImagePreferencePatch(patch: GenerationPreferencePatch): Partial<Omit<ImageGenerationPreference, "type">> {
const next: Partial<Omit<ImageGenerationPreference, "type">> = normalizeCommonPreferencePatch(patch);
if (typeof patch.aspectRatio === "string") next.aspectRatio = patch.aspectRatio;
if (typeof patch.resolution === "string") next.resolution = patch.resolution;
return next;
}
function normalizeVideoPreferencePatch(patch: GenerationPreferencePatch): Partial<Omit<VideoGenerationPreference, "type">> {
return normalizeCommonPreferencePatch(patch);
}
function normalizeAudioPreferencePatch(patch: GenerationPreferencePatch): Partial<Omit<AudioGenerationPreference, "type">> {
const next: Partial<Omit<AudioGenerationPreference, "type">> = normalizeCommonPreferencePatch(patch);
if (Object.prototype.hasOwnProperty.call(patch, "voiceId")) {
next.voiceId = typeof patch.voiceId === "string" ? patch.voiceId : undefined;
} else if (Object.prototype.hasOwnProperty.call(patch, "audioVoiceId")) {
next.voiceId = typeof patch.audioVoiceId === "string" ? patch.audioVoiceId : undefined;
}
return next;
}
function createInitialImagePreference(): ImageGenerationPreference {
@ -84,13 +161,13 @@ export const useGenerationPreferenceStore = create<GenerationPreferenceState>()(
(set) => ({
...createInitialGenerationPreferences(),
updateImagePreference: (patch) => set((state) => ({
image: { ...state.image, ...patch, type: "image" },
image: { ...state.image, ...normalizeImagePreferencePatch(patch), type: "image" },
})),
updateVideoPreference: (patch) => set((state) => ({
video: { ...state.video, ...patch, type: "video" },
video: { ...state.video, ...normalizeVideoPreferencePatch(patch), type: "video" },
})),
updateAudioPreference: (patch) => set((state) => ({
audio: { ...state.audio, ...patch, type: "audio" },
audio: { ...state.audio, ...normalizeAudioPreferencePatch(patch), type: "audio" },
})),
})
);

21
src/store/workflowStore.ts

@ -273,9 +273,28 @@ function readNodeDataDimensions(data: WorkflowNodeData): { width: number; height
function readNodeDataAspectRatio(data: WorkflowNodeData): unknown {
const directAspectRatio = (data as { aspectRatio?: unknown }).aspectRatio;
if (typeof directAspectRatio === "string" && directAspectRatio.trim()) return directAspectRatio;
const parameters = (data as { parameters?: unknown }).parameters;
if (parameters && typeof parameters === "object") {
const record = parameters as Record<string, unknown>;
const parameterAspectRatio =
record.ratio ??
record.aspectRatio ??
record.aspect_ratio ??
record.videoRatio ??
record.video_ratio ??
record.imageRatio ??
record.image_ratio;
if (typeof parameterAspectRatio === "string" && parameterAspectRatio.trim()) return parameterAspectRatio;
}
const config = (data as { config?: unknown }).config;
if (!config || typeof config !== "object") return null;
return (config as { aspectRatio?: unknown }).aspectRatio;
const configRecord = config as { aspectRatio?: unknown; parameters?: unknown };
if (typeof configRecord.aspectRatio === "string" && configRecord.aspectRatio.trim()) return configRecord.aspectRatio;
if (configRecord.parameters && typeof configRecord.parameters === "object") {
const record = configRecord.parameters as Record<string, unknown>;
return record.ratio ?? record.aspectRatio ?? record.aspect_ratio ?? record.videoRatio ?? record.video_ratio;
}
return null;
}
function resolveNodeCreationSize(

56
src/utils/defaultGenerationNodeModel.ts

@ -10,9 +10,10 @@ import {
POPISERVER_DEFAULT_LLM_MODEL_ID,
POPISERVER_DEFAULT_VIDEO_MODEL_ID,
} from "@/store/utils/defaultImageModel";
import { useGenerationPreferenceStore } from "@/store/generationPreferenceStore";
import { useGenerationPreferenceStore, type GenerationPreferenceNodeSize } from "@/store/generationPreferenceStore";
import { toSelectedModel } from "@/utils/selectedModel";
import { filterTaskRoutingParameters } from "@/utils/generationConfig";
import { getGenerationNodeSizeForAspectRatio } from "@/utils/nodeDimensions";
type GenerationNodeType = "smartImage" | "smartVideo" | "smartAudio" | "smartText" | "nanoBanana" | "generateVideo" | "generateAudio" | "llmGenerate";
@ -75,38 +76,40 @@ async function buildGenerationPreferenceNodeData(type: NodeType): Promise<Partia
const preferences = useGenerationPreferenceStore.getState();
if (type === "smartImage" || type === "nanoBanana") {
const { type: _type, model: _model, ...preferenceConfig } = preferences.image;
const aspectRatio = preferences.image.aspectRatio as AspectRatio;
const selectedModel = resolvePreferenceModel("image", preferences.image.model);
return {
...preferenceConfig,
aspectRatio,
resolution: preferences.image.resolution,
selectedModel,
batchSize: preferences.image.batchSize,
extraTaskParams: preferences.image.extraTaskParams ?? {},
subType: preferences.image.subType,
parameters: filterTaskRoutingParameters(preferences.image.parameters as Record<string, unknown> | undefined),
} as Partial<WorkflowNodeData>;
}
if (type === "smartVideo" || type === "generateVideo") {
const { type: _type, model: _model, ...preferenceConfig } = preferences.video;
const selectedModel = resolvePreferenceModel("video", preferences.video.model);
return {
...preferenceConfig,
selectedModel,
batchSize: preferences.video.batchSize,
extraTaskParams: preferences.video.extraTaskParams ?? {},
subType: preferences.video.subType ?? selectedModel.subType,
parameters: filterTaskRoutingParameters(preferences.video.parameters),
} as Partial<WorkflowNodeData>;
}
if (type === "smartAudio" || type === "generateAudio") {
const { type: _type, model: _model, ...preferenceConfig } = preferences.audio;
const parameters = preferences.audio.parameters && typeof preferences.audio.parameters === "object"
? preferences.audio.parameters as Record<string, unknown>
: {};
const selectedModel = resolvePreferenceModel("audio", preferences.audio.model);
return {
...preferenceConfig,
selectedModel,
batchSize: preferences.audio.batchSize,
extraTaskParams: preferences.audio.extraTaskParams ?? {},
subType: preferences.audio.subType,
audioVoiceId: preferences.audio.voiceId,
parameters: filterTaskRoutingParameters(parameters),
} as Partial<WorkflowNodeData>;
@ -115,6 +118,45 @@ async function buildGenerationPreferenceNodeData(type: NodeType): Promise<Partia
return undefined;
}
export function buildDefaultGenerationNodeSize(type: NodeType): GenerationPreferenceNodeSize | undefined {
const preferences = useGenerationPreferenceStore.getState();
if (type === "smartImage" || type === "nanoBanana") return preferences.image.nodeSize;
if (type === "smartVideo" || type === "generateVideo") return preferences.video.nodeSize;
if (type === "smartAudio" || type === "generateAudio") return preferences.audio.nodeSize;
return undefined;
}
function readPreferredAspectRatio(data: Partial<WorkflowNodeData> | undefined): unknown {
if (!data) return null;
const directAspectRatio = (data as { aspectRatio?: unknown }).aspectRatio;
if (typeof directAspectRatio === "string" && directAspectRatio.trim()) return directAspectRatio;
const parameters = (data as { parameters?: unknown }).parameters;
if (!parameters || typeof parameters !== "object") return null;
const record = parameters as Record<string, unknown>;
return (
record.ratio ??
record.aspectRatio ??
record.aspect_ratio ??
record.videoRatio ??
record.video_ratio ??
record.imageRatio ??
record.image_ratio ??
null
);
}
export async function buildDefaultGenerationNodeCreation(type: NodeType): Promise<{
data: Partial<WorkflowNodeData> | undefined;
size: GenerationPreferenceNodeSize | undefined;
}> {
const data = await buildDefaultGenerationNodeData(type);
const aspectRatio = readPreferredAspectRatio(data);
return {
data,
size: getGenerationNodeSizeForAspectRatio(aspectRatio) ? undefined : buildDefaultGenerationNodeSize(type),
};
}
export async function buildDefaultGenerationNodeData(
type: NodeType
): Promise<Partial<WorkflowNodeData> | undefined> {

Loading…
Cancel
Save