实现LLMNode

This commit is contained in:
adrian 2025-05-01 14:10:46 +08:00
parent 37bdf4dbfd
commit 847b3e96c8
6 changed files with 318 additions and 77 deletions

View File

@ -0,0 +1,104 @@
// 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 方法
// 从 props 更新内部状态 (如果外部数据变化)
// useEffect(() => {
// setCurrentData(data);
// }, [data]);
// 处理内部表单变化的通用回调 - 直接更新 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 react-flow__node-genericNode nopan selected selectable draggable bg-purple-50 dark:bg-gray-800 border border-purple-200 dark:border-gray-700 rounded-lg shadow-lg w-96 overflow-hidden">
<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">
<Label className="text-xs font-semibold text-gray-500 dark:text-gray-400"></Label>
<Handle type="target" position={Position.Left} id="input-text" isConnectable={isConnectable} className="w-3 h-3 !bg-blue-500 top-1/2" />
{/* 直接使用 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" />
</div>
</div>
);
};
LLMNodeComponent.displayName = 'LLMNode';
export const LLMNode = memo(LLMNodeComponent);

View File

@ -0,0 +1,27 @@
// File: frontend/app/workflow/components/OutputNode.tsx
// Description: 自定义结束节点组件
import React, { memo } from 'react';
import { Handle, Position } from 'reactflow';
import { CheckCircle } from 'lucide-react';
import type { OutputNodeData, CustomNodeProps } from './types'; // 导入类型
const OutputNodeComponent = ({ data }: CustomNodeProps<OutputNodeData>) => {
return (
<div className="react-flow__node-default bg-blue-100 dark:bg-blue-900 border-blue-300 dark:border-blue-700 p-4 rounded-lg shadow-md w-40">
<Handle
type="target"
position={Position.Top}
id="output-target"
className="w-3 h-3 !bg-blue-500"
/>
<div className="flex items-center gap-2 mb-2">
<CheckCircle size={16} className="text-blue-700 dark:text-blue-300" />
<strong className="text-blue-800 dark:text-blue-200">{data.label || '结束流程'}</strong>
</div>
</div>
);
};
OutputNodeComponent.displayName = 'OutputNode';
export const OutputNode = memo(OutputNodeComponent);

View File

@ -0,0 +1,28 @@
// File: frontend/app/workflow/components/StartNode.tsx
// Description: 自定义开始节点组件
import React, { memo } from 'react';
import { Handle, Position } from 'reactflow';
import { Play } from 'lucide-react';
import type { StartNodeData, CustomNodeProps } from './types'; // 导入类型
const StartNodeComponent = ({ data }: CustomNodeProps<StartNodeData>) => {
return (
<div className="react-flow__node-default bg-green-100 dark:bg-green-900 border-green-300 dark:border-green-700 p-4 rounded-lg shadow-md w-40">
<div className="flex items-center gap-2 mb-2">
<Play size={16} className="text-green-700 dark:text-green-300" />
<strong className="text-green-800 dark:text-green-200">{data.label || '开始流程'}</strong>
</div>
<Handle
type="source"
position={Position.Bottom}
id="start-source"
className="w-3 h-3 !bg-green-500"
/>
</div>
);
};
StartNodeComponent.displayName = 'StartNode';
// 使用 memo 优化
export const StartNode = memo(StartNodeComponent);

View File

@ -0,0 +1,44 @@
// File: frontend/app/workflow/components/WorkflowSidebar.tsx
// Description: 侧边栏组件,用于拖放节点
import React, { DragEvent } from 'react';
import { MessageSquareText, BrainCircuit, Database, LogOut, Play, CheckCircle } from 'lucide-react';
// 定义可拖拽的节点类型 (从 page.tsx 移动过来)
const nodeTypesForPalette = [
{ type: 'startNode', label: '开始流程', icon: Play, defaultData: { label: '开始' } },
{ type: 'inputNode', label: '文本输入', icon: MessageSquareText, defaultData: { text: '用户输入...' } },
{ type: 'llmNode', label: 'LLM 调用', icon: BrainCircuit, defaultData: { model: 'gpt-3.5-turbo', temperature: 0.7, systemPrompt: '你是一个乐于助人的 AI 助手。' } },
{ type: 'ragNode', label: 'RAG 查询', icon: Database, defaultData: { query: '...', knowledgeBase: 'default' } },
{ type: 'outputNode', label: '结束流程', icon: CheckCircle, defaultData: { label: '结束' } },
];
export const WorkflowSidebar = () => {
const onDragStart = (event: DragEvent, nodeType: string, defaultData: any) => {
const nodeInfo = JSON.stringify({ nodeType, defaultData });
event.dataTransfer.setData('application/reactflow', nodeInfo);
event.dataTransfer.effectAllowed = 'move';
console.log(`Drag Start: ${nodeType}`);
};
return (
<aside className="w-64 bg-white dark:bg-gray-800 p-4 border-r dark:border-gray-700 shadow-md overflow-y-auto flex-shrink-0"> {/* 添加 flex-shrink-0 */}
<h3 className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-200"></h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4"></p>
<div className="space-y-2">
{nodeTypesForPalette.map((nodeInfo) => (
<div
key={nodeInfo.type}
className="p-3 border dark:border-gray-600 rounded-lg cursor-grab flex items-center gap-2 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
onDragStart={(event) => onDragStart(event, nodeInfo.type, nodeInfo.defaultData)}
draggable
>
<nodeInfo.icon size={18} className="text-gray-600 dark:text-gray-300 flex-shrink-0" />
<span className="text-sm text-gray-700 dark:text-gray-200">{nodeInfo.label}</span>
</div>
))}
</div>
</aside>
);
};

View File

@ -0,0 +1,34 @@
// File: frontend/app/workflow/components/types.ts
// Description: Shared types for workflow components
import { NodeProps } from 'reactflow';
// 定义 LLM 节点的数据结构
export interface LLMNodeData {
model: string;
temperature: number;
systemPrompt: string;
apiKey?: string;
inputConnected?: boolean;
}
// 定义其他节点可能需要的数据类型
export interface StartNodeData {
label: string;
}
export interface OutputNodeData {
label: string;
}
// 可以将 NodeProps 包装一下,方便使用
export type CustomNodeProps<T> = NodeProps<T>;
// 可选的模型列表 (也可以放在这里共享)
export const availableModels = [
{ value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
{ value: "gpt-4", label: "GPT-4" },
{ value: "gpt-4-turbo", label: "GPT-4 Turbo" },
{ value: "gemini-pro", label: "Gemini Pro" },
{ value: "deepseek-coder", label: "DeepSeek Coder" },
];

View File

@ -1,9 +1,9 @@
// File: frontend/app/workflow/page.tsx
// Description: 工作流编辑器页面,添加侧边栏用于拖放节点
// File: frontend/app/workflow/page.tsx (更新)
// Description: 工作流编辑器主页面,引入模块化组件
'use client';
import React, { useState, useCallback, useRef, DragEvent } from 'react'; // 添加 useRef, DragEvent
import React, { useState, useCallback, useRef, DragEvent, useMemo } from 'react';
import ReactFlow, {
ReactFlowProvider,
MiniMap,
@ -21,80 +21,93 @@ import ReactFlow, {
EdgeChange,
applyNodeChanges,
applyEdgeChanges,
useReactFlow, // 导入 useReactFlow hook
useReactFlow,
XYPosition,
BackgroundVariant, // 导入 XYPosition 类型
BackgroundVariant,
} from 'reactflow';
import { MessageSquareText, BrainCircuit, Database, LogOut, Play } from 'lucide-react'; // 导入一些节点图标
// 引入自定义节点和侧边栏组件
import { StartNode } from './components/StartNode';
import { OutputNode } from './components/OutputNode';
import { LLMNode } from './components/LLMNode';
import { WorkflowSidebar } from './components/WorkflowSidebar';
// 引入类型 (如果需要)
// import type { LLMNodeData } from './components/types';
import 'reactflow/dist/style.css';
// --- 初始节点和边数据 (可以清空或保留示例) ---
// --- 初始节点和边数据 ---
const initialNodes: Node[] = [
// { id: '1', type: 'inputNode', data: { label: '开始' }, position: { x: 100, y: 100 } },
{ id: 'start-initial', type: 'startNode', data: { label: '开始' }, position: { x: 150, y: 50 } },
{
id: 'llm-initial',
type: 'llmNode',
data: {
model: 'gpt-3.5-turbo',
temperature: 0.7,
systemPrompt: '你是一个乐于助人的 AI 助手。',
inputConnected: false,
apiKey: '',
},
position: { x: 350, y: 150 }
},
{ id: 'end-initial', type: 'outputNode', data: { label: '结束' }, position: { x: 650, y: 350 } },
];
const initialEdges: Edge[] = [];
// --- 定义可拖拽的节点类型 ---
const nodeTypesForPalette = [
{ type: 'inputNode', label: '输入', icon: MessageSquareText, defaultData: { text: '用户输入...' } },
{ type: 'llmNode', label: 'LLM 调用', icon: BrainCircuit, defaultData: { model: 'gpt-3.5-turbo', prompt: '...' } },
{ type: 'ragNode', label: 'RAG 查询', icon: Database, defaultData: { query: '...', knowledgeBase: 'default' } },
{ type: 'outputNode', label: '输出', icon: LogOut, defaultData: { result: null } },
{ type: 'startNode', label: '开始流程', icon: Play, defaultData: {} }, // 添加一个开始节点类型
];
// --- 侧边栏组件 ---
const Sidebar = () => {
// 拖拽开始时设置数据
const onDragStart = (event: DragEvent, nodeType: string, defaultData: any) => {
// 将节点类型和默认数据存储在 dataTransfer 中
const nodeInfo = JSON.stringify({ nodeType, defaultData });
event.dataTransfer.setData('application/reactflow', nodeInfo);
event.dataTransfer.effectAllowed = 'move';
console.log(`Drag Start: ${nodeType}`);
};
return (
<aside className="w-64 bg-white dark:bg-gray-800 p-4 border-r dark:border-gray-700 shadow-md overflow-y-auto">
<h3 className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-200"></h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4"></p>
<div className="space-y-2">
{nodeTypesForPalette.map((nodeInfo) => (
<div
key={nodeInfo.type}
className="p-3 border dark:border-gray-600 rounded-lg cursor-grab flex items-center gap-2 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
onDragStart={(event) => onDragStart(event, nodeInfo.type, nodeInfo.defaultData)}
draggable // 设置为可拖拽
>
<nodeInfo.icon size={18} className="text-gray-600 dark:text-gray-300 flex-shrink-0" />
<span className="text-sm text-gray-700 dark:text-gray-200">{nodeInfo.label}</span>
</div>
))}
</div>
</aside>
);
};
// --- 工作流编辑器组件 ---
function WorkflowEditor() {
const reactFlowWrapper = useRef<HTMLDivElement>(null); // Ref 指向 React Flow 容器
const [nodes, setNodes] = useState<Node[]>(initialNodes);
const [edges, setEdges] = useState<Edge[]>(initialEdges);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const { project } = useReactFlow(); // 使用 hook 获取 project 方法
const onNodesChange = useCallback(
(changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds)),
[setNodes]
);
const onEdgesChange = useCallback(
(changes: EdgeChange[]) => setEdges((eds) => applyEdgeChanges(changes, eds)),
[setEdges]
);
// --- 注册自定义节点类型 ---
const nodeTypes = useMemo(() => ({
startNode: StartNode,
outputNode: OutputNode,
llmNode: LLMNode, // LLMNode 现在需要一种方式来调用 updateNodeData
// inputNode: CustomInputNode,
// ragNode: RAGNode,
}), []); // 移除 updateNodeData 依赖,因为它不应该直接传递
const onConnect = useCallback(
(connection: Connection) => setEdges((eds) => addEdge({ ...connection, markerEnd: { type: MarkerType.ArrowClosed } }, eds)),
[setEdges]
(connection: Connection) => {
setEdges((eds) => addEdge({ ...connection, markerEnd: { type: MarkerType.ArrowClosed } }, eds));
if (connection.targetHandle === 'input-text' && connection.target) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === connection.target) {
return { ...node, data: { ...node.data, inputConnected: true } };
}
return node;
})
);
}
},
[setEdges, setNodes] // 保持 updateNodeData 依赖
);
const onEdgesChangeIntercepted = useCallback(
(changes: EdgeChange[]) => {
changes.forEach(change => {
if (change.type === 'remove') {
const edgeToRemove = edges.find(edge => edge.id === change.id);
if (edgeToRemove?.targetHandle === 'input-text' && edgeToRemove.target) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === edgeToRemove.target) {
return { ...node, data: { ...node.data, inputConnected: false } };
}
return node;
})
);
}
}
});
onEdgesChange(changes);
},
[edges, onEdgesChange, setNodes] // 保持 updateNodeData 依赖
);
// 处理画布上的拖拽悬停事件
@ -146,10 +159,10 @@ function WorkflowEditor() {
id: `${nodeType}-${Date.now()}`, // 使用更唯一的 ID
type: nodeType, // 设置节点类型
position,
data: { label: `${nodeType} node`, ...defaultData }, // 合并默认数据
data: { label: defaultData?.label || `${nodeType} node`, ...defaultData },
// 根据节点类型设置 Handle 位置 (可选,也可以在自定义节点内部定义)
sourcePosition: Position.Bottom,
targetPosition: Position.Top,
// sourcePosition: Position.Bottom,
// targetPosition: Position.Top,
};
console.log('Node dropped:', newNode);
@ -159,15 +172,6 @@ function WorkflowEditor() {
[project, setNodes] // 依赖 project 方法和 setNodes
);
// --- (可选) 注册自定义节点类型 ---
// const nodeTypes = useMemo(() => ({
// inputNode: CustomInputNode,
// llmNode: LLMNode,
// ragNode: RAGNode,
// outputNode: CustomOutputNode,
// startNode: StartNode,
// }), []);
return (
<div className="flex flex-col h-full bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
<h1 className="text-xl font-semibold p-4 border-b dark:border-gray-700 text-gray-800 dark:text-gray-200">
@ -176,18 +180,18 @@ function WorkflowEditor() {
{/* 主容器使用 Flexbox */}
<div className="flex flex-1 overflow-hidden">
{/* 侧边栏 */}
<Sidebar />
<WorkflowSidebar />
{/* React Flow 画布容器 */}
<div className="flex-1 h-full" ref={reactFlowWrapper}> {/* 添加 ref */}
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onEdgesChange={onEdgesChangeIntercepted}
onConnect={onConnect}
onDragOver={onDragOver} // 添加 onDragOver
onDrop={onDrop} // 添加 onDrop
// nodeTypes={nodeTypes}
nodeTypes={nodeTypes} // <--- 传递自定义节点类型
fitView
className="bg-gray-50 dark:bg-gray-900"
>