Browse Source

输入框修改

feature/new_node
TianYun 1 month ago
parent
commit
417051bd55
  1. 220
      src/components/__tests__/GenerationComposer.test.tsx
  2. 457
      src/components/composer/GenerationComposer.tsx
  3. 44
      src/store/workflowStore.ts

220
src/components/__tests__/GenerationComposer.test.tsx

@ -11,6 +11,9 @@ import type {
ImageInputNodeData,
NanoBananaNodeData,
PromptNodeData,
SmartAudioNodeData,
SmartImageNodeData,
SmartVideoNodeData,
VideoStitchNodeData,
WorkflowEdge,
WorkflowNode,
@ -246,6 +249,27 @@ function audioNode(id: string, selected = false, data: Partial<GenerateAudioNode
} as WorkflowNode;
}
function smartImageNode(id: string, selected = false, data: Partial<SmartImageNodeData> = {}): WorkflowNode {
return {
...imageNode(id, selected, data),
type: "smartImage",
} as WorkflowNode;
}
function smartVideoNode(id: string, selected = false, data: Partial<SmartVideoNodeData> = {}): WorkflowNode {
return {
...videoNode(id, selected, data),
type: "smartVideo",
} as WorkflowNode;
}
function smartAudioNode(id: string, selected = false, data: Partial<SmartAudioNodeData> = {}): WorkflowNode {
return {
...audioNode(id, selected, data),
type: "smartAudio",
} as WorkflowNode;
}
function videoStitchNode(id: string, selected = false, data: Partial<VideoStitchNodeData> = {}): WorkflowNode {
return {
id,
@ -372,13 +396,13 @@ describe("GenerationComposer", () => {
render(<GenerationComposer />);
expect(screen.getByTestId("node-inline-composer")).toHaveStyle({
left: "310px",
left: "108px",
top: "436px",
});
expect(screen.getByTestId("node-inline-composer-panel")).toHaveStyle({
width: "640px",
width: "900px",
});
expect(screen.getByPlaceholderText(/Describe what you want to generate/)).toHaveClass("h-36");
expect(screen.getByPlaceholderText(/Describe what you want to generate/)).toHaveClass("h-[13.5rem]");
});
it("keeps the selected node editor below the node when below would leave the viewport", () => {
@ -421,6 +445,54 @@ describe("GenerationComposer", () => {
expect(useWorkflowStore.getState().nodes).toHaveLength(1);
});
it("converts a selected smart image node to nanoBanana before generating", async () => {
useWorkflowStore.setState({ nodes: [smartImageNode("smart-img-1", true)] });
render(<GenerationComposer />);
const prompt = screen.getByPlaceholderText(/Describe what you want to generate/) as HTMLTextAreaElement;
fireEvent.change(prompt, { target: { value: "smart image prompt" } });
fireEvent.click(screen.getByLabelText("Generate"));
await waitFor(() => {
expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("smart-img-1");
});
const node = useWorkflowStore.getState().nodes[0];
expect(node.type).toBe("nanoBanana");
expect((node.data as NanoBananaNodeData).inputPrompt).toBe("smart image prompt");
});
it("converts a selected smart video node to generateVideo before generating", async () => {
useWorkflowStore.setState({ nodes: [smartVideoNode("smart-vid-1", true)] });
render(<GenerationComposer />);
const prompt = screen.getByPlaceholderText(/Describe what you want to generate/) as HTMLTextAreaElement;
fireEvent.change(prompt, { target: { value: "smart video prompt" } });
fireEvent.click(screen.getByLabelText("Generate"));
await waitFor(() => {
expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("smart-vid-1");
});
const node = useWorkflowStore.getState().nodes[0];
expect(node.type).toBe("generateVideo");
expect((node.data as GenerateVideoNodeData).inputPrompt).toBe("smart video prompt");
});
it("converts a selected smart audio node to generateAudio before generating", async () => {
useWorkflowStore.setState({ nodes: [smartAudioNode("smart-aud-1", true)] });
render(<GenerationComposer />);
const prompt = screen.getByPlaceholderText(/Describe what you want to generate/) as HTMLTextAreaElement;
fireEvent.change(prompt, { target: { value: "smart audio prompt" } });
fireEvent.click(screen.getByLabelText("Generate"));
await waitFor(() => {
expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("smart-aud-1");
});
const node = useWorkflowStore.getState().nodes[0];
expect(node.type).toBe("generateAudio");
expect((node.data as GenerateAudioNodeData).inputPrompt).toBe("smart audio prompt");
});
it("does not let composer clicks bubble into the canvas selection layer", () => {
const onPointerDown = vi.fn();
const onMouseDown = vi.fn();
@ -443,6 +515,69 @@ describe("GenerationComposer", () => {
expect(onClick).not.toHaveBeenCalled();
});
it("opens the reference menu when @ is typed in the middle of the prompt", () => {
useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "before after" })] });
render(<GenerationComposer />);
const prompt = screen.getByDisplayValue("before after") as HTMLTextAreaElement;
prompt.setSelectionRange(7, 7);
fireEvent.change(prompt, { target: { value: "before @after", selectionStart: 8, selectionEnd: 8 } });
expect(screen.getByText("Reference assets")).toBeInTheDocument();
fireEvent.click(screen.getByText("Canvas assets"));
expect(prompt).toHaveValue("before @Canvas assets after");
});
it("opens the command menu when / is typed in the middle of the prompt", () => {
useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "before after" })] });
render(<GenerationComposer />);
const prompt = screen.getByDisplayValue("before after") as HTMLTextAreaElement;
prompt.setSelectionRange(7, 7);
fireEvent.change(prompt, { target: { value: "before /after", selectionStart: 8, selectionEnd: 8 } });
expect(screen.getByText("Commands")).toBeInTheDocument();
fireEvent.click(screen.getByText("Character image"));
expect(prompt.value).toMatch(/^before Generate a character image/);
expect(prompt.value).toContain("after");
});
it("selects reference menu items with arrow keys and enter", () => {
useWorkflowStore.setState({
nodes: [
imageNode("img-1", true, {
inputPrompt: "before after",
inputImages: ["data:image/png;base64,first", "data:image/png;base64,second"],
}),
],
});
render(<GenerationComposer />);
const prompt = screen.getByDisplayValue("before after") as HTMLTextAreaElement;
prompt.setSelectionRange(7, 7);
fireEvent.change(prompt, { target: { value: "before @after", selectionStart: 8, selectionEnd: 8 } });
fireEvent.keyDown(prompt, { key: "ArrowDown" });
fireEvent.keyDown(prompt, { key: "Enter" });
expect(prompt).toHaveValue("before @图2 after");
});
it("selects command menu items with arrow keys and enter", () => {
useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "before after" })] });
render(<GenerationComposer />);
const prompt = screen.getByDisplayValue("before after") as HTMLTextAreaElement;
prompt.setSelectionRange(7, 7);
fireEvent.change(prompt, { target: { value: "before /after", selectionStart: 8, selectionEnd: 8 } });
fireEvent.keyDown(prompt, { key: "ArrowDown" });
fireEvent.keyDown(prompt, { key: "Enter" });
expect(prompt.value).toMatch(/^before Generate a character turnaround/);
expect(prompt.value).toContain("after");
});
it("hides the temporary translate and settings actions", () => {
useWorkflowStore.setState({ nodes: [imageNode("img-1", true)] });
render(<GenerationComposer />);
@ -558,7 +693,7 @@ describe("GenerationComposer", () => {
nodes: [
videoNode("vid-1", true, {
inputImages: ["data:image/png;base64,ref"],
durationSeconds: 5,
durationSeconds: 4,
resolution: "720p",
soundEnabled: true,
selectedModel: {
@ -573,7 +708,7 @@ describe("GenerationComposer", () => {
{
dimensions: {
action: "textgenerate",
duration: "5s",
duration: "4s",
resolution: "720p",
has_video_input: false,
has_sound: true,
@ -583,7 +718,7 @@ describe("GenerationComposer", () => {
{
dimensions: {
action: "referencegenerate",
duration: "5s",
duration: "4s",
resolution: "720p",
has_video_input: true,
has_sound: true,
@ -782,7 +917,7 @@ describe("GenerationComposer", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("renders gateway image dimension options by schema name", () => {
it("renders gateway image dimension options by schema name", async () => {
useWorkflowStore.setState({
nodes: [
imageNode("img-1", true, {
@ -826,10 +961,14 @@ describe("GenerationComposer", () => {
fireEvent.change(screen.getByLabelText("Resolution"), { target: { value: "4k" } });
});
const data = useWorkflowStore.getState().nodes[0].data as NanoBananaNodeData;
expect(data.parameters).toMatchObject({
ratio: "9:16",
resolution: "4k",
await waitFor(() => {
const data = useWorkflowStore.getState().nodes[0].data as NanoBananaNodeData;
expect(data.aspectRatio).toBe("9:16");
expect(data.resolution).toBe("4k");
expect(data.config).toMatchObject({
aspectRatio: "9:16",
resolution: "4k",
});
});
});
@ -1128,17 +1267,17 @@ describe("GenerationComposer", () => {
it("defaults gateway video dimension quick settings to the first schema option", async () => {
useWorkflowStore.setState({
nodes: [videoNode("vid-1", true, { durationSeconds: 6 })],
nodes: [videoNode("vid-1", true, { durationSeconds: 9 })],
});
render(<GenerationComposer />);
expect(screen.getByLabelText("Duration")).toHaveValue("4");
await waitFor(() => {
expect(screen.getByLabelText("Duration")).toHaveValue("4");
});
await waitFor(() => {
const data = useWorkflowStore.getState().nodes[0].data as GenerateVideoNodeData;
expect(data.resolution).toBe("720p");
expect(data.durationSeconds).toBe(4);
expect(data.config).toMatchObject({
resolution: "720p",
durationSeconds: 4,
});
});
@ -1243,50 +1382,45 @@ describe("GenerationComposer", () => {
expect(screen.getByLabelText("Generate")).toBeDisabled();
});
it("renders uploaded reference images in order instead of stacking them", async () => {
it("does not render the local reference image upload control", () => {
useWorkflowStore.setState({ nodes: [imageNode("img-1", true)] });
render(<GenerationComposer />);
const firstImage = new File(["first-reference"], "first.png", { type: "image/png" });
const secondImage = new File(["second-reference"], "second.png", { type: "image/png" });
fireEvent.change(screen.getByLabelText("Upload reference image"), {
target: { files: [firstImage, secondImage] },
});
expect(screen.queryByLabelText("Upload reference image")).not.toBeInTheDocument();
expect(screen.queryByText("Reference")).not.toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByTitle("Reference image 1")).toBeInTheDocument();
expect(screen.getByTitle("Reference image 2")).toBeInTheDocument();
it("renders stored reference images in order instead of stacking them", () => {
useWorkflowStore.setState({
nodes: [
imageNode("img-1", true, {
inputImages: ["data:image/png;base64,first", "data:image/png;base64,second"],
}),
],
});
render(<GenerationComposer />);
const removeButtons = screen.getAllByLabelText(/Remove reference image/);
expect(removeButtons).toHaveLength(2);
fireEvent.click(screen.getByLabelText("Remove reference image 1"));
await waitFor(() => {
expect(screen.queryByTitle("Reference image 2")).not.toBeInTheDocument();
expect(screen.getByTitle("Reference image 1")).toBeInTheDocument();
});
expect(screen.getByTitle("Reference image 1")).toBeInTheDocument();
expect(screen.getByTitle("Reference image 2")).toBeInTheDocument();
expect(screen.queryByLabelText(/Remove reference image/)).not.toBeInTheDocument();
});
it("opens a reference image preview from its thumbnail", async () => {
useWorkflowStore.setState({ nodes: [imageNode("img-1", true)] });
render(<GenerationComposer />);
const imageFile = new File(["preview-reference"], "preview.png", { type: "image/png" });
fireEvent.change(screen.getByLabelText("Upload reference image"), {
target: { files: [imageFile] },
});
await waitFor(() => {
expect(screen.getByTitle("Reference image 1")).toBeInTheDocument();
useWorkflowStore.setState({
nodes: [
imageNode("img-1", true, {
inputImages: ["data:image/png;base64,preview"],
}),
],
});
render(<GenerationComposer />);
fireEvent.click(screen.getByTitle("Reference image 1"));
const dialog = screen.getByRole("dialog", { name: "Reference image 1" });
expect(within(dialog).getByAltText("Reference image 1")).toHaveAttribute(
"src",
expect.stringMatching(/^data:image\/png;base64,/)
"data:image/png;base64,preview"
);
fireEvent.keyDown(window, { key: "Escape" });

457
src/components/composer/GenerationComposer.tsx

@ -6,12 +6,13 @@ import {
useMemo,
useRef,
useState,
type ChangeEvent,
type FocusEvent,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { RightOutlined } from "@ant-design/icons";
import { useReactFlow, useViewport, ViewportPortal } from "@xyflow/react";
import { useProviderApiKeys, useWorkflowStore } from "@/store/workflowStore";
import { useModelStore } from "@/store/modelStore";
@ -86,6 +87,14 @@ type EditableNodeType = "smartImage" | "smartVideo" | "smartAudio" | "nanoBanana
type DraftField = GenerationConfigField;
type ComposerDraft = GenerationNodeConfig;
type VideoStitchLoopCount = 1 | 2 | 3;
type AssistMenuType = "command" | "reference";
interface AssistMenuItem {
key: string;
label: string;
value: string;
image?: string;
}
interface ComposerContext {
mode: ComposerMode;
@ -126,10 +135,8 @@ interface ComposerAdapter<TData extends WorkflowNodeData> {
buildPatch: (draft: ComposerDraft, dirtyFields: Set<DraftField>) => Partial<TData>;
}
const MAX_REFERENCE_IMAGES = 4;
const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024;
const COMPOSER_VIEWPORT_MARGIN = 16;
const COMPOSER_EXPANDED_WIDTH = 640;
const COMPOSER_EXPANDED_WIDTH = 900;
const COMPOSER_COLLAPSED_WIDTH = 560;
const DEFAULT_IMAGE_COUNT_OPTIONS: ImageGenerationCount[] = [1, 2, 4];
@ -628,18 +635,6 @@ function getComposerNodeDimensions(node: WorkflowNode): { width: number; height:
};
}
function readImageFileAsDataUrl(file: File): Promise<string | null> {
if (!file.type.match(/^image\/(png|jpeg|webp)$/)) return Promise.resolve(null);
if (file.size > MAX_REFERENCE_IMAGE_BYTES) return Promise.resolve(null);
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (event) => resolve((event.target?.result as string | undefined) ?? null);
reader.onerror = () => resolve(null);
reader.readAsDataURL(file);
});
}
const imageComposerAdapter: ComposerAdapter<NanoBananaNodeData> = {
nodeType: "nanoBanana",
capability: "image",
@ -799,6 +794,13 @@ function buildInitialDataForNode(nodeType: NodeType, draft: ComposerDraft): Part
return {};
}
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 IconButton({
label,
children,
@ -829,6 +831,7 @@ export function GenerationComposer() {
const isRunning = useWorkflowStore((state) => state.isRunning);
const isModalOpen = useWorkflowStore((state) => state.isModalOpen);
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 selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
@ -845,16 +848,18 @@ export function GenerationComposer() {
const [draft, setDraft] = useState(() => readDraftFromContext(context));
const [draftIdentity, setDraftIdentity] = useState(() => identity);
const [dirtyFields, setDirtyFields] = useState<Set<DraftField>>(() => new Set());
const [assistMenu, setAssistMenu] = useState<"command" | "reference" | null>(null);
const [assistMenu, setAssistMenu] = useState<AssistMenuType | null>(null);
const [assistMenuActiveIndex, setAssistMenuActiveIndex] = useState(0);
const [modelDialogOpen, setModelDialogOpen] = useState(false);
const [isComposerCollapsed, setIsComposerCollapsed] = useState(false);
const [referencePreviewIndex, setReferencePreviewIndex] = useState<number | null>(null);
const [seedanceEstimatedPointAmount, setSeedanceEstimatedPointAmount] = useState<number | null>(null);
const referenceImageInputRef = useRef<HTMLInputElement>(null);
const promptInputRef = useRef<HTMLTextAreaElement>(null);
const contextRef = useRef(context);
const draftRef = useRef(draft);
const dirtyFieldsRef = useRef(dirtyFields);
const assistTriggerIndexRef = useRef<number | null>(null);
const newApiWGPricingCacheRef = useRef<Map<string, SelectedModel["pricing"]>>(new Map());
const seedanceEstimateCacheRef = useRef<Map<string, number>>(new Map());
@ -1326,11 +1331,9 @@ export function GenerationComposer() {
const connectedReferenceImages = context.mode === "node-edit" ? connectedInputs?.images ?? [] : [];
const referenceImages = connectedReferenceImages.length > 0 ? connectedReferenceImages : draft.inputImages;
const hasComposerMediaHeader = isProcessNode || referenceImages.length > 0;
const previewReferenceImage =
referencePreviewIndex === null ? null : referenceImages[referencePreviewIndex] ?? null;
const canEditReferenceImages =
(context.mode === "empty-create" || context.mode === "node-edit") &&
connectedReferenceImages.length === 0;
const nodeData = context.node?.data as (NanoBananaNodeData | GenerateVideoNodeData | GenerateAudioNodeData | VideoStitchNodeData | EaseCurveNodeData | undefined);
const generationNodeData = context.mode === "node-edit"
? context.node?.data as NanoBananaNodeData | GenerateVideoNodeData | GenerateAudioNodeData | undefined
@ -1372,6 +1375,35 @@ export function GenerationComposer() {
draft.selectedModel.metadata?.aiModelCodeAlias,
draft.selectedModel.modelId,
]);
const commandAssistItems = useMemo<AssistMenuItem[]>(() => [
{ key: "character", label: t("composer.commandCharacter"), value: t("composer.commandCharacterValue") },
{ key: "turnaround", label: t("composer.commandTurnaround"), value: t("composer.commandTurnaroundValue") },
{ key: "video-shot", label: t("composer.commandVideoShot"), value: t("composer.commandVideoShotValue") },
], [t]);
const referenceAssistItems = useMemo<AssistMenuItem[]>(() => {
if (referenceImages.length === 0) {
return [{
key: "canvas-assets",
label: t("composer.canvasAssets"),
value: `@${t("composer.canvasAssets")} `,
}];
}
return referenceImages.map((referenceImage, imageIndex) => {
const referenceLabel = `${imageIndex + 1}`;
return {
key: `${imageIndex}-${referenceImage.slice(0, 24)}`,
label: referenceLabel,
value: `@${referenceLabel} `,
image: referenceImage,
};
});
}, [referenceImages, t]);
const activeAssistItems = assistMenu === "command"
? commandAssistItems
: assistMenu === "reference"
? referenceAssistItems
: [];
const errorMessage = nodeData?.status === "error" ? nodeData.error : null;
const nodeEditModeLabel =
context.nodeType === "smartImage" || context.nodeType === "nanoBanana"
@ -1687,42 +1719,6 @@ export function GenerationComposer() {
[clearDirtyFields, updateNodeData]
);
const handleReferenceImageChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
if (!canEditReferenceImages) return;
const files = Array.from(event.target.files ?? []);
event.target.value = "";
if (files.length === 0) return;
const loadedImages = (await Promise.all(files.map(readImageFileAsDataUrl))).filter(
(image): image is string => Boolean(image)
);
if (loadedImages.length === 0) return;
markDraft(
{
inputImages: [...draftRef.current.inputImages, ...loadedImages].slice(0, MAX_REFERENCE_IMAGES),
},
["inputImages"]
);
},
[canEditReferenceImages, markDraft]
);
const removeReferenceImage = useCallback(
(index: number) => {
if (!canEditReferenceImages) return;
markDraft(
{
inputImages: draftRef.current.inputImages.filter((_, imageIndex) => imageIndex !== index),
},
["inputImages"]
);
},
[canEditReferenceImages, markDraft]
);
useEffect(() => {
if (referencePreviewIndex !== null && referencePreviewIndex >= referenceImages.length) {
setReferencePreviewIndex(null);
@ -1740,20 +1736,45 @@ export function GenerationComposer() {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [previewReferenceImage]);
useEffect(() => {
if (promptReadOnly) {
const syncAssistMenuFromPromptCursor = useCallback((textarea: HTMLTextAreaElement | null) => {
if (promptReadOnly || !textarea) {
assistTriggerIndexRef.current = null;
setAssistMenu(null);
return;
}
const lastChar = draft.prompt.at(-1);
if (lastChar === "/") {
const cursor = textarea.selectionStart;
const previousChar = cursor > 0 ? textarea.value[cursor - 1] : "";
if (previousChar === "/") {
assistTriggerIndexRef.current = cursor - 1;
setAssistMenu("command");
} else if (lastChar === "@") {
setAssistMenuActiveIndex(0);
return;
}
if (previousChar === "@") {
assistTriggerIndexRef.current = cursor - 1;
setAssistMenu("reference");
} else if (!draft.prompt.includes("/") && !draft.prompt.includes("@")) {
setAssistMenuActiveIndex(0);
return;
}
assistTriggerIndexRef.current = null;
setAssistMenu(null);
}, [promptReadOnly]);
useEffect(() => {
if (promptReadOnly) {
assistTriggerIndexRef.current = null;
setAssistMenu(null);
}
}, [draft.prompt, promptReadOnly]);
}, [promptReadOnly]);
useEffect(() => {
if (!assistMenu) return;
setAssistMenuActiveIndex((current) =>
activeAssistItems.length === 0 ? 0 : Math.min(current, activeAssistItems.length - 1)
);
}, [activeAssistItems.length, assistMenu]);
const handleSubmit = useCallback(async () => {
if (!canSubmit) return;
@ -1804,6 +1825,22 @@ export function GenerationComposer() {
}
if (context.mode === "node-edit" && context.node) {
const concreteNodeType = getConcreteGenerationNodeType(context.node.type);
if (concreteNodeType) {
const nextDraft = {
...draftRef.current,
prompt: submitPrompt,
};
convertNodeType(
context.node.id,
concreteNodeType,
buildInitialDataForNode(concreteNodeType, nextDraft)
);
clearDirtyFields();
await regenerateNode(context.node.id);
return;
}
if (!promptForSubmit && !promptReadOnly) {
const nextDraft = { ...draftRef.current, prompt: submitPrompt };
const nextDirtyFields = new Set(dirtyFieldsRef.current);
@ -1830,6 +1867,7 @@ export function GenerationComposer() {
canSubmit,
clearDirtyFields,
context,
convertNodeType,
flushCurrentDraft,
promptForSubmit,
promptReadOnly,
@ -1837,15 +1875,72 @@ export function GenerationComposer() {
selectSingleNode,
]);
const insertCommand = useCallback((value: string) => {
markDraft({ prompt: draftRef.current.prompt.replace(/\/$/, "") + value }, ["prompt"]);
const insertAssistValue = useCallback((value: string, trigger: "/" | "@") => {
const prompt = draftRef.current.prompt;
const textarea = promptInputRef.current;
const fallbackIndex = prompt.endsWith(trigger) ? prompt.length - 1 : prompt.length;
const triggerIndex = assistTriggerIndexRef.current ?? fallbackIndex;
const shouldReplaceTrigger = prompt[triggerIndex] === trigger;
const before = shouldReplaceTrigger ? prompt.slice(0, triggerIndex) : prompt.slice(0, triggerIndex);
const after = shouldReplaceTrigger ? prompt.slice(triggerIndex + 1) : prompt.slice(triggerIndex);
const nextPrompt = before + value + after;
const nextCursor = before.length + value.length;
markDraft({ prompt: nextPrompt }, ["prompt"]);
assistTriggerIndexRef.current = null;
setAssistMenu(null);
window.requestAnimationFrame(() => {
textarea?.focus();
textarea?.setSelectionRange(nextCursor, nextCursor);
});
}, [markDraft]);
const insertCommand = useCallback((value: string) => {
insertAssistValue(value, "/");
}, [insertAssistValue]);
const insertReference = useCallback((value: string) => {
markDraft({ prompt: draftRef.current.prompt.replace(/@$/, "") + value }, ["prompt"]);
setAssistMenu(null);
}, [markDraft]);
insertAssistValue(value, "@");
}, [insertAssistValue]);
const selectAssistMenuItem = useCallback((item: AssistMenuItem) => {
if (assistMenu === "command") {
insertCommand(item.value);
return;
}
if (assistMenu === "reference") {
insertReference(item.value);
}
}, [assistMenu, insertCommand, insertReference]);
const handlePromptKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (assistMenu && activeAssistItems.length > 0) {
if (event.key === "ArrowDown") {
event.preventDefault();
setAssistMenuActiveIndex((current) => (current + 1) % activeAssistItems.length);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setAssistMenuActiveIndex((current) => (current - 1 + activeAssistItems.length) % activeAssistItems.length);
return;
}
if (event.key === "Enter") {
event.preventDefault();
selectAssistMenuItem(activeAssistItems[assistMenuActiveIndex] ?? activeAssistItems[0]);
return;
}
}
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
handleSubmit();
}
},
[activeAssistItems, assistMenu, assistMenuActiveIndex, handleSubmit, selectAssistMenuItem]
);
const toggleComposerCollapsed = useCallback(() => {
if (!isComposerCollapsed) {
@ -1909,7 +2004,7 @@ export function GenerationComposer() {
>
<section
data-testid="node-inline-composer-panel"
className="nodrag nopan nowheel pointer-events-auto max-w-[calc(100vw-2rem)] rounded-2xl border border-neutral-700/80 bg-neutral-800/95 shadow-2xl shadow-black/35 backdrop-blur transition-[width] duration-200"
className="nodrag nopan nowheel pointer-events-auto relative max-w-[calc(100vw-2rem)] rounded-2xl border border-neutral-700/80 bg-neutral-800/95 shadow-2xl shadow-black/35 backdrop-blur transition-[width] duration-200"
style={{ width: `${composerWidth}px` }}
onBlur={handleComposerBlur}
onPointerDownCapture={stopCanvasEvent}
@ -1917,15 +2012,6 @@ export function GenerationComposer() {
onClick={stopCanvasEvent}
onWheelCapture={stopCanvasEvent}
>
<input
ref={referenceImageInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
aria-label={t("composer.uploadReferenceImage")}
onChange={handleReferenceImageChange}
className="hidden"
/>
{isComposerCollapsed ? (
<div className="flex h-16 cursor-pointer items-center gap-3 px-4" onClick={handleCollapsedComposerClick}>
<div className="min-w-0 flex-1">
@ -2013,7 +2099,17 @@ export function GenerationComposer() {
</div>
) : (
<>
<div className="flex items-center gap-2 px-4 pt-4">
<div className="absolute right-3 top-3 z-10 flex items-center gap-1 text-neutral-500">
<IconButton label={t("composer.collapse")} onClick={toggleComposerCollapsed}>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 7 7 17" />
<path d="M16 17H7V8" />
</svg>
</IconButton>
</div>
{hasComposerMediaHeader && (
<div className="flex items-center gap-2 px-4 pt-4 pr-16">
{isProcessNode ? (
isVideoStitchNode ? (
<div className="flex h-14 items-center gap-3 rounded-lg border border-neutral-700 bg-neutral-900/50 px-3 text-xs text-neutral-300">
@ -2036,85 +2132,49 @@ export function GenerationComposer() {
</span>
</div>
)
) : (
<>
<button
type="button"
onClick={() => referenceImageInputRef.current?.click()}
disabled={!canEditReferenceImages || referenceImages.length >= MAX_REFERENCE_IMAGES}
title={canEditReferenceImages ? t("composer.addReferenceImage") : t("composer.referenceFromUpstream")}
className="flex h-14 w-16 flex-col items-center justify-center gap-1 rounded-lg border border-neutral-700 bg-neutral-800 text-xs text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-200 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="text-base leading-none">+</span>
<span>{t("composer.reference")}</span>
</button>
{referenceImages.length > 0 && (
<div className="ml-1 flex max-w-[18rem] items-center gap-1.5 overflow-x-auto rounded-lg pr-1">
{referenceImages.map((referenceImage, imageIndex) => (
<div
key={`${imageIndex}-${referenceImage.slice(0, 24)}`}
className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border border-neutral-700 bg-neutral-900"
>
<button
type="button"
aria-label={t("composer.referenceImage", { index: imageIndex + 1 })}
title={t("composer.referenceImage", { index: imageIndex + 1 })}
onClick={() => setReferencePreviewIndex(imageIndex)}
className="group h-full w-full overflow-hidden rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/70"
) : referenceImages.length > 0 ? (
<div className="flex max-w-[18rem] items-center gap-1.5 overflow-x-auto rounded-lg pr-1">
{referenceImages.map((referenceImage, imageIndex) => (
<div
key={`${imageIndex}-${referenceImage.slice(0, 24)}`}
className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border border-neutral-700 bg-neutral-900"
>
<button
type="button"
aria-label={t("composer.referenceImage", { index: imageIndex + 1 })}
title={t("composer.referenceImage", { index: imageIndex + 1 })}
onClick={() => setReferencePreviewIndex(imageIndex)}
className="group h-full w-full overflow-hidden rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/70"
>
<img
src={referenceImage}
alt=""
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
<span className="absolute inset-0 flex items-center justify-center bg-black/0 text-white transition-colors group-hover:bg-black/20">
<svg
className="h-4 w-4 opacity-0 transition-opacity group-hover:opacity-90"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<img
src={referenceImage}
alt=""
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
<span className="absolute inset-0 flex items-center justify-center bg-black/0 text-white transition-colors group-hover:bg-black/20">
<svg
className="h-4 w-4 opacity-0 transition-opacity group-hover:opacity-90"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M15 3h6v6" />
<path d="M21 3l-7 7" />
<path d="M9 21H3v-6" />
<path d="M3 21l7-7" />
</svg>
</span>
</button>
<span className="pointer-events-none absolute left-1 top-1 rounded-full bg-neutral-900/80 px-1 text-[10px] text-neutral-200">
{imageIndex + 1}
</span>
{canEditReferenceImages && (
<button
type="button"
aria-label={t("composer.removeReferenceImage", { index: imageIndex + 1 })}
onClick={(event) => {
event.stopPropagation();
removeReferenceImage(imageIndex);
}}
className="absolute bottom-1 right-1 flex h-4 w-4 items-center justify-center rounded-full bg-black/70 text-[10px] text-neutral-200 transition-colors hover:bg-red-600 hover:text-white"
>
?
</button>
)}
</div>
))}
<path d="M15 3h6v6" />
<path d="M21 3l-7 7" />
<path d="M9 21H3v-6" />
<path d="M3 21l7-7" />
</svg>
</span>
</button>
<span className="pointer-events-none absolute left-1 top-1 rounded-full bg-neutral-900/80 px-1 text-[10px] text-neutral-200">
{imageIndex + 1}
</span>
</div>
)}
</>
)}
<div className="ml-auto flex items-center gap-1 text-neutral-500">
<IconButton label={t("composer.collapse")} onClick={toggleComposerCollapsed}>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 7 7 17" />
<path d="M16 17H7V8" />
</svg>
</IconButton>
))}
</div>
) : null}
</div>
</div>
)}
<div className="relative px-4 pt-3">
{assistMenu && (
@ -2122,53 +2182,30 @@ export function GenerationComposer() {
<div className="border-b border-neutral-700 px-3 py-2 text-[11px] font-medium text-neutral-500">
{assistMenu === "command" ? t("composer.commands") : t("composer.referenceMaterials")}
</div>
{assistMenu === "command" ? (
<div className="py-1">
{[
{ label: t("composer.commandCharacter"), value: t("composer.commandCharacterValue") },
{ label: t("composer.commandTurnaround"), value: t("composer.commandTurnaroundValue") },
{ label: t("composer.commandVideoShot"), value: t("composer.commandVideoShotValue") },
].map((item) => (
<div className="py-1">
{activeAssistItems.map((item, itemIndex) => {
const isActive = itemIndex === assistMenuActiveIndex;
return (
<button
key={item.label}
key={item.key}
type="button"
onClick={() => insertCommand(item.value)}
className="flex w-full items-center justify-between px-3 py-2 text-left text-xs text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
>
<span>{item.label}</span>
<span className="text-neutral-500">v</span>
</button>
))}
</div>
) : (
<div className="py-1">
{referenceImages.length > 0 ? (
referenceImages.map((referenceImage, imageIndex) => {
const referenceLabel = `${imageIndex + 1}`;
return (
<button
key={`${imageIndex}-${referenceImage.slice(0, 24)}`}
type="button"
onClick={() => insertReference(`@${referenceLabel} `)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
>
<img src={referenceImage} alt="" className="h-8 w-8 rounded object-cover" />
<span>{referenceLabel}</span>
</button>
);
})
) : (
<button
type="button"
onClick={() => insertReference(`@${t("composer.canvasAssets")} `)}
className="flex w-full items-center justify-between px-3 py-2 text-left text-xs text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
onMouseEnter={() => setAssistMenuActiveIndex(itemIndex)}
onClick={() => selectAssistMenuItem(item)}
className={`flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs transition-colors ${
isActive
? "bg-neutral-700 text-neutral-100"
: "text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100"
}`}
>
<span>{t("composer.canvasAssets")}</span>
<span className="text-neutral-500">v</span>
<span className="flex min-w-0 items-center gap-2">
{item.image && <img src={item.image} alt="" className="h-8 w-8 rounded object-cover" />}
<span className="truncate">{item.label}</span>
</span>
<RightOutlined className="text-neutral-500" />
</button>
)}
</div>
)}
);
})}
</div>
</div>
)}
@ -2322,19 +2359,23 @@ export function GenerationComposer() {
) : (
<>
<textarea
ref={promptInputRef}
value={promptValue}
readOnly={promptReadOnly}
onChange={(event) => markDraft({ prompt: event.target.value }, ["prompt"])}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
handleSubmit();
}
onChange={(event) => {
markDraft({ prompt: event.target.value }, ["prompt"]);
syncAssistMenuFromPromptCursor(event.currentTarget);
}}
onClick={(event) => syncAssistMenuFromPromptCursor(event.currentTarget)}
onKeyUp={(event) => {
if (assistMenu && ["ArrowDown", "ArrowUp", "Enter"].includes(event.key)) return;
syncAssistMenuFromPromptCursor(event.currentTarget);
}}
onKeyDown={handlePromptKeyDown}
placeholder={t("composer.mainPlaceholder")}
className={`nowheel h-36 w-full resize-none rounded-lg bg-transparent text-sm leading-6 outline-none placeholder:text-neutral-500 ${
promptReadOnly ? "text-neutral-300" : "text-neutral-100"
}`}
className={`nowheel w-full resize-none rounded-lg bg-transparent pr-12 text-sm leading-6 outline-none placeholder:text-neutral-500 ${
hasComposerMediaHeader ? "h-36" : "h-[13.5rem]"
} ${promptReadOnly ? "text-neutral-300" : "text-neutral-100"}`}
/>
{promptReadOnly && (
<div className="pb-2 text-[11px] text-cyan-200/80">

44
src/store/workflowStore.ts

@ -259,6 +259,7 @@ interface WorkflowStore {
// Node operations
addNode: (type: NodeType, position: XYPosition, initialData?: Partial<WorkflowNodeData>) => string;
convertNodeType: (nodeId: string, type: NodeType, initialData?: Partial<WorkflowNodeData>) => void;
updateNodeData: (nodeId: string, data: Partial<WorkflowNodeData>) => void;
removeNode: (nodeId: string) => void;
onNodesChange: (changes: NodeChange<WorkflowNode>[]) => void;
@ -927,6 +928,49 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
return id;
},
convertNodeType: (nodeId: string, type: NodeType, initialData?: Partial<WorkflowNodeData>) => {
const sourceNode = get().nodes.find((node) => node.id === nodeId);
if (!sourceNode) return;
const { width, height } = defaultNodeDimensions[type];
const sourceData = sourceNode.data as {
label?: string;
customTitle?: string;
comment?: string;
};
const defaultData = createDefaultNodeData(type);
const preservedBaseData = {
...(sourceData.label !== undefined ? { label: sourceData.label } : {}),
...(sourceData.customTitle !== undefined ? { customTitle: sourceData.customTitle } : {}),
...(sourceData.comment !== undefined ? { comment: sourceData.comment } : {}),
};
const nodeData = {
...defaultData,
...preservedBaseData,
...initialData,
} as WorkflowNodeData;
pushUndoCheckpoint(get, set);
set((state) => ({
nodes: state.nodes.map((node) =>
node.id === nodeId
? {
...node,
type,
data: nodeData,
width,
height,
measured: undefined,
style: { ...node.style, width, height },
}
: node
) as WorkflowNode[],
hasUnsavedChanges: true,
}));
get().incrementManualChangeCount();
},
updateNodeData: (nodeId: string, data: Partial<WorkflowNodeData>) => {
const node = get().nodes.find((n) => n.id === nodeId);

Loading…
Cancel
Save