From 93dbe11014cf967690727c25e89d9d1075008c24 Mon Sep 17 00:00:00 2001 From: blackhao <13851610112@163.com> Date: Sat, 6 Dec 2025 01:30:57 -0600 Subject: UX --- frontend/src/App.tsx | 115 ++++++-- frontend/src/components/LeftSidebar.tsx | 134 ++++++++++ frontend/src/components/Sidebar.tsx | 419 ++++++++++++++++++++++++++++-- frontend/src/components/nodes/LLMNode.tsx | 22 +- frontend/src/store/flowStore.ts | 191 ++++++++++++++ 5 files changed, 834 insertions(+), 47 deletions(-) create mode 100644 frontend/src/components/LeftSidebar.tsx (limited to 'frontend/src') diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8c52751..9ec1340 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import 'reactflow/dist/style.css'; import useFlowStore from './store/flowStore'; import LLMNode from './components/nodes/LLMNode'; import Sidebar from './components/Sidebar'; +import LeftSidebar from './components/LeftSidebar'; import { ContextMenu } from './components/ContextMenu'; import { Plus } from 'lucide-react'; @@ -31,12 +32,19 @@ function Flow() { deleteEdge, deleteNode, deleteBranch, - setSelectedNode + setSelectedNode, + toggleNodeDisabled, + archiveNode, + createNodeFromArchive, + toggleTraceDisabled } = useFlowStore(); const reactFlowWrapper = useRef(null); const { project } = useReactFlow(); const [menu, setMenu] = useState<{ x: number; y: number; type: 'pane' | 'node' | 'edge'; id?: string } | null>(null); + + const [isLeftOpen, setIsLeftOpen] = useState(true); + const [isRightOpen, setIsRightOpen] = useState(true); const onPaneClick = () => { setSelectedNode(null); @@ -73,6 +81,7 @@ function Flow() { systemPrompt: '', userPrompt: '', mergeStrategy: 'smart', + reasoningEffort: 'medium', // Default for reasoning models messages: [], traces: [], outgoingTraces: [], @@ -86,12 +95,44 @@ function Flow() { }; const onNodeClick = (_: any, node: Node) => { + // Don't select disabled nodes + const nodeData = node.data as any; + if (nodeData?.disabled) return; setSelectedNode(node.id); }; + const onDragOver = (event: React.DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + }; + + const onDrop = (event: React.DragEvent) => { + event.preventDefault(); + + const archiveId = event.dataTransfer.getData('archiveId'); + if (!archiveId) return; + + const bounds = reactFlowWrapper.current?.getBoundingClientRect(); + if (!bounds) return; + + const position = project({ + x: event.clientX - bounds.left, + y: event.clientY - bounds.top + }); + + createNodeFromArchive(archiveId, position); + }; + return (
-
+ setIsLeftOpen(!isLeftOpen)} /> + +
menu.id && deleteBranch(menu.id) - } - ] : [ - { - label: 'Disconnect', - onClick: () => menu.id && deleteEdge(menu.id) - }, - { - label: 'Delete Branch', - danger: true, - onClick: () => menu.id && deleteBranch(undefined, menu.id) + ] : menu.type === 'node' ? (() => { + const targetNode = nodes.find(n => n.id === menu.id); + const isDisabled = targetNode?.data?.disabled; + + // If disabled, only show Enable option + if (isDisabled) { + return [ + { + label: 'Enable Node', + onClick: () => menu.id && toggleNodeDisabled(menu.id) + } + ]; } - ] + + // Normal node menu + return [ + { + label: 'Disable Node', + onClick: () => menu.id && toggleNodeDisabled(menu.id) + }, + { + label: 'Add to Archive', + onClick: () => menu.id && archiveNode(menu.id) + }, + { + label: 'Delete Node (Cascade)', + danger: true, + onClick: () => menu.id && deleteBranch(menu.id) + } + ]; + })() : (() => { + // Check if any node connected to this edge is disabled + const targetEdge = edges.find(e => e.id === menu.id); + const sourceNode = nodes.find(n => n.id === targetEdge?.source); + const targetNode = nodes.find(n => n.id === targetEdge?.target); + const isTraceDisabled = sourceNode?.data?.disabled || targetNode?.data?.disabled; + + return [ + { + label: isTraceDisabled ? 'Enable Trace' : 'Disable Trace', + onClick: () => menu.id && toggleTraceDisabled(menu.id) + }, + { + label: 'Disconnect', + onClick: () => menu.id && deleteEdge(menu.id) + }, + { + label: 'Delete Branch', + danger: true, + onClick: () => menu.id && deleteBranch(undefined, menu.id) + } + ]; + })() } /> )}
- + setIsRightOpen(!isRightOpen)} />
); } diff --git a/frontend/src/components/LeftSidebar.tsx b/frontend/src/components/LeftSidebar.tsx new file mode 100644 index 0000000..fa8b471 --- /dev/null +++ b/frontend/src/components/LeftSidebar.tsx @@ -0,0 +1,134 @@ +import React, { useState } from 'react'; +import { Folder, FileText, Archive, ChevronLeft, ChevronRight, Trash2, MessageSquare } from 'lucide-react'; +import useFlowStore from '../store/flowStore'; + +interface LeftSidebarProps { + isOpen: boolean; + onToggle: () => void; +} + +const LeftSidebar: React.FC = ({ isOpen, onToggle }) => { + const [activeTab, setActiveTab] = useState<'project' | 'files' | 'archive'>('project'); + const { archivedNodes, removeFromArchive, createNodeFromArchive } = useFlowStore(); + + const handleDragStart = (e: React.DragEvent, archiveId: string) => { + e.dataTransfer.setData('archiveId', archiveId); + e.dataTransfer.effectAllowed = 'copy'; + }; + + if (!isOpen) { + return ( +
+ + {/* Icons when collapsed */} +
+ + + +
+
+ ); + } + + return ( +
+ {/* Header */} +
+

Workspace

+ +
+ + {/* Tabs */} +
+ + + +
+ + {/* Content Area */} +
+ {activeTab === 'project' && ( +
+ +

Project settings coming soon

+
+ )} + {activeTab === 'files' && ( +
+ +

File manager coming soon

+
+ )} + {activeTab === 'archive' && ( +
+ {archivedNodes.length === 0 ? ( +
+ +

+ No archived nodes.
+ Right-click a node → "Add to Archive" +

+
+ ) : ( + <> +

Drag to canvas to create a copy

+ {archivedNodes.map((archived) => ( +
handleDragStart(e, archived.id)} + className="p-2 bg-gray-50 border border-gray-200 rounded-md cursor-grab hover:bg-gray-100 hover:border-gray-300 transition-colors group" + > +
+
+ + {archived.label} +
+ +
+
{archived.model}
+
+ ))} + + )} +
+ )} +
+
+ ); +}; + +export default LeftSidebar; + diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index f62f3cb..165028c 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -2,24 +2,75 @@ 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'; +import { Play, Settings, Info, Save, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText } from 'lucide-react'; -const Sidebar = () => { +interface SidebarProps { + isOpen: boolean; + onToggle: () => void; +} + +const Sidebar: React.FC = ({ isOpen, onToggle }) => { const { nodes, selectedNodeId, updateNodeData, getActiveContext } = useFlowStore(); const [activeTab, setActiveTab] = useState<'interact' | 'settings' | 'debug'>('interact'); const [streamBuffer, setStreamBuffer] = useState(''); + + // Response Modal & Edit states + const [isModalOpen, setIsModalOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editedResponse, setEditedResponse] = useState(''); + + // Summary states + const [showSummaryModal, setShowSummaryModal] = useState(false); + const [summaryModel, setSummaryModel] = useState('gpt-5-nano'); + const [isSummarizing, setIsSummarizing] = useState(false); const selectedNode = nodes.find((n) => n.id === selectedNodeId); - // Reset stream buffer when node changes + // Reset stream buffer and modal states when node changes useEffect(() => { setStreamBuffer(''); + setIsModalOpen(false); + setIsEditing(false); }, [selectedNodeId]); + + // Sync editedResponse when entering edit mode + useEffect(() => { + if (isEditing && selectedNode) { + setEditedResponse(selectedNode.data.response || ''); + } + }, [isEditing, selectedNode?.data.response]); + + if (!isOpen) { + return ( +
+ + {selectedNode && ( +
+ {selectedNode.data.label} +
+ )} +
+ ); + } if (!selectedNode) { return ( -
-

Select a node to edit

+
+
+ Details + +
+
+

Select a node to edit

+
); } @@ -43,11 +94,13 @@ const Sidebar = () => { user_prompt: selectedNode.data.userPrompt, merge_strategy: selectedNode.data.mergeStrategy || 'smart', config: { - provider: selectedNode.data.model.includes('gpt') ? 'openai' : 'google', + provider: selectedNode.data.model.includes('gpt') || selectedNode.data.model === 'o3' ? 'openai' : 'google', model_name: selectedNode.data.model, temperature: selectedNode.data.temperature, system_prompt: selectedNode.data.systemPrompt, api_key: selectedNode.data.apiKey, + enable_google_search: selectedNode.data.enableGoogleSearch !== false, // Default true + reasoning_effort: selectedNode.data.reasoningEffort || 'medium', // For reasoning models } }) }); @@ -85,6 +138,10 @@ const Sidebar = () => { response: fullResponse, messages: [...context, newUserMsg, newAssistantMsg] as any }); + + // Auto-generate title using gpt-5-nano (async, non-blocking) + // Always regenerate title after each query + generateTitle(selectedNode.id, selectedNode.data.userPrompt, fullResponse); } catch (error) { console.error(error); @@ -95,17 +152,85 @@ const Sidebar = () => { const handleChange = (field: keyof NodeData, value: any) => { updateNodeData(selectedNode.id, { [field]: value }); }; + + const handleSaveEdit = () => { + if (!selectedNode) return; + updateNodeData(selectedNode.id, { response: editedResponse }); + setIsEditing(false); + }; + + const handleCancelEdit = () => { + setIsEditing(false); + setEditedResponse(selectedNode?.data.response || ''); + }; + + // Summarize response + const handleSummarize = async () => { + if (!selectedNode?.data.response) return; + + setIsSummarizing(true); + setShowSummaryModal(false); + + try { + const res = await fetch('http://localhost:8000/api/summarize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: selectedNode.data.response, + model: summaryModel + }) + }); + + if (res.ok) { + const data = await res.json(); + if (data.summary) { + // Replace response with summary + updateNodeData(selectedNode.id, { response: data.summary }); + } + } + } catch (error) { + console.error('Summarization failed:', error); + } finally { + setIsSummarizing(false); + } + }; + + // Auto-generate title using gpt-5-nano + const generateTitle = async (nodeId: string, userPrompt: string, response: string) => { + try { + const res = await fetch('http://localhost:8000/api/generate_title', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_prompt: userPrompt, response }) + }); + + if (res.ok) { + const data = await res.json(); + if (data.title) { + updateNodeData(nodeId, { label: data.title }); + } + } + } catch (error) { + console.error('Failed to generate title:', error); + // Silently fail - keep the original title + } + }; return ( -
+
{/* Header */} -
- handleChange('label', e.target.value)} - className="font-bold text-lg bg-transparent border-none focus:ring-0 focus:outline-none w-full" - /> +
+
+ handleChange('label', e.target.value)} + className="font-bold text-lg bg-transparent border-none focus:ring-0 focus:outline-none w-full" + /> + +
{selectedNode.data.status} @@ -146,13 +271,41 @@ const Sidebar = () => {
@@ -216,10 +369,65 @@ const Sidebar = () => {
- -
- {selectedNode.data.response || streamBuffer} +
+ +
+ {selectedNode.data.response && ( + <> + + + + + )} +
+ + {isEditing ? ( +
+