summaryrefslogtreecommitdiff
path: root/frontend/src/components/Sidebar.tsx
diff options
context:
space:
mode:
authorblackhao <13851610112@163.com>2025-12-05 20:40:40 -0600
committerblackhao <13851610112@163.com>2025-12-05 20:40:40 -0600
commitd9868550e66fe8aaa7fff55a8e24b871ee51e3b1 (patch)
tree147757f77def085c5649c4d930d5a51ff44a1e3d /frontend/src/components/Sidebar.tsx
parentd87c364dc43ca241fadc9dccbad9ec8896c93a1e (diff)
init: add project files and ignore secrets
Diffstat (limited to 'frontend/src/components/Sidebar.tsx')
-rw-r--r--frontend/src/components/Sidebar.tsx320
1 files changed, 320 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;
+