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
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>
|
|
</>
|
|
);
|
|
}
|
|
|