diff options
Diffstat (limited to 'frontend/src/components')
| -rw-r--r-- | frontend/src/components/Sidebar.tsx | 320 | ||||
| -rw-r--r-- | frontend/src/components/nodes/LLMNode.tsx | 139 |
2 files changed, 459 insertions, 0 deletions
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..f62f3cb --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,320 @@ +import React, { useState, useEffect } from 'react'; +import useFlowStore from '../store/flowStore'; +import type { NodeData } from '../store/flowStore'; +import ReactMarkdown from 'react-markdown'; +import { Play, Settings, Info, Save } from 'lucide-react'; + +const Sidebar = () => { + const { nodes, selectedNodeId, updateNodeData, getActiveContext } = useFlowStore(); + const [activeTab, setActiveTab] = useState<'interact' | 'settings' | 'debug'>('interact'); + const [streamBuffer, setStreamBuffer] = useState(''); + + const selectedNode = nodes.find((n) => n.id === selectedNodeId); + + // Reset stream buffer when node changes + useEffect(() => { + setStreamBuffer(''); + }, [selectedNodeId]); + + if (!selectedNode) { + return ( + <div className="w-96 border-l border-gray-200 h-screen p-4 bg-gray-50 text-gray-500 text-center flex flex-col justify-center"> + <p>Select a node to edit</p> + </div> + ); + } + + const handleRun = async () => { + if (!selectedNode) return; + + updateNodeData(selectedNode.id, { status: 'loading', response: '' }); + setStreamBuffer(''); + + // Use getActiveContext which respects the user's selected traces + const context = getActiveContext(selectedNode.id); + + try { + const response = await fetch('http://localhost:8000/api/run_node_stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + node_id: selectedNode.id, + incoming_contexts: [{ messages: context }], // Simple list wrap for now + user_prompt: selectedNode.data.userPrompt, + merge_strategy: selectedNode.data.mergeStrategy || 'smart', + config: { + provider: selectedNode.data.model.includes('gpt') ? 'openai' : 'google', + model_name: selectedNode.data.model, + temperature: selectedNode.data.temperature, + system_prompt: selectedNode.data.systemPrompt, + api_key: selectedNode.data.apiKey, + } + }) + }); + + if (!response.body) return; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let fullResponse = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value); + fullResponse += chunk; + setStreamBuffer(prev => prev + chunk); + // We update the store less frequently or at the end to avoid too many re-renders + // But for "live" feel we might want to update local state `streamBuffer` and sync to store at end + } + + // Update final state + // Append the new interaction to the node's output messages + const newUserMsg = { + id: `msg_${Date.now()}_u`, + role: 'user', + content: selectedNode.data.userPrompt + }; + const newAssistantMsg = { + id: `msg_${Date.now()}_a`, + role: 'assistant', + content: fullResponse + }; + + updateNodeData(selectedNode.id, { + status: 'success', + response: fullResponse, + messages: [...context, newUserMsg, newAssistantMsg] as any + }); + + } catch (error) { + console.error(error); + updateNodeData(selectedNode.id, { status: 'error' }); + } + }; + + const handleChange = (field: keyof NodeData, value: any) => { + updateNodeData(selectedNode.id, { [field]: value }); + }; + + return ( + <div className="w-96 border-l border-gray-200 h-screen flex flex-col bg-white shadow-xl z-10"> + {/* Header */} + <div className="p-4 border-b border-gray-200 bg-gray-50"> + <input + type="text" + value={selectedNode.data.label} + onChange={(e) => handleChange('label', e.target.value)} + className="font-bold text-lg bg-transparent border-none focus:ring-0 focus:outline-none w-full" + /> + <div className="flex items-center justify-between mt-1"> + <div className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded uppercase"> + {selectedNode.data.status} + </div> + <div className="text-xs text-gray-500"> + ID: {selectedNode.id} + </div> + </div> + </div> + + {/* Tabs */} + <div className="flex border-b border-gray-200"> + <button + onClick={() => setActiveTab('interact')} + className={`flex-1 p-3 text-sm flex justify-center items-center gap-2 ${activeTab === 'interact' ? 'border-b-2 border-blue-500 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50'}`} + > + <Play size={16} /> Interact + </button> + <button + onClick={() => setActiveTab('settings')} + className={`flex-1 p-3 text-sm flex justify-center items-center gap-2 ${activeTab === 'settings' ? 'border-b-2 border-blue-500 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50'}`} + > + <Settings size={16} /> Settings + </button> + <button + onClick={() => setActiveTab('debug')} + className={`flex-1 p-3 text-sm flex justify-center items-center gap-2 ${activeTab === 'debug' ? 'border-b-2 border-blue-500 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50'}`} + > + <Info size={16} /> Debug + </button> + </div> + + {/* Content */} + <div className="flex-1 overflow-y-auto p-4"> + {activeTab === 'interact' && ( + <div className="space-y-4"> + <div> + <label className="block text-sm font-medium text-gray-700 mb-1">Model</label> + <select + value={selectedNode.data.model} + onChange={(e) => handleChange('model', e.target.value)} + className="w-full border border-gray-300 rounded-md p-2 text-sm" + > + <option value="gpt-4o">GPT-4o</option> + <option value="gpt-4o-mini">GPT-4o Mini</option> + <option value="gemini-1.5-pro">Gemini 1.5 Pro</option> + <option value="gemini-1.5-flash">Gemini 1.5 Flash</option> + </select> + </div> + + {/* Trace Selector */} + {selectedNode.data.traces && selectedNode.data.traces.length > 0 && ( + <div className="bg-gray-50 p-2 rounded border border-gray-200"> + <label className="block text-xs font-bold text-gray-500 mb-2 uppercase">Select Context Traces</label> + <div className="space-y-1 max-h-[150px] overflow-y-auto"> + {selectedNode.data.traces.map((trace) => { + const isActive = selectedNode.data.activeTraceIds?.includes(trace.id); + return ( + <div key={trace.id} className="flex items-start gap-2 text-sm p-1 hover:bg-white rounded cursor-pointer" + onClick={() => { + const current = selectedNode.data.activeTraceIds || []; + const next = [trace.id]; // Single select mode + handleChange('activeTraceIds', next); + }} + > + <input + type="radio" + checked={isActive || false} + readOnly + className="mt-1" + /> + <div className="flex-1"> + <div className="flex items-center gap-2"> + <div className="w-2 h-2 rounded-full" style={{ backgroundColor: trace.color }}></div> + <span className="font-mono text-xs text-gray-400">#{trace.id.slice(-4)}</span> + </div> + <div className="text-xs text-gray-600 truncate"> + From Node: {trace.sourceNodeId} + </div> + <div className="text-[10px] text-gray-400"> + {trace.messages.length} msgs + </div> + </div> + </div> + ); + })} + </div> + </div> + )} + + <div> + <label className="block text-sm font-medium text-gray-700 mb-1">User Prompt</label> + <textarea + value={selectedNode.data.userPrompt} + onChange={(e) => handleChange('userPrompt', e.target.value)} + className="w-full border border-gray-300 rounded-md p-2 text-sm min-h-[100px]" + placeholder="Type your message here..." + /> + </div> + + <button + onClick={handleRun} + disabled={selectedNode.data.status === 'loading'} + className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:bg-blue-300 flex items-center justify-center gap-2" + > + {selectedNode.data.status === 'loading' ? <Loader2 className="animate-spin" size={16} /> : <Play size={16} />} + Run Node + </button> + + <div className="mt-6"> + <label className="block text-sm font-medium text-gray-700 mb-2">Response</label> + <div className="bg-gray-50 p-3 rounded-md border border-gray-200 min-h-[150px] text-sm prose prose-sm max-w-none"> + <ReactMarkdown>{selectedNode.data.response || streamBuffer}</ReactMarkdown> + </div> + </div> + </div> + )} + + {activeTab === 'settings' && ( + <div className="space-y-4"> + <div> + <label className="block text-sm font-medium text-gray-700 mb-1">Merge Strategy</label> + <select + value={selectedNode.data.mergeStrategy || 'smart'} + onChange={(e) => handleChange('mergeStrategy', e.target.value)} + className="w-full border border-gray-300 rounded-md p-2 text-sm" + > + <option value="smart">Smart (Auto-merge roles)</option> + <option value="raw">Raw (Concatenate)</option> + </select> + <p className="text-xs text-gray-500 mt-1"> + Smart merge combines consecutive messages from the same role to avoid API errors. + </p> + </div> + + <div> + <label className="block text-sm font-medium text-gray-700 mb-1">Temperature ({selectedNode.data.temperature})</label> + <input + type="range" + min="0" + max="2" + step="0.1" + value={selectedNode.data.temperature} + onChange={(e) => handleChange('temperature', parseFloat(e.target.value))} + className="w-full" + /> + </div> + + <div> + <label className="block text-sm font-medium text-gray-700 mb-1">API Key (Optional)</label> + <input + type="password" + value={selectedNode.data.apiKey || ''} + onChange={(e) => handleChange('apiKey', e.target.value)} + className="w-full border border-gray-300 rounded-md p-2 text-sm" + placeholder="Leave empty to use backend env var" + /> + </div> + + <div> + <label className="block text-sm font-medium text-gray-700 mb-1">System Prompt Override</label> + <textarea + value={selectedNode.data.systemPrompt} + onChange={(e) => handleChange('systemPrompt', e.target.value)} + className="w-full border border-gray-300 rounded-md p-2 text-sm min-h-[100px] font-mono" + placeholder="Global system prompt will be used if empty..." + /> + </div> + </div> + )} + + {activeTab === 'debug' && ( + <div className="space-y-4"> + <div> + <label className="block text-sm font-medium text-gray-700 mb-1">Active Context (Sent to LLM)</label> + <pre className="bg-gray-900 text-gray-100 p-2 rounded text-xs overflow-x-auto"> + {JSON.stringify(getActiveContext(selectedNode.id), null, 2)} + </pre> + </div> + <div> + <label className="block text-sm font-medium text-gray-700 mb-1">Node Traces (Incoming)</label> + <pre className="bg-gray-900 text-gray-100 p-2 rounded text-xs overflow-x-auto"> + {JSON.stringify(selectedNode.data.traces, null, 2)} + </pre> + </div> + </div> + )} + </div> + </div> + ); +}; + +// Helper component for icon +const Loader2 = ({ className, size }: { className?: string, size?: number }) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={size || 24} + height={size || 24} + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + className={className} + > + <path d="M21 12a9 9 0 1 1-6.219-8.56" /> + </svg> +); + +export default Sidebar; + diff --git a/frontend/src/components/nodes/LLMNode.tsx b/frontend/src/components/nodes/LLMNode.tsx new file mode 100644 index 0000000..cdd402c --- /dev/null +++ b/frontend/src/components/nodes/LLMNode.tsx @@ -0,0 +1,139 @@ +import { useEffect } from 'react'; +import { Handle, Position, type NodeProps, useUpdateNodeInternals, useEdges } from 'reactflow'; +import type { NodeData } from '../../store/flowStore'; +import { Loader2, MessageSquare } from 'lucide-react'; +import useFlowStore from '../../store/flowStore'; + +const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { + const { updateNodeData } = useFlowStore(); + const updateNodeInternals = useUpdateNodeInternals(); + const edges = useEdges(); + + // Force update handles when traces change + useEffect(() => { + updateNodeInternals(id); + }, [id, data.outgoingTraces, data.inputs, updateNodeInternals]); + + // Determine how many input handles to show + // We want to ensure there is always at least one empty handle at the bottom + // plus all currently connected handles. + + // Find all edges connected to this node's inputs + const connectedHandles = new Set( + edges + .filter(e => e.target === id) + .map(e => e.targetHandle) + ); + + // Logic: + // If input-0 is connected, show input-1. + // If input-1 is connected, show input-2. + // We can just iterate until we find an unconnected one. + + let handleCount = 1; + while (connectedHandles.has(`input-${handleCount - 1}`)) { + handleCount++; + } + + // But wait, if we delete an edge to input-0, we still want input-1 to exist if it's connected? + // No, usually in this designs, we just render up to max(connected_index) + 1. + + // Let's get the max index connected + let maxConnectedIndex = -1; + edges.filter(e => e.target === id).forEach(e => { + const idx = parseInt(e.targetHandle?.replace('input-', '') || '0'); + if (!isNaN(idx) && idx > maxConnectedIndex) { + maxConnectedIndex = idx; + } + }); + + const inputsToShow = Math.max(maxConnectedIndex + 2, 1); + + return ( + <div className={`px-4 py-2 shadow-md rounded-md bg-white border-2 min-w-[200px] ${selected ? 'border-blue-500' : 'border-gray-200'}`}> + <div className="flex items-center mb-2"> + <div className="rounded-full w-8 h-8 flex justify-center items-center bg-gray-100"> + {data.status === 'loading' ? ( + <Loader2 className="w-4 h-4 animate-spin text-blue-500" /> + ) : ( + <MessageSquare className="w-4 h-4 text-gray-600" /> + )} + </div> + <div className="ml-2"> + <div className="text-sm font-bold truncate max-w-[150px]">{data.label}</div> + <div className="text-xs text-gray-500">{data.model}</div> + </div> + </div> + + {/* Dynamic Inputs */} + <div className="absolute left-0 top-0 bottom-0 flex flex-col justify-center w-4"> + {Array.from({ length: inputsToShow }).map((_, i) => { + // Find the connected edge to get color + const connectedEdge = edges.find(e => e.target === id && e.targetHandle === `input-${i}`); + const edgeColor = connectedEdge?.style?.stroke as string; + + return ( + <div key={i} className="relative h-4 w-4 my-1"> + <Handle + type="target" + position={Position.Left} + id={`input-${i}`} + className="!w-3 !h-3 !left-[-6px]" + style={{ + top: '50%', + transform: 'translateY(-50%)', + backgroundColor: edgeColor || '#3b82f6', // Default blue if not connected + border: edgeColor ? 'none' : undefined + }} + /> + <span className="absolute left-4 top-[-2px] text-[9px] text-gray-400 pointer-events-none"> + {i} + </span> + </div> + ); + })} + </div> + + {/* Dynamic Outputs (Traces) */} + <div className="absolute right-0 top-0 bottom-0 flex flex-col justify-center w-4"> + {/* 1. Outgoing Traces (Pass-through + Self) */} + {data.outgoingTraces && data.outgoingTraces.map((trace, i) => ( + <div key={trace.id} className="relative h-4 w-4 my-1" title={`Trace: ${trace.id}`}> + <Handle + type="source" + position={Position.Right} + id={`trace-${trace.id}`} + className="!w-3 !h-3 !right-[-6px]" + style={{ + backgroundColor: trace.color, + top: '50%', + transform: 'translateY(-50%)' + }} + /> + </div> + ))} + + {/* 2. New Branch Generator Handle (Always visible) */} + <div className="relative h-4 w-4 my-1" title="Create New Branch"> + <Handle + type="source" + position={Position.Right} + id="new-trace" + className="!w-3 !h-3 !bg-gray-400 !right-[-6px]" + style={{ top: '50%', transform: 'translateY(-50%)' }} + /> + <span className="absolute right-4 top-[-2px] text-[9px] text-gray-400 pointer-events-none w-max"> + + New + </span> + </div> + </div> + + {data.status === 'error' && ( + <div className="text-xs text-red-500 mt-2">Error</div> + )} + </div> + ); +}; + +export default LLMNode; + |
