From d9868550e66fe8aaa7fff55a8e24b871ee51e3b1 Mon Sep 17 00:00:00 2001
From: blackhao <13851610112@163.com>
Date: Fri, 5 Dec 2025 20:40:40 -0600
Subject: init: add project files and ignore secrets

---
 frontend/src/components/Sidebar.tsx | 320 ++++++++++++++++++++++++++++++++++++
 1 file changed, 320 insertions(+)
 create mode 100644 frontend/src/components/Sidebar.tsx

(limited to 'frontend/src/components/Sidebar.tsx')

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;
+
-- 
cgit v1.2.3

