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

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