summaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/App.css42
-rw-r--r--frontend/src/App.tsx138
-rw-r--r--frontend/src/assets/react.svg1
-rw-r--r--frontend/src/components/Sidebar.tsx320
-rw-r--r--frontend/src/components/nodes/LLMNode.tsx139
-rw-r--r--frontend/src/index.css11
-rw-r--r--frontend/src/main.tsx10
-rw-r--r--frontend/src/store/flowStore.ts405
8 files changed, 1066 insertions, 0 deletions
diff --git a/frontend/src/App.css b/frontend/src/App.css
new file mode 100644
index 0000000..b9d355d
--- /dev/null
+++ b/frontend/src/App.css
@@ -0,0 +1,42 @@
+#root {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 2rem;
+ text-align: center;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.react:hover {
+ filter: drop-shadow(0 0 2em #61dafbaa);
+}
+
+@keyframes logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ a:nth-of-type(2) .logo {
+ animation: logo-spin infinite 20s linear;
+ }
+}
+
+.card {
+ padding: 2em;
+}
+
+.read-the-docs {
+ color: #888;
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..1eaafec
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,138 @@
+import { useCallback, useRef } from 'react';
+import ReactFlow, {
+ Background,
+ Controls,
+ MiniMap,
+ ReactFlowProvider,
+ Panel,
+ useReactFlow
+} from 'reactflow';
+import 'reactflow/dist/style.css';
+import useFlowStore from './store/flowStore';
+import LLMNode from './components/nodes/LLMNode';
+import Sidebar from './components/Sidebar';
+import { Plus } from 'lucide-react';
+
+const nodeTypes = {
+ llmNode: LLMNode,
+};
+
+function Flow() {
+ const {
+ nodes,
+ edges,
+ onNodesChange,
+ onEdgesChange,
+ onConnect,
+ addNode,
+ setSelectedNode
+ } = useFlowStore();
+
+ const reactFlowWrapper = useRef<HTMLDivElement>(null);
+ const { project } = useReactFlow();
+
+ const handleAddNode = () => {
+ const id = `node_${Date.now()}`;
+ addNode({
+ id,
+ type: 'llmNode',
+ position: { x: Math.random() * 400, y: Math.random() * 400 },
+ data: {
+ label: 'New Question',
+ model: 'gpt-4o',
+ temperature: 0.7,
+ systemPrompt: '',
+ userPrompt: '',
+ mergeStrategy: 'smart',
+ messages: [],
+ traces: [],
+ outgoingTraces: [],
+ forkedTraces: [],
+ response: '',
+ status: 'idle',
+ inputs: 1
+ },
+ });
+ };
+
+ const onNodeClick = (_: any, node: any) => {
+ setSelectedNode(node.id);
+ };
+
+ const onPaneClick = () => {
+ setSelectedNode(null);
+ };
+
+ const onPaneContextMenu = (event: React.MouseEvent) => {
+ event.preventDefault();
+ const bounds = reactFlowWrapper.current?.getBoundingClientRect();
+ if (!bounds) return;
+
+ const position = project({
+ x: event.clientX - bounds.left,
+ y: event.clientY - bounds.top
+ });
+
+ const id = `node_${Date.now()}`;
+ addNode({
+ id,
+ type: 'llmNode',
+ position,
+ data: {
+ label: 'New Question',
+ model: 'gpt-4o',
+ temperature: 0.7,
+ systemPrompt: '',
+ userPrompt: '',
+ mergeStrategy: 'smart',
+ messages: [],
+ traces: [],
+ outgoingTraces: [],
+ forkedTraces: [],
+ response: '',
+ status: 'idle',
+ inputs: 1
+ },
+ });
+ };
+
+ return (
+ <div style={{ width: '100vw', height: '100vh', display: 'flex' }}>
+ <div style={{ flex: 1, height: '100%' }} ref={reactFlowWrapper}>
+ <ReactFlow
+ nodes={nodes}
+ edges={edges}
+ onNodesChange={onNodesChange}
+ onEdgesChange={onEdgesChange}
+ onConnect={onConnect}
+ nodeTypes={nodeTypes}
+ onNodeClick={onNodeClick}
+ onPaneClick={onPaneClick}
+ onPaneContextMenu={onPaneContextMenu} // Use Right Click to add node for now
+ fitView
+ >
+ <Background color="#aaa" gap={16} />
+ <Controls />
+ <MiniMap />
+ <Panel position="top-left">
+ <button
+ onClick={handleAddNode}
+ className="bg-white px-4 py-2 rounded-md shadow-md font-medium text-gray-700 hover:bg-gray-50 flex items-center gap-2"
+ >
+ <Plus size={16} /> Add Block
+ </button>
+ </Panel>
+ </ReactFlow>
+ </div>
+ <Sidebar />
+ </div>
+ );
+}
+
+export default function App() {
+ return (
+ <ReactFlowProvider>
+ <Flow />
+ </ReactFlowProvider>
+ );
+}
diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg
new file mode 100644
index 0000000..6c87de9
--- /dev/null
+++ b/frontend/src/assets/react.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg> \ No newline at end of file
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;
+
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..91221be
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,11 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html, body, #root {
+ height: 100%;
+ width: 100%;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+} \ No newline at end of file
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..bef5202
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.tsx'
+
+createRoot(document.getElementById('root')!).render(
+ <StrictMode>
+ <App />
+ </StrictMode>,
+)
diff --git a/frontend/src/store/flowStore.ts b/frontend/src/store/flowStore.ts
new file mode 100644
index 0000000..6083a36
--- /dev/null
+++ b/frontend/src/store/flowStore.ts
@@ -0,0 +1,405 @@
+import { create } from 'zustand';
+import {
+ addEdge,
+ applyNodeChanges,
+ applyEdgeChanges,
+ type Connection,
+ type Edge,
+ type EdgeChange,
+ type Node,
+ type NodeChange,
+ type OnNodesChange,
+ type OnEdgesChange,
+ type OnConnect,
+ getIncomers,
+ getOutgoers
+} from 'reactflow';
+
+export type NodeStatus = 'idle' | 'loading' | 'success' | 'error';
+
+export interface Message {
+ id?: string;
+ role: 'user' | 'assistant' | 'system';
+ content: string;
+}
+
+export interface Trace {
+ id: string;
+ sourceNodeId: string;
+ color: string;
+ messages: Message[];
+}
+
+export interface NodeData {
+ label: string;
+ model: string;
+ temperature: number;
+ apiKey?: string;
+ systemPrompt: string;
+ userPrompt: string;
+ mergeStrategy: 'raw' | 'smart';
+
+ // Traces logic
+ traces: Trace[]; // INCOMING Traces
+ outgoingTraces: Trace[]; // ALL Outgoing (inherited + self + forks)
+ forkedTraces: Trace[]; // Manually created forks from "New" handle
+ activeTraceIds: string[];
+
+ response: string;
+ status: NodeStatus;
+ inputs: number;
+ [key: string]: any;
+}
+
+export type LLMNode = Node<NodeData>;
+
+interface FlowState {
+ nodes: LLMNode[];
+ edges: Edge[];
+ selectedNodeId: string | null;
+
+ onNodesChange: OnNodesChange;
+ onEdgesChange: OnEdgesChange;
+ onConnect: OnConnect;
+
+ addNode: (node: LLMNode) => void;
+ updateNodeData: (nodeId: string, data: Partial<NodeData>) => void;
+ setSelectedNode: (nodeId: string | null) => void;
+
+ getActiveContext: (nodeId: string) => Message[];
+
+ propagateTraces: () => void;
+}
+
+// Hash string to color
+const getStableColor = (str: string) => {
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ const hue = Math.abs(hash % 360);
+ return `hsl(${hue}, 70%, 60%)`;
+};
+
+const useFlowStore = create<FlowState>((set, get) => ({
+ nodes: [],
+ edges: [],
+ selectedNodeId: null,
+
+ onNodesChange: (changes: NodeChange[]) => {
+ set({
+ nodes: applyNodeChanges(changes, get().nodes) as LLMNode[],
+ });
+ },
+ onEdgesChange: (changes: EdgeChange[]) => {
+ set({
+ edges: applyEdgeChanges(changes, get().edges),
+ });
+ get().propagateTraces();
+ },
+ onConnect: (connection: Connection) => {
+ const { nodes } = get();
+
+ // Check if connecting from "new-trace" handle
+ if (connection.sourceHandle === 'new-trace') {
+ // Logic: Create a new Forked Trace on the source node
+ const sourceNode = nodes.find(n => n.id === connection.source);
+ if (sourceNode) {
+ // Generate the content for this new trace (it's essentially the Self Trace of this node)
+ const myResponseMsg: Message[] = [];
+ if (sourceNode.data.userPrompt) myResponseMsg.push({ id: `${sourceNode.id}-u`, role: 'user', content: sourceNode.data.userPrompt });
+ if (sourceNode.data.response) myResponseMsg.push({ id: `${sourceNode.id}-a`, role: 'assistant', content: sourceNode.data.response });
+
+ const newForkId = `trace-${sourceNode.id}-fork-${Date.now()}`;
+ const newForkTrace: Trace = {
+ id: newForkId,
+ sourceNodeId: sourceNode.id,
+ color: getStableColor(newForkId), // Unique color for this fork
+ messages: [...myResponseMsg]
+ };
+
+ // Update Source Node to include this fork
+ get().updateNodeData(sourceNode.id, {
+ forkedTraces: [...(sourceNode.data.forkedTraces || []), newForkTrace]
+ });
+
+ // Redirect connection to the new handle
+ // Note: We must wait for propagateTraces to render the new handle?
+ // ReactFlow might complain if handle doesn't exist yet.
+ // But since we updateNodeData synchronously (mostly), it might work.
+ // Let's use the new ID for the connection.
+
+ set({
+ edges: addEdge({
+ ...connection,
+ sourceHandle: `trace-${newForkId}`, // Redirect!
+ style: { stroke: newForkTrace.color, strokeWidth: 2 }
+ }, get().edges),
+ });
+
+ // Trigger propagation to update downstream
+ setTimeout(() => get().propagateTraces(), 0);
+ return;
+ }
+ }
+
+ // Normal connection
+ set({
+ edges: addEdge({
+ ...connection,
+ style: { stroke: '#888', strokeWidth: 2 }
+ }, get().edges),
+ });
+ setTimeout(() => get().propagateTraces(), 0);
+ },
+
+ addNode: (node: LLMNode) => {
+ set((state) => ({ nodes: [...state.nodes, node] }));
+ setTimeout(() => get().propagateTraces(), 0);
+ },
+
+ updateNodeData: (nodeId: string, data: Partial<NodeData>) => {
+ set((state) => ({
+ nodes: state.nodes.map((node) => {
+ if (node.id === nodeId) {
+ return { ...node, data: { ...node.data, ...data } };
+ }
+ return node;
+ }),
+ }));
+
+ if (data.response !== undefined || data.userPrompt !== undefined) {
+ get().propagateTraces();
+ }
+ },
+
+ setSelectedNode: (nodeId: string | null) => {
+ set({ selectedNodeId: nodeId });
+ },
+
+ getActiveContext: (nodeId: string) => {
+ const node = get().nodes.find(n => n.id === nodeId);
+ if (!node) return [];
+
+ // The traces stored in node.data.traces are the INCOMING traces.
+ // If we select one, we want its history.
+
+ const activeTraces = node.data.traces.filter(t =>
+ node.data.activeTraceIds?.includes(t.id)
+ );
+
+ const contextMessages: Message[] = [];
+ activeTraces.forEach(t => {
+ contextMessages.push(...t.messages);
+ });
+
+ return contextMessages;
+ },
+
+ propagateTraces: () => {
+ const { nodes, edges } = get();
+
+ // We need to calculate traces for each node, AND update edge colors.
+ // Topological Sort
+ const inDegree = new Map<string, number>();
+ const graph = new Map<string, string[]>();
+
+ nodes.forEach(node => {
+ inDegree.set(node.id, 0);
+ graph.set(node.id, []);
+ });
+
+ edges.forEach(edge => {
+ inDegree.set(edge.target, (inDegree.get(edge.target) || 0) + 1);
+ graph.get(edge.source)?.push(edge.target);
+ });
+
+ const topoQueue: string[] = [];
+ inDegree.forEach((count, id) => {
+ if (count === 0) topoQueue.push(id);
+ });
+
+ const sortedNodes: string[] = [];
+ while (topoQueue.length > 0) {
+ const u = topoQueue.shift()!;
+ sortedNodes.push(u);
+
+ const children = graph.get(u) || [];
+ children.forEach(v => {
+ inDegree.set(v, (inDegree.get(v) || 0) - 1);
+ if (inDegree.get(v) === 0) {
+ topoQueue.push(v);
+ }
+ });
+ }
+
+ // Map<NodeID, Trace[]>: Traces LEAVING this node
+ const nodeOutgoingTraces = new Map<string, Trace[]>();
+ // Map<NodeID, Trace[]>: Traces ENTERING this node (to update NodeData)
+ const nodeIncomingTraces = new Map<string, Trace[]>();
+
+ // Also track Edge updates (Color AND SourceHandle)
+ const updatedEdges = [...edges];
+ let edgesChanged = false;
+
+ // Iterate
+ sortedNodes.forEach(nodeId => {
+ const node = nodes.find(n => n.id === nodeId);
+ if (!node) return;
+
+ // 1. Gather Incoming Traces
+ const incomingEdges = edges.filter(e => e.target === nodeId);
+ const myIncomingTraces: Trace[] = [];
+
+ incomingEdges.forEach(edge => {
+ const parentOutgoing = nodeOutgoingTraces.get(edge.source) || [];
+
+ // Find match based on Handle ID
+ // EXACT match first
+ // Since we removed 'new-trace' handle, we only look for exact trace matches.
+ let matchedTrace = parentOutgoing.find(t => edge.sourceHandle === `trace-${t.id}`);
+
+ // If no exact match, try to find a "Semantic Match" (Auto-Reconnect)
+ // If edge.sourceHandle was 'trace-X', and now we have 'trace-X_Parent', that's a likely evolution.
+ if (!matchedTrace && edge.sourceHandle?.startsWith('trace-')) {
+ const oldId = edge.sourceHandle.replace('trace-', '');
+ matchedTrace = parentOutgoing.find(t => t.id === `${oldId}_${edge.source}`);
+ }
+
+ // Fallback: If still no match, and parent has traces, try to connect to the most logical one.
+ // If parent has only 1 trace, connect to it.
+ // This handles cases where edge.sourceHandle might be null or outdated.
+ if (!matchedTrace && parentOutgoing.length > 0) {
+ // If edge has no handle ID, default to the last generated trace (usually Self Trace)
+ if (!edge.sourceHandle) {
+ matchedTrace = parentOutgoing[parentOutgoing.length - 1];
+ }
+ }
+
+ if (matchedTrace) {
+ myIncomingTraces.push(matchedTrace);
+
+ // Update Edge Visuals & Logical Connection
+ const edgeIndex = updatedEdges.findIndex(e => e.id === edge.id);
+ if (edgeIndex !== -1) {
+ const currentEdge = updatedEdges[edgeIndex];
+ const newHandleId = `trace-${matchedTrace.id}`;
+
+ // Check if we need to update
+ if (currentEdge.sourceHandle !== newHandleId || currentEdge.style?.stroke !== matchedTrace.color) {
+ updatedEdges[edgeIndex] = {
+ ...currentEdge,
+ sourceHandle: newHandleId, // Auto-update handle connection!
+ style: { ...currentEdge.style, stroke: matchedTrace.color, strokeWidth: 2 }
+ };
+ edgesChanged = true;
+ }
+ }
+ }
+ });
+
+ // Deduplicate incoming traces by ID (in case multiple edges carry same trace)
+ const uniqueIncoming = Array.from(new Map(myIncomingTraces.map(t => [t.id, t])).values());
+ nodeIncomingTraces.set(nodeId, uniqueIncoming);
+
+ // 2. Generate Outgoing Traces
+ // Every incoming trace gets appended with this node's response.
+ // PLUS, we always generate a "Self Trace" (Start New) that starts here.
+
+ const myResponseMsg: Message[] = [];
+ if (node.data.userPrompt) {
+ myResponseMsg.push({
+ id: `${node.id}-user`, // Deterministic ID for stability
+ role: 'user',
+ content: node.data.userPrompt
+ });
+ }
+ if (node.data.response) {
+ myResponseMsg.push({
+ id: `${node.id}-assistant`,
+ role: 'assistant',
+ content: node.data.response
+ });
+ }
+
+ const myOutgoingTraces: Trace[] = [];
+
+ // A. Pass-through traces (append history)
+ uniqueIncoming.forEach(t => {
+ // When a trace passes through a node and gets modified, it effectively becomes a NEW branch of that trace.
+ // We must append the current node ID to the trace ID to distinguish branches.
+ // e.g. Trace "root" -> passes Node A -> becomes "root_A"
+ // If it passes Node B -> becomes "root_B"
+ // Downstream Node D can then distinguish "root_A" from "root_B".
+
+ // Match Logic:
+ // We need to find if this edge was PREVIOUSLY connected to a trace that has now evolved into 'newTrace'.
+ // The edge.sourceHandle might be the OLD ID.
+ // We need a heuristic: if edge.sourceHandle contains the ROOT ID of this trace, we assume it's a match.
+ // But this is risky if multiple branches exist.
+
+ // Better heuristic:
+ // When we extend a trace t -> t_new (with id t.id + '_' + node.id),
+ // we record this evolution mapping.
+
+ const newTraceId = `${t.id}_${node.id}`;
+
+ myOutgoingTraces.push({
+ ...t,
+ id: newTraceId,
+ messages: [...t.messages, ...myResponseMsg]
+ });
+ });
+
+ // B. Self Trace (New Branch) -> This is the "Default" self trace (always there?)
+ // Actually, if we use Manual Forks, maybe we don't need an automatic self trace?
+ // Or maybe the "Default" self trace is just one of the outgoing ones.
+ // Let's keep it for compatibility if downstream picks it up automatically.
+ const selfTrace: Trace = {
+ id: `trace-${node.id}`,
+ sourceNodeId: node.id,
+ color: getStableColor(node.id),
+ messages: [...myResponseMsg]
+ };
+ myOutgoingTraces.push(selfTrace);
+
+ // C. Manual Forks
+ if (node.data.forkedTraces) {
+ // We need to keep them updated with the latest messages (if prompt changed)
+ // But keep their IDs and Colors stable.
+ const updatedForks = node.data.forkedTraces.map(fork => ({
+ ...fork,
+ messages: [...myResponseMsg] // Re-sync messages
+ }));
+ myOutgoingTraces.push(...updatedForks);
+ }
+
+ nodeOutgoingTraces.set(nodeId, myOutgoingTraces);
+
+ // Update Node Data with INCOMING traces (for sidebar selection)
+ // We store uniqueIncoming in node.data.traces
+ // Note: We need to update the node in the `nodes` array, but we are inside the loop.
+ // We'll do a bulk set at the end.
+ });
+
+ // Bulk Update Store
+ set(state => ({
+ edges: updatedEdges,
+ nodes: state.nodes.map(n => {
+ const traces = nodeIncomingTraces.get(n.id) || [];
+ const outTraces = nodeOutgoingTraces.get(n.id) || [];
+ return {
+ ...n,
+ data: {
+ ...n.data,
+ traces,
+ outgoingTraces: outTraces,
+ activeTraceIds: n.data.activeTraceIds
+ }
+ };
+ })
+ }));
+ }
+}));
+
+export default useFlowStore;