1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
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;
|