"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Handle, Position, NodeProps, Node } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { useWorkflowStore } from "@/store/workflowStore"; import { LLMGenerateNodeData, LLMProvider, LLMModelType } from "@/types"; import { useInlineParameters } from "@/hooks/useInlineParameters"; import { InlineParameterPanel } from "./InlineParameterPanel"; import { GenerationPromptInput, LIBTV_TEXT_PROMPT_PLACEHOLDER } from "./GenerationPromptInput"; import { SettingsTabBar } from "./SettingsTabBar"; import { TextNodePlaceholder } from "./TextNodePlaceholder"; import { useShowHandleLabels } from "@/hooks/useShowHandleLabels"; import { HandleLabel } from "./HandleLabel"; // LLM providers and models const LLM_PROVIDERS: { value: LLMProvider; label: string }[] = [ { value: "google", label: "Google" }, { value: "openai", label: "OpenAI" }, { value: "anthropic", label: "Anthropic" }, ]; const LLM_MODELS: Record = { google: [ { value: "gemini-3-flash-preview", label: "Gemini 3 Flash" }, { value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, { value: "gemini-3-pro-preview", label: "Gemini 3.0 Pro" }, { value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro" }, ], openai: [ { value: "gpt-4.1-mini", label: "GPT-4.1 Mini" }, { value: "gpt-4.1-nano", label: "GPT-4.1 Nano" }, ], anthropic: [ { value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5" }, { value: "claude-haiku-4.5", label: "Claude Haiku 4.5" }, { value: "claude-opus-4.6", label: "Claude Opus 4.6" }, ], }; type LLMGenerateNodeType = Node; export function LLMGenerateNode({ id, data, selected }: NodeProps) { const nodeData = data; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const isRunning = useWorkflowStore((state) => state.isRunning); const showLabels = useShowHandleLabels(selected); // Inline parameters infrastructure const { inlineParametersEnabled } = useInlineParameters(); const shouldShowInlineParameters = inlineParametersEnabled && !!selected; const handleRegenerate = useCallback(() => { regenerateNode(id); }, [id, regenerateNode]); const handleClearOutput = useCallback(() => { updateNodeData(id, { outputText: null, status: "idle", error: null }); }, [id, updateNodeData]); const [copied, setCopied] = useState(false); const [settingsTab, setSettingsTab] = useState<"primary" | "fallback">("primary"); useEffect(() => { if (!nodeData.fallbackModel && settingsTab === "fallback") { setSettingsTab("primary"); } }, [nodeData.fallbackModel, settingsTab]); const handleCopyOutput = useCallback(async () => { if (nodeData.outputText) { try { await navigator.clipboard.writeText(nodeData.outputText); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch (err) { console.error("Failed to copy text:", err); } } }, [nodeData.outputText]); // Inline parameters: shown while the node is selected, collapsed otherwise. const isParamsExpanded = shouldShowInlineParameters; const handleToggleParams = useCallback(() => { updateNodeData(id, { parametersExpanded: !isParamsExpanded }); }, [id, isParamsExpanded, updateNodeData]); const handleInlinePromptChange = useCallback((inputPrompt: string | null) => { updateNodeData(id, { inputPrompt }); }, [id, updateNodeData]); // LLM parameter handlers const handleProviderChange = useCallback( (e: React.ChangeEvent) => { const newProvider = e.target.value as LLMProvider; const firstModelForProvider = LLM_MODELS[newProvider][0].value; const updates: Partial = { provider: newProvider, model: firstModelForProvider, }; if (newProvider === "anthropic" && (nodeData.temperature ?? 0.7) > 1) { updates.temperature = 1; } updateNodeData(id, updates); }, [id, nodeData.temperature, updateNodeData] ); const handleModelChange = useCallback( (e: React.ChangeEvent) => { updateNodeData(id, { model: e.target.value as LLMModelType }); }, [id, updateNodeData] ); const handleTemperatureChange = useCallback( (e: React.ChangeEvent) => { updateNodeData(id, { temperature: parseFloat(e.target.value) }); }, [id, updateNodeData] ); const handleMaxTokensChange = useCallback( (e: React.ChangeEvent) => { updateNodeData(id, { maxTokens: parseInt(e.target.value, 10) }); }, [id, updateNodeData] ); const provider = nodeData.provider || "google"; const availableModels = LLM_MODELS[provider] || LLM_MODELS.google; const currentModel = nodeData.model || availableModels[0].value; const currentModelLabel = availableModels.find((model) => model.value === currentModel)?.label || currentModel; const libtvTextModelValue = `${provider}|${currentModel}`; const libtvTextModelOptions = useMemo(() => ( LLM_PROVIDERS.flatMap((providerOption) => LLM_MODELS[providerOption.value].map((model) => ({ value: `${providerOption.value}|${model.value}`, label: model.label, })) ) ), []); const handleLibtvTextModelChange = useCallback((value: string) => { const [nextProviderRaw, nextModelRaw] = value.split("|"); const nextProvider = nextProviderRaw as LLMProvider; const nextModel = nextModelRaw as LLMModelType; if (!LLM_MODELS[nextProvider]?.some((model) => model.value === nextModel)) return; const updates: Partial = { provider: nextProvider, model: nextModel, }; if (nextProvider === "anthropic" && (nodeData.temperature ?? 0.7) > 1) { updates.temperature = 1; } updateNodeData(id, updates); }, [id, nodeData.temperature, updateNodeData]); return ( ) : undefined} > {/* Image input - optional */} {/* Text input */} {/* Text output */}
{nodeData.status === "loading" ? (
) : nodeData.status === "error" ? (
Generation failed {nodeData.error && ( {nodeData.error} )}
) : nodeData.outputText ? (
{nodeData.__usedFallback && (
Fallback used
)}

{nodeData.outputText}

) : (
Run to generate
)}
); }