Prompt Crafter Node

PreviousNext

A React Flow node component for building dynamic prompts by combining multiple inputs using a template-based approach.

Docs
simple-aiui

Preview

Loading preview…
./src/registry/ui/flow/prompt-crafter-node.tsx
"use client";

import { StreamLanguage } from "@codemirror/language";
import type { EditorView } from "@codemirror/view";
import { tags as t } from "@lezer/highlight";
import { createTheme } from "@uiw/codemirror-themes";
import CodeMirror from "@uiw/react-codemirror";
import type { Node } from "@xyflow/react";
import {
	type NodeProps,
	Position,
	useUpdateNodeInternals,
} from "@xyflow/react";
import { BetweenVerticalEnd, PencilRuler, Plus, Trash } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
	Command,
	CommandEmpty,
	CommandGroup,
	CommandInput,
	CommandItem,
	CommandList,
} from "@/components/ui/command";
import {
	Popover,
	PopoverContent,
	PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { BaseNode } from "@/registry/ui/flow/base-node";
import {
	EditableHandle,
	EditableHandleDialog,
} from "@/registry/ui/flow/editable-handle";
import { LabeledHandle } from "@/registry/ui/flow/labeled-handle";
import {
	NodeHeader,
	NodeHeaderAction,
	NodeHeaderActions,
	NodeHeaderIcon,
	NodeHeaderTitle,
} from "@/registry/ui/flow/node-header";
import { NodeHeaderStatus } from "@/registry/ui/flow/node-header-status";

type PromptCrafterData = {
	status: "processing" | "error" | "success" | "idle" | undefined;
	config: {
		template: string;
	};
	dynamicHandles: {
		"template-tags": {
			id: string;
			name: string;
		}[];
	};
};

export type PromptCrafterNode = Node<PromptCrafterData, "prompt-crafter">;

interface PromptCrafterProps extends NodeProps<PromptCrafterNode> {
	onPromptTextChange: (value: string) => void;
	onCreateInput: (name: string) => boolean;
	onRemoveInput: (handleId: string) => void;
	onUpdateInputName: (handleId: string, newLabel: string) => boolean;
	onDeleteNode: () => void;
}

// Custom theme that matches your app's design
const promptTheme = createTheme({
	theme: "dark",
	settings: {
		background: "transparent",
		foreground: "hsl(var(--foreground))",
		caret: "black",
		selection: "#3B82F6",
		lineHighlight: "transparent",
	},
	styles: [
		{ tag: t.variableName, color: "#10c43d" },
		{ tag: t.string, color: "hsl(var(--foreground))" },
		{ tag: t.invalid, color: "#DC2626" },
	],
});

// Create a function to generate the language with the current inputs
const createPromptLanguage = (validInputs: string[] = []) =>
	StreamLanguage.define({
		token(stream) {
			if (stream.match(/{{[^}]*}}/)) {
				const match = stream.current();
				const inputName = match.slice(2, -2);
				// Check if the input name is valid
				if (validInputs.includes(inputName)) {
					return "variableName";
				}
				return "invalid";
			}
			stream.next();
			return null;
		},
	});

export function PromptCrafterNode({
	id,
	selected,
	deletable,
	data,
	onPromptTextChange,
	onCreateInput,
	onRemoveInput,
	onUpdateInputName,
	onDeleteNode,
}: PromptCrafterProps) {
	const updateNodeInternals = useUpdateNodeInternals();
	const [isPopoverOpen, setIsPopoverOpen] = useState(false);
	const editorViewRef = useRef<EditorView | null>(null);

	const handleCreateInput = useCallback(
		(name: string) => {
			const result = onCreateInput(name);
			if (result) {
				updateNodeInternals(id);
			}
			return result;
		},
		[onCreateInput, id, updateNodeInternals],
	);

	const handleRemoveInput = useCallback(
		(handleId: string) => {
			onRemoveInput(handleId);
			updateNodeInternals(id);
		},
		[onRemoveInput, id, updateNodeInternals],
	);

	const handleUpdateInputName = useCallback(
		(handleId: string, newLabel: string) => {
			const result = onUpdateInputName(handleId, newLabel);
			if (result) {
				updateNodeInternals(id);
			}
			return result;
		},
		[onUpdateInputName, id, updateNodeInternals],
	);

	const insertInputAtCursor = useCallback((inputName: string) => {
		const view = editorViewRef.current;
		if (!view) {
			return;
		}

		const inputTag = `{{${inputName}}}`;
		const from = view.state.selection.main.from;
		view.dispatch({
			changes: { from, insert: inputTag },
			selection: { anchor: from + inputTag.length },
		});
		setIsPopoverOpen(false);
	}, []);

	// Create language with current inputs
	const extensions = useMemo(() => {
		const validLabels = (data.dynamicHandles["template-tags"] || []).map(
			(input) => input.name,
		);
		return [createPromptLanguage(validLabels)];
	}, [data.dynamicHandles["template-tags"]]);

	return (
		<BaseNode
			selected={selected}
			className={cn("w-[350px] p-0 hover:ring-orange-500", {
				"border-orange-500": data.status === "processing",
				"border-red-500": data.status === "error",
			})}
		>
			<NodeHeader className="m-0">
				<NodeHeaderIcon>
					<PencilRuler />
				</NodeHeaderIcon>
				<NodeHeaderTitle>Prompt Crafter</NodeHeaderTitle>
				<NodeHeaderActions>
					<NodeHeaderStatus status={data.status} />
					{deletable && (
						<NodeHeaderAction
							onClick={onDeleteNode}
							variant="ghost"
							label="Delete node"
						>
							<Trash />
						</NodeHeaderAction>
					)}
				</NodeHeaderActions>
			</NodeHeader>
			<Separator />
			<div className="p-2">
				<div className="flex items-center gap-2 mb-1">
					<Popover
						open={isPopoverOpen}
						onOpenChange={setIsPopoverOpen}
					>
						<PopoverTrigger asChild>
							<Button
								variant="outline"
								size="sm"
								className="h-7 px-2"
							>
								<BetweenVerticalEnd className="h-4 w-4 mr-1" />
								Insert Input into Prompt
							</Button>
						</PopoverTrigger>
						<PopoverContent className="p-0" align="start">
							<Command>
								<CommandInput placeholder="Search inputs..." />
								<CommandList>
									<CommandEmpty>
										No inputs found.
									</CommandEmpty>
									<CommandGroup>
										{data.dynamicHandles[
											"template-tags"
										]?.map(
											(input) =>
												input.name && (
													<CommandItem
														key={input.id}
														onSelect={() =>
															insertInputAtCursor(
																input.name,
															)
														}
														className="text-base"
													>
														{input.name}
													</CommandItem>
												),
										)}
									</CommandGroup>
								</CommandList>
							</Command>
						</PopoverContent>
					</Popover>
				</div>
				<CodeMirror
					value={data.config.template || ""}
					height="150px"
					theme={promptTheme}
					extensions={extensions}
					onChange={onPromptTextChange}
					onCreateEditor={(view) => {
						editorViewRef.current = view;
					}}
					className="nodrag border rounded-md overflow-hidden [&_.cm-content]:!cursor-text [&_.cm-line]:!cursor-text nodrag nopan nowheel"
					placeholder="Craft your prompt here... Use {{input-name}} to reference inputs"
					basicSetup={{
						lineNumbers: false,
						foldGutter: false,
						dropCursor: false,
						allowMultipleSelections: false,
						indentOnInput: false,
					}}
				/>
			</div>
			<div className="grid grid-cols-[2fr,1fr] pb-2 text-sm gap-4">
				<div className="flex flex-col min-w-0">
					<div className="flex items-center justify-between py-2 px-4 bg-muted rounded-r-xl">
						<span className="text-sm font-medium">Inputs</span>
						<EditableHandleDialog
							variant="create"
							label=""
							onSave={handleCreateInput}
							onCancel={() => {}}
							align="end"
						>
							<Button
								variant="outline"
								size="sm"
								className="w-fit h-7 px-2 mx-1"
							>
								<Plus className="h-4 w-4 mr-1" />
								New Input
							</Button>
						</EditableHandleDialog>
					</div>
					{data.dynamicHandles["template-tags"]?.map((input) => (
						<EditableHandle
							key={input.id}
							nodeId={id}
							handleId={input.id}
							name={input.name}
							type="target"
							position={Position.Left}
							wrapperClassName="w-full"
							onUpdateTool={handleUpdateInputName}
							onDelete={handleRemoveInput}
						/>
					))}
				</div>
				<div className="self-stretch border-l border-border flex items-center justify-end">
					<LabeledHandle
						id="result"
						title="Result"
						type="source"
						position={Position.Right}
					/>
				</div>
			</div>
		</BaseNode>
	);
}

Installation

npx shadcn@latest add @simple-ai/prompt-crafter-node

Usage

import { PromptCrafterNode } from "@/components/ui/prompt-crafter-node"
<PromptCrafterNode />