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.
221 lines
8.0 KiB
221 lines
8.0 KiB
"use client";
|
|
|
|
import { useCallback } 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";
|
|
|
|
const PROVIDERS: { value: LLMProvider; label: string }[] = [
|
|
{ value: "google", label: "Google" },
|
|
{ value: "openai", label: "OpenAI" },
|
|
];
|
|
|
|
const MODELS: Record<LLMProvider, { value: LLMModelType; label: string }[]> = {
|
|
google: [
|
|
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
|
{ value: "gemini-3-pro-preview", label: "Gemini 3.0 Pro" },
|
|
],
|
|
openai: [
|
|
{ value: "gpt-4.1-mini", label: "GPT-4.1 Mini" },
|
|
{ value: "gpt-4.1-nano", label: "GPT-4.1 Nano" },
|
|
],
|
|
};
|
|
|
|
type LLMGenerateNodeType = Node<LLMGenerateNodeData, "llmGenerate">;
|
|
|
|
export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNodeType>) {
|
|
const nodeData = data;
|
|
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
|
|
|
|
const handleProviderChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
const newProvider = e.target.value as LLMProvider;
|
|
const firstModelForProvider = MODELS[newProvider][0].value;
|
|
updateNodeData(id, {
|
|
provider: newProvider,
|
|
model: firstModelForProvider
|
|
});
|
|
},
|
|
[id, updateNodeData]
|
|
);
|
|
|
|
const handleModelChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
updateNodeData(id, { model: e.target.value as LLMModelType });
|
|
},
|
|
[id, updateNodeData]
|
|
);
|
|
|
|
const handleTemperatureChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
updateNodeData(id, { temperature: parseFloat(e.target.value) });
|
|
},
|
|
[id, updateNodeData]
|
|
);
|
|
|
|
const handleMaxTokensChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
updateNodeData(id, { maxTokens: parseInt(e.target.value, 10) });
|
|
},
|
|
[id, updateNodeData]
|
|
);
|
|
|
|
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
|
|
const isRunning = useWorkflowStore((state) => state.isRunning);
|
|
|
|
const handleRegenerate = useCallback(() => {
|
|
regenerateNode(id);
|
|
}, [id, regenerateNode]);
|
|
|
|
const handleClearOutput = useCallback(() => {
|
|
updateNodeData(id, { outputText: null, status: "idle", error: null });
|
|
}, [id, updateNodeData]);
|
|
|
|
const availableModels = MODELS[nodeData.provider];
|
|
|
|
return (
|
|
<BaseNode
|
|
id={id}
|
|
title="LLM Generate"
|
|
selected={selected}
|
|
hasError={nodeData.status === "error"}
|
|
>
|
|
{/* Text input */}
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
id="text"
|
|
style={{ top: "50%" }}
|
|
data-handletype="text"
|
|
/>
|
|
{/* Text output */}
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id="text"
|
|
data-handletype="text"
|
|
/>
|
|
|
|
<div className="flex-1 flex flex-col min-h-0 gap-2">
|
|
{/* Output preview area */}
|
|
<div className="relative w-full flex-1 min-h-[80px] border border-dashed border-neutral-600 rounded p-2 overflow-auto">
|
|
{nodeData.status === "loading" ? (
|
|
<div className="h-full flex items-center justify-center">
|
|
<svg
|
|
className="w-4 h-4 animate-spin text-neutral-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="3"
|
|
/>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
) : nodeData.status === "error" ? (
|
|
<span className="text-[10px] text-red-400">
|
|
{nodeData.error || "Failed"}
|
|
</span>
|
|
) : nodeData.outputText ? (
|
|
<>
|
|
<p className="text-[10px] text-neutral-300 whitespace-pre-wrap break-words pr-6">
|
|
{nodeData.outputText}
|
|
</p>
|
|
<div className="absolute top-1 right-1 flex gap-1">
|
|
<button
|
|
onClick={handleRegenerate}
|
|
disabled={isRunning}
|
|
className="w-5 h-5 bg-neutral-900/80 hover:bg-blue-600/80 disabled:opacity-50 disabled:cursor-not-allowed rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
|
|
title="Regenerate"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={handleClearOutput}
|
|
className="w-5 h-5 bg-neutral-900/80 hover:bg-red-600/80 rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
|
|
title="Clear output"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="h-full flex items-center justify-center">
|
|
<span className="text-neutral-500 text-[10px]">
|
|
Run to generate
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Provider selector */}
|
|
<select
|
|
value={nodeData.provider}
|
|
onChange={handleProviderChange}
|
|
className="w-full text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300 shrink-0"
|
|
>
|
|
{PROVIDERS.map((p) => (
|
|
<option key={p.value} value={p.value}>
|
|
{p.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Model selector */}
|
|
<select
|
|
value={nodeData.model}
|
|
onChange={handleModelChange}
|
|
className="w-full text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300 shrink-0"
|
|
>
|
|
{availableModels.map((m) => (
|
|
<option key={m.value} value={m.value}>
|
|
{m.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Temperature and Max Tokens row */}
|
|
<div className="flex gap-1.5 shrink-0">
|
|
<div className="flex-1 flex flex-col gap-0.5">
|
|
<label className="text-[9px] text-neutral-500">Temp: {nodeData.temperature.toFixed(1)}</label>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="2"
|
|
step="0.1"
|
|
value={nodeData.temperature}
|
|
onChange={handleTemperatureChange}
|
|
className="w-full h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-neutral-400"
|
|
/>
|
|
</div>
|
|
<select
|
|
value={nodeData.maxTokens}
|
|
onChange={handleMaxTokensChange}
|
|
className="w-16 text-[10px] py-1 px-1 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300"
|
|
title="Max tokens"
|
|
>
|
|
<option value={256}>256</option>
|
|
<option value={512}>512</option>
|
|
<option value={1024}>1K</option>
|
|
<option value={2048}>2K</option>
|
|
<option value={4096}>4K</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</BaseNode>
|
|
);
|
|
}
|
|
|