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.
437 lines
18 KiB
437 lines
18 KiB
"use client";
|
|
|
|
import { useCallback, useState, useEffect, useMemo, useRef, ReactNode } from "react";
|
|
import { Position, NodeProps, Node } from "@xyflow/react";
|
|
import { BaseNode } from "./BaseNode";
|
|
import { NodeHandle } from "./NodeHandle";
|
|
import { TextNodeResizer } from "./TextNodeResizer";
|
|
import { usePromptAutocomplete } from "@/hooks/usePromptAutocomplete";
|
|
import { useWorkflowStore } from "@/store/workflowStore";
|
|
import { PromptConstructorNodeData, PromptNodeData, LLMGenerateNodeData, AvailableVariable } from "@/types";
|
|
import { resolveTextSourcesThroughRouters } from "@/store/utils/connectedInputs";
|
|
import { selectNodeById } from "@/store/utils/nodesById";
|
|
import { parseVarTags } from "@/utils/parseVarTags";
|
|
import { useI18n } from "@/i18n";
|
|
import { defaultNodeDimensions } from "@/constants/nodeDimensions";
|
|
import { getNodeOutputMediaType, SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles";
|
|
import { useSelectedNodeCount } from "@/hooks/useSelectedNodeCount";
|
|
import { NodeActionCapsule, type NodeActionCapsuleAction } from "./NodeActionCapsule";
|
|
|
|
type PromptConstructorNodeType = Node<PromptConstructorNodeData, "promptConstructor">;
|
|
const PROMPT_CONSTRUCTOR_DIMENSIONS = defaultNodeDimensions.promptConstructor;
|
|
|
|
function EmptyPromptConstructorIcon() {
|
|
return (
|
|
<svg className="w-8 h-8 text-neutral-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M7 4h7l3 3v13H7z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 4v4h4M9 12h6M9 15h4" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 9 3 11l2 2M19 13l2-2-2-2" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function DeleteIcon() {
|
|
return (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 7h12M9 7V5h6v2m-8 0 1 13h8l1-13" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptConstructorNodeType>) {
|
|
const { t } = useI18n();
|
|
const nodeData = data;
|
|
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
|
|
const removeNode = useWorkflowStore((state) => state.removeNode);
|
|
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
|
|
|
|
// Local state for template to prevent cursor jumping
|
|
const [localTemplate, setLocalTemplate] = useState(nodeData.template);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const wasSelectedRef = useRef(selected);
|
|
|
|
// Sync from props when not actively editing
|
|
useEffect(() => {
|
|
if (!isEditing) {
|
|
setLocalTemplate(nodeData.template);
|
|
}
|
|
}, [nodeData.template, isEditing]);
|
|
|
|
// Get available variables from connected prompt nodes (named variables + inline
|
|
// <var> tags). Computed inside the store selector and returned as a JSON string
|
|
// so the subscription is Object.is-stable during drag (variables depend on
|
|
// upstream text data, not node positions) — no re-render on unrelated drags.
|
|
const availableVariablesJson = useWorkflowStore((state) => {
|
|
const directTextNodes = state.edges
|
|
.filter((e) => e.target === id && e.targetHandle === SINGLE_INPUT_HANDLE_ID)
|
|
.map((e) => selectNodeById(state.nodes, e.source))
|
|
.filter((n): n is NonNullable<typeof n> => n !== undefined && getNodeOutputMediaType(n) === "text");
|
|
const connectedTextNodes = resolveTextSourcesThroughRouters(directTextNodes, state.nodes, state.edges);
|
|
|
|
const vars: AvailableVariable[] = [];
|
|
const usedNames = new Set<string>();
|
|
|
|
// First pass: named variables from Prompt nodes (these take precedence)
|
|
connectedTextNodes.forEach((node) => {
|
|
if (node.type === "prompt") {
|
|
const promptData = node.data as PromptNodeData;
|
|
if (promptData.variableName) {
|
|
vars.push({
|
|
name: promptData.variableName,
|
|
value: promptData.prompt || "",
|
|
nodeId: node.id,
|
|
});
|
|
usedNames.add(promptData.variableName);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Second pass: parse inline <var> tags from all connected text nodes
|
|
connectedTextNodes.forEach((node) => {
|
|
let text: string | null = null;
|
|
if (node.type === "prompt") {
|
|
text = (node.data as PromptNodeData).prompt || null;
|
|
} else if (node.type === "llmGenerate") {
|
|
text = (node.data as LLMGenerateNodeData).outputText || null;
|
|
} else if (node.type === "promptConstructor") {
|
|
const pcData = node.data as PromptConstructorNodeData;
|
|
text = pcData.outputText ?? pcData.template ?? null;
|
|
}
|
|
|
|
if (text) {
|
|
const parsed = parseVarTags(text);
|
|
parsed.forEach(({ name, value }) => {
|
|
if (!usedNames.has(name)) {
|
|
vars.push({
|
|
name,
|
|
value,
|
|
nodeId: `${node.id}-var-${name}`,
|
|
});
|
|
usedNames.add(name);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
return JSON.stringify(vars);
|
|
});
|
|
|
|
const availableVariables = useMemo(
|
|
(): AvailableVariable[] => JSON.parse(availableVariablesJson) as AvailableVariable[],
|
|
[availableVariablesJson]
|
|
);
|
|
|
|
// Autocomplete via shared hook
|
|
const {
|
|
showAutocomplete,
|
|
autocompletePosition,
|
|
filteredAutocompleteVars,
|
|
selectedAutocompleteIndex,
|
|
handleChange,
|
|
handleKeyDown,
|
|
handleAutocompleteSelect,
|
|
closeAutocomplete,
|
|
} = usePromptAutocomplete({
|
|
availableVariables,
|
|
textareaRef,
|
|
localTemplate,
|
|
setLocalTemplate,
|
|
onTemplateCommit: (newTemplate) => updateNodeData(id, { template: newTemplate }),
|
|
});
|
|
|
|
// Compute unresolved variables client-side
|
|
const unresolvedVars = useMemo(() => {
|
|
const varPattern = /@(\w+)/g;
|
|
const unresolved: string[] = [];
|
|
const matches = localTemplate.matchAll(varPattern);
|
|
const availableNames = new Set(availableVariables.map(v => v.name));
|
|
|
|
for (const match of matches) {
|
|
const varName = match[1];
|
|
if (!availableNames.has(varName) && !unresolved.includes(varName)) {
|
|
unresolved.push(varName);
|
|
}
|
|
}
|
|
|
|
return unresolved;
|
|
}, [localTemplate, availableVariables]);
|
|
|
|
// Compute resolved text client-side for preview (single-pass to avoid prefix collisions)
|
|
const resolvedPreview = useMemo(() => {
|
|
const valueMap = new Map(availableVariables.map(v => [v.name, v.value]));
|
|
return localTemplate.replace(/@(\w+)/g, (match, name) => valueMap.get(name) ?? match);
|
|
}, [localTemplate, availableVariables]);
|
|
|
|
// Sync resolved text to outputText so downstream nodes can read it before execution
|
|
useEffect(() => {
|
|
const valueMap = new Map(availableVariables.map(v => [v.name, v.value]));
|
|
const resolved = nodeData.template.replace(/@(\w+)/g, (match, name) => valueMap.get(name) ?? match);
|
|
const outputValue = resolved || null;
|
|
if (outputValue !== nodeData.outputText) {
|
|
updateNodeData(id, { outputText: outputValue });
|
|
}
|
|
}, [nodeData.template, availableVariables, id, updateNodeData, nodeData.outputText]);
|
|
|
|
const highlightRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Sync highlight overlay scroll with textarea
|
|
const handleScroll = useCallback(() => {
|
|
if (textareaRef.current && highlightRef.current) {
|
|
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
|
|
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
|
|
}
|
|
}, []);
|
|
|
|
// Build highlighted text with blue for resolved, red for unresolved variables
|
|
const highlightedContent = useMemo((): ReactNode[] => {
|
|
const availableNames = new Set(availableVariables.map(v => v.name));
|
|
const pattern = /@(\w+)/g;
|
|
const parts: ReactNode[] = [];
|
|
let lastIndex = 0;
|
|
const matches = localTemplate.matchAll(pattern);
|
|
for (const match of matches) {
|
|
const idx = match.index!;
|
|
if (idx > lastIndex) {
|
|
parts.push(localTemplate.slice(lastIndex, idx));
|
|
}
|
|
const isResolved = availableNames.has(match[1]);
|
|
parts.push(
|
|
<mark key={idx} className={`${isResolved ? "bg-blue-400/30" : "bg-red-400/50"} text-transparent rounded-sm px-0.5 -mx-0.5 py-0.5`}>{match[0]}</mark>
|
|
);
|
|
lastIndex = idx + match[0].length;
|
|
}
|
|
if (lastIndex < localTemplate.length) {
|
|
parts.push(localTemplate.slice(lastIndex));
|
|
}
|
|
return parts;
|
|
}, [localTemplate, availableVariables]);
|
|
|
|
useEffect(() => {
|
|
if (!isEditing) return;
|
|
const frameId = window.requestAnimationFrame(() => {
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) return;
|
|
textarea.focus();
|
|
const cursorPosition = textarea.value.length;
|
|
textarea.setSelectionRange(cursorPosition, cursorPosition);
|
|
});
|
|
return () => window.cancelAnimationFrame(frameId);
|
|
}, [isEditing]);
|
|
|
|
const handleFocus = useCallback(() => {
|
|
setIsEditing(true);
|
|
}, []);
|
|
|
|
const handleBlur = useCallback(() => {
|
|
setIsEditing(false);
|
|
if (localTemplate !== nodeData.template) {
|
|
updateNodeData(id, { template: localTemplate });
|
|
}
|
|
// Close autocomplete on blur
|
|
setTimeout(() => closeAutocomplete(), 200);
|
|
}, [id, localTemplate, nodeData.template, updateNodeData, closeAutocomplete]);
|
|
|
|
useEffect(() => {
|
|
if (wasSelectedRef.current && !selected && isEditing) {
|
|
setIsEditing(false);
|
|
if (localTemplate !== nodeData.template) {
|
|
updateNodeData(id, { template: localTemplate });
|
|
}
|
|
setTimeout(() => closeAutocomplete(), 200);
|
|
}
|
|
wasSelectedRef.current = selected;
|
|
}, [closeAutocomplete, id, isEditing, localTemplate, nodeData.template, selected, updateNodeData]);
|
|
|
|
const handleStartEditing = useCallback(() => {
|
|
setIsEditing(true);
|
|
}, []);
|
|
|
|
const handleDelete = useCallback((e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
removeNode(id);
|
|
}, [id, removeNode]);
|
|
|
|
const templateText = localTemplate.trim();
|
|
const showFooter = availableVariables.length > 0 || unresolvedVars.length > 0;
|
|
const selectedNodeCount = useSelectedNodeCount();
|
|
const showSelectedActions = Boolean(selected && selectedNodeCount === 1);
|
|
const selectedActions: NodeActionCapsuleAction[] = [
|
|
{
|
|
key: "delete",
|
|
icon: <DeleteIcon />,
|
|
label: t("prompt.deleteNode"),
|
|
text: t("prompt.deleteNode"),
|
|
tone: "danger",
|
|
onClick: handleDelete,
|
|
},
|
|
];
|
|
|
|
const handleToolbarMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<BaseNode
|
|
id={id}
|
|
selected={selected}
|
|
minWidth={PROMPT_CONSTRUCTOR_DIMENSIONS.width}
|
|
minHeight={PROMPT_CONSTRUCTOR_DIMENSIONS.height}
|
|
fullBleed
|
|
className={selected ? "!border-[var(--node-selected-border)] !ring-0" : ""}
|
|
>
|
|
<TextNodeResizer
|
|
id={id}
|
|
selected={selected}
|
|
minWidth={PROMPT_CONSTRUCTOR_DIMENSIONS.width}
|
|
minHeight={PROMPT_CONSTRUCTOR_DIMENSIONS.height}
|
|
/>
|
|
{showSelectedActions && !isEditing && (
|
|
<NodeActionCapsule actions={selectedActions} />
|
|
)}
|
|
|
|
{isEditing && (
|
|
<NodeActionCapsule actions={selectedActions} onMouseDown={handleToolbarMouseDown} />
|
|
)}
|
|
|
|
{/* Text input handle */}
|
|
<NodeHandle
|
|
type="target"
|
|
position={Position.Left}
|
|
id={SINGLE_INPUT_HANDLE_ID}
|
|
handleType="generic-input"
|
|
style={{ zIndex: 10 }}
|
|
/>
|
|
|
|
{isEditing ? (
|
|
<div className="flex h-full w-full items-center justify-center bg-[var(--surface-1)] p-8">
|
|
<div className="relative h-full min-h-28 w-full rounded-lg border-2 border-dashed border-[var(--border-subtle)] bg-[var(--surface-1)] transition-colors focus-within:border-[var(--border-strong)] focus-within:bg-[var(--surface-hover)]">
|
|
{highlightedContent.length > 0 && (
|
|
<div
|
|
ref={highlightRef}
|
|
className={`pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words rounded-lg p-5 text-lg leading-relaxed text-transparent ${showFooter ? "pb-10" : ""}`}
|
|
aria-hidden="true"
|
|
>
|
|
{highlightedContent}
|
|
</div>
|
|
)}
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={localTemplate}
|
|
onChange={handleChange}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
onKeyDown={handleKeyDown}
|
|
onScroll={handleScroll}
|
|
placeholder="Type @ to insert variables..."
|
|
className={`nodrag nopan nowheel relative h-full min-h-28 w-full resize-none rounded-lg p-5 text-lg leading-relaxed text-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] ${highlightedContent.length > 0 ? "bg-transparent" : "bg-transparent"} ${showFooter ? "pb-10" : ""}`}
|
|
title={resolvedPreview ? `Preview: ${resolvedPreview}` : undefined}
|
|
/>
|
|
|
|
{showAutocomplete && filteredAutocompleteVars.length > 0 && (
|
|
<div
|
|
className="absolute z-20 max-h-40 overflow-y-auto rounded border border-neutral-600 bg-neutral-800 shadow-xl"
|
|
style={{
|
|
top: autocompletePosition.top,
|
|
left: autocompletePosition.left,
|
|
}}
|
|
>
|
|
{filteredAutocompleteVars.map((variable, index) => (
|
|
<button
|
|
key={variable.nodeId}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
handleAutocompleteSelect(variable.name);
|
|
}}
|
|
className={`flex w-full flex-col gap-0.5 px-3 py-2 text-left text-[11px] transition-colors ${
|
|
index === selectedAutocompleteIndex
|
|
? "bg-neutral-700 text-neutral-100"
|
|
: "text-neutral-300 hover:bg-neutral-700"
|
|
}`}
|
|
>
|
|
<div className="font-medium text-blue-400">@{variable.name}</div>
|
|
<div className="max-w-[200px] truncate text-neutral-500">
|
|
{variable.value || "(empty)"}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{showFooter && (
|
|
<div className="pointer-events-none absolute bottom-0 left-0 right-0 z-10 flex items-center justify-between gap-2 rounded-b-lg bg-neutral-900/90 px-3 py-1.5 text-[10px]">
|
|
<span className="truncate text-neutral-500">
|
|
{availableVariables.length > 0 ? `Available: ${availableVariables.map(v => `@${v.name}`).join(", ")}` : ""}
|
|
</span>
|
|
{unresolvedVars.length > 0 && (
|
|
<span className="whitespace-nowrap text-red-400">
|
|
{unresolvedVars.length} {unresolvedVars.length === 1 ? "var" : "vars"} missing
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center bg-[var(--surface-1)] p-8">
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={t("prompt.doubleClickToEdit")}
|
|
onDoubleClick={handleStartEditing}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
selectSingleNode(id);
|
|
setIsEditing(true);
|
|
}
|
|
}}
|
|
className={`nowheel flex h-full min-h-28 w-full cursor-text rounded-lg border-2 border-dashed border-[var(--border-subtle)] bg-[var(--surface-1)] p-5 text-center outline-none transition-colors hover:border-[var(--border-strong)] hover:bg-[var(--surface-hover)] ${
|
|
templateText
|
|
? "items-start justify-start overflow-hidden"
|
|
: "items-center justify-center"
|
|
}`}
|
|
title={resolvedPreview ? `Preview: ${resolvedPreview}` : undefined}
|
|
>
|
|
{templateText ? (
|
|
<div className="nowheel max-h-full w-full overflow-y-auto whitespace-pre-wrap break-words pr-1 text-left text-lg leading-relaxed text-[var(--text-primary)] select-text">
|
|
{localTemplate}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center text-[var(--text-muted)]">
|
|
<EmptyPromptConstructorIcon />
|
|
<span className="mt-2 text-xs text-[var(--text-muted)]">{t("prompt.doubleClickToEdit")}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!isEditing && showFooter && (
|
|
<div className="pointer-events-none absolute bottom-8 left-8 right-8 z-10 flex items-center justify-between gap-2 rounded-b-lg bg-neutral-900/90 px-3 py-1.5 text-[10px]">
|
|
<span className="truncate text-neutral-500">
|
|
{availableVariables.length > 0 ? `Available: ${availableVariables.map(v => `@${v.name}`).join(", ")}` : ""}
|
|
</span>
|
|
{unresolvedVars.length > 0 && (
|
|
<span className="whitespace-nowrap text-red-400">
|
|
{unresolvedVars.length} {unresolvedVars.length === 1 ? "var" : "vars"} missing
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Text output handle */}
|
|
<NodeHandle
|
|
type="source"
|
|
position={Position.Right}
|
|
id={SINGLE_OUTPUT_HANDLE_ID}
|
|
handleType="text"
|
|
style={{ zIndex: 10 }}
|
|
/>
|
|
</BaseNode>
|
|
</>
|
|
);
|
|
}
|
|
|