|
|
|
@ -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"> |
|
|
|
|