232 lines
7.5 KiB
TypeScript
232 lines
7.5 KiB
TypeScript
// File: frontend/app/workflow/components/LLMNode.tsx
|
||
// Description: 自定义 LLM 节点组件
|
||
|
||
import React, {
|
||
useState,
|
||
useCallback,
|
||
useEffect,
|
||
memo,
|
||
ChangeEvent,
|
||
} from "react";
|
||
import {
|
||
Handle,
|
||
Position,
|
||
useUpdateNodeInternals,
|
||
useReactFlow,
|
||
Node,
|
||
} from "reactflow";
|
||
import { BrainCircuit } from "lucide-react";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Slider } from "@/components/ui/slider";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
type LLMNodeData,
|
||
type CustomNodeProps,
|
||
availableModels,
|
||
} from "./types"; // 导入类型和模型列表
|
||
|
||
const LLMNodeComponent = ({
|
||
id,
|
||
data,
|
||
isConnectable,
|
||
}: CustomNodeProps<LLMNodeData>) => {
|
||
// const [currentData, setCurrentData] = useState<LLMNodeData>(data);
|
||
const updateNodeInternals = useUpdateNodeInternals();
|
||
const { setNodes } = useReactFlow<LLMNodeData>(); // 获取 setNodes 方法
|
||
|
||
// 处理内部表单变化的通用回调 - 直接更新 React Flow 主状态
|
||
const handleDataChange = useCallback(
|
||
(key: keyof LLMNodeData, value: any) => {
|
||
// 使用 setNodes 更新特定节点的数据
|
||
setNodes(
|
||
(
|
||
nds: Node<LLMNodeData>[] // 显式指定类型
|
||
) =>
|
||
nds.map((node) => {
|
||
if (node.id === id) {
|
||
// 创建一个新的 data 对象
|
||
const updatedData = { ...node.data, [key]: value };
|
||
return { ...node, data: updatedData };
|
||
}
|
||
return node;
|
||
})
|
||
);
|
||
console.log(`(LLMNode) Node ${id} data updated:`, { [key]: value });
|
||
},
|
||
[id, setNodes]
|
||
); // 依赖 id 和 setNodes
|
||
|
||
const handleSliderChange = useCallback(
|
||
(value: number[]) => {
|
||
handleDataChange("temperature", value[0]);
|
||
},
|
||
[handleDataChange]
|
||
);
|
||
const handleSelectChange = useCallback(
|
||
(value: string) => {
|
||
handleDataChange("model", value);
|
||
},
|
||
[handleDataChange]
|
||
);
|
||
const handleTextChange = useCallback(
|
||
(event: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||
const { name, value } = event.target;
|
||
handleDataChange(name as keyof LLMNodeData, value);
|
||
},
|
||
[handleDataChange]
|
||
);
|
||
|
||
// 检查 inputConnected 状态是否需要更新 (如果外部更新了)
|
||
// 注意:这个逻辑依赖于父组件正确更新了 data.inputConnected
|
||
useEffect(() => {
|
||
// 可以在这里添加逻辑,如果 props.data.inputConnected 和 UI 显示不一致时触发更新
|
||
// 但通常连接状态由 onConnect/onEdgesChange 在父组件处理更佳
|
||
// updateNodeInternals(id); // 如果 Handle 显示依赖 inputConnected,可能需要调用
|
||
}, [data.inputConnected, id, updateNodeInternals]);
|
||
|
||
return (
|
||
// 调整宽度,例如 w-96 (24rem)
|
||
<div className="react-flow__node-genericNode nopan bg-purple-50 dark:bg-gray-800 border border-purple-200 dark:border-gray-700 rounded-lg shadow-lg w-96">
|
||
<div className="bg-purple-100 dark:bg-gray-700 p-3 border-b border-purple-200 dark:border-gray-600">
|
||
<div className="flex items-center gap-2">
|
||
<BrainCircuit
|
||
size={18}
|
||
className="text-purple-700 dark:text-purple-300"
|
||
/>
|
||
<strong className="text-purple-800 dark:text-purple-200">
|
||
LLM 调用
|
||
</strong>
|
||
</div>
|
||
<p className="text-xs text-purple-600 dark:text-purple-400 mt-1">
|
||
使用大语言模型生成文本
|
||
</p>
|
||
</div>
|
||
<div className="p-4 space-y-3">
|
||
<div className="relative nodrag">
|
||
<Handle
|
||
type="target"
|
||
position={Position.Left}
|
||
id="input-text"
|
||
isConnectable={isConnectable}
|
||
className="!w-3 !h-3 !bg-blue-500 top-1/2 z-50 !-left-5"
|
||
/>
|
||
<Label className="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||
输入
|
||
</Label>
|
||
{/* 直接使用 props.data.inputConnected */}
|
||
{!data.inputConnected && (
|
||
<p className="text-xs text-gray-400 italic mt-1">连接文本或提示</p>
|
||
)}
|
||
</div>
|
||
<div className="nodrag">
|
||
<Label
|
||
htmlFor={`systemPrompt-${id}`}
|
||
className="text-xs font-semibold"
|
||
>
|
||
系统提示
|
||
</Label>
|
||
{/* 直接使用 props.data 和 onChange 回调 */}
|
||
<Textarea
|
||
id={`systemPrompt-${id}`}
|
||
name="systemPrompt"
|
||
value={data.systemPrompt}
|
||
onChange={handleTextChange}
|
||
placeholder="例如:你是一个乐于助人的助手。"
|
||
className="mt-1 text-xs min-h-[60px] bg-white dark:bg-gray-700"
|
||
rows={3}
|
||
/>
|
||
</div>
|
||
<div className="nodrag">
|
||
<Label htmlFor={`model-${id}`} className="text-xs font-semibold">
|
||
模型名称
|
||
</Label>
|
||
<Select
|
||
name="model"
|
||
value={data.model}
|
||
onValueChange={handleSelectChange}
|
||
>
|
||
<SelectTrigger
|
||
id={`model-${id}`}
|
||
className="mt-1 h-8 text-xs bg-white dark:bg-gray-700"
|
||
>
|
||
<SelectValue placeholder="选择模型" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{availableModels.map((model) => (
|
||
<SelectItem
|
||
key={model.value}
|
||
value={model.value}
|
||
className="text-xs"
|
||
>
|
||
{model.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="nodrag">
|
||
<Label htmlFor={`apiKey-${id}`} className="text-xs font-semibold">
|
||
API Key (不安全)
|
||
</Label>
|
||
<Input
|
||
id={`apiKey-${id}`}
|
||
name="apiKey"
|
||
type="password"
|
||
value={data.apiKey || ""}
|
||
onChange={handleTextChange}
|
||
placeholder="sk-..."
|
||
className="mt-1 h-8 text-xs bg-white dark:bg-gray-700"
|
||
/>
|
||
<p className="text-xs text-red-500 mt-1">
|
||
警告:不建议在此处输入密钥。
|
||
</p>
|
||
</div>
|
||
<div className="nodrag">
|
||
<Label
|
||
htmlFor={`temperature-${id}`}
|
||
className="text-xs font-semibold"
|
||
>
|
||
温度: {data.temperature?.toFixed(1) ?? "N/A"}
|
||
</Label>
|
||
<Slider
|
||
id={`temperature-${id}`}
|
||
min={0}
|
||
max={1}
|
||
step={0.1}
|
||
value={[data.temperature ?? 0.7]}
|
||
onValueChange={handleSliderChange}
|
||
className="mt-2"
|
||
/>
|
||
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
<span>精确</span>
|
||
<span>创造性</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="relative p-2 border-t border-purple-200 dark:border-gray-600">
|
||
<Label className="text-xs font-semibold text-gray-500 dark:text-gray-400 block text-right pr-6">
|
||
输出
|
||
</Label>
|
||
<Handle
|
||
type="source"
|
||
position={Position.Right}
|
||
id="output-message"
|
||
isConnectable={isConnectable}
|
||
className="!w-3 !h-3 !bg-green-500 top-1/2 z-50"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
LLMNodeComponent.displayName = "LLMNode";
|
||
export const LLMNode = memo(LLMNodeComponent);
|