From 417051bd55a77998772e5d2cb4fe7664792c6cef Mon Sep 17 00:00:00 2001 From: TianYun Date: Tue, 2 Jun 2026 17:41:30 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=A1=86=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/GenerationComposer.test.tsx | 220 +++++++-- .../composer/GenerationComposer.tsx | 457 ++++++++++-------- src/store/workflowStore.ts | 44 ++ 3 files changed, 470 insertions(+), 251 deletions(-) diff --git a/src/components/__tests__/GenerationComposer.test.tsx b/src/components/__tests__/GenerationComposer.test.tsx index 089fffc2..10bacf90 100644 --- a/src/components/__tests__/GenerationComposer.test.tsx +++ b/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 = {}): WorkflowNode { + return { + ...imageNode(id, selected, data), + type: "smartImage", + } as WorkflowNode; +} + +function smartVideoNode(id: string, selected = false, data: Partial = {}): WorkflowNode { + return { + ...videoNode(id, selected, data), + type: "smartVideo", + } as WorkflowNode; +} + +function smartAudioNode(id: string, selected = false, data: Partial = {}): WorkflowNode { + return { + ...audioNode(id, selected, data), + type: "smartAudio", + } as WorkflowNode; +} + function videoStitchNode(id: string, selected = false, data: Partial = {}): WorkflowNode { return { id, @@ -372,13 +396,13 @@ describe("GenerationComposer", () => { render(); 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); @@ -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(); - 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(); - 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(); - 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(); - - 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(); 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" }); diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index 71581707..925e31a0 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/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 { buildPatch: (draft: ComposerDraft, dirtyFields: Set) => Partial; } -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 { - 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 = { 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>(() => new Set()); - const [assistMenu, setAssistMenu] = useState<"command" | "reference" | null>(null); + const [assistMenu, setAssistMenu] = useState(null); + const [assistMenuActiveIndex, setAssistMenuActiveIndex] = useState(0); const [modelDialogOpen, setModelDialogOpen] = useState(false); const [isComposerCollapsed, setIsComposerCollapsed] = useState(false); const [referencePreviewIndex, setReferencePreviewIndex] = useState(null); const [seedanceEstimatedPointAmount, setSeedanceEstimatedPointAmount] = useState(null); - const referenceImageInputRef = useRef(null); + const promptInputRef = useRef(null); const contextRef = useRef(context); const draftRef = useRef(draft); const dirtyFieldsRef = useRef(dirtyFields); + const assistTriggerIndexRef = useRef(null); const newApiWGPricingCacheRef = useRef>(new Map()); const seedanceEstimateCacheRef = useRef>(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(() => [ + { 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(() => { + 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) => { - 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) => { + 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() { >
- {isComposerCollapsed ? (
@@ -2013,7 +2099,17 @@ export function GenerationComposer() {
) : ( <> -
+
+ + + + + + +
+ + {hasComposerMediaHeader && ( +
{isProcessNode ? ( isVideoStitchNode ? (
@@ -2036,85 +2132,49 @@ export function GenerationComposer() {
) - ) : ( - <> - - - {referenceImages.length > 0 && ( -
- {referenceImages.map((referenceImage, imageIndex) => ( -
- - - {imageIndex + 1} - - {canEditReferenceImages && ( - - )} -
- ))} + + + + + + + + + {imageIndex + 1} +
- )} - - )} - -
- - - - - - + ))} +
+ ) : null}
-
+ )}
{assistMenu && ( @@ -2122,53 +2182,30 @@ export function GenerationComposer() {
{assistMenu === "command" ? t("composer.commands") : t("composer.referenceMaterials")}
- {assistMenu === "command" ? ( -
- {[ - { 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) => ( +
+ {activeAssistItems.map((item, itemIndex) => { + const isActive = itemIndex === assistMenuActiveIndex; + return ( - ))} -
- ) : ( -
- {referenceImages.length > 0 ? ( - referenceImages.map((referenceImage, imageIndex) => { - const referenceLabel = `图${imageIndex + 1}`; - return ( - - ); - }) - ) : ( - - )} -
- )} + ); + })} +
)} @@ -2322,19 +2359,23 @@ export function GenerationComposer() { ) : ( <>