You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

575 lines
23 KiB

"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type DragEvent as ReactDragEvent,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
type RefObject,
} from "react";
import { CloseOutlined, FileTextOutlined, RightOutlined } from "@ant-design/icons";
import { Dropdown, Popover, Tooltip } from "antd";
import { useI18n } from "@/i18n";
import {
getReferenceMaterialKey,
type ComposerReferenceMaterial,
} from "@/utils/composerReferenceSubjects";
import { useWorkflowStore } from "@/store/workflowStore";
import type { GenerationNodeConfig } from "@/utils/generationConfig";
import { ComposerMentionInput, type ComposerMentionReferenceItem } from "@/components/composer/ComposerMentionInput";
import { ComposerReferenceMaterialCard } from "@/components/composer/ComposerReferenceMaterialCard";
import type { ComposerCapability } from "@/utils/composerNodeDescriptor";
import type { SelectedModel } from "@/types";
import {
POPI_VIDEO_SUBTYPE_FIRST_LAST_FRAME,
POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO,
POPI_VIDEO_SUBTYPE_OMNI_REFERENCE,
} from "@/constants/generationTask";
export interface ComposerConnectedTextCard {
text: string;
edgeId?: string;
}
interface ComposerVideoSubTypeOption {
value: number;
label: string;
disabledReason?: string;
}
const EMPTY_PARAMETERS: Record<string, unknown> = {};
function selectedModelSubTypes(model: SelectedModel): number[] {
return Array.isArray(model.subTypes)
? model.subTypes.filter((subType) => Number.isFinite(subType))
: [];
}
function isVideoSubTypeAllowedForMaterials(
subType: number,
referenceImageCount: number,
hasVideoOrAudio: boolean
): boolean {
if (subType === POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO) return referenceImageCount === 1 && !hasVideoOrAudio;
if (subType === POPI_VIDEO_SUBTYPE_FIRST_LAST_FRAME) return referenceImageCount === 2 && !hasVideoOrAudio;
return true;
}
type AssistMenuType = "command";
interface AssistMenuItem {
key: string;
label: string;
value: string;
image?: string;
sourceNodeId?: string;
referenceType?: ComposerReferenceMaterial["type"];
}
interface ComposerPromptMaterialsProps {
hasMediaHeader: boolean;
headerStatusContent?: ReactNode;
activeCapability: ComposerCapability;
nodeId?: string;
selectedModel: SelectedModel;
connectedTextCards: ComposerConnectedTextCard[];
referenceMaterials: ComposerReferenceMaterial[];
playingReferenceAudioKey: string | null;
onRemoveConnectedEdge: (edgeId: string) => void;
onReferenceMaterialDragStart: (
event: ReactDragEvent<HTMLDivElement>,
material: ComposerReferenceMaterial
) => void;
onReferenceMaterialDrop: (
event: ReactDragEvent<HTMLDivElement>,
material: ComposerReferenceMaterial
) => void;
onRemoveReferenceMaterial: (material: ComposerReferenceMaterial) => void;
onToggleReferenceAudio: (material: ComposerReferenceMaterial) => void;
onStopReferenceAudio: () => void;
onVideoSubTypeChange?: (subType: number) => void;
bodyContent?: ReactNode;
promptInputRef: RefObject<HTMLDivElement | null>;
promptValue: string;
promptReadOnly: boolean;
promptMinHeightPx: number;
promptMaxHeightPx: number;
referenceItems: ComposerMentionReferenceItem[];
onDraftPatch: (patch: Partial<GenerationNodeConfig>, fields: Array<keyof GenerationNodeConfig>) => void;
onPromptFocus?: () => void;
onSubmitShortcut: () => void;
}
function getContentEditableCaretOffset(element: HTMLDivElement): number {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return element.textContent?.length ?? 0;
const range = selection.getRangeAt(0);
if (!element.contains(range.endContainer)) return element.textContent?.length ?? 0;
const beforeRange = range.cloneRange();
beforeRange.selectNodeContents(element);
beforeRange.setEnd(range.endContainer, range.endOffset);
return beforeRange.toString().length;
}
function getTextNodeAtOffset(root: Node, offset: number): { node: Text; offset: number } | null {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let remaining = offset;
let current = walker.nextNode();
while (current) {
const text = current.textContent ?? "";
if (remaining <= text.length) {
return { node: current as Text, offset: remaining };
}
remaining -= text.length;
current = walker.nextNode();
}
return null;
}
function setContentEditableCaretOffset(element: HTMLDivElement, offset: number): void {
const selection = window.getSelection();
if (!selection) return;
const textLength = element.textContent?.length ?? 0;
const safeOffset = Math.max(0, Math.min(offset, textLength));
const textPosition = getTextNodeAtOffset(element, safeOffset);
const range = document.createRange();
if (textPosition) {
range.setStart(textPosition.node, textPosition.offset);
} else {
range.setStart(element, element.childNodes.length);
}
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
export function ComposerPromptMaterials({
hasMediaHeader,
headerStatusContent,
activeCapability,
nodeId,
selectedModel,
connectedTextCards,
referenceMaterials,
playingReferenceAudioKey,
onRemoveConnectedEdge,
onReferenceMaterialDragStart,
onReferenceMaterialDrop,
onRemoveReferenceMaterial,
onToggleReferenceAudio,
onStopReferenceAudio,
onVideoSubTypeChange,
bodyContent,
promptInputRef,
promptValue,
promptReadOnly,
promptMinHeightPx,
promptMaxHeightPx,
referenceItems,
onDraftPatch,
onPromptFocus,
onSubmitShortcut,
}: ComposerPromptMaterialsProps) {
const { t } = useI18n();
const storeSubType = useWorkflowStore((state) => {
if (!nodeId) return undefined;
const node = state.nodes.find((candidate) => candidate.id === nodeId);
const subType = (node?.data as { subType?: unknown } | undefined)?.subType;
return typeof subType === "number" && Number.isFinite(subType) ? subType : undefined;
});
const assistTriggerIndexRef = useRef<number | null>(null);
const [assistMenu, setAssistMenu] = useState<AssistMenuType | null>(null);
const [assistMenuActiveIndex, setAssistMenuActiveIndex] = useState(0);
const [assistMenuPosition, setAssistMenuPosition] = useState<{ left: number; top: number } | null>(null);
const videoSubTypeOptions = useMemo<ComposerVideoSubTypeOption[]>(() => {
if (activeCapability !== "video") return [];
const subTypes = selectedModelSubTypes(selectedModel);
const shouldDisableInvalidOptions = subTypes.length > 1;
const referenceImageCount = referenceMaterials.filter((material) => material.type === "image").length;
const hasVideoOrAudio = referenceMaterials.some((material) => material.type === "video" || material.type === "audio");
return subTypes.flatMap((subType): ComposerVideoSubTypeOption[] => {
const disabledReason = shouldDisableInvalidOptions && !isVideoSubTypeAllowedForMaterials(subType, referenceImageCount, hasVideoOrAudio)
? subType === POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO
? t("composer.videoSubTypeImageToVideoImageCountRequired")
: t("composer.videoSubTypeFirstLastFrameImageCountRequired")
: undefined;
let label: string | null = null;
if (subType === POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO) {
label = t("composer.videoSubTypeMultiReference");
}
if (subType === POPI_VIDEO_SUBTYPE_OMNI_REFERENCE) {
label = t("composer.videoSubTypeTextToVideo");
}
if (subType === POPI_VIDEO_SUBTYPE_FIRST_LAST_FRAME) {
label = t("composer.videoSubTypeFirstLastFrame");
}
return label ? [{ value: subType, label, disabledReason }] : [];
});
}, [activeCapability, referenceMaterials, selectedModel, t]);
const selectedVideoSubType = typeof storeSubType === "number" && Number.isFinite(storeSubType)
? storeSubType
: typeof selectedModel.subType === "number" && Number.isFinite(selectedModel.subType)
? selectedModel.subType
: videoSubTypeOptions[0]?.value;
const hasVideoSubTypeHeader = videoSubTypeOptions.length > 0;
const hasReferenceHeader = connectedTextCards.length > 0 || referenceMaterials.length > 0;
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 activeAssistItems = assistMenu === "command" ? commandAssistItems : [];
const closeAssistMenu = useCallback(() => {
assistTriggerIndexRef.current = null;
setAssistMenu(null);
setAssistMenuPosition(null);
}, []);
const syncAssistMenuFromPromptCursor = useCallback((input: HTMLDivElement | null) => {
if (promptReadOnly || !input) {
closeAssistMenu();
return;
}
const cursor = getContentEditableCaretOffset(input);
const inputValue = input.textContent ?? "";
const previousChar = cursor > 0 ? inputValue[cursor - 1] : "";
const selection = window.getSelection();
const selectionRect = selection?.rangeCount ? selection.getRangeAt(0).getBoundingClientRect() : null;
const inputRect = input.getBoundingClientRect();
const nextMenuPosition = {
left: Math.max(8, Math.min((selectionRect?.left || inputRect.left) + 16, window.innerWidth - 296)),
top: Math.max(8, (selectionRect?.top || inputRect.top) - 8),
};
if (previousChar === "/") {
assistTriggerIndexRef.current = cursor - 1;
setAssistMenuPosition(nextMenuPosition);
setAssistMenu("command");
setAssistMenuActiveIndex(0);
return;
}
closeAssistMenu();
}, [closeAssistMenu, promptReadOnly]);
useEffect(() => {
if (promptReadOnly) closeAssistMenu();
}, [closeAssistMenu, promptReadOnly]);
useEffect(() => {
if (!assistMenu) return;
setAssistMenuActiveIndex((current) =>
activeAssistItems.length === 0 ? 0 : Math.min(current, activeAssistItems.length - 1)
);
}, [activeAssistItems.length, assistMenu]);
const insertAssistValue = useCallback((value: string, trigger: "/" | "@") => {
const input = promptInputRef.current;
const fallbackIndex = promptValue.endsWith(trigger) ? promptValue.length - 1 : promptValue.length;
const triggerIndex = assistTriggerIndexRef.current ?? fallbackIndex;
const shouldReplaceTrigger = promptValue[triggerIndex] === trigger;
const before = shouldReplaceTrigger ? promptValue.slice(0, triggerIndex) : promptValue.slice(0, triggerIndex);
const after = shouldReplaceTrigger ? promptValue.slice(triggerIndex + 1) : promptValue.slice(triggerIndex);
const nextPrompt = `${before}${value}${after}`;
onDraftPatch({ prompt: nextPrompt }, ["prompt"]);
closeAssistMenu();
window.setTimeout(() => {
input?.focus();
const nextCursor = before.length + value.length;
if (input) setContentEditableCaretOffset(input, nextCursor);
}, 0);
}, [closeAssistMenu, onDraftPatch, promptInputRef, promptValue]);
const insertCommand = useCallback((value: string) => {
insertAssistValue(value, "/");
}, [insertAssistValue]);
const selectAssistMenuItem = useCallback((item: AssistMenuItem) => {
if (assistMenu === "command") {
insertCommand(item.value);
}
}, [assistMenu, insertCommand]);
const handlePromptKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
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();
onSubmitShortcut();
}
}, [activeAssistItems, assistMenu, assistMenuActiveIndex, onSubmitShortcut, selectAssistMenuItem]);
const assistMenuOverlay = assistMenu && assistMenuPosition
? (
<Dropdown
open
onOpenChange={(nextOpen) => {
if (nextOpen) return;
closeAssistMenu();
}}
trigger={["click"]}
popupRender={() => (
<div className="nodrag nopan nowheel pointer-events-auto w-72 overflow-hidden rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-2)] shadow-[var(--shadow-panel)]">
<div className="border-b border-[var(--border-subtle)] px-3 py-2 text-[11px] font-medium text-[var(--text-muted)]">
{t("composer.commands")}
</div>
<div className="py-1">
{activeAssistItems.map((item, itemIndex) => {
const isActive = itemIndex === assistMenuActiveIndex;
return (
<button
key={item.key}
type="button"
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-[var(--surface-active)] text-[var(--text-primary)]"
: "text-[var(--text-secondary)] hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
}`}
>
<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-[var(--text-muted)]" />
</button>
);
})}
</div>
</div>
)}
>
<div
aria-hidden="true"
className="pointer-events-none fixed z-[10080] h-px w-72"
style={{
left: assistMenuPosition.left,
top: assistMenuPosition.top,
}}
/>
</Dropdown>
)
: null;
return (
<>
{assistMenuOverlay}
{hasMediaHeader && (
<div className="space-y-2 px-2 pt-2 pr-16">
{hasVideoSubTypeHeader && (
<div className="flex max-w-[24rem] items-center gap-1 overflow-x-auto">
{videoSubTypeOptions.map((option) => {
const selected = option.value === selectedVideoSubType;
const disabled = Boolean(option.disabledReason);
return (
<button
key={option.value}
type="button"
disabled={disabled}
onClick={() => onVideoSubTypeChange?.(option.value)}
title={option.disabledReason ?? option.label}
className={`h-8 shrink-0 rounded-lg border px-3 text-xs transition-colors ${
selected
? "border-[var(--text-primary)] bg-[var(--surface-hover)] text-[var(--text-primary)]"
: disabled
? "cursor-not-allowed border-[var(--border-subtle)] text-[var(--text-disabled)] opacity-60"
: "border-[var(--border-subtle)] text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
}`}
>
{option.label}
</button>
);
})}
</div>
)}
{headerStatusContent ?? (hasReferenceHeader ? (
<div className="flex max-w-[24rem] items-center gap-1.5 overflow-x-auto rounded-lg pr-1">
{connectedTextCards.map((item, index) => {
const edgeId = item.edgeId;
return (
<Tooltip
key={`text-${index}-${item.text.slice(0, 24)}`}
placement="top"
color="#000000"
title={(
<div className="max-h-[160px] max-w-[400px] overflow-hidden whitespace-pre-wrap break-words text-xs">
{item.text}
</div>
)}
>
<div
aria-label={`${t("node.text")}${index + 1}`}
title={item.text}
className="group relative flex h-12 w-12 shrink-0 items-center justify-center rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-1)] text-[var(--text-secondary)]"
>
<FileTextOutlined className="text-lg" />
<span className="pointer-events-none absolute right-0.5 top-0.5 z-10 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--surface-1)] px-1 text-[10px] text-[var(--text-secondary)] transition-opacity group-hover:opacity-0">
{index + 1}
</span>
{edgeId ? (
<button
type="button"
aria-label={t("composer.removeConnection")}
title={t("composer.removeConnection")}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onRemoveConnectedEdge(edgeId);
}}
className="nodrag nopan absolute right-0.5 top-0.5 z-20 flex h-4 w-4 items-center justify-center rounded-full bg-[var(--surface-overlay)] text-[var(--text-on-overlay)] opacity-0 transition hover:bg-[var(--danger)] group-hover:opacity-100 focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/70"
>
<CloseOutlined className="text-[9px]" />
</button>
) : null}
</div>
</Tooltip>
);
})}
{referenceMaterials.map((material, materialIndex) => {
const materialKey = getReferenceMaterialKey(material);
const referenceLabel = material.type === "image"
? t("composer.referenceImage", { index: material.id })
: material.name;
const badgeLabel =
selectedVideoSubType === POPI_VIDEO_SUBTYPE_FIRST_LAST_FRAME && material.type === "image"
? materialIndex === 0
? t("composer.firstFrame")
: materialIndex === 1
? t("composer.lastFrame")
: material.id
: material.id;
const referenceCard = (
<ComposerReferenceMaterialCard
material={material}
label={referenceLabel}
badgeLabel={badgeLabel}
isAudioPlaying={playingReferenceAudioKey === materialKey}
onDragStart={onReferenceMaterialDragStart}
onDrop={onReferenceMaterialDrop}
onRemoveMaterial={onRemoveReferenceMaterial}
onToggleAudio={onToggleReferenceAudio}
onStopAudio={onStopReferenceAudio}
playLabel={t("common.play")}
pauseLabel={t("common.pause")}
removeLabel={t("composer.removeConnection")}
/>
);
if (material.type === "audio") {
return <div key={materialKey}>{referenceCard}</div>;
}
return (
<Popover
key={materialKey}
placement="top"
trigger="hover"
arrow={false}
content={(
<div className="overflow-hidden rounded-lg bg-[var(--media-preview-bg)]">
{material.type === "image" ? (
<img
src={material.url}
alt=""
className="max-h-[320px] max-w-[320px] object-contain"
style={{ minWidth: 120, minHeight: 120 }}
/>
) : (
<video
src={material.url}
muted
autoPlay
loop
playsInline
preload="metadata"
className="max-h-[320px] max-w-[320px] bg-[var(--media-preview-bg)] object-contain"
style={{ minWidth: 120, minHeight: 120 }}
/>
)}
</div>
)}
styles={{
container: { padding: 0 },
content: {
padding: 0,
background: "transparent",
boxShadow: "none",
},
}}
>
<div className="inline-flex">
{referenceCard}
</div>
</Popover>
);
})}
</div>
) : null)}
</div>
)}
<div className="relative shrink-0 px-2 pt-2">
{bodyContent ?? (
<>
<ComposerMentionInput
ref={promptInputRef}
value={promptValue}
readOnly={promptReadOnly}
placeholder={t("composer.mainPlaceholder")}
minHeightPx={promptMinHeightPx}
maxHeightPx={promptMaxHeightPx}
onChange={(nextValue) => {
onDraftPatch({ prompt: nextValue }, ["prompt"]);
syncAssistMenuFromPromptCursor(promptInputRef.current);
}}
onFocus={onPromptFocus}
onClick={(element) => syncAssistMenuFromPromptCursor(element)}
onKeyUp={(event) => {
if (assistMenu && ["ArrowDown", "ArrowUp", "Enter"].includes(event.key)) return;
syncAssistMenuFromPromptCursor(event.currentTarget);
}}
onKeyDown={handlePromptKeyDown}
referenceItems={referenceItems}
referenceMenuLabel={t("composer.referenceMaterials")}
className={`nodrag nopan nowheel w-full overflow-y-auto rounded-lg bg-transparent pr-12 text-sm leading-6 outline-none ${promptReadOnly ? "text-[var(--text-secondary)]" : "text-[var(--text-primary)]"}`}
/>
{promptReadOnly && (
<div className="pb-2 text-[11px] text-cyan-200/80">
{t("composer.promptProvidedByUpstream")}
</div>
)}
</>
)}
</div>
</>
);
}